Skip to content

Commit a9b9904

Browse files
authored
feat: implementation for JSON format. (#245)
* feat: draft implementation for JSON format. Only CloudEvent serialization included. Signed-off-by: Tudor Plugaru <[email protected]> * Implement read method Signed-off-by: Tudor Plugaru <[email protected]> * small fix to read method Signed-off-by: Tudor Plugaru <[email protected]> * wrap up JSON format completely and make it compliant with the spec Signed-off-by: Tudor Plugaru <[email protected]> * add type annotations Signed-off-by: Tudor Plugaru <[email protected]> * chore(docs): move docstrings from implementation to protocol class Signed-off-by: Tudor Plugaru <[email protected]> * chore(docs): document Format protocol Signed-off-by: Tudor Plugaru <[email protected]> --------- Signed-off-by: Tudor Plugaru <[email protected]>
1 parent a73c870 commit a9b9904

File tree

9 files changed

+693
-74
lines changed

9 files changed

+693
-74
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ keywords = [
3232
]
3333
dependencies = [
3434
"ruff>=0.6.8",
35+
"python-dateutil>=2.8.2",
3536
]
3637

3738
[project.urls]
@@ -53,6 +54,7 @@ dev-dependencies = [
5354
"flake8-print>=5.0.0",
5455
"pre-commit>=3.8.0",
5556
"pytest-cov>=5.0.0",
57+
"types-python-dateutil>=2.9.0.20241003",
5658
]
5759

5860
[tool.uv.pip]

src/cloudevents/core/base.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
16+
from datetime import datetime
17+
from typing import Any, Optional, Protocol, Union
18+
19+
20+
class BaseCloudEvent(Protocol):
21+
"""
22+
The CloudEvent Python wrapper contract exposing generically-available
23+
properties and APIs.
24+
25+
Implementations might handle fields and have other APIs exposed but are
26+
obliged to follow this contract.
27+
"""
28+
29+
def __init__(
30+
self, attributes: dict[str, Any], data: Optional[Union[dict, str, bytes]] = None
31+
) -> None:
32+
"""
33+
Create a new CloudEvent instance.
34+
35+
:param attributes: The attributes of the CloudEvent instance.
36+
:param data: The payload of the CloudEvent instance.
37+
38+
:raises ValueError: If any of the required attributes are missing or have invalid values.
39+
:raises TypeError: If any of the attributes have invalid types.
40+
"""
41+
...
42+
43+
def get_id(self) -> str:
44+
"""
45+
Retrieve the ID of the event.
46+
47+
:return: The ID of the event.
48+
"""
49+
...
50+
51+
def get_source(self) -> str:
52+
"""
53+
Retrieve the source of the event.
54+
55+
:return: The source of the event.
56+
"""
57+
...
58+
59+
def get_type(self) -> str:
60+
"""
61+
Retrieve the type of the event.
62+
63+
:return: The type of the event.
64+
"""
65+
...
66+
67+
def get_specversion(self) -> str:
68+
"""
69+
Retrieve the specversion of the event.
70+
71+
:return: The specversion of the event.
72+
"""
73+
...
74+
75+
def get_datacontenttype(self) -> Optional[str]:
76+
"""
77+
Retrieve the datacontenttype of the event.
78+
79+
:return: The datacontenttype of the event.
80+
"""
81+
...
82+
83+
def get_dataschema(self) -> Optional[str]:
84+
"""
85+
Retrieve the dataschema of the event.
86+
87+
:return: The dataschema of the event.
88+
"""
89+
...
90+
91+
def get_subject(self) -> Optional[str]:
92+
"""
93+
Retrieve the subject of the event.
94+
95+
:return: The subject of the event.
96+
"""
97+
...
98+
99+
def get_time(self) -> Optional[datetime]:
100+
"""
101+
Retrieve the time of the event.
102+
103+
:return: The time of the event.
104+
"""
105+
...
106+
107+
def get_extension(self, extension_name: str) -> Any:
108+
"""
109+
Retrieve an extension attribute of the event.
110+
111+
:param extension_name: The name of the extension attribute.
112+
:return: The value of the extension attribute.
113+
"""
114+
...
115+
116+
def get_data(self) -> Optional[Union[dict, str, bytes]]:
117+
"""
118+
Retrieve data of the event.
119+
120+
:return: The data of the event.
121+
"""
122+
...
123+
124+
def get_attributes(self) -> dict[str, Any]:
125+
"""
126+
Retrieve all attributes of the event.
127+
128+
:return: The attributes of the event.
129+
"""
130+
...
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
16+
from typing import Callable, Optional, Protocol, Union
17+
18+
from cloudevents.core.base import BaseCloudEvent
19+
20+
21+
class Format(Protocol):
22+
"""
23+
Protocol defining the contract for CloudEvent format implementations.
24+
25+
Format implementations are responsible for serializing and deserializing CloudEvents
26+
to and from specific wire formats (e.g., JSON, Avro, Protobuf). Each format must
27+
implement both read and write operations to convert between CloudEvent objects and
28+
their byte representations according to the CloudEvents specification.
29+
"""
30+
31+
def read(
32+
self,
33+
event_factory: Callable[
34+
[dict, Optional[Union[dict, str, bytes]]], BaseCloudEvent
35+
],
36+
data: Union[str, bytes],
37+
) -> BaseCloudEvent:
38+
"""
39+
Deserialize a CloudEvent from its wire format representation.
40+
41+
:param event_factory: A factory function that creates CloudEvent instances from
42+
attributes and data. The factory should accept a dictionary of attributes and
43+
optional event data (dict, str, or bytes).
44+
:param data: The serialized CloudEvent data as a string or bytes.
45+
:return: A CloudEvent instance constructed from the deserialized data.
46+
:raises ValueError: If the data cannot be parsed or is invalid according to the format.
47+
"""
48+
...
49+
50+
def write(self, event: BaseCloudEvent) -> bytes:
51+
"""
52+
Serialize a CloudEvent to its wire format representation.
53+
54+
:param event: The CloudEvent instance to serialize.
55+
:return: The CloudEvent serialized as bytes in the format's wire representation.
56+
:raises ValueError: If the event cannot be serialized according to the format.
57+
"""
58+
...
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
16+
import base64
17+
import re
18+
from datetime import datetime
19+
from json import JSONEncoder, dumps, loads
20+
from typing import Any, Callable, Final, Optional, Pattern, Union
21+
22+
from dateutil.parser import isoparse # type: ignore[import-untyped]
23+
24+
from cloudevents.core.base import BaseCloudEvent
25+
from cloudevents.core.formats.base import Format
26+
27+
28+
class _JSONEncoderWithDatetime(JSONEncoder):
29+
"""
30+
Custom JSON encoder to handle datetime objects in the format required by the CloudEvents spec.
31+
"""
32+
33+
def default(self, obj: Any) -> Any:
34+
if isinstance(obj, datetime):
35+
dt = obj.isoformat()
36+
# 'Z' denotes a UTC offset of 00:00 see
37+
# https://www.rfc-editor.org/rfc/rfc3339#section-2
38+
if dt.endswith("+00:00"):
39+
dt = dt.removesuffix("+00:00") + "Z"
40+
return dt
41+
42+
return super().default(obj)
43+
44+
45+
class JSONFormat(Format):
46+
CONTENT_TYPE: Final[str] = "application/cloudevents+json"
47+
JSON_CONTENT_TYPE_PATTERN: Pattern[str] = re.compile(
48+
r"^(application|text)/([a-zA-Z0-9\-\.]+\+)?json(;.*)?$"
49+
)
50+
51+
def read(
52+
self,
53+
event_factory: Callable[
54+
[dict, Optional[Union[dict, str, bytes]]], BaseCloudEvent
55+
],
56+
data: Union[str, bytes],
57+
) -> BaseCloudEvent:
58+
"""
59+
Read a CloudEvent from a JSON formatted byte string.
60+
61+
:param event_factory: A factory function to create CloudEvent instances.
62+
:param data: The JSON formatted byte array.
63+
:return: The CloudEvent instance.
64+
"""
65+
decoded_data: str
66+
if isinstance(data, bytes):
67+
decoded_data = data.decode("utf-8")
68+
else:
69+
decoded_data = data
70+
71+
event_attributes = loads(decoded_data)
72+
73+
if "time" in event_attributes:
74+
event_attributes["time"] = isoparse(event_attributes["time"])
75+
76+
event_data: Union[dict, str, bytes, None] = event_attributes.pop("data", None)
77+
if event_data is None:
78+
event_data_base64 = event_attributes.pop("data_base64", None)
79+
if event_data_base64 is not None:
80+
event_data = base64.b64decode(event_data_base64)
81+
82+
return event_factory(event_attributes, event_data)
83+
84+
def write(self, event: BaseCloudEvent) -> bytes:
85+
"""
86+
Write a CloudEvent to a JSON formatted byte string.
87+
88+
:param event: The CloudEvent to write.
89+
:return: The CloudEvent as a JSON formatted byte array.
90+
"""
91+
event_data = event.get_data()
92+
event_dict: dict[str, Any] = dict(event.get_attributes())
93+
94+
if event_data is not None:
95+
if isinstance(event_data, (bytes, bytearray)):
96+
event_dict["data_base64"] = base64.b64encode(event_data).decode("utf-8")
97+
else:
98+
datacontenttype = event_dict.get("datacontenttype", "application/json")
99+
if re.match(JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype):
100+
event_dict["data"] = event_data
101+
else:
102+
event_dict["data"] = str(event_data)
103+
104+
return dumps(event_dict, cls=_JSONEncoderWithDatetime).encode("utf-8")

0 commit comments

Comments
 (0)