From 6f0d848b59ac620e9c6626a6869c8970d4a0bfb6 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 6 Jul 2020 18:56:29 -0500 Subject: [PATCH 01/44] Version 1.5.0 (#69) * Revert "Version 2.0.0 (#67)" This reverts commit f2471b427ea7dbd8fd51f6634a6541b32fe821a2. * Revert "Add Cloud Events support for #55 (#56) (#64)" This reverts commit 8f3fe352a5aa909251fb4b8bb6b417b2e96a4beb. * Version 1.5.0 --- CHANGELOG.md | 11 +- README.md | 44 +--- examples/README.md | 3 +- examples/cloud_run_cloudevents/Dockerfile | 15 -- examples/cloud_run_cloudevents/README.md | 52 ----- examples/cloud_run_cloudevents/main.py | 21 -- .../cloud_run_cloudevents/requirements.txt | 1 - examples/cloud_run_event/Dockerfile | 2 +- examples/cloud_run_event/README.md | 3 - setup.py | 3 +- src/functions_framework/__init__.py | 119 +++------- src/functions_framework/_cli.py | 2 +- tests/test_cloudevent_functions.py | 120 ---------- tests/test_event_functions.py | 213 ------------------ tests/test_functions.py | 149 +++++++++++- tests/test_functions/cloudevents/main.py | 40 ---- tests/test_view_functions.py | 35 +++ 17 files changed, 224 insertions(+), 609 deletions(-) delete mode 100644 examples/cloud_run_cloudevents/Dockerfile delete mode 100644 examples/cloud_run_cloudevents/README.md delete mode 100644 examples/cloud_run_cloudevents/main.py delete mode 100644 examples/cloud_run_cloudevents/requirements.txt delete mode 100644 examples/cloud_run_event/README.md delete mode 100644 tests/test_cloudevent_functions.py delete mode 100644 tests/test_event_functions.py delete mode 100644 tests/test_functions/cloudevents/main.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4263d416..6353d57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.0.0] - 2020-07-01 -### Added -- Support `cloudevent` signature type ([#55], [#56]) - +## [1.5.0] - 2020-07-06 ### Changed - Framework will consume entire request before responding ([#66]) @@ -70,8 +67,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.0.0...HEAD -[2.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.0.0 +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.5.0 [1.4.4]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.4 [1.4.3]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.3 [1.4.2]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.2 @@ -86,8 +83,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#66]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/66 [#61]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/61 -[#56]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/56 -[#55]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/55 [#49]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/49 [#44]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/44 [#38]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/38 diff --git a/README.md b/README.md index a85848c9..11c01c14 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.0.0 +functions-framework==1.5.0 ``` # Quickstart: Hello, World on your local machine @@ -129,18 +129,13 @@ You can configure the Functions Framework using command-line flags or environmen | `--host` | `HOST` | The host on which the Functions Framework listens for requests. Default: `0.0.0.0` | | `--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` | | `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` | -| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http` or `event` or `cloudevent` | +| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http` or `event` | | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | +# Enable CloudEvents -# Enable Google Cloud Functions Events - -The Functions Framework can unmarshall incoming -Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `data` and `context` objects. -These will be passed as arguments to your function when it receives a request. -Note that your function must use the `event`-style function signature: - +The Functions Framework can unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to `data` and `context` objects. These will be passed as arguments to your function when it receives a request. Note that your function must use the event-style function signature: ```python def hello(data, context): @@ -148,38 +143,13 @@ def hello(data, context): print(context) ``` -To enable automatic unmarshalling, set the function signature type to `event` -using a command-line flag or an environment variable. By default, the HTTP -signature will be used and automatic event unmarshalling will be disabled. - -For more details on this signature type, check out the Google Cloud Functions -documentation on -[background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). - -See the [running example](examples/cloud_run_event). - -# Enable CloudEvents - -The Functions Framework can unmarshall incoming -[CloudEvent](http://cloudevents.io) payloads to a `cloudevent` object. -It will be passed as an argument to your function when it receives a request. -Note that your function must use the `cloudevent`-style function signature - - -```python -def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID()) - return 200 -``` - -To enable automatic unmarshalling, set the function signature type to `cloudevent` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. +To enable automatic unmarshalling, set the function signature type to `event` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. -See the [running example](examples/cloud_run_cloudevents). +For more details on this signature type, check out the Google Cloud Functions documentation on [background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). # Advanced Examples -More advanced guides can be found in the [`examples/`](./examples/) directory. You can also find examples -on using the CloudEvent Python SDK [here](https://github.com/cloudevents/sdk-python). +More advanced guides can be found in the [`examples/`](./examples/) directory. # Contributing diff --git a/examples/README.md b/examples/README.md index fc027bfe..33ce2a76 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,4 @@ # Python Functions Frameworks Examples * [`cloud_run_http`](./cloud_run_http/) - Deploying an HTTP function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework -* [`cloud_run_event`](./cloud_run_event/) - Deploying a [Google Cloud Functions Event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework -* [`cloud_run_cloudevents`](./cloud_run_cloudevents/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_event`](./cloud_run_event/) - Deploying a CloudEvent function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile deleted file mode 100644 index 10163c5f..00000000 --- a/examples/cloud_run_cloudevents/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Use the official Python image. -# https://hub.docker.com/_/python -FROM python:3.7-slim - -# Copy local code to the container image. -ENV APP_HOME /app -WORKDIR $APP_HOME -COPY . . - -# Install production dependencies. -RUN pip install gunicorn cloudevents functions-framework -RUN pip install -r requirements.txt - -# Run the web service on container startup. -CMD exec functions-framework --target=hello --signature-type=cloudevent diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md deleted file mode 100644 index 0f3c8fe0..00000000 --- a/examples/cloud_run_cloudevents/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Deploying a CloudEvent function to Cloud Run with the Functions Framework -This sample uses the [Cloud Events SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. - -## How to run this locally -Build the Docker image: - -```commandline -docker build --tag ff_example . -``` - -Run the image and bind the correct ports: - -```commandline -docker run -p:8080:8080 ff_example -``` - -Send an event to the container: - -```python -from cloudevents.sdk import converters -from cloudevents.sdk import marshaller -from cloudevents.sdk.converters import structured -from cloudevents.sdk.event import v1 -import requests -import json - -def run_structured(event, url): - http_marshaller = marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = http_marshaller.ToRequest( - event, converters.TypeStructured, json.dumps - ) - print("structured CloudEvent") - print(structured_data.getvalue()) - - response = requests.post(url, - headers=structured_headers, - data=structured_data.getvalue()) - response.raise_for_status() - -event = ( - v1.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") -) - -run_structured(event, "http://0.0.0.0:8080/") - -``` diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py deleted file mode 100644 index 94b2734a..00000000 --- a/examples/cloud_run_cloudevents/main.py +++ /dev/null @@ -1,21 +0,0 @@ -# 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. - -# This sample creates a function that accepts a Cloud Event per -# https://github.com/cloudevents/sdk-python -import sys - - -def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID(), file=sys.stdout, flush=True) diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloudevents/requirements.txt deleted file mode 100644 index 33c5f99f..00000000 --- a/examples/cloud_run_cloudevents/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# Optionally include additional dependencies here diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index 6b31c042..b3e7ffeb 100644 --- a/examples/cloud_run_event/Dockerfile +++ b/examples/cloud_run_event/Dockerfile @@ -12,4 +12,4 @@ RUN pip install gunicorn functions-framework RUN pip install -r requirements.txt # Run the web service on container startup. -CMD exec functions-framework --target=hello --signature_type=event +CMD exec functions-framework --target=hello --signature-type=event diff --git a/examples/cloud_run_event/README.md b/examples/cloud_run_event/README.md deleted file mode 100644 index 62d34cca..00000000 --- a/examples/cloud_run_event/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Google Cloud Functions Events Example -This example demonstrates how to write an event function. Note that you can also use [CloudEvents](https://github.com/cloudevents/sdk-python) -([example](../cloud_run_cloudevents)), which is a different construct. \ No newline at end of file diff --git a/setup.py b/setup.py index 81cc49b5..93d4d8d0 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.0.0", + version="1.5.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", @@ -52,7 +52,6 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<1.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 857c5fa8..b7f38290 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -12,19 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import enum +import functools import importlib.util -import io -import json import os.path import pathlib import sys import types -import cloudevents.sdk -import cloudevents.sdk.event -import cloudevents.sdk.event.v1 -import cloudevents.sdk.marshaller import flask import werkzeug @@ -42,12 +36,6 @@ MAX_CONTENT_LENGTH = 10 * 1024 * 1024 -class _EventType(enum.Enum): - LEGACY = 1 - CLOUDEVENT_BINARY = 2 - CLOUDEVENT_STRUCTURED = 3 - - class _Event(object): """Event passed to background functions.""" @@ -80,83 +68,38 @@ def view_func(path): return view_func -def _get_cloudevent_version(): - return cloudevents.sdk.event.v1.Event() - - -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_cloudevent(function, request, cloudevent_def): - data = io.BytesIO(request.get_data()) - http_marshaller = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = http_marshaller.FromRequest( - cloudevent_def, request.headers, data, json.load - ) - - function(event) - - -def _run_structured_cloudevent(function, request, cloudevent_def): - data = io.StringIO(request.get_data(as_text=True)) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = m.FromRequest(cloudevent_def, request.headers, data, json.loads) - function(event) - - -def _get_event_type(request): - if ( +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") - ): - return _EventType.CLOUDEVENT_BINARY - elif request.headers.get("Content-Type") == "application/cloudevents+json": - return _EventType.CLOUDEVENT_STRUCTURED - else: - return _EventType.LEGACY + ) def _event_view_func_wrapper(function, request): def view_func(path): - 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. - flask.abort( - 400, - description="The FUNCTION_SIGNATURE_TYPE for this function is set to event " - "but no Google Cloud Functions Event was given. If you are using CloudEvents set " - "FUNCTION_SIGNATURE_TYPE=cloudevent", + 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"), ) - - return "OK" - - return view_func - - -def _cloudevent_view_func_wrapper(function, request): - def view_func(path): - cloudevent_def = _get_cloudevent_version() - event_type = _get_event_type(request) - if event_type == _EventType.CLOUDEVENT_STRUCTURED: - _run_structured_cloudevent(function, request, cloudevent_def) - elif event_type == _EventType.CLOUDEVENT_BINARY: - _run_binary_cloudevent(function, request, cloudevent_def) + function(data, context) else: - flask.abort( - 400, - description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a cloudevent as a request.", - ) + # 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) return "OK" @@ -263,27 +206,19 @@ 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") app.after_request(read_request) - elif signature_type == "event" or signature_type == "cloudevent": + elif signature_type == "event": app.url_map.add( werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + "/", defaults={"path": ""}, endpoint="run", methods=["POST"] ) ) app.url_map.add( - werkzeug.routing.Rule( - "/", endpoint=signature_type, methods=["POST"] - ) + werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) ) - + 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: "" - - # Add the view functions - app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) - app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( - function, flask.request - ) else: raise FunctionsFrameworkException( "Invalid signature type: {signature_type}".format( diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 663ea50f..4fe6e427 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -26,7 +26,7 @@ @click.option( "--signature-type", envvar="FUNCTION_SIGNATURE_TYPE", - type=click.Choice(["http", "event", "cloudevent"]), + type=click.Choice(["http", "event"]), default="http", ) @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py deleted file mode 100644 index 17e6f23c..00000000 --- a/tests/test_cloudevent_functions.py +++ /dev/null @@ -1,120 +0,0 @@ -# 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 cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.event.v03 -import cloudevents.sdk.marshaller -import pytest - -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 cloudevent_1_0(): - event = ( - cloudevents.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 cloudevent_0_3(): - event = ( - cloudevents.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(cloudevent_1_0): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_1_0, cloudevents.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(cloudevent_1_0): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - - binary_headers, binary_data = m.ToRequest( - cloudevent_1_0, cloudevents.sdk.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(cloudevent_0_3): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_0_3, cloudevents.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_non_cloudevent_(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - resp = client.post("/", json="{not_event}") - assert resp.status_code == 400 - assert resp.data != b"OK" diff --git a/tests/test_event_functions.py b/tests/test_event_functions.py deleted file mode 100644 index 7b274672..00000000 --- a/tests/test_event_functions.py +++ /dev/null @@ -1,213 +0,0 @@ -# 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 re - -import cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.marshaller -import pytest - -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 background_json(tmpdir): - return { - "context": { - "eventId": "some-eventId", - "timestamp": "some-timestamp", - "eventType": "some-eventType", - "resource": "some-resource", - }, - "data": {"filename": str(tmpdir / "filename.txt"), "value": "some-value"}, - } - - -def test_non_legacy_event_fails(): - cloudevent = ( - cloudevents.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") - ) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent, cloudevents.sdk.converters.TypeStructured, json.dumps - ) - - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) - assert resp.status_code == 400 - assert resp.data != b"OK" - - -def test_background_function_executes(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_background_function_supports_get(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.get("/") - assert resp.status_code == 200 - - -def test_background_function_executes_entry_point_one(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionFoo" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_background_function_executes_entry_point_two(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionBar" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_multiple_calls(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionFoo" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_pubsub_payload(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - - assert resp.status_code == 200 - assert resp.data == b"OK" - - with open(background_json["data"]["filename"]) as f: - assert f.read() == '{{"entryPoint": "function", "value": "{}"}}'.format( - background_json["data"]["value"] - ) - - -def test_background_function_no_data(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/") - assert resp.status_code == 400 - - -def test_invalid_function_definition_multiple_entry_points(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "function" - - with pytest.raises(exceptions.MissingTargetException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "File .* is expected to contain a function named function", str(excinfo.value) - ) - - -def test_invalid_function_definition_multiple_entry_points_invalid_function(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "invalidFunction" - - with pytest.raises(exceptions.MissingTargetException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "File .* is expected to contain a function named invalidFunction", - str(excinfo.value), - ) - - -def test_invalid_function_definition_multiple_entry_points_not_a_function(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "notAFunction" - - with pytest.raises(exceptions.InvalidTargetTypeException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "The function defined in file .* as notAFunction needs to be of type " - "function. Got: .*", - str(excinfo.value), - ) - - -def test_invalid_function_definition_function_syntax_error(): - source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" - target = "function" - - with pytest.raises(SyntaxError) as excinfo: - create_app(target, source, "event") - - assert any( - ( - "invalid syntax" in str(excinfo.value), # Python <3.8 - "unmatched ')'" in str(excinfo.value), # Python >3.8 - ) - ) - - -def test_invalid_function_definition_missing_dependency(): - source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" - target = "function" - - with pytest.raises(_ModuleNotFoundError) as excinfo: - create_app(target, source, "event") - - assert "No module named 'nonexistentpackage'" in str(excinfo.value) diff --git a/tests/test_functions.py b/tests/test_functions.py index 792a646e..c6eccb91 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,6 +11,7 @@ # 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 pathlib import re import time @@ -22,7 +23,8 @@ from functions_framework import LazyWSGIApp, create_app, exceptions -TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" + # Python 3.5: ModuleNotFoundError does not exist try: @@ -167,6 +169,87 @@ def test_http_function_execution_time(): assert resp.data == b"OK" +def test_background_function_executes(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_background_function_supports_get(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.get("/") + assert resp.status_code == 200 + + +def test_background_function_executes_entry_point_one(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionFoo" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_background_function_executes_entry_point_two(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionBar" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_multiple_calls(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionFoo" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_pubsub_payload(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + + assert resp.status_code == 200 + assert resp.data == b"OK" + + with open(background_json["data"]["filename"]) as f: + assert f.read() == '{{"entryPoint": "function", "value": "{}"}}'.format( + background_json["data"]["value"] + ) + + +def test_background_function_no_data(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/") + assert resp.status_code == 400 + + def test_invalid_function_definition_missing_function_file(): source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" target = "functions" @@ -179,6 +262,70 @@ def test_invalid_function_definition_missing_function_file(): ) +def test_invalid_function_definition_multiple_entry_points(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "function" + + with pytest.raises(exceptions.MissingTargetException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "File .* is expected to contain a function named function", str(excinfo.value) + ) + + +def test_invalid_function_definition_multiple_entry_points_invalid_function(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "invalidFunction" + + with pytest.raises(exceptions.MissingTargetException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "File .* is expected to contain a function named invalidFunction", + str(excinfo.value), + ) + + +def test_invalid_function_definition_multiple_entry_points_not_a_function(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "notAFunction" + + with pytest.raises(exceptions.InvalidTargetTypeException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "The function defined in file .* as notAFunction needs to be of type " + "function. Got: .*", + str(excinfo.value), + ) + + +def test_invalid_function_definition_function_syntax_error(): + source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" + target = "function" + + with pytest.raises(SyntaxError) as excinfo: + create_app(target, source, "event") + + assert any( + ( + "invalid syntax" in str(excinfo.value), # Python <3.8 + "unmatched ')'" in str(excinfo.value), # Python >3.8 + ) + ) + + +def test_invalid_function_definition_missing_dependency(): + source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" + target = "function" + + with pytest.raises(_ModuleNotFoundError) as excinfo: + create_app(target, source, "event") + + assert "No module named 'nonexistentpackage'" in str(excinfo.value) + + def test_invalid_configuration(): with pytest.raises(exceptions.InvalidConfigurationException) as excinfo: create_app(None, None, None) diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py deleted file mode 100644 index f2fdb6f3..00000000 --- a/tests/test_functions/cloudevents/main.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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. - -"""Function used to test handling Cloud Event functions.""" -import flask - - -def function(cloudevent): - """Test Event function that checks to see if a valid CloudEvent was sent. - - The function returns 200 if it received the expected event, otherwise 500. - - Args: - cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. - - Returns: - HTTP status code indicating whether valid event was sent or not. - - """ - valid_event = ( - cloudevent.EventID() == "my-id" - and cloudevent.Data() == '{"name":"john"}' - and cloudevent.Source() == "from-galaxy-far-far-away" - and cloudevent.EventTime() == "tomorrow" - and cloudevent.EventType() == "cloudevent.greet.you" - ) - - if not valid_event: - flask.abort(500) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index a9e13bb7..51dad087 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -60,6 +60,41 @@ def test_event_view_func_wrapper(monkeypatch): ] +def test_binary_event_view_func_wrapper(monkeypatch): + data = pretend.stub() + request = pretend.stub( + headers={ + "ce-type": "something", + "ce-specversion": "something", + "ce-source": "something", + "ce-id": "something", + "ce-eventId": "some-eventId", + "ce-timestamp": "some-timestamp", + "ce-eventType": "some-eventType", + "ce-resource": "some-resource", + }, + get_data=lambda: data, + ) + + context_stub = pretend.stub() + context_class = pretend.call_recorder(lambda *a, **kw: context_stub) + monkeypatch.setattr(functions_framework, "Context", context_class) + function = pretend.call_recorder(lambda data, context: "Hello") + + view_func = functions_framework._event_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(data, context_stub)] + assert context_class.calls == [ + pretend.call( + eventId="some-eventId", + timestamp="some-timestamp", + eventType="some-eventType", + resource="some-resource", + ) + ] + + def test_legacy_event_view_func_wrapper(monkeypatch): data = pretend.stub() json = { From 95d0b353358ed39599ecca703407f63b2df800b7 Mon Sep 17 00:00:00 2001 From: Katie McLaughlin Date: Thu, 16 Jul 2020 10:10:32 +1000 Subject: [PATCH 02/44] Improve documentation around Dockerfiles (#70) * Add a link to an example Dockerfile in the top README.md * Update the inline Dockerfile to match file * Remove explicit gunicorn installation * make readme links absolute, useful Useful for when this readme appears on both github and pypi --- README.md | 4 ++-- examples/cloud_run_http/Dockerfile | 2 +- examples/cloud_run_http/README.md | 9 +++------ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 11c01c14..863a9e54 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ After you've written your function, you can simply deploy it from your local mac ## Cloud Run/Cloud Run on GKE -Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). +Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_http) If you want even more control over the environment, you can [deploy your container image to Cloud Run on GKE](https://cloud.google.com/run/docs/quickstarts/prebuilt-deploy-gke). With Cloud Run on GKE, you can run your function on a GKE cluster, which gives you additional control over the environment (including use of GPU-based instances, longer timeouts and more). @@ -149,7 +149,7 @@ For more details on this signature type, check out the Google Cloud Functions do # Advanced Examples -More advanced guides can be found in the [`examples/`](./examples/) directory. +More advanced guides can be found in the [`examples/`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/) directory. # Contributing diff --git a/examples/cloud_run_http/Dockerfile b/examples/cloud_run_http/Dockerfile index e8ab5287..c81596c3 100644 --- a/examples/cloud_run_http/Dockerfile +++ b/examples/cloud_run_http/Dockerfile @@ -8,7 +8,7 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt # Run the web service on container startup. diff --git a/examples/cloud_run_http/README.md b/examples/cloud_run_http/README.md index 6cf53980..4cbe96d4 100644 --- a/examples/cloud_run_http/README.md +++ b/examples/cloud_run_http/README.md @@ -26,14 +26,11 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt -# Run the web service on container startup. Here we use the gunicorn -# webserver, with one worker process and 8 threads. -# For environments with multiple CPU cores, increase the number of workers -# to be equal to the cores available. -CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello functions_framework:app +# Run the web service on container startup. +CMD exec functions-framework --target=hello ``` Start the container locally by running `docker build` and `docker run`: From ac48e645900ece7a39587d5708ae6d02c7d6c762 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 12 Aug 2020 15:40:21 -0700 Subject: [PATCH 03/44] added cloudevents 1.0.0 Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/README.md | 51 +++++++---------- examples/cloud_run_cloudevents/main.py | 6 +- setup.py | 2 +- src/functions_framework/__init__.py | 63 ++++++++------------- tests/test_cloudevent_functions.py | 72 ++++++++++-------------- tests/test_event_functions.py | 5 +- tests/test_functions/cloudevents/main.py | 23 ++++---- 7 files changed, 92 insertions(+), 130 deletions(-) diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 0f3c8fe0..7aa0e829 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -1,52 +1,41 @@ # Deploying a CloudEvent function to Cloud Run with the Functions Framework + This sample uses the [Cloud Events SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. ## How to run this locally + Build the Docker image: ```commandline -docker build --tag ff_example . +docker build -t ff_example . ``` Run the image and bind the correct ports: ```commandline -docker run -p:8080:8080 ff_example +docker run --rm -p 8080:8080 -e PORT=8080 ff_example ``` Send an event to the container: ```python -from cloudevents.sdk import converters -from cloudevents.sdk import marshaller -from cloudevents.sdk.converters import structured -from cloudevents.sdk.event import v1 +from cloudevents.http import CloudEvent, to_structured_http import requests import json -def run_structured(event, url): - http_marshaller = marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = http_marshaller.ToRequest( - event, converters.TypeStructured, json.dumps - ) - print("structured CloudEvent") - print(structured_data.getvalue()) - - response = requests.post(url, - headers=structured_headers, - data=structured_data.getvalue()) - response.raise_for_status() - -event = ( - v1.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") -) - -run_structured(event, "http://0.0.0.0:8080/") - +# Create event +attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you" +} +data = {"name":"john"} + +event = CloudEvent(attributes, data) +print(event) + +# Send event +headers, data = to_structured_http(event) +response = requests.post("http://localhost:8080/", headers=headers, data=data) +response.raise_for_status() ``` diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 94b2734a..bc11a8a4 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -18,4 +18,8 @@ def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID(), file=sys.stdout, flush=True) + print( + f"Received event with ID: {cloudevent['id']}", + file=sys.stdout, + flush=True + ) diff --git a/setup.py b/setup.py index 81cc49b5..9d59f1a4 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<1.0", + "cloudevents>=1.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 857c5fa8..8a361d0b 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -21,10 +21,9 @@ import sys import types -import cloudevents.sdk -import cloudevents.sdk.event -import cloudevents.sdk.event.v1 -import cloudevents.sdk.marshaller +from cloudevents.http import from_http +from cloudevents.sdk.converters import is_binary + import flask import werkzeug @@ -80,10 +79,6 @@ def view_func(path): return view_func -def _get_cloudevent_version(): - return cloudevents.sdk.event.v1.Event() - - def _run_legacy_event(function, request): event_data = request.get_json() if not event_data: @@ -94,30 +89,14 @@ def _run_legacy_event(function, request): function(data, context) -def _run_binary_cloudevent(function, request, cloudevent_def): - data = io.BytesIO(request.get_data()) - http_marshaller = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = http_marshaller.FromRequest( - cloudevent_def, request.headers, data, json.load - ) - - function(event) - - -def _run_structured_cloudevent(function, request, cloudevent_def): - data = io.StringIO(request.get_data(as_text=True)) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = m.FromRequest(cloudevent_def, request.headers, data, json.loads) +def _run_cloudevent(function, request): + data = request.get_data() + event = from_http(data, request.headers) 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") - ): + if is_binary(request.headers): return _EventType.CLOUDEVENT_BINARY elif request.headers.get("Content-Type") == "application/cloudevents+json": return _EventType.CLOUDEVENT_STRUCTURED @@ -145,19 +124,15 @@ def view_func(path): def _cloudevent_view_func_wrapper(function, request): def view_func(path): - cloudevent_def = _get_cloudevent_version() event_type = _get_event_type(request) - if event_type == _EventType.CLOUDEVENT_STRUCTURED: - _run_structured_cloudevent(function, request, cloudevent_def) - elif event_type == _EventType.CLOUDEVENT_BINARY: - _run_binary_cloudevent(function, request, cloudevent_def) + if event_type in [_EventType.CLOUDEVENT_STRUCTURED, _EventType.CLOUDEVENT_BINARY]: + _run_cloudevent(function, request) else: flask.abort( 400, description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a cloudevent as a request.", + " but it did not receive a valid cloudevent as a request.", ) - return "OK" return view_func @@ -258,10 +233,13 @@ def create_app(target=None, source=None, signature_type=None): werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") ) app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) - app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) + app.url_map.add(werkzeug.routing.Rule( + "/favicon.ico", endpoint="error")) app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) - app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) - app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") + app.view_functions["run"] = _http_view_func_wrapper( + function, flask.request) + app.view_functions["error"] = lambda: flask.abort( + 404, description="Not Found") app.after_request(read_request) elif signature_type == "event" or signature_type == "cloudevent": app.url_map.add( @@ -276,11 +254,13 @@ def create_app(target=None, source=None, signature_type=None): ) # Add a dummy endpoint for GET / - app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) + app.url_map.add(werkzeug.routing.Rule( + "/", endpoint="get", methods=["GET"])) app.view_functions["get"] = lambda: "" # Add the view functions - app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) + app.view_functions["event"] = _event_view_func_wrapper( + function, flask.request) app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( function, flask.request ) @@ -314,7 +294,8 @@ def __init__(self, target=None, source=None, signature_type=None): def __call__(self, *args, **kwargs): if not self.app: - self.app = create_app(self.target, self.source, self.signature_type) + self.app = create_app( + self.target, self.source, self.signature_type) return self.app(*args, **kwargs) diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 17e6f23c..f277e40c 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -14,10 +14,8 @@ import json import pathlib -import cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.event.v03 -import cloudevents.sdk.marshaller +from cloudevents.http import CloudEvent, to_structured_http, to_binary_http, from_http + import pytest from functions_framework import LazyWSGIApp, create_app, exceptions @@ -33,30 +31,29 @@ @pytest.fixture def cloudevent_1_0(): - event = ( - cloudevents.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 + attributes = { + "Content-Type": "application/json", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "time": "tomorrow", + "type": "cloudevent.greet.you" + } + data = {"name": "john"} + return CloudEvent(attributes, data) @pytest.fixture def cloudevent_0_3(): - event = ( - cloudevents.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 + attributes = { + "Content-Type": "application/json", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "time": "tomorrow", + "type": "cloudevent.greet.you", + "specversion": "0.3" + } + data = {"name": "john"} + return CloudEvent(attributes, data) def test_event_1_0(cloudevent_1_0): @@ -64,15 +61,11 @@ def test_event_1_0(cloudevent_1_0): target = "function" client = create_app(target, source, "cloudevent").test_client() + headers, data = to_structured_http(cloudevent_1_0) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_1_0, cloudevents.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" + resp = client.post("/", headers=headers, data=data) + # assert resp.status_code == 200 + # assert resp.data == b"OK" def test_binary_event_1_0(cloudevent_1_0): @@ -81,13 +74,9 @@ def test_binary_event_1_0(cloudevent_1_0): client = create_app(target, source, "cloudevent").test_client() - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - - binary_headers, binary_data = m.ToRequest( - cloudevent_1_0, cloudevents.sdk.converters.TypeBinary, json.dumps - ) + headers, data = to_binary_http(cloudevent_1_0) - resp = client.post("/", headers=binary_headers, data=binary_data) + resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" @@ -99,12 +88,9 @@ def test_event_0_3(cloudevent_0_3): client = create_app(target, source, "cloudevent").test_client() - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_0_3, cloudevents.sdk.converters.TypeStructured, json.dumps - ) + headers, data = to_structured_http(cloudevent_0_3) - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) + resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" diff --git a/tests/test_event_functions.py b/tests/test_event_functions.py index 7b274672..3c167f81 100644 --- a/tests/test_event_functions.py +++ b/tests/test_event_functions.py @@ -63,7 +63,7 @@ def test_non_legacy_event_fails(): target = "function" client = create_app(target, source, "event").test_client() - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) + resp = client.post("/", headers=structured_headers, data=structured_data) assert resp.status_code == 400 assert resp.data != b"OK" @@ -157,7 +157,8 @@ def test_invalid_function_definition_multiple_entry_points(): create_app(target, source, "event") assert re.match( - "File .* is expected to contain a function named function", str(excinfo.value) + "File .* is expected to contain a function named function", str( + excinfo.value) ) diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index f2fdb6f3..41ca5a7b 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -19,21 +19,22 @@ def function(cloudevent): """Test Event function that checks to see if a valid CloudEvent was sent. - The function returns 200 if it received the expected event, otherwise 500. + The function returns 200 if it received the expected event, otherwise 500. - Args: - cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. + Args: + cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. - Returns: - HTTP status code indicating whether valid event was sent or not. + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ - """ valid_event = ( - cloudevent.EventID() == "my-id" - and cloudevent.Data() == '{"name":"john"}' - and cloudevent.Source() == "from-galaxy-far-far-away" - and cloudevent.EventTime() == "tomorrow" - and cloudevent.EventType() == "cloudevent.greet.you" + cloudevent['id'] == "my-id" + and cloudevent.data == {"name": "john"} + and cloudevent['source'] == "from-galaxy-far-far-away" + and cloudevent['time'] == "tomorrow" + and cloudevent['type'] == "cloudevent.greet.you" ) if not valid_event: From 0d19c7f928b38e0a2911ddb53ec27a9a36c0763a Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 12 Aug 2020 16:04:38 -0700 Subject: [PATCH 04/44] reverted auto format Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 39 +++++++++++++---------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 8a361d0b..3a94296c 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -112,9 +112,11 @@ def view_func(path): # here for defensive backwards compatibility in case we make a mistake in rollout. flask.abort( 400, - description="The FUNCTION_SIGNATURE_TYPE for this function is set to event " - "but no Google Cloud Functions Event was given. If you are using CloudEvents set " - "FUNCTION_SIGNATURE_TYPE=cloudevent", + description=( + "The FUNCTION_SIGNATURE_TYPE for this function is set to event but" + " no Google Cloud Functions Event was given. If you are using" + " CloudEvents set FUNCTION_SIGNATURE_TYPE=cloudevent" + ), ) return "OK" @@ -124,15 +126,16 @@ def view_func(path): def _cloudevent_view_func_wrapper(function, request): def view_func(path): - event_type = _get_event_type(request) - if event_type in [_EventType.CLOUDEVENT_STRUCTURED, _EventType.CLOUDEVENT_BINARY]: - _run_cloudevent(function, request) - else: + if _get_event_type(request) == _EventType.LEGACY: flask.abort( 400, - description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a valid cloudevent as a request.", + description=( + "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " + " but it did not receive a valid cloudevent as a request." + ), ) + else: + _run_cloudevent(function, request) return "OK" return view_func @@ -233,13 +236,10 @@ def create_app(target=None, source=None, signature_type=None): werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") ) app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) - app.url_map.add(werkzeug.routing.Rule( - "/favicon.ico", endpoint="error")) + app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) - app.view_functions["run"] = _http_view_func_wrapper( - function, flask.request) - app.view_functions["error"] = lambda: flask.abort( - 404, description="Not Found") + app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) + app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") app.after_request(read_request) elif signature_type == "event" or signature_type == "cloudevent": app.url_map.add( @@ -254,13 +254,11 @@ def create_app(target=None, source=None, signature_type=None): ) # Add a dummy endpoint for GET / - app.url_map.add(werkzeug.routing.Rule( - "/", endpoint="get", methods=["GET"])) + app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) app.view_functions["get"] = lambda: "" # Add the view functions - app.view_functions["event"] = _event_view_func_wrapper( - function, flask.request) + app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( function, flask.request ) @@ -294,8 +292,7 @@ def __init__(self, target=None, source=None, signature_type=None): def __call__(self, *args, **kwargs): if not self.app: - self.app = create_app( - self.target, self.source, self.signature_type) + self.app = create_app(self.target, self.source, self.signature_type) return self.app(*args, **kwargs) From 000d83c13414ae00c91aca244e3b73cd287d4de3 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 12 Aug 2020 16:09:28 -0700 Subject: [PATCH 05/44] lint fixes Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 6 +++--- tests/test_cloudevent_functions.py | 8 ++++---- tests/test_event_functions.py | 3 +-- tests/test_functions/cloudevents/main.py | 8 ++++---- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 3a94296c..c2732721 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -21,12 +21,12 @@ import sys import types -from cloudevents.http import from_http -from cloudevents.sdk.converters import is_binary - import flask import werkzeug +from cloudevents.http import from_http +from cloudevents.sdk.converters import is_binary + from functions_framework.exceptions import ( FunctionsFrameworkException, InvalidConfigurationException, diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index f277e40c..0dfa7d4a 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -14,10 +14,10 @@ import json import pathlib -from cloudevents.http import CloudEvent, to_structured_http, to_binary_http, from_http - import pytest +from cloudevents.http import CloudEvent, from_http, to_binary_http, to_structured_http + from functions_framework import LazyWSGIApp, create_app, exceptions TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" @@ -36,7 +36,7 @@ def cloudevent_1_0(): "id": "my-id", "source": "from-galaxy-far-far-away", "time": "tomorrow", - "type": "cloudevent.greet.you" + "type": "cloudevent.greet.you", } data = {"name": "john"} return CloudEvent(attributes, data) @@ -50,7 +50,7 @@ def cloudevent_0_3(): "source": "from-galaxy-far-far-away", "time": "tomorrow", "type": "cloudevent.greet.you", - "specversion": "0.3" + "specversion": "0.3", } data = {"name": "john"} return CloudEvent(attributes, data) diff --git a/tests/test_event_functions.py b/tests/test_event_functions.py index 3c167f81..301a85f7 100644 --- a/tests/test_event_functions.py +++ b/tests/test_event_functions.py @@ -157,8 +157,7 @@ def test_invalid_function_definition_multiple_entry_points(): create_app(target, source, "event") assert re.match( - "File .* is expected to contain a function named function", str( - excinfo.value) + "File .* is expected to contain a function named function", str(excinfo.value) ) diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index 41ca5a7b..6a529e94 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -30,11 +30,11 @@ def function(cloudevent): """ valid_event = ( - cloudevent['id'] == "my-id" + cloudevent["id"] == "my-id" and cloudevent.data == {"name": "john"} - and cloudevent['source'] == "from-galaxy-far-far-away" - and cloudevent['time'] == "tomorrow" - and cloudevent['type'] == "cloudevent.greet.you" + and cloudevent["source"] == "from-galaxy-far-far-away" + and cloudevent["time"] == "tomorrow" + and cloudevent["type"] == "cloudevent.greet.you" ) if not valid_event: From 8f78a8bc78030773d64d396dd4a72d982e51a442 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 12 Aug 2020 16:24:44 -0700 Subject: [PATCH 06/44] changed cloudevents to <=1.0 in setup Signed-off-by: Curtis Mason --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9d59f1a4..c3aaf4d6 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents>=1.0", + "cloudevents<=1.0", ], entry_points={ "console_scripts": [ From b9695ac6a73b83be6fe3ab685754e10e34310cc6 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 12 Aug 2020 16:31:36 -0700 Subject: [PATCH 07/44] made cloudevents==1.0 Signed-off-by: Curtis Mason --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c3aaf4d6..06d64dc3 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<=1.0", + "cloudevents==1.0", ], entry_points={ "console_scripts": [ From eb40068962b4ae070a5d518a8474362c2c8a466e Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 07:59:05 -0700 Subject: [PATCH 08/44] added cloudevent_view tests Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/README.md | 2 +- tests/test_view_functions.py | 64 +++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 7aa0e829..19b8caa3 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -37,5 +37,5 @@ print(event) # Send event headers, data = to_structured_http(event) response = requests.post("http://localhost:8080/", headers=headers, data=data) -response.raise_for_status() +response.content ``` diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 51dad087..9a985545 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -11,6 +11,9 @@ # 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.http import from_http + +import json import pretend @@ -22,7 +25,8 @@ def test_http_view_func_wrapper(): request_object = pretend.stub() local_proxy = pretend.stub(_get_current_object=lambda: request_object) - view_func = functions_framework._http_view_func_wrapper(function, local_proxy) + view_func = functions_framework._http_view_func_wrapper( + function, local_proxy) view_func("/some/path") assert function.calls == [pretend.call(request_object)] @@ -60,6 +64,64 @@ def test_event_view_func_wrapper(monkeypatch): ] +def test_cloudevent_view_func_wrapper(monkeypatch): + headers = {'Content-Type': 'application/cloudevents+json'} + data = json.dumps({ + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "specversion": "1.0", + "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "time": "2020-08-13T02:12:14.946587+00:00", + "data": {"name": "john"} + }) + + request = pretend.stub(headers=headers, get_data=lambda: data) + # event = from_http(request.get_data(), request.headers) + + function = pretend.call_recorder(lambda cloudevent: cloudevent) + + view_func = functions_framework._cloudevent_view_func_wrapper( + function, + request + ) + view_func("/some/path") + + # cloudevents v1.0.0 does not support __eq__ overload yet + # therefore we cannot do: + # assert functions.calls == [pretend.calls(event)] + assert function.calls[0].__dict__['args'][0].data == {"name": "john"} + assert function.calls[0].__dict__['args'][0]['id'] == \ + "f6a65fcd-eed2-429d-9f71-ec0663d83025" + + +def test_binary_cloudevent_view_func_wrapper(monkeypatch): + headers = { + "ce-specversion": "1.0", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloudevent.greet.you", + "ce-id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "ce-time": "2020-08-13T02:12:14.946587+00:00" + } + data = json.dumps({"name": "john"}) + + request = pretend.stub(headers=headers, get_data=lambda: data) + # event = from_http(request.get_data(), request.headers) + + function = pretend.call_recorder(lambda cloudevent: cloudevent) + + view_func = functions_framework._cloudevent_view_func_wrapper( + function, + request + ) + view_func("/some/path") + + # cloudevents v1.0.0 does not support __eq__ overload yet + # therefore we cannot do: + # assert functions.calls == [pretend.calls(event)] + assert function.calls[0].__dict__['args'][0].data == {"name": "john"} + assert function.calls[0].__dict__['args'][0]['id'] == headers['ce-id'] + + def test_binary_event_view_func_wrapper(monkeypatch): data = pretend.stub() request = pretend.stub( From ab1b800d0056c7e83f8a04e4133fa45a76256d1b Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 08:06:05 -0700 Subject: [PATCH 09/44] test lint fixes Signed-off-by: Curtis Mason --- .../background_load_error/main.py | 18 +++++++++--------- tests/test_view_functions.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_functions/background_load_error/main.py b/tests/test_functions/background_load_error/main.py index d9db3c71..a726fa6d 100644 --- a/tests/test_functions/background_load_error/main.py +++ b/tests/test_functions/background_load_error/main.py @@ -16,14 +16,14 @@ def function(event, context): - """Test function with a syntax error. + """Test function with a syntax error. - The Worker is expected to detect this error when loading the function, and - return appropriate load response. + The Worker is expected to detect this error when loading the function, and + return appropriate load response. - Args: - event: The event data which triggered this background function. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ - # Syntax error: an extra closing parenthesis in the line below. - print('foo')) + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ + # Syntax error: an extra closing parenthesis in the line below. + print('foo') diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 9a985545..224085c2 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -64,7 +64,7 @@ def test_event_view_func_wrapper(monkeypatch): ] -def test_cloudevent_view_func_wrapper(monkeypatch): +def test_cloudevent_view_func_wrapper(): headers = {'Content-Type': 'application/cloudevents+json'} data = json.dumps({ "source": "from-galaxy-far-far-away", @@ -94,7 +94,7 @@ def test_cloudevent_view_func_wrapper(monkeypatch): "f6a65fcd-eed2-429d-9f71-ec0663d83025" -def test_binary_cloudevent_view_func_wrapper(monkeypatch): +def test_binary_cloudevent_view_func_wrapper(): headers = { "ce-specversion": "1.0", "ce-source": "from-galaxy-far-far-away", From 19f066c1563b19416a07c7b2d33d48d190a16561 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 08:46:46 -0700 Subject: [PATCH 10/44] implemented try_catch in cloudevent view wrapper Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 4 +- tests/test_cloudevent_functions.py | 10 +++ .../background_load_error/main.py | 18 ++--- tests/test_view_functions.py | 66 ++++++++++++------- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 2a183b33..46dcc790 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -79,9 +79,9 @@ def _run_cloudevent(function, request): def _cloudevent_view_func_wrapper(function, request): def view_func(path): - if is_binary(request.headers) or is_structured(request.headers): + try: _run_cloudevent(function, request) - else: + except ValueError: flask.abort( 400, description=( diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 7ace97d1..b4f344f6 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -93,3 +93,13 @@ def test_event_0_3(cloudevent_0_3): resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" + + +def test_invalid_cloudevent(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" + target = "function" + + client = create_app(target, source, "cloudevent").test_client() + + resp = client.post("/", headers={}, data="data") + assert resp.status_code == 400 diff --git a/tests/test_functions/background_load_error/main.py b/tests/test_functions/background_load_error/main.py index a726fa6d..d9db3c71 100644 --- a/tests/test_functions/background_load_error/main.py +++ b/tests/test_functions/background_load_error/main.py @@ -16,14 +16,14 @@ def function(event, context): - """Test function with a syntax error. + """Test function with a syntax error. - The Worker is expected to detect this error when loading the function, and - return appropriate load response. + The Worker is expected to detect this error when loading the function, and + return appropriate load response. - Args: - event: The event data which triggered this background function. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ - # Syntax error: an extra closing parenthesis in the line below. - print('foo') + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ + # Syntax error: an extra closing parenthesis in the line below. + print('foo')) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 224085c2..59087fea 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -25,8 +25,7 @@ def test_http_view_func_wrapper(): request_object = pretend.stub() local_proxy = pretend.stub(_get_current_object=lambda: request_object) - view_func = functions_framework._http_view_func_wrapper( - function, local_proxy) + view_func = functions_framework._http_view_func_wrapper(function, local_proxy) view_func("/some/path") assert function.calls == [pretend.call(request_object)] @@ -64,34 +63,53 @@ def test_event_view_func_wrapper(monkeypatch): ] +def test_run_cloudevent(): + headers = {"Content-Type": "application/cloudevents+json"} + data = json.dumps( + { + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "specversion": "1.0", + "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "time": "2020-08-13T02:12:14.946587+00:00", + "data": {"name": "john"}, + } + ) + request = pretend.stub(headers=headers, get_data=lambda: data) + function = pretend.call_recorder(lambda cloudevent: "hello") + functions_framework._run_cloudevent(function, request) + event = function.calls[0].__dict__["args"][0] + assert event.data == {"name": "john"} + assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" + + def test_cloudevent_view_func_wrapper(): - headers = {'Content-Type': 'application/cloudevents+json'} - data = json.dumps({ - "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", - "specversion": "1.0", - "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", - "time": "2020-08-13T02:12:14.946587+00:00", - "data": {"name": "john"} - }) + headers = {"Content-Type": "application/cloudevents+json"} + data = json.dumps( + { + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "specversion": "1.0", + "id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", + "time": "2020-08-13T02:12:14.946587+00:00", + "data": {"name": "john"}, + } + ) request = pretend.stub(headers=headers, get_data=lambda: data) # event = from_http(request.get_data(), request.headers) function = pretend.call_recorder(lambda cloudevent: cloudevent) - view_func = functions_framework._cloudevent_view_func_wrapper( - function, - request - ) + view_func = functions_framework._cloudevent_view_func_wrapper(function, request) view_func("/some/path") # cloudevents v1.0.0 does not support __eq__ overload yet # therefore we cannot do: # assert functions.calls == [pretend.calls(event)] - assert function.calls[0].__dict__['args'][0].data == {"name": "john"} - assert function.calls[0].__dict__['args'][0]['id'] == \ - "f6a65fcd-eed2-429d-9f71-ec0663d83025" + event = function.calls[0].__dict__["args"][0] + assert event.data == {"name": "john"} + assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" def test_binary_cloudevent_view_func_wrapper(): @@ -100,7 +118,7 @@ def test_binary_cloudevent_view_func_wrapper(): "ce-source": "from-galaxy-far-far-away", "ce-type": "cloudevent.greet.you", "ce-id": "f6a65fcd-eed2-429d-9f71-ec0663d83025", - "ce-time": "2020-08-13T02:12:14.946587+00:00" + "ce-time": "2020-08-13T02:12:14.946587+00:00", } data = json.dumps({"name": "john"}) @@ -109,17 +127,15 @@ def test_binary_cloudevent_view_func_wrapper(): function = pretend.call_recorder(lambda cloudevent: cloudevent) - view_func = functions_framework._cloudevent_view_func_wrapper( - function, - request - ) + view_func = functions_framework._cloudevent_view_func_wrapper(function, request) view_func("/some/path") # cloudevents v1.0.0 does not support __eq__ overload yet # therefore we cannot do: # assert functions.calls == [pretend.calls(event)] - assert function.calls[0].__dict__['args'][0].data == {"name": "john"} - assert function.calls[0].__dict__['args'][0]['id'] == headers['ce-id'] + event = function.calls[0].__dict__["args"][0] + assert event.data == {"name": "john"} + assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" def test_binary_event_view_func_wrapper(monkeypatch): From 5b480ec1a469774c29ee5ec6dbc1fbbc54b82241 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 08:49:24 -0700 Subject: [PATCH 11/44] import fix Signed-off-by: Curtis Mason --- tests/test_view_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 59087fea..3efa6fa1 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -11,12 +11,12 @@ # 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.http import from_http - import json import pretend +from cloudevents.http import from_http + import functions_framework From 351bf5869121da72d3c18aa43e817330eaf399de Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 12:01:15 -0700 Subject: [PATCH 12/44] adjusted cloud_run_cloudevents readme Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/README.md | 20 +---------- .../cloud_run_cloudevents/send_cloudevent.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 examples/cloud_run_cloudevents/send_cloudevent.py diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 19b8caa3..2a9437e7 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -19,23 +19,5 @@ docker run --rm -p 8080:8080 -e PORT=8080 ff_example Send an event to the container: ```python -from cloudevents.http import CloudEvent, to_structured_http -import requests -import json - -# Create event -attributes = { - "Content-Type": "application/json", - "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you" -} -data = {"name":"john"} - -event = CloudEvent(attributes, data) -print(event) - -# Send event -headers, data = to_structured_http(event) -response = requests.post("http://localhost:8080/", headers=headers, data=data) -response.content +python send_cloudevent.py ``` diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py new file mode 100644 index 00000000..ea600f1e --- /dev/null +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -0,0 +1,33 @@ +# 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. +from cloudevents.http import CloudEvent, to_structured_http +import requests +import json + + +# Create event +attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you" +} +data = {"name":"john"} + +event = CloudEvent(attributes, data) +print(event) + +# Send event +headers, data = to_structured_http(event) +response = requests.post("http://localhost:8080/", headers=headers, data=data) +response.content \ No newline at end of file From 9c323c9422f980093697add067ed4963434bae63 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 12:06:29 -0700 Subject: [PATCH 13/44] added elif for cloudevent Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 46dcc790..004ceb8a 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -223,7 +223,7 @@ 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") app.after_request(read_request) - elif signature_type == "event" or signature_type == "cloudevent": + elif signature_type == "event": app.url_map.add( werkzeug.routing.Rule( "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] @@ -241,6 +241,18 @@ def create_app(target=None, source=None, signature_type=None): # Add the view functions app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) + elif signature_type == "cloudevent": + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule( + "/", endpoint=signature_type, methods=["POST"] + ) + ) + app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( function, flask.request ) From 9fc5a56a5c51802258d36858c70db6bfb03bc3f7 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 12:35:20 -0700 Subject: [PATCH 14/44] adjusted README Signed-off-by: Curtis Mason --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 904757e2..4471f388 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,11 @@ You can configure the Functions Framework using command-line flags or environmen | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | -# Enable CloudEvents - -The Functions Framework can unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to `data` and `context` objects. These will be passed as arguments to your function when it receives a request. Note that your function must use the event-style function signature: +# Enable Google Cloud Functions Events +The Functions Framework can unmarshall incoming +Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `data` and `context` objects. +These will be passed as arguments to your function when it receives a request. +Note that your function must use the `event`-style function signature: ```python def hello(data, context): @@ -143,6 +145,16 @@ def hello(data, context): print(context) ``` +To enable automatic unmarshalling, set the function signature type to `event` +using a command-line flag or an environment variable. By default, the HTTP +signature will be used and automatic event unmarshalling will be disabled. + +For more details on this signature type, check out the Google Cloud Functions +documentation on +[background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). + +# Enable CloudEvents + The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloudevent` object. This will be passed as a [cloudevent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the cloudevents-style function signature: ```python From a0d841ba062b7d217bc25b9d1e66b3ad3588651d Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 22:10:15 -0700 Subject: [PATCH 15/44] upgraded to cloudevents 1.0.1 Signed-off-by: Curtis Mason --- setup.py | 2 +- src/functions_framework/__init__.py | 23 +++++++++--- tests/test_cloudevent_functions.py | 58 +++++++++++++++++++++++++++-- tests/test_view_functions.py | 18 ++------- 4 files changed, 77 insertions(+), 24 deletions(-) diff --git a/setup.py b/setup.py index f1be41bc..b42e1bbf 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<=1.0", + "cloudevents<=1.0.1", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 004ceb8a..96c02044 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -17,13 +17,14 @@ import os.path import pathlib import sys +import json import types import flask import werkzeug -from cloudevents.http import from_http -from cloudevents.sdk.converters import is_binary, is_structured +from cloudevents.http import from_http, is_binary +import cloudevents.exceptions as cloud_exceptions from functions_framework.exceptions import ( FunctionsFrameworkException, @@ -51,7 +52,7 @@ def __init__( timestamp="", eventType="", resource="", - **kwargs + **kwargs, ): self.context = context if not self.context: @@ -81,12 +82,24 @@ def _cloudevent_view_func_wrapper(function, request): def view_func(path): try: _run_cloudevent(function, request) - except ValueError: + except ( + cloud_exceptions.CloudEventMissingRequiredFields, + cloud_exceptions.CloudEventTypeErrorRequiredFields, + ): flask.abort( 400, description=( "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a valid cloudevent as a request." + "but it did not receive a valid cloudevent as a request." + ), + ) + except json.decoder.JSONDecodeError: + flask.abort( + 400, + description=( + "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" + " was unable to read cloudevent with http headers:" + f" {request.headers} and json: {request.get_data()}" ), ) return "OK" diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index b4f344f6..c2d5d9e4 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -32,7 +32,6 @@ @pytest.fixture def cloudevent_1_0(): attributes = { - "Content-Type": "application/json", "id": "my-id", "source": "from-galaxy-far-far-away", "time": "tomorrow", @@ -45,7 +44,6 @@ def cloudevent_1_0(): @pytest.fixture def cloudevent_0_3(): attributes = { - "Content-Type": "application/json", "id": "my-id", "source": "from-galaxy-far-far-away", "time": "tomorrow", @@ -95,11 +93,63 @@ def test_event_0_3(cloudevent_0_3): assert resp.data == b"OK" -def test_invalid_cloudevent(): +def test_unparsable_cloudevent(): source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" target = "function" client = create_app(target, source, "cloudevent").test_client() - resp = client.post("/", headers={}, data="data") + resp = client.post("/", headers={}, data="") + assert resp.status_code == 400 + + +def test_cloudevent_missing_required_binary_fields(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" + target = "function" + + client = create_app(target, source, "cloudevent").test_client() + headers = { + "ce-id": "my-id", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloudevent.greet.you", + "ce-specversion": "1.0", + } + data = {"name": "john"} + for remove_key in headers: + invalid_headers = {key: headers[key] for key in headers if key != remove_key} + resp = client.post("/", headers=invalid_headers, data=data) + assert resp.status_code == 400 + + +def test_cloudevent_missing_required_structured_fields(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" + target = "function" + + client = create_app(target, source, "cloudevent").test_client() + headers = {"Content-Type": "application/cloudevents+json"} + data = { + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "specversion": "1.0", + } + for remove_key in data: + invalid_data = {key: data[key] for key in data if key != remove_key} + resp = client.post("/", headers=headers, data=invalid_data) + assert resp.status_code == 400 + + +def test_invalid_fields_binary(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" + target = "function" + + client = create_app(target, source, "cloudevent").test_client() + headers = { + "ce-id": "my-id", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloudevent.greet.you", + "ce-specversion": None, + } + data = {"name": "john"} + resp = client.post("/", headers=headers, data=data) assert resp.status_code == 400 diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 3efa6fa1..75f5385d 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -97,19 +97,14 @@ def test_cloudevent_view_func_wrapper(): ) request = pretend.stub(headers=headers, get_data=lambda: data) - # event = from_http(request.get_data(), request.headers) + event = from_http(request.get_data(), request.headers) function = pretend.call_recorder(lambda cloudevent: cloudevent) view_func = functions_framework._cloudevent_view_func_wrapper(function, request) view_func("/some/path") - # cloudevents v1.0.0 does not support __eq__ overload yet - # therefore we cannot do: - # assert functions.calls == [pretend.calls(event)] - event = function.calls[0].__dict__["args"][0] - assert event.data == {"name": "john"} - assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" + assert function.calls == [pretend.call(event)] def test_binary_cloudevent_view_func_wrapper(): @@ -123,19 +118,14 @@ def test_binary_cloudevent_view_func_wrapper(): data = json.dumps({"name": "john"}) request = pretend.stub(headers=headers, get_data=lambda: data) - # event = from_http(request.get_data(), request.headers) + event = from_http(request.get_data(), request.headers) function = pretend.call_recorder(lambda cloudevent: cloudevent) view_func = functions_framework._cloudevent_view_func_wrapper(function, request) view_func("/some/path") - # cloudevents v1.0.0 does not support __eq__ overload yet - # therefore we cannot do: - # assert functions.calls == [pretend.calls(event)] - event = function.calls[0].__dict__["args"][0] - assert event.data == {"name": "john"} - assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" + assert function.calls == [pretend.call(event)] def test_binary_event_view_func_wrapper(monkeypatch): From d48d0b98043a086c026b2b6e7f0f82f19cc3f184 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Thu, 13 Aug 2020 22:12:04 -0700 Subject: [PATCH 16/44] import ordering lint fix Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 96c02044..3a86a8b1 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -14,17 +14,17 @@ import functools import importlib.util +import json import os.path import pathlib import sys -import json import types +import cloudevents.exceptions as cloud_exceptions import flask import werkzeug from cloudevents.http import from_http, is_binary -import cloudevents.exceptions as cloud_exceptions from functions_framework.exceptions import ( FunctionsFrameworkException, From cd2f753f7468da56c1702780f8f0a7fa3ef2ac6b Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Fri, 14 Aug 2020 12:20:26 -0700 Subject: [PATCH 17/44] removed event from readme cloudevents section Signed-off-by: Curtis Mason --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4471f388..7622bc72 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ def hello(cloudevent): print(f"Received event with ID: {cloudevent['id']}") ``` -To enable automatic unmarshalling, set the function signature type to either `event` or `cloudevent` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. +To enable automatic unmarshalling, set the function signature type to `cloudevent` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. For more details on this signature type, check out the Google Cloud Functions documentation on [background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). From 0d94485d521449369ca667641e35c8ef43e4e817 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Fri, 14 Aug 2020 13:00:26 -0700 Subject: [PATCH 18/44] resolved various nits and reverted event code Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/README.md | 4 ++-- examples/cloud_run_cloudevents/main.py | 4 ++-- examples/cloud_run_cloudevents/send_cloudevent.py | 4 ++-- src/functions_framework/__init__.py | 11 +++-------- tests/test_cloudevent_functions.py | 3 +-- tests/test_functions/cloudevents/main.py | 1 - 6 files changed, 10 insertions(+), 17 deletions(-) diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 2a9437e7..318b5632 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -1,6 +1,6 @@ -# Deploying a CloudEvent function to Cloud Run with the Functions Framework +# Deploying a CloudEvent Function to Cloud Run with the Functions Framework -This sample uses the [Cloud Events SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. +This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. ## How to run this locally diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index bc11a8a4..71a947fa 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -# This sample creates a function that accepts a Cloud Event per -# https://github.com/cloudevents/sdk-python +# This sample creates a function using the CloudEvents SDK +# (https://github.com/cloudevents/sdk-python) import sys diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index ea600f1e..83672778 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -16,7 +16,7 @@ import json -# Create event +# CloudEvent constructor minimally requires a source and type field attributes = { "Content-Type": "application/json", "source": "from-galaxy-far-far-away", @@ -30,4 +30,4 @@ # Send event headers, data = to_structured_http(event) response = requests.post("http://localhost:8080/", headers=headers, data=data) -response.content \ No newline at end of file +response.content diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 3a86a8b1..d770fc36 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -239,21 +239,16 @@ def create_app(target=None, source=None, signature_type=None): elif signature_type == "event": app.url_map.add( werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + "/", defaults={"path": ""}, endpoint="run", methods=["POST"] ) ) app.url_map.add( - werkzeug.routing.Rule( - "/", endpoint=signature_type, methods=["POST"] - ) + werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) ) - + 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: "" - - # Add the view functions - app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) elif signature_type == "cloudevent": app.url_map.add( werkzeug.routing.Rule( diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index c2d5d9e4..a6f5244d 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -32,9 +32,9 @@ @pytest.fixture def cloudevent_1_0(): attributes = { + "specversion": "1.0", "id": "my-id", "source": "from-galaxy-far-far-away", - "time": "tomorrow", "type": "cloudevent.greet.you", } data = {"name": "john"} @@ -46,7 +46,6 @@ def cloudevent_0_3(): attributes = { "id": "my-id", "source": "from-galaxy-far-far-away", - "time": "tomorrow", "type": "cloudevent.greet.you", "specversion": "0.3", } diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index 6a529e94..cc85f90d 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -33,7 +33,6 @@ def function(cloudevent): cloudevent["id"] == "my-id" and cloudevent.data == {"name": "john"} and cloudevent["source"] == "from-galaxy-far-far-away" - and cloudevent["time"] == "tomorrow" and cloudevent["type"] == "cloudevent.greet.you" ) From 2b0a8abaf13e6695f747d7d0b57a38b7db5054e2 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Fri, 14 Aug 2020 13:02:29 -0700 Subject: [PATCH 19/44] dockerfile env variables Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/Dockerfile | 2 ++ examples/cloud_run_event/Dockerfile | 2 ++ examples/cloud_run_http/Dockerfile | 2 ++ 3 files changed, 6 insertions(+) diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile index 10163c5f..fb374a15 100644 --- a/examples/cloud_run_cloudevents/Dockerfile +++ b/examples/cloud_run_cloudevents/Dockerfile @@ -4,6 +4,8 @@ FROM python:3.7-slim # Copy local code to the container image. ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + WORKDIR $APP_HOME COPY . . diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index b3e7ffeb..7fa0df13 100644 --- a/examples/cloud_run_event/Dockerfile +++ b/examples/cloud_run_event/Dockerfile @@ -4,6 +4,8 @@ FROM python:3.7-slim # Copy local code to the container image. ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + WORKDIR $APP_HOME COPY . . diff --git a/examples/cloud_run_http/Dockerfile b/examples/cloud_run_http/Dockerfile index c81596c3..b7d6f502 100644 --- a/examples/cloud_run_http/Dockerfile +++ b/examples/cloud_run_http/Dockerfile @@ -4,6 +4,8 @@ FROM python:3.7-slim # Copy local code to the container image. ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + WORKDIR $APP_HOME COPY . . From a859e7b40b1f1505d05dcb593cc0f5d796474028 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Fri, 14 Aug 2020 16:03:08 -0400 Subject: [PATCH 20/44] Update examples/cloud_run_cloudevents/main.py Co-authored-by: Dustin Ingram Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/main.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 71a947fa..b0cda2c9 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -18,8 +18,4 @@ def hello(cloudevent): - print( - f"Received event with ID: {cloudevent['id']}", - file=sys.stdout, - flush=True - ) + print(f"Received event with ID: {cloudevent['id']}") From fb48d89dbe6473a9d76dfb1364168b2aaa8754c0 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Fri, 14 Aug 2020 22:32:54 -0700 Subject: [PATCH 21/44] cleaned up test_cloudevent_functions Signed-off-by: Curtis Mason --- .../cloud_run_cloudevents/send_cloudevent.py | 2 - setup.py | 2 +- src/functions_framework/__init__.py | 14 +- tests/test_cloudevent_functions.py | 174 ++++++++++++------ .../test_functions/cloudevents/empty_data.py | 39 ++++ 5 files changed, 163 insertions(+), 68 deletions(-) create mode 100644 tests/test_functions/cloudevents/empty_data.py diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index 83672778..907a8eed 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -25,9 +25,7 @@ data = {"name":"john"} event = CloudEvent(attributes, data) -print(event) # Send event headers, data = to_structured_http(event) response = requests.post("http://localhost:8080/", headers=headers, data=data) -response.content diff --git a/setup.py b/setup.py index b42e1bbf..e500b2b2 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<=1.0.1", + "cloudevents==1.0.1", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index d770fc36..1fad8fe5 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -85,21 +85,23 @@ def view_func(path): except ( cloud_exceptions.CloudEventMissingRequiredFields, cloud_exceptions.CloudEventTypeErrorRequiredFields, - ): + ) as e: flask.abort( 400, description=( - "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - "but it did not receive a valid cloudevent as a request." + "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" + " failed to find all required cloudevent fields. Found http" + f" headers: {request.headers} and data: {request.get_data()}" ), ) - except json.decoder.JSONDecodeError: + except json.decoder.JSONDecodeError as e: + # TODO: more detailed error messages or error handling in sdk-python flask.abort( 400, description=( "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" - " was unable to read cloudevent with http headers:" - f" {request.headers} and json: {request.get_data()}" + " could not json decode data payload. Found http headers:" + f" {request.headers} and data: {request.get_data()}" ), ) return "OK" diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index a6f5244d..eed77170 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -29,6 +29,21 @@ _ModuleNotFoundError = ImportError +@pytest.fixture +def data_payload(): + return {"name": "john"} + + +@pytest.fixture +def json_decode_errmsg(): + return "could not json decode data payload" + + +@pytest.fixture +def missing_fields_errmsg(): + return "failed to find all required cloudevent fields" + + @pytest.fixture def cloudevent_1_0(): attributes = { @@ -53,102 +68,143 @@ def cloudevent_0_3(): return CloudEvent(attributes, data) -def test_event_1_0(cloudevent_1_0): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" +@pytest.fixture +def create_headers_binary(): + return lambda specversion: { + "ce-id": "my-id", + "ce-source": "from-galaxy-far-far-away", + "ce-type": "cloudevent.greet.you", + "ce-specversion": specversion, + } - client = create_app(target, source, "cloudevent").test_client() - headers, data = to_structured_http(cloudevent_1_0) - resp = client.post("/", headers=headers, data=data) - assert resp.status_code == 200 - assert resp.data == b"OK" +@pytest.fixture +def create_structured_data(): + return lambda specversion: { + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "specversion": specversion, + } -def test_binary_event_1_0(cloudevent_1_0): +@pytest.fixture +def client(): source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" target = "function" + return create_app(target, source, "cloudevent").test_client() - client = create_app(target, source, "cloudevent").test_client() - headers, data = to_binary_http(cloudevent_1_0) +@pytest.fixture +def empty_client(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "empty_data.py" + target = "function" + return create_app(target, source, "cloudevent").test_client() + +def test_event(client, cloudevent_1_0): + headers, data = to_structured_http(cloudevent_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 assert resp.data == b"OK" -def test_event_0_3(cloudevent_0_3): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" +def test_binary_event(client, cloudevent_1_0): + headers, data = to_binary_http(cloudevent_1_0) + resp = client.post("/", headers=headers, data=data) - client = create_app(target, source, "cloudevent").test_client() + assert resp.status_code == 200 + assert resp.data == b"OK" - headers, data = to_structured_http(cloudevent_0_3) +def test_event_0_3(client, cloudevent_0_3): + headers, data = to_structured_http(cloudevent_0_3) resp = client.post("/", headers=headers, data=data) + assert resp.status_code == 200 assert resp.data == b"OK" -def test_unparsable_cloudevent(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() +def test_binary_event_0_3(client, cloudevent_0_3): + headers, data = to_binary_http(cloudevent_0_3) + resp = client.post("/", headers=headers, data=data) - resp = client.post("/", headers={}, data="") - assert resp.status_code == 400 + assert resp.status_code == 200 + assert resp.data == b"OK" -def test_cloudevent_missing_required_binary_fields(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_cloudevent_missing_required_binary_fields( + client, specversion, missing_fields_errmsg, create_headers_binary, data_payload +): + headers = create_headers_binary(specversion) - client = create_app(target, source, "cloudevent").test_client() - headers = { - "ce-id": "my-id", - "ce-source": "from-galaxy-far-far-away", - "ce-type": "cloudevent.greet.you", - "ce-specversion": "1.0", - } - data = {"name": "john"} for remove_key in headers: invalid_headers = {key: headers[key] for key in headers if key != remove_key} - resp = client.post("/", headers=invalid_headers, data=data) - assert resp.status_code == 400 + resp = client.post("/", headers=invalid_headers, json=data_payload) + assert resp.status_code == 400 + assert missing_fields_errmsg in resp.get_data().decode() -def test_cloudevent_missing_required_structured_fields(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - client = create_app(target, source, "cloudevent").test_client() +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_cloudevent_missing_required_structured_fields( + client, specversion, create_structured_data, missing_fields_errmsg +): headers = {"Content-Type": "application/cloudevents+json"} - data = { - "id": "my-id", - "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", - "specversion": "1.0", - } + data = create_structured_data(specversion) + for remove_key in data: invalid_data = {key: data[key] for key in data if key != remove_key} - resp = client.post("/", headers=headers, data=invalid_data) + resp = client.post("/", headers=headers, json=invalid_data) + assert resp.status_code == 400 + assert missing_fields_errmsg in resp.get_data().decode() -def test_invalid_fields_binary(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" +def test_invalid_fields_binary( + client, create_headers_binary, missing_fields_errmsg, data_payload +): + # Testing none specversion fails + headers = create_headers_binary(None) + resp = client.post("/", headers=headers, json=data_payload) + + assert resp.status_code == 400 + assert missing_fields_errmsg in resp.data.decode() + + +def test_unparsable_cloudevent(client, json_decode_errmsg): + resp = client.post("/", headers={}, data="") - client = create_app(target, source, "cloudevent").test_client() - headers = { - "ce-id": "my-id", - "ce-source": "from-galaxy-far-far-away", - "ce-type": "cloudevent.greet.you", - "ce-specversion": None, - } - data = {"name": "john"} - resp = client.post("/", headers=headers, data=data) assert resp.status_code == 400 + assert json_decode_errmsg in resp.data.decode() + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_empty_data_binary(empty_client, create_headers_binary, specversion): + headers = create_headers_binary(specversion) + resp = empty_client.post("/", headers=headers, json="") + + assert resp.status_code == 200 + assert resp.get_data() == b"OK" + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_empty_data_structured(empty_client, specversion, create_structured_data): + headers = {"Content-Type": "application/cloudevents+json"} + + data = create_structured_data(specversion) + resp = empty_client.post("/", headers=headers, json=data) + + assert resp.status_code == 200 + assert resp.get_data() == b"OK" + + +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_no_mime_type_structured(empty_client, specversion, create_structured_data): + data = create_structured_data(specversion) + resp = empty_client.post("/", headers={}, json=data) + + assert resp.status_code == 200 + assert resp.get_data() == b"OK" diff --git a/tests/test_functions/cloudevents/empty_data.py b/tests/test_functions/cloudevents/empty_data.py new file mode 100644 index 00000000..1d7b4751 --- /dev/null +++ b/tests/test_functions/cloudevents/empty_data.py @@ -0,0 +1,39 @@ +# 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. + +"""Function used to test handling Cloud Event functions.""" +import flask + + +def function(cloudevent): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloudevent: A Cloud event as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + + valid_event = ( + cloudevent["id"] == "my-id" + and cloudevent["source"] == "from-galaxy-far-far-away" + and cloudevent["type"] == "cloudevent.greet.you" + ) + + if not valid_event: + flask.abort(500) From cb61aab2eb6be0a529d939571ae464cc6ea20912 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Sat, 15 Aug 2020 18:16:04 -0400 Subject: [PATCH 22/44] Update examples/cloud_run_cloudevents/Dockerfile Co-authored-by: Adam Ross Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile index fb374a15..6e299f2f 100644 --- a/examples/cloud_run_cloudevents/Dockerfile +++ b/examples/cloud_run_cloudevents/Dockerfile @@ -14,4 +14,4 @@ RUN pip install gunicorn cloudevents functions-framework RUN pip install -r requirements.txt # Run the web service on container startup. -CMD exec functions-framework --target=hello --signature-type=cloudevent +CMD ["functions-framework", "--target=hello", "--signature-type=cloudevent"] From 33f867c6cddd8d4311e121c9e40d47449ba25e46 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Sun, 16 Aug 2020 13:37:43 -0700 Subject: [PATCH 23/44] tunneled cloud_exceptions in flask abort Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 1fad8fe5..2974ac31 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -91,7 +91,8 @@ def view_func(path): description=( "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " failed to find all required cloudevent fields. Found http" - f" headers: {request.headers} and data: {request.get_data()}" + f" headers: {request.headers} and data: {request.get_data()}. " + f"cloudevents.exceptions.{type(e).__name__}: {e}" ), ) except json.decoder.JSONDecodeError as e: @@ -99,9 +100,11 @@ def view_func(path): flask.abort( 400, description=( + f"{e} " "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " could not json decode data payload. Found http headers:" - f" {request.headers} and data: {request.get_data()}" + f" {request.headers} and data: {request.get_data()}. " + f"json.decoder.JSONDecodeError: {e}" ), ) return "OK" From 80306420efa5d4ef460fd69f7b2bb79764c26d8e Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Sun, 16 Aug 2020 13:48:05 -0700 Subject: [PATCH 24/44] Added additional documentation in sample code Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/main.py | 2 +- examples/cloud_run_cloudevents/send_cloudevent.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index b0cda2c9..6c7bdc5b 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -18,4 +18,4 @@ def hello(cloudevent): - print(f"Received event with ID: {cloudevent['id']}") + print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index 907a8eed..e6d54806 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -16,7 +16,10 @@ import json -# CloudEvent constructor minimally requires a source and type field +# Create a cloudevent using https://github.com/cloudevents/sdk-python +# Note we only need source and type because the cloudevents constructor by +# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) +# and "id" to a generated uuid.uuid4 string. attributes = { "Content-Type": "application/json", "source": "from-galaxy-far-far-away", @@ -26,6 +29,6 @@ event = CloudEvent(attributes, data) -# Send event +# Send the event to our local docker container listening on port 8080 headers, data = to_structured_http(event) response = requests.post("http://localhost:8080/", headers=headers, data=data) From 430a3d1feaa02ff821810ac8fe29ae3e3a8f6155 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Sun, 16 Aug 2020 14:52:42 -0700 Subject: [PATCH 25/44] added time to tests Signed-off-by: Curtis Mason --- tests/test_cloudevent_functions.py | 11 +++++++++++ tests/test_functions/cloudevents/main.py | 1 + 2 files changed, 12 insertions(+) diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index eed77170..dbc9cba7 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -51,6 +51,8 @@ def cloudevent_1_0(): "id": "my-id", "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", + "time": "2020-08-16T13:58:54.471765" + } data = {"name": "john"} return CloudEvent(attributes, data) @@ -63,6 +65,7 @@ def cloudevent_0_3(): "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", "specversion": "0.3", + "time": "2020-08-16T13:58:54.471765" } data = {"name": "john"} return CloudEvent(attributes, data) @@ -75,6 +78,7 @@ def create_headers_binary(): "ce-source": "from-galaxy-far-far-away", "ce-type": "cloudevent.greet.you", "ce-specversion": specversion, + "time": "2020-08-16T13:58:54.471765" } @@ -85,6 +89,7 @@ def create_structured_data(): "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", "specversion": specversion, + "time": "2020-08-16T13:58:54.471765" } @@ -141,6 +146,9 @@ def test_cloudevent_missing_required_binary_fields( headers = create_headers_binary(specversion) for remove_key in headers: + if remove_key == "time": + continue + invalid_headers = {key: headers[key] for key in headers if key != remove_key} resp = client.post("/", headers=invalid_headers, json=data_payload) @@ -156,6 +164,9 @@ def test_cloudevent_missing_required_structured_fields( data = create_structured_data(specversion) for remove_key in data: + if remove_key == "time": + continue + invalid_data = {key: data[key] for key in data if key != remove_key} resp = client.post("/", headers=headers, json=invalid_data) diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index cc85f90d..94ebbc74 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -34,6 +34,7 @@ def function(cloudevent): and cloudevent.data == {"name": "john"} and cloudevent["source"] == "from-galaxy-far-far-away" and cloudevent["type"] == "cloudevent.greet.you" + and cloudevent["time"] == "2020-08-16T13:58:54.471765" ) if not valid_event: From 1c2c1b4dc1f5a8a9f7cb6caae97a40ab906d53a1 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:40:07 -0400 Subject: [PATCH 26/44] Update README.md Co-authored-by: Dustin Ingram --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7622bc72..6680969e 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ You can configure the Functions Framework using command-line flags or environmen | `--host` | `HOST` | The host on which the Functions Framework listens for requests. Default: `0.0.0.0` | | `--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` | | `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` | -| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http` or `event` or `cloudevent` | +| `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http`, `event` or `cloudevent` | | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | From 78cef16f1885de136c66b4e5d0a0497e5e79d430 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:40:39 -0400 Subject: [PATCH 27/44] Update README.md Co-authored-by: Dustin Ingram --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6680969e..09ba5039 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ documentation on # Enable CloudEvents -The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloudevent` object. This will be passed as a [cloudevent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the cloudevents-style function signature: +The Functions framework can also unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to the `cloudevent` object. This will be passed as a [cloudevent](https://github.com/cloudevents/sdk-python) to your function when it receives a request. Note that your function must use the `cloudevents`-style function signature: ```python def hello(cloudevent): From d2cdec00c1f437ffbaddad62c446589f2022e06b Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:41:16 -0400 Subject: [PATCH 28/44] Update README.md Co-authored-by: Dustin Ingram --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 09ba5039..232f6d9d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ To enable automatic unmarshalling, set the function signature type to `event` using a command-line flag or an environment variable. By default, the HTTP signature will be used and automatic event unmarshalling will be disabled. -For more details on this signature type, check out the Google Cloud Functions +For more details on this signature type, see the Google Cloud Functions documentation on [background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). From 1f0c56b9fac8aa907d0ed01bed9c6cf6c42874f9 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:42:06 -0400 Subject: [PATCH 29/44] Update README.md Co-authored-by: Dustin Ingram --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 232f6d9d..de8c4c55 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ def hello(data, context): ``` To enable automatic unmarshalling, set the function signature type to `event` -using a command-line flag or an environment variable. By default, the HTTP + using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature will be used and automatic event unmarshalling will be disabled. For more details on this signature type, see the Google Cloud Functions From 280379f6a0a4ab3eca7d428f20891fcdd9e4cd02 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:43:41 -0400 Subject: [PATCH 30/44] Update examples/cloud_run_cloudevents/send_cloudevent.py Co-authored-by: Dustin Ingram --- examples/cloud_run_cloudevents/send_cloudevent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index e6d54806..e1d67a68 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -31,4 +31,4 @@ # Send the event to our local docker container listening on port 8080 headers, data = to_structured_http(event) -response = requests.post("http://localhost:8080/", headers=headers, data=data) +requests.post("http://localhost:8080/", headers=headers, data=data) From 3032e3e7834faab18bc1a0d5987f9b58411a6aa1 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:44:13 -0400 Subject: [PATCH 31/44] Update examples/cloud_run_cloudevents/README.md Co-authored-by: Dustin Ingram --- examples/cloud_run_cloudevents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 318b5632..7b55d0a1 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -1,6 +1,6 @@ # Deploying a CloudEvent Function to Cloud Run with the Functions Framework -This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. +This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a [CloudEvent](http://cloudevents.io) on Cloud Run. ## How to run this locally From 6400a6d7d1666ca5d0c0120e6a59160d0432f60c Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:44:38 -0400 Subject: [PATCH 32/44] Update examples/cloud_run_cloudevents/README.md Co-authored-by: Dustin Ingram --- examples/cloud_run_cloudevents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index 7b55d0a1..ba6063cc 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -7,7 +7,7 @@ This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python Build the Docker image: ```commandline -docker build -t ff_example . +docker build -t cloudevent_example . ``` Run the image and bind the correct ports: From c8f1948a13558a1b55463c54540a6a762a947b0b Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:46:06 -0400 Subject: [PATCH 33/44] Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram --- src/functions_framework/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 2974ac31..8f929bbe 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -102,8 +102,8 @@ def view_func(path): description=( f"{e} " "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" - " could not json decode data payload. Found http headers:" - f" {request.headers} and data: {request.get_data()}. " + " could not deserialize the payload as JSON. Found HTTP headers:" + f" {request.headers} and payload: {request.get_data()}. " f"json.decoder.JSONDecodeError: {e}" ), ) From 3c20027a6e79c6b0476d42419e1ecd26a01b563e Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 10:47:37 -0400 Subject: [PATCH 34/44] Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram --- src/functions_framework/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 8f929bbe..d43aa640 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -100,7 +100,6 @@ def view_func(path): flask.abort( 400, description=( - f"{e} " "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " could not deserialize the payload as JSON. Found HTTP headers:" f" {request.headers} and payload: {request.get_data()}. " From 1b67e3d056725c61cbb07d39265c9d955eceef29 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Tue, 18 Aug 2020 10:02:02 -0700 Subject: [PATCH 35/44] cloudevents 1.1.0 update Signed-off-by: Curtis Mason --- .../cloud_run_cloudevents/send_cloudevent.py | 4 +- setup.py | 2 +- src/functions_framework/__init__.py | 24 +++++---- tests/test_cloudevent_functions.py | 53 ++++++++----------- tests/test_view_functions.py | 4 +- 5 files changed, 41 insertions(+), 46 deletions(-) diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index e1d67a68..d4cac535 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -11,7 +11,7 @@ # 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.http import CloudEvent, to_structured_http +from cloudevents.http import CloudEvent, to_structured import requests import json @@ -30,5 +30,5 @@ event = CloudEvent(attributes, data) # Send the event to our local docker container listening on port 8080 -headers, data = to_structured_http(event) +headers, data = to_structured(event) requests.post("http://localhost:8080/", headers=headers, data=data) diff --git a/setup.py b/setup.py index e500b2b2..4f516b3f 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents==1.0.1", + "cloudevents>=1.1.0,<2.0.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index d43aa640..161288bb 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -74,7 +74,7 @@ def view_func(path): def _run_cloudevent(function, request): data = request.get_data() - event = from_http(data, request.headers) + event = from_http(request.headers, data) function(event) @@ -82,28 +82,34 @@ def _cloudevent_view_func_wrapper(function, request): def view_func(path): try: _run_cloudevent(function, request) - except ( - cloud_exceptions.CloudEventMissingRequiredFields, - cloud_exceptions.CloudEventTypeErrorRequiredFields, - ) as e: + except cloud_exceptions.MissingRequiredFields as e: flask.abort( 400, description=( "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" - " failed to find all required cloudevent fields. Found http" + " failed to find all required cloudevent fields. Found HTTP" f" headers: {request.headers} and data: {request.get_data()}. " f"cloudevents.exceptions.{type(e).__name__}: {e}" ), ) - except json.decoder.JSONDecodeError as e: - # TODO: more detailed error messages or error handling in sdk-python + except cloud_exceptions.InvalidRequiredFields as e: + flask.abort( + 400, + description=( + "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" + " found one or more invalid required cloudevent fields. Found HTTP" + f" headers: {request.headers} and data: {request.get_data()}. " + f"cloudevents.exceptions.{type(e).__name__}: {e}" + ), + ) + except cloud_exceptions.InvalidStructuredJSON as e: flask.abort( 400, description=( "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " could not deserialize the payload as JSON. Found HTTP headers:" f" {request.headers} and payload: {request.get_data()}. " - f"json.decoder.JSONDecodeError: {e}" + f"cloudevents.exceptions.{type(e).__name__}: {e}" ), ) return "OK" diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index dbc9cba7..1082e864 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -16,7 +16,7 @@ import pytest -from cloudevents.http import CloudEvent, from_http, to_binary_http, to_structured_http +from cloudevents.http import CloudEvent, from_http, to_binary, to_structured from functions_framework import LazyWSGIApp, create_app, exceptions @@ -34,16 +34,6 @@ def data_payload(): return {"name": "john"} -@pytest.fixture -def json_decode_errmsg(): - return "could not json decode data payload" - - -@pytest.fixture -def missing_fields_errmsg(): - return "failed to find all required cloudevent fields" - - @pytest.fixture def cloudevent_1_0(): attributes = { @@ -51,8 +41,7 @@ def cloudevent_1_0(): "id": "my-id", "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", - "time": "2020-08-16T13:58:54.471765" - + "time": "2020-08-16T13:58:54.471765", } data = {"name": "john"} return CloudEvent(attributes, data) @@ -65,7 +54,7 @@ def cloudevent_0_3(): "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", "specversion": "0.3", - "time": "2020-08-16T13:58:54.471765" + "time": "2020-08-16T13:58:54.471765", } data = {"name": "john"} return CloudEvent(attributes, data) @@ -78,7 +67,7 @@ def create_headers_binary(): "ce-source": "from-galaxy-far-far-away", "ce-type": "cloudevent.greet.you", "ce-specversion": specversion, - "time": "2020-08-16T13:58:54.471765" + "time": "2020-08-16T13:58:54.471765", } @@ -89,7 +78,7 @@ def create_structured_data(): "source": "from-galaxy-far-far-away", "type": "cloudevent.greet.you", "specversion": specversion, - "time": "2020-08-16T13:58:54.471765" + "time": "2020-08-16T13:58:54.471765", } @@ -108,7 +97,7 @@ def empty_client(): def test_event(client, cloudevent_1_0): - headers, data = to_structured_http(cloudevent_1_0) + headers, data = to_structured(cloudevent_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 @@ -116,7 +105,7 @@ def test_event(client, cloudevent_1_0): def test_binary_event(client, cloudevent_1_0): - headers, data = to_binary_http(cloudevent_1_0) + headers, data = to_binary(cloudevent_1_0) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 @@ -124,7 +113,7 @@ def test_binary_event(client, cloudevent_1_0): def test_event_0_3(client, cloudevent_0_3): - headers, data = to_structured_http(cloudevent_0_3) + headers, data = to_structured(cloudevent_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 @@ -132,7 +121,7 @@ def test_event_0_3(client, cloudevent_0_3): def test_binary_event_0_3(client, cloudevent_0_3): - headers, data = to_binary_http(cloudevent_0_3) + headers, data = to_binary(cloudevent_0_3) resp = client.post("/", headers=headers, data=data) assert resp.status_code == 200 @@ -141,7 +130,7 @@ def test_binary_event_0_3(client, cloudevent_0_3): @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) def test_cloudevent_missing_required_binary_fields( - client, specversion, missing_fields_errmsg, create_headers_binary, data_payload + client, specversion, create_headers_binary, data_payload ): headers = create_headers_binary(specversion) @@ -153,12 +142,14 @@ def test_cloudevent_missing_required_binary_fields( resp = client.post("/", headers=invalid_headers, json=data_payload) assert resp.status_code == 400 - assert missing_fields_errmsg in resp.get_data().decode() + assert ( + "cloudevents.exceptions.MissingRequiredFields" in resp.get_data().decode() + ) @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) def test_cloudevent_missing_required_structured_fields( - client, specversion, create_structured_data, missing_fields_errmsg + client, specversion, create_structured_data ): headers = {"Content-Type": "application/cloudevents+json"} data = create_structured_data(specversion) @@ -166,30 +157,28 @@ def test_cloudevent_missing_required_structured_fields( for remove_key in data: if remove_key == "time": continue - + invalid_data = {key: data[key] for key in data if key != remove_key} resp = client.post("/", headers=headers, json=invalid_data) assert resp.status_code == 400 - assert missing_fields_errmsg in resp.get_data().decode() + assert "cloudevents.exceptions.MissingRequiredFields" in resp.data.decode() -def test_invalid_fields_binary( - client, create_headers_binary, missing_fields_errmsg, data_payload -): +def test_invalid_fields_binary(client, create_headers_binary, data_payload): # Testing none specversion fails - headers = create_headers_binary(None) + headers = create_headers_binary("not a spec version") resp = client.post("/", headers=headers, json=data_payload) assert resp.status_code == 400 - assert missing_fields_errmsg in resp.data.decode() + assert "cloudevents.exceptions.InvalidRequiredFields" in resp.data.decode() -def test_unparsable_cloudevent(client, json_decode_errmsg): +def test_unparsable_cloudevent(client): resp = client.post("/", headers={}, data="") assert resp.status_code == 400 - assert json_decode_errmsg in resp.data.decode() + assert "cloudevents.exceptions.InvalidStructuredJSON" in resp.data.decode() @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index 75f5385d..b6e060bd 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -97,7 +97,7 @@ def test_cloudevent_view_func_wrapper(): ) request = pretend.stub(headers=headers, get_data=lambda: data) - event = from_http(request.get_data(), request.headers) + event = from_http(request.headers, request.get_data()) function = pretend.call_recorder(lambda cloudevent: cloudevent) @@ -118,7 +118,7 @@ def test_binary_cloudevent_view_func_wrapper(): data = json.dumps({"name": "john"}) request = pretend.stub(headers=headers, get_data=lambda: data) - event = from_http(request.get_data(), request.headers) + event = from_http(request.headers, request.get_data()) function = pretend.call_recorder(lambda cloudevent: cloudevent) From e83785de675c42aa8e135f4e2d02e2d7f0e01d86 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Tue, 18 Aug 2020 10:04:41 -0700 Subject: [PATCH 36/44] simplified exceptions debug Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 161288bb..bb43b588 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -89,7 +89,7 @@ def view_func(path): "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " failed to find all required cloudevent fields. Found HTTP" f" headers: {request.headers} and data: {request.get_data()}. " - f"cloudevents.exceptions.{type(e).__name__}: {e}" + f"cloudevents.exceptions.MissingRequiredFields: {e}" ), ) except cloud_exceptions.InvalidRequiredFields as e: @@ -99,7 +99,7 @@ def view_func(path): "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " found one or more invalid required cloudevent fields. Found HTTP" f" headers: {request.headers} and data: {request.get_data()}. " - f"cloudevents.exceptions.{type(e).__name__}: {e}" + f"cloudevents.exceptions.InvalidRequiredFields: {e}" ), ) except cloud_exceptions.InvalidStructuredJSON as e: @@ -109,7 +109,7 @@ def view_func(path): "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" " could not deserialize the payload as JSON. Found HTTP headers:" f" {request.headers} and payload: {request.get_data()}. " - f"cloudevents.exceptions.{type(e).__name__}: {e}" + f"cloudevents.exceptions.InvalidStructuredJSON: {e}" ), ) return "OK" From a546ce2a2e7fe1828ca9facbbb6cf85ccd8eb042 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Tue, 18 Aug 2020 10:09:50 -0700 Subject: [PATCH 37/44] simplified cloudevent view test Signed-off-by: Curtis Mason --- tests/test_view_functions.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index b6e060bd..592c8300 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -76,11 +76,12 @@ def test_run_cloudevent(): } ) request = pretend.stub(headers=headers, get_data=lambda: data) + function = pretend.call_recorder(lambda cloudevent: "hello") functions_framework._run_cloudevent(function, request) - event = function.calls[0].__dict__["args"][0] - assert event.data == {"name": "john"} - assert event["id"] == "f6a65fcd-eed2-429d-9f71-ec0663d83025" + expected_cloudevent = from_http(request.headers, request.get_data()) + + assert function.calls == [pretend.call(expected_cloudevent)] def test_cloudevent_view_func_wrapper(): From 7f5780355f3a506c82cfccce51d3377536f1b868 Mon Sep 17 00:00:00 2001 From: Curtis Mason <31265687+cumason123@users.noreply.github.com> Date: Tue, 18 Aug 2020 13:22:09 -0400 Subject: [PATCH 38/44] Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram --- src/functions_framework/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index bb43b588..25e407b5 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -271,7 +271,7 @@ def create_app(target=None, source=None, signature_type=None): ) ) - app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( + app.view_functions[signature_type] = _cloudevent_view_func_wrapper( function, flask.request ) else: From 8ec12b49cff19a808b2bfc062a9f8a8a472c1bef Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Tue, 18 Aug 2020 12:00:53 -0700 Subject: [PATCH 39/44] shebang cloudevent executable Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/Dockerfile | 1 + examples/cloud_run_cloudevents/README.md | 4 ++-- examples/cloud_run_cloudevents/requirements.txt | 2 ++ examples/cloud_run_cloudevents/send_cloudevent.py | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile index 6e299f2f..bc9df896 100644 --- a/examples/cloud_run_cloudevents/Dockerfile +++ b/examples/cloud_run_cloudevents/Dockerfile @@ -12,6 +12,7 @@ COPY . . # Install production dependencies. RUN pip install gunicorn cloudevents functions-framework RUN pip install -r requirements.txt +RUN chmod +x send_cloudevent.py # Run the web service on container startup. CMD ["functions-framework", "--target=hello", "--signature-type=cloudevent"] diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md index ba6063cc..03a9931a 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -13,11 +13,11 @@ docker build -t cloudevent_example . Run the image and bind the correct ports: ```commandline -docker run --rm -p 8080:8080 -e PORT=8080 ff_example +docker run --rm -p 8080:8080 -e PORT=8080 cloudevent_example ``` Send an event to the container: ```python -python send_cloudevent.py +docker run -t cloudevent_example send_cloudevent.py ``` diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloudevents/requirements.txt index 33c5f99f..a10ffae5 100644 --- a/examples/cloud_run_cloudevents/requirements.txt +++ b/examples/cloud_run_cloudevents/requirements.txt @@ -1 +1,3 @@ # Optionally include additional dependencies here +cloudevents>=1.1.0 +requests diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index d4cac535..f30909ca 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -1,3 +1,5 @@ +#!/usr/local/bin/python + # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); From bf0a40fd2d80fd3215704de491c1dcf21a2958e7 Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 19 Aug 2020 15:01:42 -0700 Subject: [PATCH 40/44] cloudevents version bump Signed-off-by: Curtis Mason --- examples/cloud_run_cloudevents/requirements.txt | 2 +- setup.py | 2 +- tests/test_cloudevent_functions.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloudevents/requirements.txt index a10ffae5..0a7427c7 100644 --- a/examples/cloud_run_cloudevents/requirements.txt +++ b/examples/cloud_run_cloudevents/requirements.txt @@ -1,3 +1,3 @@ # Optionally include additional dependencies here -cloudevents>=1.1.0 +cloudevents>=1.2.0 requests diff --git a/setup.py b/setup.py index 4f516b3f..6f55f1ec 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents>=1.1.0,<2.0.0", + "cloudevents>=1.2.0,<2.0.0", ], entry_points={ "console_scripts": [ diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 1082e864..d2afcb7a 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -178,7 +178,7 @@ def test_unparsable_cloudevent(client): resp = client.post("/", headers={}, data="") assert resp.status_code == 400 - assert "cloudevents.exceptions.InvalidStructuredJSON" in resp.data.decode() + assert "cloudevents.exceptions.MissingRequiredFields" in resp.data.decode() @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) From e1e0395e74503dbde93bb1241e808d1da771b4ae Mon Sep 17 00:00:00 2001 From: Curtis Mason Date: Wed, 19 Aug 2020 16:51:58 -0700 Subject: [PATCH 41/44] Removed InvalidStructuredJSON exception Signed-off-by: Curtis Mason --- src/functions_framework/__init__.py | 10 ---------- tests/test_cloudevent_functions.py | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index ad0eb12a..2c52e406 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -102,16 +102,6 @@ def view_func(path): f"cloudevents.exceptions.InvalidRequiredFields: {e}" ), ) - except cloud_exceptions.InvalidStructuredJSON as e: - flask.abort( - 400, - description=( - "Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but" - " could not deserialize the payload as JSON. Found HTTP headers:" - f" {request.headers} and payload: {request.get_data()}. " - f"cloudevents.exceptions.InvalidStructuredJSON: {e}" - ), - ) return "OK" return view_func diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index d2afcb7a..90cebe05 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -172,6 +172,7 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): assert resp.status_code == 400 assert "cloudevents.exceptions.InvalidRequiredFields" in resp.data.decode() + assert "found one or more invalid required cloudevent field" in resp.data.decode() def test_unparsable_cloudevent(client): From 5e17f8fe9c130311a033e2962d73cacb18d30027 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 10 Sep 2020 13:40:48 -0500 Subject: [PATCH 42/44] Don't bump version in a feature branch --- README.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index de8c4c55..bc5f0e76 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==1.5.0 +functions-framework==2.0.0 ``` # Quickstart: Hello, World on your local machine diff --git a/setup.py b/setup.py index 6f55f1ec..e20723b1 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="1.5.0", + version="2.0.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown", From 7477dead07361b91f7153e43d179fc292e33be35 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 10 Sep 2020 13:42:22 -0500 Subject: [PATCH 43/44] Add back missing CHANGELOG lines --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e78b011..a2036832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#70]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/70 [#66]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/66 [#61]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/61 +[#56]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/56 +[#55]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/55 [#49]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/49 [#44]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/44 [#38]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/38 From 2d2413ef49435c5d10cf4e7c554254da25fc6fec Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 10 Sep 2020 13:44:40 -0500 Subject: [PATCH 44/44] Reformat with latest black --- tests/test_functions.py | 5 +- .../background_missing_dependency/main.py | 14 +++--- .../background_multiple_entry_points/main.py | 46 +++++++++---------- .../test_functions/background_trigger/main.py | 16 +++---- tests/test_functions/http_check_env/main.py | 14 +++--- .../http_flask_render_template/main.py | 22 ++++----- .../test_functions/http_method_check/main.py | 10 ++-- .../test_functions/http_request_check/main.py | 20 ++++---- tests/test_functions/http_trigger/main.py | 22 ++++----- .../test_functions/http_trigger_sleep/main.py | 14 +++--- tests/test_functions/http_with_import/main.py | 10 ++-- tests/test_functions/returns_none/main.py | 12 ++--- tests/test_http.py | 8 +++- 13 files changed, 110 insertions(+), 103 deletions(-) diff --git a/tests/test_functions.py b/tests/test_functions.py index 80932e9d..c102f820 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -416,7 +416,10 @@ def test_error_paths(path): @pytest.mark.parametrize( "target, source, signature_type", - [(None, None, None), (pretend.stub(), pretend.stub(), pretend.stub()),], + [ + (None, None, None), + (pretend.stub(), pretend.stub(), pretend.stub()), + ], ) def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): actual_app_stub = pretend.stub() diff --git a/tests/test_functions/background_missing_dependency/main.py b/tests/test_functions/background_missing_dependency/main.py index 19857092..3050adfc 100644 --- a/tests/test_functions/background_missing_dependency/main.py +++ b/tests/test_functions/background_missing_dependency/main.py @@ -19,14 +19,14 @@ def function(event, context): """Test function which uses a package which has not been provided. - The packaged imported above does not exist. Therefore, this import should - fail, the Worker should detect this error, and return appropriate load - response. + The packaged imported above does not exist. Therefore, this import should + fail, the Worker should detect this error, and return appropriate load + response. - Args: - event: The event data which triggered this background function. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ del event del context nonexistentpackage.wontwork("This function isn't expected to work.") diff --git a/tests/test_functions/background_multiple_entry_points/main.py b/tests/test_functions/background_multiple_entry_points/main.py index 9d976570..56b1a73f 100644 --- a/tests/test_functions/background_multiple_entry_points/main.py +++ b/tests/test_functions/background_multiple_entry_points/main.py @@ -18,14 +18,14 @@ def fun(name, event): """Test function implementation. - It writes the expected output (entry point name and the given value) to the - given file, as a response from the background function, verified by the test. + It writes the expected output (entry point name and the given value) to the + given file, as a response from the background function, verified by the test. - Args: - name: Entry point function which called this helper function. - event: The event which triggered this background function. Must contain - entries for 'value' and 'filename' keys in the data dictionary. - """ + Args: + name: Entry point function which called this helper function. + event: The event which triggered this background function. Must contain + entries for 'value' and 'filename' keys in the data dictionary. + """ filename = event["filename"] value = event["value"] f = open(filename, "w") @@ -38,15 +38,15 @@ def myFunctionFoo( ): # Used in test, pylint: disable=invalid-name,unused-argument """Test function at entry point myFunctionFoo. - Loaded in a test which verifies entry point handling in a file with multiple - entry points. + Loaded in a test which verifies entry point handling in a file with multiple + entry points. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the data - dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the data + dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ fun("myFunctionFoo", event) @@ -55,15 +55,15 @@ def myFunctionBar( ): # Used in test, pylint: disable=invalid-name,unused-argument """Test function at entry point myFunctionBar. - Loaded in a test which verifies entry point handling in a file with multiple - entry points. + Loaded in a test which verifies entry point handling in a file with multiple + entry points. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the data - dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the data + dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ fun("myFunctionBar", event) diff --git a/tests/test_functions/background_trigger/main.py b/tests/test_functions/background_trigger/main.py index 75cc3303..842c4889 100644 --- a/tests/test_functions/background_trigger/main.py +++ b/tests/test_functions/background_trigger/main.py @@ -20,15 +20,15 @@ def function( ): # Required by function definition pylint: disable=unused-argument """Test background function. - It writes the expected output (entry point name and the given value) to the - given file, as a response from the background function, verified by the test. + It writes the expected output (entry point name and the given value) to the + given file, as a response from the background function, verified by the test. - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the - data dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the + data dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ filename = event["filename"] value = event["value"] f = open(filename, "w") diff --git a/tests/test_functions/http_check_env/main.py b/tests/test_functions/http_check_env/main.py index 84859634..9c68dee8 100644 --- a/tests/test_functions/http_check_env/main.py +++ b/tests/test_functions/http_check_env/main.py @@ -23,13 +23,13 @@ def function(request): """Test function which returns the requested environment variable value. - Args: - request: The HTTP request which triggered this function. Must contain name - of the requested environment variable in the 'mode' field in JSON document - in request body. + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested environment variable in the 'mode' field in JSON document + in request body. - Returns: - Value of the requested environment variable. - """ + Returns: + Value of the requested environment variable. + """ name = request.get_json().get("mode") return os.environ[name] diff --git a/tests/test_functions/http_flask_render_template/main.py b/tests/test_functions/http_flask_render_template/main.py index 413f0c9c..58e40231 100644 --- a/tests/test_functions/http_flask_render_template/main.py +++ b/tests/test_functions/http_flask_render_template/main.py @@ -20,20 +20,20 @@ def function(request): """Test HTTP function whose behavior depends on the given mode. - The function returns a success, a failure, or throws an exception, depending - on the given mode. + The function returns a success, a failure, or throws an exception, depending + on the given mode. - Args: - request: The HTTP request which triggered this function. Must contain name - of the requested mode in the 'mode' field in JSON document in request - body. + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. - Returns: - Value and status code defined for the given mode. + Returns: + Value and status code defined for the given mode. - Raises: - Exception: Thrown when requested in the incoming mode specification. - """ + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ if request.args and "message" in request.args: message = request.args.get("message") elif request.get_json() and "message" in request.get_json(): diff --git a/tests/test_functions/http_method_check/main.py b/tests/test_functions/http_method_check/main.py index 0782b616..bbba6bc7 100644 --- a/tests/test_functions/http_method_check/main.py +++ b/tests/test_functions/http_method_check/main.py @@ -18,10 +18,10 @@ def function(request): """Test HTTP function which returns the method it was called with - Args: - request: The HTTP request which triggered this function. + Args: + request: The HTTP request which triggered this function. - Returns: - The HTTP method which was used to call this function - """ + Returns: + The HTTP method which was used to call this function + """ return request.method diff --git a/tests/test_functions/http_request_check/main.py b/tests/test_functions/http_request_check/main.py index 636069d8..1f678960 100644 --- a/tests/test_functions/http_request_check/main.py +++ b/tests/test_functions/http_request_check/main.py @@ -18,18 +18,18 @@ def function(request): """Test function which returns the requested element of the HTTP request. - Name of the requested HTTP request element is provided in the 'mode' field in - the incoming JSON document. + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. - Args: - request: The HTTP request which triggered this function. Must contain name - of the requested HTTP request element in the 'mode' field in JSON document - in request body. + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. - Returns: - Value of the requested HTTP request element, or 'Bad Request' status in case - of unrecognized incoming request. - """ + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ mode = request.get_json().get("mode") if mode == "path": return request.path diff --git a/tests/test_functions/http_trigger/main.py b/tests/test_functions/http_trigger/main.py index ca207a48..b80d85b6 100644 --- a/tests/test_functions/http_trigger/main.py +++ b/tests/test_functions/http_trigger/main.py @@ -20,20 +20,20 @@ def function(request): """Test HTTP function whose behavior depends on the given mode. - The function returns a success, a failure, or throws an exception, depending - on the given mode. + The function returns a success, a failure, or throws an exception, depending + on the given mode. - Args: - request: The HTTP request which triggered this function. Must contain name - of the requested mode in the 'mode' field in JSON document in request - body. + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. - Returns: - Value and status code defined for the given mode. + Returns: + Value and status code defined for the given mode. - Raises: - Exception: Thrown when requested in the incoming mode specification. - """ + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ mode = request.get_json().get("mode") print("Mode: " + mode) # pylint: disable=superfluous-parens if mode == "SUCCESS": diff --git a/tests/test_functions/http_trigger_sleep/main.py b/tests/test_functions/http_trigger_sleep/main.py index 46203a73..fcccf9b2 100644 --- a/tests/test_functions/http_trigger_sleep/main.py +++ b/tests/test_functions/http_trigger_sleep/main.py @@ -19,14 +19,14 @@ def function(request): """Test function which sleeps for the given number of seconds. - The test verifies that it gets the response from the function only after the - given number of seconds. + The test verifies that it gets the response from the function only after the + given number of seconds. - Args: - request: The HTTP request which triggered this function. Must contain the - requested number of seconds in the 'mode' field in JSON document in - request body. - """ + Args: + request: The HTTP request which triggered this function. Must contain the + requested number of seconds in the 'mode' field in JSON document in + request body. + """ sleep_sec = int(request.get_json().get("mode")) / 1000.0 time.sleep(sleep_sec) return "OK" diff --git a/tests/test_functions/http_with_import/main.py b/tests/test_functions/http_with_import/main.py index f65996da..a07d646b 100644 --- a/tests/test_functions/http_with_import/main.py +++ b/tests/test_functions/http_with_import/main.py @@ -20,10 +20,10 @@ def function(request): """Test HTTP function which imports from another file - Args: - request: The HTTP request which triggered this function. + Args: + request: The HTTP request which triggered this function. - Returns: - The imported return value and status code defined for the given mode. - """ + Returns: + The imported return value and status code defined for the given mode. + """ return bar diff --git a/tests/test_functions/returns_none/main.py b/tests/test_functions/returns_none/main.py index f6a4acaa..9bd68bdf 100644 --- a/tests/test_functions/returns_none/main.py +++ b/tests/test_functions/returns_none/main.py @@ -16,12 +16,12 @@ def function(request): """Test HTTP function when using legacy GCF behavior. - The function returns None, which should be a 200 response. + The function returns None, which should be a 200 response. - Args: - request: The HTTP request which triggered this function. + Args: + request: The HTTP request which triggered this function. - Returns: - None. - """ + Returns: + None. + """ return None diff --git a/tests/test_http.py b/tests/test_http.py index e9929d94..704ca079 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -53,7 +53,9 @@ def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): options = {"a": pretend.stub(), "b": pretend.stub()} monkeypatch.setattr( - functions_framework._http, "FlaskApplication", server_classes["flask"], + functions_framework._http, + "FlaskApplication", + server_classes["flask"], ) if gunicorn_missing or platform.system() == "Windows": monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) @@ -61,7 +63,9 @@ def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): from functions_framework._http import gunicorn monkeypatch.setattr( - gunicorn, "GunicornApplication", server_classes["gunicorn"], + gunicorn, + "GunicornApplication", + server_classes["gunicorn"], ) wrapper = functions_framework._http.HTTPServer(app, debug, **options)