diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..39e7196a --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,16 @@ +## Expected Behavior + + +## Actual Behavior + + +## Steps to Reproduce the Problem + +1. +2. +3. + +## Specifications + +- Platform: +- Python Version: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 626357dd..75a0e8ff 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,10 @@ -- Link to issue this resolves +Fixes # -- What I did +## Changes -- How I did it -- How to verify it +## One line description for the changelog -- One line description for the changelog + +- [ ] Tests pass +- [ ] Appropriate changes to README are included in PR diff --git a/README.md b/README.md index df5d7b28..5e392270 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,9 @@ This SDK is still considered a work in progress, therefore things might (and will) break with every update. This SDK current supports the following versions of CloudEvents: + - v1.0 - v0.3 -- v0.2 -- v0.1 ## Python SDK @@ -116,7 +115,6 @@ In this topic you'd find various example how to integrate an SDK with various HT One of popular framework is [`requests`](http://docs.python-requests.org/en/master/). - #### CloudEvent to request The code below shows how integrate both libraries in order to convert a CloudEvent into an HTTP request: @@ -155,27 +153,29 @@ Complete example of turning a CloudEvent into a request you can find [here](samp #### Request to CloudEvent The code below shows how integrate both libraries in order to create a CloudEvent from an HTTP request: + ```python response = requests.get(url) response.raise_for_status() headers = response.headers data = io.BytesIO(response.content) - event = v02.Event() + event = v1.Event() http_marshaller = marshaller.NewDefaultHTTPMarshaller() event = http_marshaller.FromRequest( event, headers, data, json.load) ``` -Complete example of turning a CloudEvent into a request you can find [here](samples/python-requests/request_to_cloudevent.py). +Complete example of turning a CloudEvent into a request you can find [here](samples/python-requests/request_to_cloudevent.py). ## SDK versioning The goal of this package is to provide support for all released versions of CloudEvents, ideally while maintaining the same API. It will use semantic versioning with following rules: -* MAJOR version increments when backwards incompatible changes is introduced. -* MINOR version increments when backwards compatible feature is introduced INCLUDING support for new CloudEvents version. -* PATCH version increments when a backwards compatible bug fix is introduced. + +- MAJOR version increments when backwards incompatible changes is introduced. +- MINOR version increments when backwards compatible feature is introduced INCLUDING support for new CloudEvents version. +- PATCH version increments when a backwards compatible bug fix is introduced. ## Community diff --git a/cloudevents/sdk/converters/binary.py b/cloudevents/sdk/converters/binary.py index 97c4e440..7bc0025e 100644 --- a/cloudevents/sdk/converters/binary.py +++ b/cloudevents/sdk/converters/binary.py @@ -17,13 +17,13 @@ from cloudevents.sdk import exceptions from cloudevents.sdk.converters import base from cloudevents.sdk.event import base as event_base -from cloudevents.sdk.event import v02, v03, v1 +from cloudevents.sdk.event import v03, v1 class BinaryHTTPCloudEventConverter(base.Converter): TYPE = "binary" - SUPPORTED_VERSIONS = [v02.Event, v03.Event, v1.Event] + SUPPORTED_VERSIONS = [v03.Event, v1.Event] def can_read(self, content_type: str) -> bool: return True diff --git a/cloudevents/sdk/event/base.py b/cloudevents/sdk/event/base.py index d392ae8b..a8bb099e 100644 --- a/cloudevents/sdk/event/base.py +++ b/cloudevents/sdk/event/base.py @@ -16,6 +16,21 @@ import json import typing +_ce_required_fields = { + 'id', + 'source', + 'type', + 'specversion' +} + + +_ce_optional_fields = { + 'datacontenttype', + 'schema', + 'subject', + 'time' +} + # TODO(slinkydeveloper) is this really needed? class EventGetterSetter(object): @@ -117,6 +132,7 @@ def MarshalJSON(self, data_marshaller: typing.Callable) -> typing.IO: def UnmarshalJSON(self, b: typing.IO, data_unmarshaller: typing.Callable): raw_ce = json.load(b) + for name, value in raw_ce.items(): if name == "data": value = data_unmarshaller(value) @@ -134,7 +150,6 @@ def UnmarshalBinary( self.SetContentType(value) elif header.startswith("ce-"): self.Set(header[3:], value) - self.Set("data", data_unmarshaller(body)) def MarshalBinary( diff --git a/cloudevents/sdk/event/v01.py b/cloudevents/sdk/event/v01.py deleted file mode 100644 index 5192d8f8..00000000 --- a/cloudevents/sdk/event/v01.py +++ /dev/null @@ -1,137 +0,0 @@ -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from cloudevents.sdk.event import base -from cloudevents.sdk.event import opt - - -class Event(base.BaseEvent): - def __init__(self): - self.ce__cloudEventsVersion = opt.Option( - "cloudEventsVersion", - "0.1", - True - ) - self.ce__eventType = opt.Option( - "eventType", - None, - True - ) - self.ce__eventTypeVersion = opt.Option( - "eventTypeVersion", - None, - False - ) - self.ce__source = opt.Option( - "source", - None, - True - ) - self.ce__eventID = opt.Option( - "eventID", - None, - True - ) - self.ce__eventTime = opt.Option( - "eventTime", - None, - True - ) - self.ce__schemaURL = opt.Option( - "schemaURL", - None, - False - ) - self.ce__contentType = opt.Option( - "contentType", - None, - False - ) - self.ce__data = opt.Option( - "data", - None, - False - ) - self.ce__extensions = opt.Option( - "extensions", - dict(), - False - ) - - def CloudEventVersion(self) -> str: - return self.ce__cloudEventsVersion.get() - - def EventType(self) -> str: - return self.ce__eventType.get() - - def Source(self) -> str: - return self.ce__source.get() - - def EventID(self) -> str: - return self.ce__eventID.get() - - def EventTime(self) -> str: - return self.ce__eventTime.get() - - def SchemaURL(self) -> str: - return self.ce__schemaURL.get() - - def Data(self) -> object: - return self.ce__data.get() - - def Extensions(self) -> dict: - return self.ce__extensions.get() - - def ContentType(self) -> str: - return self.ce__contentType.get() - - def SetEventType(self, eventType: str) -> base.BaseEvent: - self.Set("eventType", eventType) - return self - - def SetSource(self, source: str) -> base.BaseEvent: - self.Set("source", source) - return self - - def SetEventID(self, eventID: str) -> base.BaseEvent: - self.Set("eventID", eventID) - return self - - def SetEventTime(self, eventTime: str) -> base.BaseEvent: - self.Set("eventTime", eventTime) - return self - - def SetSchemaURL(self, schemaURL: str) -> base.BaseEvent: - self.Set("schemaURL", schemaURL) - return self - - def SetData(self, data: object) -> base.BaseEvent: - self.Set("data", data) - return self - - def SetExtensions(self, extensions: dict) -> base.BaseEvent: - self.Set("extensions", extensions) - return self - - def SetContentType(self, contentType: str) -> base.BaseEvent: - self.Set("contentType", contentType) - return self - - # additional getter/setter - def EventTypeVersion(self) -> str: - return self.ce__eventTypeVersion.get() - - def WithEventTypeVersion(self, eventTypeVersion: str) -> base.BaseEvent: - self.Set("eventTypeVersion", eventTypeVersion) - return self diff --git a/cloudevents/sdk/event/v02.py b/cloudevents/sdk/event/v02.py deleted file mode 100644 index f2da7929..00000000 --- a/cloudevents/sdk/event/v02.py +++ /dev/null @@ -1,88 +0,0 @@ -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from cloudevents.sdk.event import base -from cloudevents.sdk.event import opt - - -class Event(base.BaseEvent): - def __init__(self): - self.ce__specversion = opt.Option("specversion", "0.2", True) - self.ce__type = opt.Option("type", None, True) - self.ce__source = opt.Option("source", None, True) - self.ce__id = opt.Option("id", None, True) - self.ce__time = opt.Option("time", None, True) - self.ce__schemaurl = opt.Option("schemaurl", None, False) - self.ce__contenttype = opt.Option("contenttype", None, False) - self.ce__data = opt.Option("data", None, False) - self.ce__extensions = opt.Option("extensions", dict(), False) - - def CloudEventVersion(self) -> str: - return self.ce__specversion.get() - - def EventType(self) -> str: - return self.ce__type.get() - - def Source(self) -> str: - return self.ce__source.get() - - def EventID(self) -> str: - return self.ce__id.get() - - def EventTime(self) -> str: - return self.ce__time.get() - - def SchemaURL(self) -> str: - return self.ce__schemaurl.get() - - def Data(self) -> object: - return self.ce__data.get() - - def Extensions(self) -> dict: - return self.ce__extensions.get() - - def ContentType(self) -> str: - return self.ce__contenttype.get() - - def SetEventType(self, eventType: str) -> base.BaseEvent: - self.Set("type", eventType) - return self - - def SetSource(self, source: str) -> base.BaseEvent: - self.Set("source", source) - return self - - def SetEventID(self, eventID: str) -> base.BaseEvent: - self.Set("id", eventID) - return self - - def SetEventTime(self, eventTime: str) -> base.BaseEvent: - self.Set("time", eventTime) - return self - - def SetSchemaURL(self, schemaURL: str) -> base.BaseEvent: - self.Set("schemaurl", schemaURL) - return self - - def SetData(self, data: object) -> base.BaseEvent: - self.Set("data", data) - return self - - def SetExtensions(self, extensions: dict) -> base.BaseEvent: - self.Set("extensions", extensions) - return self - - def SetContentType(self, contentType: str) -> base.BaseEvent: - self.Set("contenttype", contentType) - return self diff --git a/cloudevents/sdk/http_events.py b/cloudevents/sdk/http_events.py new file mode 100644 index 00000000..4c5de1c2 --- /dev/null +++ b/cloudevents/sdk/http_events.py @@ -0,0 +1,136 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import copy + +import json +import typing + +from cloudevents.sdk import marshaller + +from cloudevents.sdk.event import base +from cloudevents.sdk.event import v03, v1 + + +class CloudEvent(base.BaseEvent): + """ + Python-friendly cloudevent class supporting v1 events + Currently only supports binary content mode CloudEvents + """ + + def __init__( + self, + headers: dict, + data: dict, + data_unmarshaller: typing.Callable = lambda x: x + ): + """ + Event HTTP Constructor + :param headers: a dict with HTTP headers + e.g. { + "content-type": "application/cloudevents+json", + "ce-id": "16fb5f0b-211e-1102-3dfe-ea6e2806f124", + "ce-source": "", + "ce-type": "cloudevent.event.type", + "ce-specversion": "0.2" + } + :type headers: dict + :param data: a dict to be stored inside Event + :type data: dict + :param binary: a bool indicating binary events + :type binary: bool + :param data_unmarshaller: callable function for reading/extracting data + :type data_unmarshaller: typing.Callable + """ + headers = {key.lower(): value for key, value in headers.items()} + data = {key.lower(): value for key, value in data.items()} + event_version = CloudEvent.detect_event_version(headers, data) + if CloudEvent.is_binary_cloud_event(headers): + + # Headers validation for binary events + for field in base._ce_required_fields: + ce_prefixed_field = f"ce-{field}" + + # Verify field exists else throw TypeError + if ce_prefixed_field not in headers: + raise TypeError( + "parameter headers has no required attribute {0}" + .format( + ce_prefixed_field + )) + + if not isinstance(headers[ce_prefixed_field], str): + raise TypeError( + "in parameter headers attribute " + "{0} expected type str but found type {1}".format( + ce_prefixed_field, type(headers[ce_prefixed_field]) + )) + + for field in base._ce_optional_fields: + ce_prefixed_field = f"ce-{field}" + if ce_prefixed_field in headers and not \ + isinstance(headers[ce_prefixed_field], str): + raise TypeError( + "in parameter headers attribute " + "{0} expected type str but found type {1}".format( + ce_prefixed_field, type(headers[ce_prefixed_field]) + )) + + else: + # TODO: Support structured CloudEvents + raise NotImplementedError + + self.headers = copy.deepcopy(headers) + self.data = copy.deepcopy(data) + self.marshall = marshaller.NewDefaultHTTPMarshaller() + self.event_handler = event_version() + self.marshall.FromRequest( + self.event_handler, + self.headers, + self.data, + data_unmarshaller + ) + + @staticmethod + def is_binary_cloud_event(headers): + for field in base._ce_required_fields: + if f"ce-{field}" not in headers: + return False + return True + + @staticmethod + def detect_event_version(headers, data): + """ + Returns event handler depending on specversion within + headers for binary cloudevents or within data for structured + cloud events + """ + specversion = headers.get('ce-specversion', data.get('specversion')) + if specversion == '1.0': + return v1.Event + elif specversion == '0.3': + return v03.Event + else: + raise TypeError(f"specversion {specversion} " + "currently unsupported") + + def __repr__(self): + return json.dumps( + { + 'Event': { + 'headers': self.headers, + 'data': self.data + } + }, + indent=4 + ) diff --git a/cloudevents/tests/data.py b/cloudevents/tests/data.py index e1d615f6..ffe63aee 100644 --- a/cloudevents/tests/data.py +++ b/cloudevents/tests/data.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.sdk.event import v02, v03, v1 +from cloudevents.sdk.event import v03, v1 contentType = "application/json" ce_type = "word.found.exclamation" @@ -22,16 +22,8 @@ body = '{"name":"john"}' headers = { - v02.Event: { - "ce-specversion": "0.2", - "ce-type": ce_type, - "ce-id": ce_id, - "ce-time": eventTime, - "ce-source": source, - "Content-Type": contentType, - }, v03.Event: { - "ce-specversion": "0.3", + "ce-specversion": "1.0", "ce-type": ce_type, "ce-id": ce_id, "ce-time": eventTime, @@ -49,16 +41,8 @@ } json_ce = { - v02.Event: { - "specversion": "0.2", - "type": ce_type, - "id": ce_id, - "time": eventTime, - "source": source, - "contenttype": contentType, - }, v03.Event: { - "specversion": "0.3", + "specversion": "1.0", "type": ce_type, "id": ce_id, "time": eventTime, diff --git a/cloudevents/tests/test_event_from_request_converter.py b/cloudevents/tests/test_event_from_request_converter.py index 76930c5e..65a89703 100644 --- a/cloudevents/tests/test_event_from_request_converter.py +++ b/cloudevents/tests/test_event_from_request_converter.py @@ -19,8 +19,6 @@ from cloudevents.sdk import exceptions from cloudevents.sdk import marshaller -from cloudevents.sdk.event import v01 -from cloudevents.sdk.event import v02 from cloudevents.sdk.event import v03 from cloudevents.sdk.event import v1 @@ -30,18 +28,23 @@ from cloudevents.tests import data -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_binary_converter_upstream(event_class): m = marshaller.NewHTTPMarshaller( [binary.NewBinaryHTTPCloudEventConverter()]) - event = m.FromRequest(event_class(), data.headers[event_class], None, lambda x: x) + event = m.FromRequest( + event_class(), + data.headers[event_class], + None, + lambda x: x + ) assert event is not None assert event.EventType() == data.ce_type assert event.EventID() == data.ce_id assert event.ContentType() == data.contentType -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_structured_converter_upstream(event_class): m = marshaller.NewHTTPMarshaller( [structured.NewJSONHTTPCloudEventConverter()]) @@ -58,49 +61,7 @@ def test_structured_converter_upstream(event_class): assert event.ContentType() == data.contentType -def test_binary_converter_v01(): - m = marshaller.NewHTTPMarshaller( - [binary.NewBinaryHTTPCloudEventConverter()]) - - pytest.raises( - exceptions.UnsupportedEventConverter, - m.FromRequest, - v01.Event, - {}, - None, - lambda x: x, - ) - - -def test_unsupported_converter_v01(): - m = marshaller.NewHTTPMarshaller( - [structured.NewJSONHTTPCloudEventConverter()]) - - pytest.raises( - exceptions.UnsupportedEventConverter, - m.FromRequest, - v01.Event, - {}, - None, - lambda x: x, - ) - - -def test_structured_converter_v01(): - m = marshaller.NewHTTPMarshaller( - [structured.NewJSONHTTPCloudEventConverter()]) - event = m.FromRequest( - v01.Event(), - {"Content-Type": "application/cloudevents+json"}, - io.StringIO(json.dumps(data.json_ce[v02.Event])), - lambda x: x.read(), - ) - - assert event is not None - assert event.Get("type") == (data.ce_type, True) - assert event.Get("id") == (data.ce_id, True) - -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_default_http_marshaller_with_structured(event_class): m = marshaller.NewDefaultHTTPMarshaller() @@ -116,7 +77,7 @@ def test_default_http_marshaller_with_structured(event_class): assert event.ContentType() == data.contentType -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_default_http_marshaller_with_binary(event_class): m = marshaller.NewDefaultHTTPMarshaller() @@ -130,32 +91,3 @@ def test_default_http_marshaller_with_binary(event_class): assert event.EventID() == data.ce_id assert event.ContentType() == data.contentType assert event.Data() == data.body - - -def test_unsupported_event_configuration(): - m = marshaller.NewHTTPMarshaller( - [binary.NewBinaryHTTPCloudEventConverter()]) - pytest.raises( - exceptions.UnsupportedEventConverter, - m.FromRequest, - v01.Event(), - {"Content-Type": "application/cloudevents+json"}, - io.StringIO(json.dumps(data.json_ce[v02.Event])), - lambda x: x.read(), - ) - - -def test_invalid_data_unmarshaller(): - m = marshaller.NewDefaultHTTPMarshaller() - pytest.raises( - exceptions.InvalidDataUnmarshaller, - m.FromRequest, - v01.Event(), {}, None, None - ) - - -def test_invalid_data_marshaller(): - m = marshaller.NewDefaultHTTPMarshaller() - pytest.raises( - exceptions.InvalidDataMarshaller, m.ToRequest, v01.Event(), "blah", None - ) diff --git a/cloudevents/tests/test_event_pipeline.py b/cloudevents/tests/test_event_pipeline.py index 554d8b29..09f029b2 100644 --- a/cloudevents/tests/test_event_pipeline.py +++ b/cloudevents/tests/test_event_pipeline.py @@ -16,7 +16,7 @@ import json import pytest -from cloudevents.sdk.event import v01, v02, v03, v1 +from cloudevents.sdk.event import v03, v1 from cloudevents.sdk import converters from cloudevents.sdk import marshaller @@ -24,7 +24,8 @@ from cloudevents.tests import data -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) + +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_event_pipeline_upstream(event_class): event = ( event_class() @@ -52,37 +53,12 @@ def test_event_pipeline_upstream(event_class): def test_extensions_are_set_upstream(): extensions = {'extension-key': 'extension-value'} event = ( - v02.Event() + v1.Event() .SetExtensions(extensions) ) m = marshaller.NewDefaultHTTPMarshaller() - new_headers, body = m.ToRequest(event, converters.TypeBinary, lambda x: x) + new_headers, _ = m.ToRequest(event, converters.TypeBinary, lambda x: x) assert event.Extensions() == extensions assert "ce-extension-key" in new_headers - - -def test_event_pipeline_v01(): - event = ( - v01.Event() - .SetContentType(data.contentType) - .SetData(data.body) - .SetEventID(data.ce_id) - .SetSource(data.source) - .SetEventTime(data.eventTime) - .SetEventType(data.ce_type) - ) - m = marshaller.NewHTTPMarshaller([structured.NewJSONHTTPCloudEventConverter()]) - - _, body = m.ToRequest(event, converters.TypeStructured, lambda x: x) - assert isinstance(body, io.BytesIO) - new_headers = json.load(io.TextIOWrapper(body, encoding="utf-8")) - assert new_headers is not None - assert "cloudEventsVersion" in new_headers - assert "eventType" in new_headers - assert "source" in new_headers - assert "eventID" in new_headers - assert "eventTime" in new_headers - assert "contentType" in new_headers - assert data.body == new_headers["data"] diff --git a/cloudevents/tests/test_event_to_request_converter.py b/cloudevents/tests/test_event_to_request_converter.py index 0719035f..06f2e679 100644 --- a/cloudevents/tests/test_event_to_request_converter.py +++ b/cloudevents/tests/test_event_to_request_converter.py @@ -21,14 +21,12 @@ from cloudevents.sdk import marshaller from cloudevents.sdk.converters import structured -from cloudevents.sdk.event import v01, v02, v03, v1 -from cloudevents.sdk.event import v02 - +from cloudevents.sdk.event import v03, v1 from cloudevents.tests import data -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_binary_event_to_request_upstream(event_class): m = marshaller.NewDefaultHTTPMarshaller() event = m.FromRequest( @@ -48,13 +46,18 @@ def test_binary_event_to_request_upstream(event_class): assert "ce-specversion" in new_headers -@pytest.mark.parametrize("event_class", [v02.Event, v03.Event, v1.Event]) +@pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_structured_event_to_request_upstream(event_class): copy_of_ce = copy.deepcopy(data.json_ce[event_class]) m = marshaller.NewDefaultHTTPMarshaller() http_headers = {"content-type": "application/cloudevents+json"} event = m.FromRequest( - event_class(), http_headers, io.StringIO(json.dumps(data.json_ce[event_class])), lambda x: x.read() + event_class(), + http_headers, + io.StringIO( + json.dumps(data.json_ce[event_class]) + ), + lambda x: x.read() ) assert event is not None assert event.EventType() == data.ce_type @@ -67,22 +70,3 @@ def test_structured_event_to_request_upstream(event_class): assert new_headers[key] == http_headers[key] continue assert key in copy_of_ce - - -def test_structured_event_to_request_v01(): - copy_of_ce = copy.deepcopy(data.json_ce[v02.Event]) - m = marshaller.NewHTTPMarshaller([structured.NewJSONHTTPCloudEventConverter()]) - http_headers = {"content-type": "application/cloudevents+json"} - event = m.FromRequest( - v01.Event(), http_headers, io.StringIO(json.dumps(data.json_ce[v02.Event])), lambda x: x.read() - ) - assert event is not None - assert event.Get("type") == (data.ce_type, True) - assert event.Get("id") == (data.ce_id, True) - - new_headers, _ = m.ToRequest(event, converters.TypeStructured, lambda x: x) - for key in new_headers: - if key == "content-type": - assert new_headers[key] == http_headers[key] - continue - assert key in copy_of_ce diff --git a/cloudevents/tests/test_http_events.py b/cloudevents/tests/test_http_events.py new file mode 100644 index 00000000..943e219e --- /dev/null +++ b/cloudevents/tests/test_http_events.py @@ -0,0 +1,146 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import json + +import copy + +from cloudevents.sdk.http_events import CloudEvent + +from sanic import response +from sanic import Sanic + +import pytest + + +invalid_test_headers = [ + { + "ce-source": "", + "ce-type": "cloudevent.event.type", + "ce-specversion": "1.0" + }, { + "ce-id": "my-id", + "ce-type": "cloudevent.event.type", + "ce-specversion": "1.0" + }, { + "ce-id": "my-id", + "ce-source": "", + "ce-specversion": "1.0" + }, { + "ce-id": "my-id", + "ce-source": "", + "ce-type": "cloudevent.event.type", + } +] + +test_data = { + "payload-content": "Hello World!" +} + +app = Sanic(__name__) + + +def post(url, headers, json): + return app.test_client.post(url, headers=headers, data=json) + + +@app.route("/event", ["POST"]) +async def echo(request): + assert isinstance(request.json, dict) + event = CloudEvent(dict(request.headers), request.json) + return response.text(json.dumps(event.data), headers=event.headers) + + +@pytest.mark.parametrize("headers", invalid_test_headers) +def test_invalid_binary_headers(headers): + with pytest.raises((TypeError, NotImplementedError)): + # CloudEvent constructor throws TypeError if missing required field + # and NotImplementedError because structured calls aren't + # implemented. In this instance one of the required keys should have + # prefix e-id instead of ce-id therefore it should throw + _ = CloudEvent(headers, test_data) + + +@pytest.mark.parametrize("specversion", ['1.0', '0.3']) +def test_emit_binary_event(specversion): + headers = { + "ce-id": "my-id", + "ce-source": "", + "ce-type": "cloudevent.event.type", + "ce-specversion": specversion, + "Content-Type": "application/json" + } + event = CloudEvent(headers, test_data) + _, r = app.test_client.post( + "/event", + headers=event.headers, + data=json.dumps(event.data) + ) + + # Convert byte array to dict + # e.g. r.body = b'{"payload-content": "Hello World!"}' + body = json.loads(r.body.decode('utf-8')) + + # Check response fields + for key in test_data: + assert body[key] == test_data[key] + for key in headers: + assert r.headers[key] == headers[key] + assert r.status_code == 200 + + +@pytest.mark.parametrize("specversion", ['1.0', '0.3']) +def test_missing_ce_prefix_binary_event(specversion): + headers = { + "ce-id": "my-id", + "ce-source": "", + "ce-type": "cloudevent.event.type", + "ce-specversion": specversion + } + for key in headers: + val = headers.pop(key) + + # breaking prefix e.g. e-id instead of ce-id + headers[key[1:]] = val + with pytest.raises((TypeError, NotImplementedError)): + # CloudEvent constructor throws TypeError if missing required field + # and NotImplementedError because structured calls aren't + # implemented. In this instance one of the required keys should have + # prefix e-id instead of ce-id therefore it should throw + _ = CloudEvent(headers, test_data) + + +@pytest.mark.parametrize("specversion", ['1.0', '0.3']) +def test_valid_cloud_events(specversion): + # Test creating multiple cloud events + events_queue = [] + headers = {} + num_cloudevents = 30 + for i in range(num_cloudevents): + headers = { + "ce-id": f"id{i}", + "ce-source": f"source{i}.com.test", + "ce-type": f"cloudevent.test.type", + "ce-specversion": specversion + } + data = {'payload': f"payload-{i}"} + events_queue.append(CloudEvent(headers, data)) + + for i, event in enumerate(events_queue): + headers = event.headers + data = event.data + + assert headers['ce-id'] == f"id{i}" + assert headers['ce-source'] == f"source{i}.com.test" + assert headers['ce-specversion'] == specversion + assert data['payload'] == f"payload-{i}" diff --git a/cloudevents/tests/test_with_sanic.py b/cloudevents/tests/test_with_sanic.py index ca6f68e8..2fd99337 100644 --- a/cloudevents/tests/test_with_sanic.py +++ b/cloudevents/tests/test_with_sanic.py @@ -14,7 +14,7 @@ from cloudevents.sdk import marshaller from cloudevents.sdk import converters -from cloudevents.sdk.event import v02 +from cloudevents.sdk.event import v1 from sanic import Sanic from sanic import response @@ -29,7 +29,7 @@ @app.route("/is-ok", ["POST"]) async def is_ok(request): m.FromRequest( - v02.Event(), + v1.Event(), dict(request.headers), request.body, lambda x: x @@ -40,7 +40,7 @@ async def is_ok(request): @app.route("/echo", ["POST"]) async def echo(request): event = m.FromRequest( - v02.Event(), + v1.Event(), dict(request.headers), request.body, lambda x: x @@ -50,28 +50,29 @@ async def echo(request): def test_reusable_marshaller(): - for i in range(10): + for _ in range(10): _, r = app.test_client.post( - "/is-ok", headers=test_data.headers[v02.Event], data=test_data.body + "/is-ok", headers=test_data.headers[v1.Event], data=test_data.body ) assert r.status == 200 def test_web_app_integration(): _, r = app.test_client.post( - "/is-ok", headers=test_data.headers[v02.Event], data=test_data.body + "/is-ok", headers=test_data.headers[v1.Event], data=test_data.body ) assert r.status == 200 def test_web_app_echo(): - _, r = app.test_client.post("/echo", headers=test_data.headers[v02.Event], data=test_data.body) + _, r = app.test_client.post( + "/echo", headers=test_data.headers[v1.Event], data=test_data.body) assert r.status == 200 - event = m.FromRequest(v02.Event(), dict(r.headers), r.body, lambda x: x) + event = m.FromRequest(v1.Event(), dict(r.headers), r.body, lambda x: x) assert event is not None props = event.Properties() - for key in test_data.headers[v02.Event].keys(): + for key in test_data.headers[v1.Event].keys(): if key == "Content-Type": - assert "contenttype" in props + assert "datacontenttype" in props else: assert key.lstrip("ce-") in props diff --git a/requirements/test.txt b/requirements/test.txt index e9df186e..12894086 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -7,4 +7,4 @@ pytest==4.0.0 pytest-cov==2.4.0 # web app tests sanic -aiohttp \ No newline at end of file +aiohttp diff --git a/samples/python-event-requests/cloudevent_to_request.py b/samples/python-event-requests/cloudevent_to_request.py new file mode 100644 index 00000000..4b9b5678 --- /dev/null +++ b/samples/python-event-requests/cloudevent_to_request.py @@ -0,0 +1,44 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import sys +import io +from cloudevents.sdk.http_events import CloudEvent + +import requests + +if __name__ == "__main__": + # expects a url from command line. e.g. + # python3 sample-server.py http://localhost:3000/event + if len(sys.argv) < 2: + sys.exit("Usage: python with_requests.py " + "") + + url = sys.argv[1] + + # CloudEvent headers and data + headers = { + "ce-id": "my-id", + "ce-source": "", + "ce-type": "cloudevent.event.type", + "ce-specversion": "1.0" + } + data = {"payload-content": "Hello World!"} + + # Create a CloudEvent + event = CloudEvent(headers=headers, data=data) + + # Print the created CloudEvent then send it to some url we got from + # command line + print(f"Sent {event}") + requests.post(url, headers=event.headers, json=event.data) diff --git a/samples/python-event-requests/sample-server.py b/samples/python-event-requests/sample-server.py new file mode 100644 index 00000000..fd9f1870 --- /dev/null +++ b/samples/python-event-requests/sample-server.py @@ -0,0 +1,34 @@ +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from cloudevents.sdk.http_events import CloudEvent +from flask import Flask, request +app = Flask(__name__) + + +# Create an endpoint at http://localhost:/3000/event +@app.route('/event', methods=['POST']) +def hello(): + # Convert headers to dict + headers = dict(request.headers) + + # Create a CloudEvent + event = CloudEvent(headers=headers, data=request.json) + + # Print the received CloudEvent + print(f"Received {event}") + return '', 204 + + +if __name__ == '__main__': + app.run(port=3000) diff --git a/samples/python-requests/cloudevent_to_request.py b/samples/python-requests/cloudevent_to_request.py index 4b0f4acf..0ae1d113 100644 --- a/samples/python-requests/cloudevent_to_request.py +++ b/samples/python-requests/cloudevent_to_request.py @@ -19,7 +19,7 @@ from cloudevents.sdk import converters from cloudevents.sdk import marshaller -from cloudevents.sdk.event import v02 +from cloudevents.sdk.event import v1 def run_binary(event, url): @@ -60,7 +60,7 @@ def run_structured(event, url): http_marshaller = marshaller.NewDefaultHTTPMarshaller() event = ( - v02.Event(). + v1.Event(). SetContentType("application/json"). SetData({"name": "denis"}). SetEventID("my-id"). diff --git a/samples/python-requests/request_to_cloudevent.py b/samples/python-requests/request_to_cloudevent.py index 11d3cc72..0ec7e8d2 100644 --- a/samples/python-requests/request_to_cloudevent.py +++ b/samples/python-requests/request_to_cloudevent.py @@ -19,7 +19,7 @@ from cloudevents.sdk import marshaller -from cloudevents.sdk.event import v02 +from cloudevents.sdk.event import v1 if __name__ == "__main__": @@ -33,7 +33,7 @@ response.raise_for_status() headers = response.headers data = io.BytesIO(response.content) - event = v02.Event() + event = v1.Event() http_marshaller = marshaller.NewDefaultHTTPMarshaller() event = http_marshaller.FromRequest( event, headers, data, json.load)