Skip to content

Commit eddbc7e

Browse files
joelgerardJoel Gerarddi
authored
Add Cloud Events support for #55 (#56)
* Ignore IDE files. * Use the test file directory as a basis instead of cwd. Allows tests to be run from anywhere and enables IDE debugger * Add support for Cloud Events. Rough draft. I will squash a bunch of these interim commits before submitting the PR. DO NOT SUBMIT * Return the functions return value. Test Cloud Events SDK 0.3. Add some error handling. Please see all the TODO questions before I finish off this PR. DO NOT SUBMIT * Minor cleanup. Split test code. * Clean up unused paths, split large test files into two, ensure functions DO NOT return a custom value. General tidy-up. Support binary functions. * Fix lint errors with black. * Fix lint errors with black. * Update setup.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update tests/test_cloudevent_functions.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update tests/test_cloudevent_functions.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update tests/test_functions/cloudevents/main.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Clearer imports. * don't factor out routes. * Add a TODO for testing the different combinations of events and signature types. * Add cloudevent as a signature type in the argument list. * Clarify import. * Clarify import. * A sample that shows how to use a CloudEvent. * In the case of a sig type / event type mismatch throw a 400 * Update the docs to use CloudEvent sig type instead of Event sig type. Note that I wrote the "Event" type is deprecated. Not sure if this is accurate. * Lint fixes. * Tests for checking correct event type corresponds to correct function sig. Fixed abort import error. * Sort imports. * Remove old example. * Readme to explain how to run the sample locally. * Rename cloud_event to cloudevent * For legacy docs, add a notice to the new docs. * There is no 1.1 event type. * use the term cloudevent rather than event everywhere where we are talking about a CloudEvent to disambiguate these signature types. * Update examples/cloudevents/README.md Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update examples/cloudevents/README.md Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update examples/cloudevents/README.md Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update examples/cloudevents/main.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Update tests/test_view_functions.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Add legacy event back to docs. * Add legacy event back to docs. * Use abort from flask for consistency and fix return in event test. * update docs and error messages to better mirror the other runtimes. * Minor fixes to docs w.r.t. naming. * Update src/functions_framework/__init__.py Co-authored-by: Dustin Ingram <di@users.noreply.github.com> * Fix enum per reviewer suggestion. * Rename text event => strucuture event. Co-authored-by: Joel Gerard <joelgerard@google.com> Co-authored-by: Dustin Ingram <di@users.noreply.github.com>
1 parent 32bba7d commit eddbc7e

File tree

16 files changed

+599
-219
lines changed

16 files changed

+599
-219
lines changed

Diff for: README.md

+36-6
Original file line numberDiff line numberDiff line change
@@ -129,27 +129,57 @@ You can configure the Functions Framework using command-line flags or environmen
129129
| `--host` | `HOST` | The host on which the Functions Framework listens for requests. Default: `0.0.0.0` |
130130
| `--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` |
131131
| `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` |
132-
| `--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` |
132+
| `--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` |
133133
| `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) |
134134
| `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` |
135135

136-
# Enable CloudEvents
137136

138-
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:
137+
# Enable Google Cloud Functions Events
138+
139+
The Functions Framework can unmarshall incoming
140+
Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `data` and `context` objects.
141+
These will be passed as arguments to your function when it receives a request.
142+
Note that your function must use the `event`-style function signature:
143+
139144

140145
```python
141146
def hello(data, context):
142147
print(data)
143148
print(context)
144149
```
145150

146-
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.
151+
To enable automatic unmarshalling, set the function signature type to `event`
152+
using a command-line flag or an environment variable. By default, the HTTP
153+
signature will be used and automatic event unmarshalling will be disabled.
154+
155+
For more details on this signature type, check out the Google Cloud Functions
156+
documentation on
157+
[background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example).
158+
159+
See the [running example](examples/cloud_run_event).
160+
161+
# Enable CloudEvents
162+
163+
The Functions Framework can unmarshall incoming
164+
[CloudEvent](http://cloudevents.io) payloads to a `cloudevent` object.
165+
It will be passed as an argument to your function when it receives a request.
166+
Note that your function must use the `cloudevent`-style function signature
167+
168+
169+
```python
170+
def hello(cloudevent):
171+
print("Received event with ID: %s" % cloudevent.EventID())
172+
return 200
173+
```
174+
175+
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.
147176

148-
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).
177+
See the [running example](examples/cloud_run_cloudevents).
149178

150179
# Advanced Examples
151180

152-
More advanced guides can be found in the [`examples/`](./examples/) directory.
181+
More advanced guides can be found in the [`examples/`](./examples/) directory. You can also find examples
182+
on using the CloudEvent Python SDK [here](https://github.com/cloudevents/sdk-python).
153183

154184
# Contributing
155185

Diff for: examples/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Python Functions Frameworks Examples
22

33
* [`cloud_run_http`](./cloud_run_http/) - Deploying an HTTP function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework
4-
* [`cloud_run_event`](./cloud_run_event/) - Deploying a CloudEvent function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework
4+
* [`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
5+
* [`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 for: examples/cloud_run_cloudevents/Dockerfile

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Use the official Python image.
2+
# https://hub.docker.com/_/python
3+
FROM python:3.7-slim
4+
5+
# Copy local code to the container image.
6+
ENV APP_HOME /app
7+
WORKDIR $APP_HOME
8+
COPY . .
9+
10+
# Install production dependencies.
11+
RUN pip install gunicorn cloudevents functions-framework
12+
RUN pip install -r requirements.txt
13+
14+
# Run the web service on container startup.
15+
CMD exec functions-framework --target=hello --signature-type=cloudevent

Diff for: examples/cloud_run_cloudevents/README.md

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Deploying a CloudEvent function to Cloud Run with the Functions Framework
2+
This sample uses the [Cloud Events SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run.
3+
4+
## How to run this locally
5+
Build the Docker image:
6+
7+
```commandline
8+
docker build --tag ff_example .
9+
```
10+
11+
Run the image and bind the correct ports:
12+
13+
```commandline
14+
docker run -p:8080:8080 ff_example
15+
```
16+
17+
Send an event to the container:
18+
19+
```python
20+
from cloudevents.sdk import converters
21+
from cloudevents.sdk import marshaller
22+
from cloudevents.sdk.converters import structured
23+
from cloudevents.sdk.event import v1
24+
import requests
25+
import json
26+
27+
def run_structured(event, url):
28+
http_marshaller = marshaller.NewDefaultHTTPMarshaller()
29+
structured_headers, structured_data = http_marshaller.ToRequest(
30+
event, converters.TypeStructured, json.dumps
31+
)
32+
print("structured CloudEvent")
33+
print(structured_data.getvalue())
34+
35+
response = requests.post(url,
36+
headers=structured_headers,
37+
data=structured_data.getvalue())
38+
response.raise_for_status()
39+
40+
event = (
41+
v1.Event()
42+
.SetContentType("application/json")
43+
.SetData('{"name":"john"}')
44+
.SetEventID("my-id")
45+
.SetSource("from-galaxy-far-far-away")
46+
.SetEventTime("tomorrow")
47+
.SetEventType("cloudevent.greet.you")
48+
)
49+
50+
run_structured(event, "http://0.0.0.0:8080/")
51+
52+
```

Diff for: examples/cloud_run_cloudevents/main.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
# This sample creates a function that accepts a Cloud Event per
16+
# https://github.com/cloudevents/sdk-python
17+
import sys
18+
19+
20+
def hello(cloudevent):
21+
print("Received event with ID: %s" % cloudevent.EventID(), file=sys.stdout, flush=True)

Diff for: examples/cloud_run_cloudevents/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Optionally include additional dependencies here

Diff for: examples/cloud_run_event/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ RUN pip install gunicorn functions-framework
1212
RUN pip install -r requirements.txt
1313

1414
# Run the web service on container startup.
15-
CMD exec functions-framework --target=hello --signature-type=event
15+
CMD exec functions-framework --target=hello --signature_type=event

Diff for: examples/cloud_run_event/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Google Cloud Functions Events Example
2+
This example demonstrates how to write an event function. Note that you can also use [CloudEvents](https://github.com/cloudevents/sdk-python)
3+
([example](../cloud_run_cloudevents)), which is a different construct.

Diff for: setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"click>=7.0,<8.0",
5353
"watchdog>=0.10.0",
5454
"gunicorn>=19.2.0,<21.0; platform_system!='Windows'",
55+
"cloudevents<1.0",
5556
],
5657
extras_require={"test": ["pytest", "tox"]},
5758
entry_points={

Diff for: src/functions_framework/__init__.py

+92-27
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import functools
15+
import enum
1616
import importlib.util
17+
import io
18+
import json
1719
import os.path
1820
import pathlib
1921
import sys
2022
import types
2123

24+
import cloudevents.sdk
25+
import cloudevents.sdk.event
26+
import cloudevents.sdk.event.v1
27+
import cloudevents.sdk.marshaller
2228
import flask
2329
import werkzeug
2430

@@ -35,6 +41,12 @@
3541
DEFAULT_SIGNATURE_TYPE = "http"
3642

3743

44+
class _EventType(enum.Enum):
45+
LEGACY = 1
46+
CLOUDEVENT_BINARY = 2
47+
CLOUDEVENT_STRUCTURED = 3
48+
49+
3850
class _Event(object):
3951
"""Event passed to background functions."""
4052

@@ -67,38 +79,83 @@ def view_func(path):
6779
return view_func
6880

6981

70-
def _is_binary_cloud_event(request):
71-
return (
82+
def _get_cloudevent_version():
83+
return cloudevents.sdk.event.v1.Event()
84+
85+
86+
def _run_legacy_event(function, request):
87+
event_data = request.get_json()
88+
if not event_data:
89+
flask.abort(400)
90+
event_object = _Event(**event_data)
91+
data = event_object.data
92+
context = Context(**event_object.context)
93+
function(data, context)
94+
95+
96+
def _run_binary_cloudevent(function, request, cloudevent_def):
97+
data = io.BytesIO(request.get_data())
98+
http_marshaller = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller()
99+
event = http_marshaller.FromRequest(
100+
cloudevent_def, request.headers, data, json.load
101+
)
102+
103+
function(event)
104+
105+
106+
def _run_structured_cloudevent(function, request, cloudevent_def):
107+
data = io.StringIO(request.get_data(as_text=True))
108+
m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller()
109+
event = m.FromRequest(cloudevent_def, request.headers, data, json.loads)
110+
function(event)
111+
112+
113+
def _get_event_type(request):
114+
if (
72115
request.headers.get("ce-type")
73116
and request.headers.get("ce-specversion")
74117
and request.headers.get("ce-source")
75118
and request.headers.get("ce-id")
76-
)
119+
):
120+
return _EventType.CLOUDEVENT_BINARY
121+
elif request.headers.get("Content-Type") == "application/cloudevents+json":
122+
return _EventType.CLOUDEVENT_STRUCTURED
123+
else:
124+
return _EventType.LEGACY
77125

78126

79127
def _event_view_func_wrapper(function, request):
80128
def view_func(path):
81-
if _is_binary_cloud_event(request):
82-
# Support CloudEvents in binary content mode, with data being the
83-
# whole request body and context attributes retrieved from request
84-
# headers.
85-
data = request.get_data()
86-
context = Context(
87-
eventId=request.headers.get("ce-eventId"),
88-
timestamp=request.headers.get("ce-timestamp"),
89-
eventType=request.headers.get("ce-eventType"),
90-
resource=request.headers.get("ce-resource"),
129+
if _get_event_type(request) == _EventType.LEGACY:
130+
_run_legacy_event(function, request)
131+
else:
132+
# here for defensive backwards compatibility in case we make a mistake in rollout.
133+
flask.abort(
134+
400,
135+
description="The FUNCTION_SIGNATURE_TYPE for this function is set to event "
136+
"but no Google Cloud Functions Event was given. If you are using CloudEvents set "
137+
"FUNCTION_SIGNATURE_TYPE=cloudevent",
91138
)
92-
function(data, context)
139+
140+
return "OK"
141+
142+
return view_func
143+
144+
145+
def _cloudevent_view_func_wrapper(function, request):
146+
def view_func(path):
147+
cloudevent_def = _get_cloudevent_version()
148+
event_type = _get_event_type(request)
149+
if event_type == _EventType.CLOUDEVENT_STRUCTURED:
150+
_run_structured_cloudevent(function, request, cloudevent_def)
151+
elif event_type == _EventType.CLOUDEVENT_BINARY:
152+
_run_binary_cloudevent(function, request, cloudevent_def)
93153
else:
94-
# This is a regular CloudEvent
95-
event_data = request.get_json()
96-
if not event_data:
97-
flask.abort(400)
98-
event_object = _Event(**event_data)
99-
data = event_object.data
100-
context = Context(**event_object.context)
101-
function(data, context)
154+
flask.abort(
155+
400,
156+
description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent "
157+
" but it did not receive a cloudevent as a request.",
158+
)
102159

103160
return "OK"
104161

@@ -179,19 +236,27 @@ def create_app(target=None, source=None, signature_type=None):
179236
app.url_map.add(werkzeug.routing.Rule("/<path:path>", endpoint="run"))
180237
app.view_functions["run"] = _http_view_func_wrapper(function, flask.request)
181238
app.view_functions["error"] = lambda: flask.abort(404, description="Not Found")
182-
elif signature_type == "event":
239+
elif signature_type == "event" or signature_type == "cloudevent":
183240
app.url_map.add(
184241
werkzeug.routing.Rule(
185-
"/", defaults={"path": ""}, endpoint="run", methods=["POST"]
242+
"/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"]
186243
)
187244
)
188245
app.url_map.add(
189-
werkzeug.routing.Rule("/<path:path>", endpoint="run", methods=["POST"])
246+
werkzeug.routing.Rule(
247+
"/<path:path>", endpoint=signature_type, methods=["POST"]
248+
)
190249
)
191-
app.view_functions["run"] = _event_view_func_wrapper(function, flask.request)
250+
192251
# Add a dummy endpoint for GET /
193252
app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"]))
194253
app.view_functions["get"] = lambda: ""
254+
255+
# Add the view functions
256+
app.view_functions["event"] = _event_view_func_wrapper(function, flask.request)
257+
app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper(
258+
function, flask.request
259+
)
195260
else:
196261
raise FunctionsFrameworkException(
197262
"Invalid signature type: {signature_type}".format(

Diff for: src/functions_framework/_cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
@click.option(
2727
"--signature-type",
2828
envvar="FUNCTION_SIGNATURE_TYPE",
29-
type=click.Choice(["http", "event"]),
29+
type=click.Choice(["http", "event", "cloudevent"]),
3030
default="http",
3131
)
3232
@click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0")

0 commit comments

Comments
 (0)