Skip to content

Commit fa0ec99

Browse files
committed
feat: draft implementation for JSON format. Only CloudEvent serialization included.
Signed-off-by: Tudor Plugaru <[email protected]>
1 parent a73c870 commit fa0ec99

File tree

7 files changed

+332
-1
lines changed

7 files changed

+332
-1
lines changed

src/cloudevents/core/base.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
def get_id(self) -> str: ...
22+
23+
def get_source(self) -> str: ...
24+
25+
def get_type(self) -> str: ...
26+
27+
def get_specversion(self) -> str: ...
28+
29+
def get_datacontenttype(self) -> Optional[str]: ...
30+
31+
def get_dataschema(self) -> Optional[str]: ...
32+
33+
def get_subject(self) -> Optional[str]: ...
34+
35+
def get_time(self) -> Optional[datetime]: ...
36+
37+
def get_extension(self, extension_name: str) -> Any: ...
38+
39+
def get_data(self) -> Optional[Union[dict, str, bytes]]: ...
40+
41+
def get_attributes(self) -> dict[str, Any]: ...
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 Protocol, Union
17+
18+
from cloudevents.core.base import BaseCloudEvent
19+
20+
21+
class Format(Protocol):
22+
def read(self, data: Union[str, bytes]) -> BaseCloudEvent: ...
23+
24+
def write(self, event: BaseCloudEvent) -> str: ...
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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
20+
from typing import Any, Final, Pattern, Union
21+
22+
from cloudevents.core.base import BaseCloudEvent
23+
from cloudevents.core.formats.base import Format
24+
25+
26+
class _JSONEncoderWithDatetime(JSONEncoder):
27+
"""
28+
Custom JSON encoder to handle datetime objects in the format required by the CloudEvents spec.
29+
"""
30+
31+
def default(self, obj: Any) -> Any:
32+
if isinstance(obj, datetime):
33+
dt = obj.isoformat()
34+
# 'Z' denotes a UTC offset of 00:00 see
35+
# https://www.rfc-editor.org/rfc/rfc3339#section-2
36+
if dt.endswith("+00:00"):
37+
dt = dt.removesuffix("+00:00") + "Z"
38+
return dt
39+
40+
return super().default(obj)
41+
42+
43+
class JSONFormat(Format):
44+
CONTENT_TYPE: Final[str] = "application/cloudevents+json"
45+
JSON_CONTENT_TYPE_PATTERN: Pattern[str] = re.compile(
46+
r"^(application|text)\\/([a-zA-Z]+\\+)?json(;.*)*$"
47+
)
48+
49+
def read(self, data: Union[str, bytes]) -> BaseCloudEvent:
50+
pass
51+
52+
def write(self, event: BaseCloudEvent) -> bytes:
53+
"""
54+
Write a CloudEvent to a JSON formatted byte string.
55+
56+
:param event: The CloudEvent to write.
57+
:return: The CloudEvent as a JSON formatted byte array.
58+
"""
59+
event_data = event.get_data()
60+
event_dict: dict[str, Any] = {**event.get_attributes()}
61+
62+
if event_data is not None:
63+
if isinstance(event_data, (bytes, bytearray)):
64+
event_dict["data_base64"] = base64.b64encode(event_data).decode("utf-8")
65+
else:
66+
datacontenttype = event_dict.get(
67+
"datacontenttype", JSONFormat.CONTENT_TYPE
68+
)
69+
if re.match(JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype):
70+
event_dict["data"] = dumps(event_data)
71+
else:
72+
event_dict["data"] = str(event_data)
73+
74+
return dumps(event_dict, cls=_JSONEncoderWithDatetime).encode("utf-8")

src/cloudevents/core/v1/event.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from datetime import datetime
1818
from typing import Any, Final, Optional
1919

20+
from cloudevents.core.base import BaseCloudEvent
2021
from cloudevents.core.v1.exceptions import (
2122
BaseCloudEventException,
2223
CloudEventValidationError,
@@ -35,7 +36,7 @@
3536
]
3637

3738

38-
class CloudEvent:
39+
class CloudEvent(BaseCloudEvent):
3940
"""
4041
The CloudEvent Python wrapper contract exposing generically-available
4142
properties and APIs.
@@ -322,3 +323,11 @@ def get_data(self) -> Optional[dict]:
322323
:return: The data of the event.
323324
"""
324325
return self._data
326+
327+
def get_attributes(self) -> dict[str, Any]:
328+
"""
329+
Retrieve all attributes of the event.
330+
331+
:return: The attributes of the event.
332+
"""
333+
return self._attributes
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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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, timezone
17+
18+
import pytest
19+
20+
from cloudevents.core.formats.json import JSONFormat
21+
from cloudevents.core.v1.event import CloudEvent
22+
23+
24+
def test_write_cloud_event_to_json_with_attributes_only() -> None:
25+
attributes = {
26+
"id": "123",
27+
"source": "source",
28+
"type": "type",
29+
"specversion": "1.0",
30+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
31+
"datacontenttype": "application/json",
32+
"dataschema": "http://example.com/schema",
33+
"subject": "test_subject",
34+
}
35+
event = CloudEvent(attributes=attributes, data=None)
36+
formatter = JSONFormat()
37+
result = formatter.write(event)
38+
39+
assert (
40+
result
41+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode(
42+
"utf-8"
43+
)
44+
)
45+
46+
47+
def test_write_cloud_event_to_json_with_data_as_json() -> None:
48+
attributes = {
49+
"id": "123",
50+
"source": "source",
51+
"type": "type",
52+
"specversion": "1.0",
53+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
54+
"datacontenttype": "application/json",
55+
"dataschema": "http://example.com/schema",
56+
"subject": "test_subject",
57+
}
58+
event = CloudEvent(attributes=attributes, data={"key": "value"})
59+
formatter = JSONFormat()
60+
result = formatter.write(event)
61+
62+
assert (
63+
result
64+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "{\'key\': \'value\'}"}'.encode(
65+
"utf-8"
66+
)
67+
)
68+
69+
70+
def test_write_cloud_event_to_json_with_data_as_bytes() -> None:
71+
attributes = {
72+
"id": "123",
73+
"source": "source",
74+
"type": "type",
75+
"specversion": "1.0",
76+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
77+
"datacontenttype": "application/json",
78+
"dataschema": "http://example.com/schema",
79+
"subject": "test_subject",
80+
}
81+
event = CloudEvent(attributes=attributes, data=b"test")
82+
formatter = JSONFormat()
83+
result = formatter.write(event)
84+
85+
assert (
86+
result
87+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode(
88+
"utf-8"
89+
)
90+
)
91+
92+
93+
def test_write_cloud_event_to_json_with_data_as_str_and_content_type_not_json() -> None:
94+
attributes = {
95+
"id": "123",
96+
"source": "source",
97+
"type": "type",
98+
"specversion": "1.0",
99+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
100+
"datacontenttype": "text/plain",
101+
"dataschema": "http://example.com/schema",
102+
"subject": "test_subject",
103+
}
104+
event = CloudEvent(attributes=attributes, data="test")
105+
formatter = JSONFormat()
106+
result = formatter.write(event)
107+
108+
assert (
109+
result
110+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "text/plain", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "test"}'.encode(
111+
"utf-8"
112+
)
113+
)
114+
115+
116+
def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_str() -> None:
117+
attributes = {
118+
"id": "123",
119+
"source": "source",
120+
"type": "type",
121+
"specversion": "1.0",
122+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
123+
"dataschema": "http://example.com/schema",
124+
"subject": "test_subject",
125+
}
126+
event = CloudEvent(attributes=attributes, data="I'm just a string")
127+
formatter = JSONFormat()
128+
result = formatter.write(event)
129+
130+
assert (
131+
result
132+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "I\'m just a string"}'.encode(
133+
"utf-8"
134+
)
135+
)
136+
137+
138+
def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_json() -> None:
139+
attributes = {
140+
"id": "123",
141+
"source": "source",
142+
"type": "type",
143+
"specversion": "1.0",
144+
"time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc),
145+
"dataschema": "http://example.com/schema",
146+
"subject": "test_subject",
147+
}
148+
event = CloudEvent(attributes=attributes, data={"key": "value"})
149+
formatter = JSONFormat()
150+
result = formatter.write(event)
151+
152+
assert (
153+
result
154+
== '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "{\'key\': \'value\'}"}'.encode(
155+
"utf-8"
156+
)
157+
)

0 commit comments

Comments
 (0)