diff --git a/README.md b/README.md index 8a580c40..b4d59c53 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,18 @@ 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` | +| `--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` | | `--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,13 +148,38 @@ def hello(data, context): print(context) ``` -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. +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. -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_cloudevents). # Advanced Examples -More advanced guides can be found in the [`examples/`](./examples/) directory. +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). # Contributing diff --git a/examples/README.md b/examples/README.md index 33ce2a76..fc027bfe 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +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 CloudEvent 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 diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile new file mode 100644 index 00000000..10163c5f --- /dev/null +++ b/examples/cloud_run_cloudevents/Dockerfile @@ -0,0 +1,15 @@ +# 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 new file mode 100644 index 00000000..0f3c8fe0 --- /dev/null +++ b/examples/cloud_run_cloudevents/README.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 00000000..94b2734a --- /dev/null +++ b/examples/cloud_run_cloudevents/main.py @@ -0,0 +1,21 @@ +# 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 new file mode 100644 index 00000000..33c5f99f --- /dev/null +++ b/examples/cloud_run_cloudevents/requirements.txt @@ -0,0 +1 @@ +# Optionally include additional dependencies here diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index b3e7ffeb..6b31c042 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 new file mode 100644 index 00000000..62d34cca --- /dev/null +++ b/examples/cloud_run_event/README.md @@ -0,0 +1,3 @@ +# 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 2a16fe7f..2509ca22 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", + "cloudevents<1.0", ], extras_require={"test": ["pytest", "tox"]}, entry_points={ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index ce0e2fbf..5f5422a7 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -12,13 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -import functools +import enum 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 @@ -35,6 +41,12 @@ DEFAULT_SIGNATURE_TYPE = "http" +class _EventType(enum.Enum): + LEGACY = 1 + CLOUDEVENT_BINARY = 2 + CLOUDEVENT_STRUCTURED = 3 + + class _Event(object): """Event passed to background functions.""" @@ -67,38 +79,83 @@ def view_func(path): return view_func -def _is_binary_cloud_event(request): - return ( +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 ( 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 _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"), + 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", ) - function(data, context) + + 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) 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) + flask.abort( + 400, + description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " + " but it did not receive a cloudevent as a request.", + ) return "OK" @@ -179,19 +236,27 @@ def create_app(target=None, source=None, signature_type=None): 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") - elif signature_type == "event": + elif signature_type == "event" or signature_type == "cloudevent": app.url_map.add( werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint="run", methods=["POST"] + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] ) ) app.url_map.add( - werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) + werkzeug.routing.Rule( + "/", endpoint=signature_type, 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 4fe6e427..663ea50f 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"]), + type=click.Choice(["http", "event", "cloudevent"]), 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 new file mode 100644 index 00000000..17e6f23c --- /dev/null +++ b/tests/test_cloudevent_functions.py @@ -0,0 +1,120 @@ +# 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 new file mode 100644 index 00000000..7b274672 --- /dev/null +++ b/tests/test_event_functions.py @@ -0,0 +1,213 @@ +# 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 c6eccb91..792a646e 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,7 +11,6 @@ # 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 @@ -23,8 +22,7 @@ from functions_framework import LazyWSGIApp, create_app, exceptions -TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" - +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" # Python 3.5: ModuleNotFoundError does not exist try: @@ -169,87 +167,6 @@ 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" @@ -262,70 +179,6 @@ 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 new file mode 100644 index 00000000..f2fdb6f3 --- /dev/null +++ b/tests/test_functions/cloudevents/main.py @@ -0,0 +1,40 @@ +# 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 51dad087..a9e13bb7 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -60,41 +60,6 @@ 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 = {