diff --git a/README.md b/README.md index cd96afe2..04b96384 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,10 @@ 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` | - ## Enable Google Cloud Functions Events The Functions Framework can unmarshall incoming @@ -191,7 +190,6 @@ Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/ 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): print(data) @@ -199,10 +197,10 @@ 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, 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). @@ -210,21 +208,16 @@ 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 - +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): - print("Received event with ID: %s" % cloudevent.EventID()) - return 200 + print(f"Received event with ID: {cloudevent['id']}") ``` 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. -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 diff --git a/examples/README.md b/examples/README.md index fc027bfe..343e19c9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,5 @@ # 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 +* [`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 \ No newline at end of file diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile index 10163c5f..bc9df896 100644 --- a/examples/cloud_run_cloudevents/Dockerfile +++ b/examples/cloud_run_cloudevents/Dockerfile @@ -4,12 +4,15 @@ FROM python:3.7-slim # Copy local code to the container image. ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + WORKDIR $APP_HOME 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 exec functions-framework --target=hello --signature-type=cloudevent +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 0f3c8fe0..03a9931a 100644 --- a/examples/cloud_run_cloudevents/README.md +++ b/examples/cloud_run_cloudevents/README.md @@ -1,52 +1,23 @@ -# 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. +# 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](http://cloudevents.io) on Cloud Run. ## How to run this locally + Build the Docker image: ```commandline -docker build --tag ff_example . +docker build -t cloudevent_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 cloudevent_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/") - +docker run -t cloudevent_example send_cloudevent.py ``` diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 94b2734a..6c7bdc5b 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -12,10 +12,10 @@ # 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 def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID(), file=sys.stdout, flush=True) + print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloudevents/requirements.txt index 33c5f99f..0a7427c7 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.2.0 +requests diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py new file mode 100644 index 00000000..f30909ca --- /dev/null +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -0,0 +1,36 @@ +#!/usr/local/bin/python + +# 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 +import requests +import json + + +# 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", + "type": "cloudevent.greet.you" +} +data = {"name":"john"} + +event = CloudEvent(attributes, data) + +# Send the event to our local docker container listening on port 8080 +headers, data = to_structured(event) +requests.post("http://localhost:8080/", headers=headers, data=data) diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index 6b31c042..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 . . @@ -12,4 +14,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/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 . . diff --git a/setup.py b/setup.py index 26d41c97..5594d97e 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.2.0,<2.0.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 81b18225..356f7a03 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -12,22 +12,20 @@ # 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 cloudevents.exceptions as cloud_exceptions import flask import werkzeug +from cloudevents.http import from_http, is_binary + from functions_framework.exceptions import ( FunctionsFrameworkException, InvalidConfigurationException, @@ -42,12 +40,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.""" @@ -60,7 +52,7 @@ def __init__( timestamp="", eventType="", resource="", - **kwargs + **kwargs, ): self.context = context if not self.context: @@ -80,83 +72,64 @@ 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 - ) - +def _run_cloudevent(function, request): + data = request.get_data() + event = from_http(request.headers, data) 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 ( - 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 _cloudevent_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. + try: + _run_cloudevent(function, request) + except cloud_exceptions.MissingRequiredFields as e: 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=( + "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.MissingRequiredFields: {e}" + ), + ) + 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.InvalidRequiredFields: {e}" + ), ) - return "OK" return view_func -def _cloudevent_view_func_wrapper(function, request): +def _event_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) - else: - flask.abort( - 400, - description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a cloudevent as a request.", + if is_binary(request.headers): + # Support CloudEvents in binary content mode, with data being the + # whole request body and context attributes retrieved from request + # headers. + data = request.get_data() + context = Context( + eventId=request.headers.get("ce-eventId"), + timestamp=request.headers.get("ce-timestamp"), + eventType=request.headers.get("ce-eventType"), + resource=request.headers.get("ce-resource"), ) + function(data, context) + else: + # This is a regular CloudEvent + event_data = request.get_json() + if not event_data: + flask.abort(400) + event_object = _Event(**event_data) + data = event_object.data + context = Context(**event_object.context) + function(data, context) return "OK" @@ -279,7 +252,20 @@ def handle_none(rv): 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="run", methods=["POST"] + ) + ) + app.url_map.add( + 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: "" + elif signature_type == "cloudevent": app.url_map.add( werkzeug.routing.Rule( "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] @@ -291,13 +277,7 @@ def handle_none(rv): ) ) - # 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( + app.view_functions[signature_type] = _cloudevent_view_func_wrapper( function, flask.request ) else: diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 17e6f23c..90cebe05 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -14,12 +14,10 @@ import json import pathlib -import cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.event.v03 -import cloudevents.sdk.marshaller import pytest +from cloudevents.http import CloudEvent, from_http, to_binary, to_structured + from functions_framework import LazyWSGIApp, create_app, exceptions TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" @@ -31,90 +29,183 @@ _ModuleNotFoundError = ImportError +@pytest.fixture +def data_payload(): + return {"name": "john"} + + @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 = { + "specversion": "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) @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" + attributes = { + "id": "my-id", + "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) - 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 - ) +@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, + "time": "2020-08-16T13:58:54.471765", + } - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) - 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, + "time": "2020-08-16T13:58:54.471765", + } -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() + + +@pytest.fixture +def empty_client(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "empty_data.py" + target = "function" + return create_app(target, source, "cloudevent").test_client() - client = create_app(target, source, "cloudevent").test_client() - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() +def test_event(client, cloudevent_1_0): + headers, data = to_structured(cloudevent_1_0) + resp = client.post("/", headers=headers, data=data) + + assert resp.status_code == 200 + assert resp.data == b"OK" - binary_headers, binary_data = m.ToRequest( - cloudevent_1_0, cloudevents.sdk.converters.TypeBinary, json.dumps - ) - resp = client.post("/", headers=binary_headers, data=binary_data) +def test_binary_event(client, cloudevent_1_0): + headers, data = to_binary(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_event_0_3(client, cloudevent_0_3): + headers, data = to_structured(cloudevent_0_3) + resp = client.post("/", headers=headers, data=data) + + assert resp.status_code == 200 + assert resp.data == b"OK" - 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 - ) +def test_binary_event_0_3(client, cloudevent_0_3): + headers, data = to_binary(cloudevent_0_3) + resp = client.post("/", headers=headers, data=data) - 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" +@pytest.mark.parametrize("specversion", ["0.3", "1.0"]) +def test_cloudevent_missing_required_binary_fields( + client, specversion, create_headers_binary, data_payload +): + headers = create_headers_binary(specversion) - client = create_app(target, source, "cloudevent").test_client() + 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) + + assert resp.status_code == 400 + 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 +): + headers = {"Content-Type": "application/cloudevents+json"} + 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) + + assert resp.status_code == 400 + assert "cloudevents.exceptions.MissingRequiredFields" in resp.data.decode() + + +def test_invalid_fields_binary(client, create_headers_binary, data_payload): + # Testing none specversion fails + headers = create_headers_binary("not a spec version") + resp = client.post("/", headers=headers, json=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): + resp = client.post("/", headers={}, data="") - resp = client.post("/", json="{not_event}") assert resp.status_code == 400 - assert resp.data != b"OK" + assert "cloudevents.exceptions.MissingRequiredFields" 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_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 f408a786..2f0ca05b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import os import pathlib import re @@ -24,7 +25,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: @@ -169,6 +171,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" @@ -181,6 +264,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/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) diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py index d876fa22..e9fb1c9c 100644 --- a/tests/test_functions/cloudevents/main.py +++ b/tests/test_functions/cloudevents/main.py @@ -22,18 +22,18 @@ def function(cloudevent): 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. + 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. + 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["type"] == "cloudevent.greet.you" + and cloudevent["time"] == "2020-08-16T13:58:54.471765" ) if not valid_event: diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index a9e13bb7..592c8300 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -11,9 +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. +import json import pretend +from cloudevents.http import from_http + import functions_framework @@ -60,6 +63,107 @@ 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) + expected_cloudevent = from_http(request.headers, request.get_data()) + + assert function.calls == [pretend.call(expected_cloudevent)] + + +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"}, + } + ) + + request = pretend.stub(headers=headers, get_data=lambda: data) + event = from_http(request.headers, request.get_data()) + + function = pretend.call_recorder(lambda cloudevent: cloudevent) + + view_func = functions_framework._cloudevent_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(event)] + + +def test_binary_cloudevent_view_func_wrapper(): + 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.headers, request.get_data()) + + function = pretend.call_recorder(lambda cloudevent: cloudevent) + + view_func = functions_framework._cloudevent_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(event)] + + +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 = {