Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Cloud Events support for #55 #56

Merged
merged 50 commits into from
Jun 19, 2020
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
86723ec
Ignore IDE files.
Jun 6, 2020
b0cd71a
Use the test file directory as a basis instead of cwd. Allows tests t…
joelgerard Jun 6, 2020
f1ebf4a
Merge branch 'master' of github.com:joelgerard/functions-framework-py…
Jun 8, 2020
64d8e3d
Add support for Cloud Events. Rough draft.
joelgerard Jun 9, 2020
bbf6d35
Return the functions return value. Test Cloud Events SDK 0.3. Add som…
joelgerard Jun 10, 2020
3bcb69d
Minor cleanup. Split test code.
joelgerard Jun 12, 2020
42ea896
Clean up unused paths, split large test files into two, ensure functi…
joelgerard Jun 15, 2020
4b3a10e
Merge branch 'master' of github.com:joelgerard/functions-framework-py…
joelgerard Jun 15, 2020
a1dddbb
Fix lint errors with black.
joelgerard Jun 15, 2020
4407259
Fix lint errors with black.
joelgerard Jun 15, 2020
47acff6
Merge remote-tracking branch 'origin/master'
joelgerard Jun 16, 2020
d40f4a0
Update setup.py
joelgerard Jun 16, 2020
1d83d3c
Update tests/test_cloudevent_functions.py
joelgerard Jun 16, 2020
7c46f60
Update tests/test_cloudevent_functions.py
joelgerard Jun 16, 2020
8832081
Update src/functions_framework/__init__.py
joelgerard Jun 16, 2020
7a16331
Update tests/test_functions/cloudevents/main.py
joelgerard Jun 16, 2020
16a6858
Clearer imports.
joelgerard Jun 16, 2020
bdbf5e2
don't factor out routes.
joelgerard Jun 16, 2020
ea2e28c
Add a TODO for testing the different combinations of events and signa…
joelgerard Jun 16, 2020
9e8d874
Add cloudevent as a signature type in the argument list.
joelgerard Jun 16, 2020
06ffc2d
Clarify import.
joelgerard Jun 16, 2020
ecb0b61
Clarify import.
joelgerard Jun 16, 2020
fa727f1
Merge remote-tracking branch 'origin/master'
joelgerard Jun 16, 2020
209a8d6
A sample that shows how to use a CloudEvent.
joelgerard Jun 16, 2020
f573a1e
In the case of a sig type / event type mismatch throw a 400
joelgerard Jun 16, 2020
7212640
Update the docs to use CloudEvent sig type instead of Event sig type.…
joelgerard Jun 16, 2020
974d52b
Lint fixes.
joelgerard Jun 16, 2020
9b87a6e
Tests for checking correct event type corresponds to correct function…
joelgerard Jun 16, 2020
8d33033
Sort imports.
joelgerard Jun 16, 2020
c297097
Remove old example.
joelgerard Jun 17, 2020
5176b8c
Readme to explain how to run the sample locally.
joelgerard Jun 17, 2020
f499790
Rename cloud_event to cloudevent
joelgerard Jun 17, 2020
973beaf
For legacy docs, add a notice to the new docs.
joelgerard Jun 17, 2020
30b9259
There is no 1.1 event type.
joelgerard Jun 17, 2020
48e939e
remove uneeded git installation.
joelgerard Jun 17, 2020
2532cec
use the term cloudevent rather than event everywhere where we are tal…
joelgerard Jun 17, 2020
7928d5d
Update examples/cloudevents/README.md
joelgerard Jun 17, 2020
a38b480
Update examples/cloudevents/README.md
joelgerard Jun 17, 2020
154de1c
Update examples/cloudevents/README.md
joelgerard Jun 17, 2020
2a92d9c
Update examples/cloudevents/main.py
joelgerard Jun 17, 2020
a2b3c67
Update tests/test_view_functions.py
joelgerard Jun 17, 2020
dbc44a3
Add legacy event back to docs.
joelgerard Jun 17, 2020
eafa781
Add legacy event back to docs.
joelgerard Jun 17, 2020
6ce6f63
Use abort from flask for consistency and fix return in event test.
joelgerard Jun 17, 2020
18432e8
Merge branch 'master' of github.com:joelgerard/functions-framework-py…
joelgerard Jun 17, 2020
c2ba64d
update docs and error messages to better mirror the other runtimes.
joelgerard Jun 17, 2020
6889f54
Minor fixes to docs w.r.t. naming.
joelgerard Jun 18, 2020
271e976
Update src/functions_framework/__init__.py
joelgerard Jun 18, 2020
7cb1d88
Fix enum per reviewer suggestion.
joelgerard Jun 18, 2020
2e34e8b
Rename text event => strucuture event.
joelgerard Jun 19, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"click>=7.0,<8.0",
"watchdog>=0.10.0",
"gunicorn>=19.2.0,<21.0; platform_system!='Windows'",
"cloudevents<=1.0",
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
],
extras_require={"test": ["pytest", "tox"]},
entry_points={
Expand Down
133 changes: 93 additions & 40 deletions src/functions_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import functools
from enum import Enum
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from enum import Enum
import enum

import importlib.util
import io
import json
import os.path
import pathlib
import sys
Expand All @@ -31,9 +33,18 @@
)
from google.cloud.functions.context import Context


from cloudevents.sdk.event import v1
from cloudevents.sdk import marshaller
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from cloudevents.sdk.event import v1
from cloudevents.sdk import marshaller
import cloudevents.sdk
import cloudevents.sdk.event

Also these imports should be moved up with the other 3rd party imports (flask/werkzeug)


DEFAULT_SOURCE = os.path.realpath("./main.py")
DEFAULT_SIGNATURE_TYPE = "http"

class _EventType(Enum):
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
LEGACY = 1
CLOUD_EVENT_BINARY = 2
CLOUD_EVENT_TEXT = 3
joelgerard marked this conversation as resolved.
Show resolved Hide resolved


class _Event(object):
"""Event passed to background functions."""
Expand All @@ -59,51 +70,100 @@ def __init__(
}
self.data = data


def _http_view_func_wrapper(function, request):
def view_func(path):
return function(request._get_current_object())

return view_func


def _is_binary_cloud_event(request):
return (
request.headers.get("ce-type")
and request.headers.get("ce-specversion")
and request.headers.get("ce-source")
and request.headers.get("ce-id")
)
def _get_cloud_event_version():
return v1.Event()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- def _get_cloud_event_version():
-     return v1.Event()
+ def _get_cloud_event_version(request):
+     ...

We should be able to infer the event version from the request. If it's not a V1 event, I'm not sure what should happen (maybe abort(400)?), but this would be the right place to check it.



def _run_legacy_event(function, request):
event_data = request.get_json()
if not event_data:
flask.abort(400)
event_object = _Event(**event_data)
data = event_object.data
context = Context(**event_object.context)
function(data, context)


def _run_binary_cloud_event(function, request, cloud_event_def):
data = io.BytesIO(request.get_data())
http_marshaller = marshaller.NewDefaultHTTPMarshaller()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
http_marshaller = marshaller.NewDefaultHTTPMarshaller()
http_marshaller = cloudevent.sdk.marshaller.NewDefaultHTTPMarshaller()

event = http_marshaller.FromRequest(
cloud_event_def, request.headers, data, json.load)

function(event)


def _run_text_cloud_event(function, request, cloud_event_def):
data = io.StringIO(request.get_data(as_text=True))
m = marshaller.NewDefaultHTTPMarshaller()
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
event = m.FromRequest(cloud_event_def, request.headers, data, json.loads)
function(event)


def _get_event_type(request):
if request.headers.get("ce-type") \
and request.headers.get("ce-specversion") \
and request.headers.get("ce-source") \
and request.headers.get("ce-id"):
return _EventType.CLOUD_EVENT_BINARY
elif request.headers.get("Content-Type") == "application/cloudevents+json":
return _EventType.CLOUD_EVENT_TEXT
else:
return _EventType.LEGACY


def _event_view_func_wrapper(function, request):
def view_func(path):
if _is_binary_cloud_event(request):
# Support CloudEvents in binary content mode, with data being the
# whole request body and context attributes retrieved from request
# headers.
data = request.get_data()
context = Context(
eventId=request.headers.get("ce-eventId"),
timestamp=request.headers.get("ce-timestamp"),
eventType=request.headers.get("ce-eventType"),
resource=request.headers.get("ce-resource"),
)
function(data, context)
if _get_event_type(request) == _EventType.LEGACY:
_run_legacy_event(function, request)
else:
# here for defensive backwards compatibility in case we make a mistake in rollout.
raise InvalidConfigurationException("The FUNCTION_SIGNATURE_TYPE for this function is set to event "
"but no legacy event was given. If you are using CloudEvents set "
"FUNCTION_SIGNATURE_TYPE=cloudevent")


return "OK"

return view_func


def _cloudevent_view_func_wrapper(function, request):
def view_func(path):
cloud_event_def = _get_cloud_event_version()
event_type = _get_event_type(request)
if event_type == _EventType.CLOUD_EVENT_TEXT:
_run_text_cloud_event(function, request, cloud_event_def)
elif event_type == _EventType.CLOUD_EVENT_BINARY:
_run_binary_cloud_event(function, request, cloud_event_def)
else:
# This is a regular CloudEvent
event_data = request.get_json()
if not event_data:
flask.abort(400)
event_object = _Event(**event_data)
data = event_object.data
context = Context(**event_object.context)
function(data, context)
raise InvalidConfigurationException("Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent "
" but it did not receive a cloudevent as a request.")

return "OK"

return view_func

def _setup_event_routes(app):
app.url_map.add(
werkzeug.routing.Rule(
"/", defaults={"path": ""}, endpoint="run", methods=["POST"]
)
)
app.url_map.add(
werkzeug.routing.Rule("/<path:path>", endpoint="run", methods=["POST"])
)

# Add a dummy endpoint for GET /
app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"]))
app.view_functions["get"] = lambda: ""

def create_app(target=None, source=None, signature_type=None):
# Get the configured function target
Expand Down Expand Up @@ -180,18 +240,11 @@ def create_app(target=None, source=None, signature_type=None):
app.view_functions["run"] = _http_view_func_wrapper(function, flask.request)
app.view_functions["error"] = lambda: flask.abort(404, description="Not Found")
elif signature_type == "event":
app.url_map.add(
werkzeug.routing.Rule(
"/", defaults={"path": ""}, endpoint="run", methods=["POST"]
)
)
app.url_map.add(
werkzeug.routing.Rule("/<path:path>", endpoint="run", methods=["POST"])
)
_setup_event_routes(app)
app.view_functions["run"] = _event_view_func_wrapper(function, flask.request)
# Add a dummy endpoint for GET /
app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"]))
app.view_functions["get"] = lambda: ""
elif signature_type == "cloudevent":
_setup_event_routes(app)
app.view_functions["run"] = _cloudevent_view_func_wrapper(function, flask.request)
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
else:
raise FunctionsFrameworkException(
"Invalid signature type: {signature_type}".format(
Expand Down
109 changes: 109 additions & 0 deletions tests/test_cloudevent_functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Copyright 2020 Google LLC
#
# 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 pathlib
import pytest
from cloudevents.sdk import marshaller
from cloudevents.sdk.event import v1
from cloudevents.sdk.event import v03
from cloudevents.sdk import converters
joelgerard marked this conversation as resolved.
Show resolved Hide resolved

from functions_framework import LazyWSGIApp, create_app, exceptions

TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions"

# Python 3.5: ModuleNotFoundError does not exist
try:
_ModuleNotFoundError = ModuleNotFoundError
except:
_ModuleNotFoundError = ImportError


@pytest.fixture
def event_1_10():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def event_1_10():
def event_1_0():

event = (
v1.Event()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
v1.Event()
cloudevent.sdk.event.v1.Event()

.SetContentType("application/json")
.SetData('{"name":"john"}')
.SetEventID("my-id")
.SetSource("from-galaxy-far-far-away")
.SetEventTime("tomorrow")
.SetEventType("cloudevent.greet.you")
)
return event


@pytest.fixture
def event_0_3():
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
event = (
v03.Event()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
v03.Event()
cloudevent.sdk.event.v03.Event()

.SetContentType("application/json")
.SetData('{"name":"john"}')
.SetEventID("my-id")
.SetSource("from-galaxy-far-far-away")
.SetEventTime("tomorrow")
.SetEventType("cloudevent.greet.you")
)
return event


def test_event_1_0(event_1_10):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_event_1_0(event_1_10):
def test_event_1_0(event_1_0):

source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py"
target = "function"

client = create_app(target, source, "cloudevent").test_client()

m = marshaller.NewDefaultHTTPMarshaller()
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
structured_headers, structured_data = m.ToRequest(
event_1_10, converters.TypeStructured, json.dumps
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
event_1_10, converters.TypeStructured, json.dumps
event_1_0, cloudevent.sdk.converters.TypeStructured, json.dumps

)

resp = client.post("/", headers=structured_headers,
data=structured_data.getvalue())
assert resp.status_code == 200
assert resp.data == b"OK"

def test_binary_event_1_0(event_1_10):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def test_binary_event_1_0(event_1_10):
def test_binary_event_1_0(event_1_0):

source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py"
target = "function"

client = create_app(target, source, "cloudevent").test_client()

m = marshaller.NewDefaultHTTPMarshaller()
joelgerard marked this conversation as resolved.
Show resolved Hide resolved

binary_headers, binary_data = m.ToRequest(
event_1_10, converters.TypeBinary, json.dumps)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
event_1_10, converters.TypeBinary, json.dumps)
event_1_0, converters.TypeBinary, json.dumps)


resp = client.post(
"/", headers=binary_headers, data=binary_data)

assert resp.status_code == 200
assert resp.data == b"OK"


def test_event_0_3(event_0_3):
source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py"
target = "function"

client = create_app(target, source, "cloudevent").test_client()

m = marshaller.NewDefaultHTTPMarshaller()
joelgerard marked this conversation as resolved.
Show resolved Hide resolved
structured_headers, structured_data = m.ToRequest(
event_0_3, converters.TypeStructured, json.dumps
)

resp = client.post("/", headers=structured_headers,
data=structured_data.getvalue())
assert resp.status_code == 200
assert resp.data == b"OK"
Loading