Created CloudEvent class (#36)
CloudEvents is a more pythonic interface for using cloud events. It is powered by internal marshallers and cloud event base classes. It performs basic validation on fields, and cloud event type checking. Signed-off-by: Curtis Mason <cumason@google.com> Signed-off-by: Dustin Ingram <di@users.noreply.github.com>
This commit is contained in:
parent
d551dba58a
commit
0b8f56de92
|
@ -16,6 +16,21 @@ import io
|
||||||
import json
|
import json
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
_ce_required_fields = {
|
||||||
|
'id',
|
||||||
|
'source',
|
||||||
|
'type',
|
||||||
|
'specversion'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ce_optional_fields = {
|
||||||
|
'datacontenttype',
|
||||||
|
'schema',
|
||||||
|
'subject',
|
||||||
|
'time'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# TODO(slinkydeveloper) is this really needed?
|
# TODO(slinkydeveloper) is this really needed?
|
||||||
class EventGetterSetter(object):
|
class EventGetterSetter(object):
|
||||||
|
@ -117,6 +132,7 @@ class BaseEvent(EventGetterSetter):
|
||||||
|
|
||||||
def UnmarshalJSON(self, b: typing.IO, data_unmarshaller: typing.Callable):
|
def UnmarshalJSON(self, b: typing.IO, data_unmarshaller: typing.Callable):
|
||||||
raw_ce = json.load(b)
|
raw_ce = json.load(b)
|
||||||
|
|
||||||
for name, value in raw_ce.items():
|
for name, value in raw_ce.items():
|
||||||
if name == "data":
|
if name == "data":
|
||||||
value = data_unmarshaller(value)
|
value = data_unmarshaller(value)
|
||||||
|
@ -134,7 +150,6 @@ class BaseEvent(EventGetterSetter):
|
||||||
self.SetContentType(value)
|
self.SetContentType(value)
|
||||||
elif header.startswith("ce-"):
|
elif header.startswith("ce-"):
|
||||||
self.Set(header[3:], value)
|
self.Set(header[3:], value)
|
||||||
|
|
||||||
self.Set("data", data_unmarshaller(body))
|
self.Set("data", data_unmarshaller(body))
|
||||||
|
|
||||||
def MarshalBinary(
|
def MarshalBinary(
|
||||||
|
|
|
@ -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": "<event-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
|
||||||
|
)
|
|
@ -23,7 +23,7 @@ body = '{"name":"john"}'
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
v03.Event: {
|
v03.Event: {
|
||||||
"ce-specversion": "0.3",
|
"ce-specversion": "1.0",
|
||||||
"ce-type": ce_type,
|
"ce-type": ce_type,
|
||||||
"ce-id": ce_id,
|
"ce-id": ce_id,
|
||||||
"ce-time": eventTime,
|
"ce-time": eventTime,
|
||||||
|
@ -42,7 +42,7 @@ headers = {
|
||||||
|
|
||||||
json_ce = {
|
json_ce = {
|
||||||
v03.Event: {
|
v03.Event: {
|
||||||
"specversion": "0.3",
|
"specversion": "1.0",
|
||||||
"type": ce_type,
|
"type": ce_type,
|
||||||
"id": ce_id,
|
"id": ce_id,
|
||||||
"time": eventTime,
|
"time": eventTime,
|
||||||
|
|
|
@ -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": "<event-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": "<event-source>",
|
||||||
|
"ce-specversion": "1.0"
|
||||||
|
}, {
|
||||||
|
"ce-id": "my-id",
|
||||||
|
"ce-source": "<event-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": "<event-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": "<event-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}"
|
|
@ -7,4 +7,4 @@ pytest==4.0.0
|
||||||
pytest-cov==2.4.0
|
pytest-cov==2.4.0
|
||||||
# web app tests
|
# web app tests
|
||||||
sanic
|
sanic
|
||||||
aiohttp
|
aiohttp
|
||||||
|
|
|
@ -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 "
|
||||||
|
"<CloudEvents controller URL>")
|
||||||
|
|
||||||
|
url = sys.argv[1]
|
||||||
|
|
||||||
|
# CloudEvent headers and data
|
||||||
|
headers = {
|
||||||
|
"ce-id": "my-id",
|
||||||
|
"ce-source": "<event-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)
|
|
@ -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)
|
Loading…
Reference in New Issue