Skip to content

Commit 35dee7d

Browse files
committed
chore: Add support for custom extension names and validate them
Signed-off-by: Tudor <plugaru.tudor@protonmail.com>
1 parent 8db1e29 commit 35dee7d

File tree

2 files changed

+80
-13
lines changed

2 files changed

+80
-13
lines changed

src/cloudevents/core/v1/event.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414

1515
from typing import Any, Optional
1616
from datetime import datetime
17+
import re
1718

1819
REQUIRED_ATTRIBUTES = {"id", "source", "type", "specversion"}
1920
OPTIONAL_ATTRIBUTES = {"datacontenttype", "dataschema", "subject", "time"}
2021

2122

2223
class CloudEvent:
23-
def __init__(self, attributes: dict, data: Optional[dict] = None) -> 'CloudEvent':
24+
def __init__(self, attributes: dict, data: Optional[dict] = None) -> None:
2425
"""
2526
Create a new CloudEvent instance.
2627
@@ -32,11 +33,14 @@ def __init__(self, attributes: dict, data: Optional[dict] = None) -> 'CloudEvent
3233
:raises ValueError: If any of the required attributes are missing or have invalid values.
3334
:raises TypeError: If any of the attributes have invalid types.
3435
"""
35-
self.__validate_attribute(attributes)
36+
self._validate_attribute(attributes)
3637
self._attributes = attributes
3738
self._data = data
3839

39-
def __validate_attribute(self, attributes: dict):
40+
def _validate_attribute(self, attributes: dict) -> None:
41+
"""
42+
Private method that validates the attributes of the CloudEvent as per the CloudEvents specification.
43+
"""
4044
missing_attributes = [
4145
attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes
4246
]
@@ -47,6 +51,7 @@ def __validate_attribute(self, attributes: dict):
4751

4852
if attributes["id"] is None:
4953
raise ValueError("Attribute 'id' must not be None")
54+
5055
if not isinstance(attributes["id"], str):
5156
raise TypeError("Attribute 'id' must be a string")
5257

@@ -58,6 +63,7 @@ def __validate_attribute(self, attributes: dict):
5863

5964
if not isinstance(attributes["specversion"], str):
6065
raise TypeError("Attribute 'specversion' must be a string")
66+
6167
if attributes["specversion"] != "1.0":
6268
raise ValueError("Attribute 'specversion' must be '1.0'")
6369

@@ -89,13 +95,36 @@ def __validate_attribute(self, attributes: dict):
8995
if not attributes["dataschema"]:
9096
raise ValueError("Attribute 'dataschema' must not be empty")
9197

98+
for custom_extension in (
99+
set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES
100+
):
101+
if custom_extension == "data":
102+
raise ValueError(
103+
"Extension attribute 'data' is reserved and must not be used"
104+
)
105+
106+
if not custom_extension[0].isalpha():
107+
raise ValueError(
108+
f"Extension attribute '{custom_extension}' should start with a letter"
109+
)
110+
111+
if not (5 <= len(custom_extension) <= 20):
112+
raise ValueError(
113+
f"Extension attribute '{custom_extension}' should be between 5 and 20 characters long"
114+
)
115+
116+
if not re.match(r"^[a-z0-9]+$", custom_extension):
117+
raise ValueError(
118+
f"Extension attribute '{custom_extension}' should only contain lowercase letters and numbers"
119+
)
120+
92121
def get_attribute(self, attribute: str) -> Optional[Any]:
93122
"""
94123
Retrieve a value of an attribute of the event denoted by the given `attribute`.
95-
124+
96125
:param attribute: The name of the event attribute to retrieve the value for.
97126
:type attribute: str
98-
127+
99128
:return: The event attribute value.
100129
:rtype: Optional[Any]
101130
"""

tests/test_core/test_v1/test_event.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from datetime import datetime
5+
from typing import Any, Optional
56

67

78
@pytest.mark.parametrize(
@@ -13,7 +14,7 @@
1314
({"id": "1", "source": "/", "type": "test"}, "specversion"),
1415
],
1516
)
16-
def test_missing_required_attribute(attributes, missing_attribute) -> None:
17+
def test_missing_required_attribute(attributes: dict, missing_attribute: str) -> None:
1718
with pytest.raises(ValueError) as e:
1819
CloudEvent(attributes)
1920

@@ -27,15 +28,15 @@ def test_missing_required_attribute(attributes, missing_attribute) -> None:
2728
(12, "Attribute 'id' must be a string"),
2829
],
2930
)
30-
def test_id_validation(id, error) -> None:
31+
def test_id_validation(id: Optional[Any], error: str) -> None:
3132
with pytest.raises((ValueError, TypeError)) as e:
3233
CloudEvent({"id": id, "source": "/", "type": "test", "specversion": "1.0"})
3334

3435
assert str(e.value) == error
3536

3637

3738
@pytest.mark.parametrize("source,error", [(123, "Attribute 'source' must be a string")])
38-
def test_source_validation(source, error) -> None:
39+
def test_source_validation(source: Any, error: str) -> None:
3940
with pytest.raises((ValueError, TypeError)) as e:
4041
CloudEvent({"id": "1", "source": source, "type": "test", "specversion": "1.0"})
4142

@@ -49,7 +50,7 @@ def test_source_validation(source, error) -> None:
4950
("1.4", "Attribute 'specversion' must be '1.0'"),
5051
],
5152
)
52-
def test_specversion_validation(specversion, error) -> None:
53+
def test_specversion_validation(specversion: Any, error: str) -> None:
5354
with pytest.raises((ValueError, TypeError)) as e:
5455
CloudEvent(
5556
{"id": "1", "source": "/", "type": "test", "specversion": specversion}
@@ -68,7 +69,7 @@ def test_specversion_validation(specversion, error) -> None:
6869
),
6970
],
7071
)
71-
def test_time_validation(time, error) -> None:
72+
def test_time_validation(time: Any, error: str) -> None:
7273
with pytest.raises((ValueError, TypeError)) as e:
7374
CloudEvent(
7475
{
@@ -93,7 +94,7 @@ def test_time_validation(time, error) -> None:
9394
),
9495
],
9596
)
96-
def test_subject_validation(subject, error) -> None:
97+
def test_subject_validation(subject: Any, error: str) -> None:
9798
with pytest.raises((ValueError, TypeError)) as e:
9899
CloudEvent(
99100
{
@@ -118,7 +119,7 @@ def test_subject_validation(subject, error) -> None:
118119
),
119120
],
120121
)
121-
def test_datacontenttype_validation(datacontenttype, error) -> None:
122+
def test_datacontenttype_validation(datacontenttype: Any, error: str) -> None:
122123
with pytest.raises((ValueError, TypeError)) as e:
123124
CloudEvent(
124125
{
@@ -143,7 +144,7 @@ def test_datacontenttype_validation(datacontenttype, error) -> None:
143144
),
144145
],
145146
)
146-
def test_dataschema_validation(dataschema, error) -> None:
147+
def test_dataschema_validation(dataschema: Any, error: str) -> None:
147148
with pytest.raises((ValueError, TypeError)) as e:
148149
CloudEvent(
149150
{
@@ -156,3 +157,40 @@ def test_dataschema_validation(dataschema, error) -> None:
156157
)
157158

158159
assert str(e.value) == error
160+
161+
162+
@pytest.mark.parametrize(
163+
"extension_name,error",
164+
[
165+
("123", "Extension attribute '123' should start with a letter"),
166+
(
167+
"shrt",
168+
"Extension attribute 'shrt' should be between 5 and 20 characters long",
169+
),
170+
(
171+
"thisisaverylongextension",
172+
"Extension attribute 'thisisaverylongextension' should be between 5 and 20 characters long",
173+
),
174+
(
175+
"ThisIsNotValid",
176+
"Extension attribute 'ThisIsNotValid' should only contain lowercase letters and numbers",
177+
),
178+
(
179+
"data",
180+
"Extension attribute 'data' is reserved and must not be used",
181+
),
182+
],
183+
)
184+
def test_custom_extension(extension_name: str, error: str) -> None:
185+
with pytest.raises(ValueError) as e:
186+
CloudEvent(
187+
{
188+
"id": "1",
189+
"source": "/",
190+
"type": "test",
191+
"specversion": "1.0",
192+
extension_name: "value",
193+
}
194+
)
195+
196+
assert str(e.value) == error

0 commit comments

Comments
 (0)