Skip to content

Commit eb6761e

Browse files
authored
Add support for background to CloudEvent conversion (GoogleCloudPlatform#116)
1 parent e0e6bfa commit eb6761e

File tree

13 files changed

+624
-52
lines changed

13 files changed

+624
-52
lines changed

.github/workflows/conformance.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,25 @@ jobs:
2424
go-version: '1.13'
2525

2626
- name: Run HTTP conformance tests
27-
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].2
27+
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].7
2828
with:
2929
functionType: 'http'
3030
useBuildpacks: false
3131
validateMapping: false
3232
cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'"
3333

3434
- name: Run event conformance tests
35-
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].2
35+
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].7
3636
with:
3737
functionType: 'legacyevent'
3838
useBuildpacks: false
3939
validateMapping: false
4040
cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'"
4141

4242
- name: Run cloudevent conformance tests
43-
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].2
43+
uses: GoogleCloudPlatform/functions-framework-conformance/[email protected].7
4444
with:
4545
functionType: 'cloudevent'
4646
useBuildpacks: false
47-
validateMapping: false
47+
validateMapping: true
4848
cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'"

src/functions_framework/__init__.py

Lines changed: 26 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727

2828
from cloudevents.http import from_http, is_binary
2929

30+
from functions_framework import event_conversion
31+
from functions_framework.background_event import BackgroundEvent
3032
from functions_framework.exceptions import (
33+
EventConversionException,
3134
FunctionsFrameworkException,
3235
InvalidConfigurationException,
3336
InvalidTargetTypeException,
@@ -43,30 +46,7 @@
4346
_FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status"
4447
_CRASH = "crash"
4548

46-
47-
class _Event(object):
48-
"""Event passed to background functions."""
49-
50-
# Supports both v1beta1 and v1beta2 event formats.
51-
def __init__(
52-
self,
53-
context=None,
54-
data="",
55-
eventId="",
56-
timestamp="",
57-
eventType="",
58-
resource="",
59-
**kwargs,
60-
):
61-
self.context = context
62-
if not self.context:
63-
self.context = {
64-
"eventId": eventId,
65-
"timestamp": timestamp,
66-
"eventType": eventType,
67-
"resource": resource,
68-
}
69-
self.data = data
49+
_CLOUDEVENT_MIME_TYPE = "application/cloudevents+json"
7050

7151

7252
class _LoggingHandler(io.TextIOWrapper):
@@ -97,26 +77,32 @@ def _run_cloudevent(function, request):
9777

9878
def _cloudevent_view_func_wrapper(function, request):
9979
def view_func(path):
80+
ce_exception = None
81+
event = None
10082
try:
101-
_run_cloudevent(function, request)
102-
except cloud_exceptions.MissingRequiredFields as e:
103-
flask.abort(
104-
400,
105-
description=(
106-
"Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but"
107-
" failed to find all required cloudevent fields. Found HTTP"
108-
f" headers: {request.headers} and data: {request.get_data()}. "
109-
f"cloudevents.exceptions.MissingRequiredFields: {e}"
110-
),
111-
)
112-
except cloud_exceptions.InvalidRequiredFields as e:
83+
event = from_http(request.headers, request.get_data())
84+
except (
85+
cloud_exceptions.MissingRequiredFields,
86+
cloud_exceptions.InvalidRequiredFields,
87+
) as e:
88+
ce_exception = e
89+
90+
if not ce_exception:
91+
function(event)
92+
return "OK"
93+
94+
# Not a CloudEvent. Try converting to a CloudEvent.
95+
try:
96+
function(event_conversion.background_event_to_cloudevent(request))
97+
except EventConversionException as e:
11398
flask.abort(
11499
400,
115100
description=(
116101
"Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent but"
117-
" found one or more invalid required cloudevent fields. Found HTTP"
118-
f" headers: {request.headers} and data: {request.get_data()}. "
119-
f"cloudevents.exceptions.InvalidRequiredFields: {e}"
102+
" parsing CloudEvent failed and converting from background event to"
103+
f" CloudEvent also failed.\nGot HTTP headers: {request.headers}\nGot"
104+
f" data: {request.get_data()}\nGot CloudEvent exception: {repr(ce_exception)}"
105+
f"\nGot background event conversion exception: {repr(e)}"
120106
),
121107
)
122108
return "OK"
@@ -143,7 +129,7 @@ def view_func(path):
143129
event_data = request.get_json()
144130
if not event_data:
145131
flask.abort(400)
146-
event_object = _Event(**event_data)
132+
event_object = BackgroundEvent(**event_data)
147133
data = event_object.data
148134
context = Context(**event_object.context)
149135
function(data, context)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 2021 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+
16+
class BackgroundEvent(object):
17+
"""BackgroundEvent is an event passed to GCF background event functions.
18+
19+
Background event functions take data and context as parameters, both of
20+
which this class represents. By contrast, CloudEvent functions take a
21+
single CloudEvent object as their parameter. This class does not represent
22+
CloudEvents.
23+
"""
24+
25+
# Supports v1beta1, v1beta2, and v1 event formats.
26+
def __init__(
27+
self,
28+
context=None,
29+
data="",
30+
eventId="",
31+
timestamp="",
32+
eventType="",
33+
resource="",
34+
**kwargs,
35+
):
36+
self.context = context
37+
if not self.context:
38+
self.context = {
39+
"eventId": eventId,
40+
"timestamp": timestamp,
41+
"eventType": eventType,
42+
"resource": resource,
43+
}
44+
self.data = data
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright 2021 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+
import re
15+
16+
from typing import Tuple
17+
18+
from cloudevents.http import CloudEvent
19+
20+
from functions_framework.background_event import BackgroundEvent
21+
from functions_framework.exceptions import EventConversionException
22+
from google.cloud.functions.context import Context
23+
24+
_CLOUDEVENT_SPEC_VERSION = "1.0"
25+
26+
# Maps background/legacy event types to their equivalent CloudEvent types.
27+
# For more info on event mappings see
28+
# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/master/docs/mapping.md
29+
_BACKGROUND_TO_CE_TYPE = {
30+
"google.pubsub.topic.publish": "google.cloud.pubsub.topic.v1.messagePublished",
31+
"providers/cloud.pubsub/eventTypes/topic.publish": "google.cloud.pubsub.topic.v1.messagePublished",
32+
"google.storage.object.finalize": "google.cloud.storage.object.v1.finalized",
33+
"google.storage.object.delete": "google.cloud.storage.object.v1.deleted",
34+
"google.storage.object.archive": "google.cloud.storage.object.v1.archived",
35+
"google.storage.object.metadataUpdate": "google.cloud.storage.object.v1.metadataUpdated",
36+
"providers/cloud.firestore/eventTypes/document.write": "google.cloud.firestore.document.v1.written",
37+
"providers/cloud.firestore/eventTypes/document.create": "google.cloud.firestore.document.v1.created",
38+
"providers/cloud.firestore/eventTypes/document.update": "google.cloud.firestore.document.v1.updated",
39+
"providers/cloud.firestore/eventTypes/document.delete": "google.cloud.firestore.document.v1.deleted",
40+
"providers/firebase.auth/eventTypes/user.create": "google.firebase.auth.user.v1.created",
41+
"providers/firebase.auth/eventTypes/user.delete": "google.firebase.auth.user.v1.deleted",
42+
"providers/google.firebase.analytics/eventTypes/event.log": "google.firebase.analytics.log.v1.written",
43+
"providers/google.firebase.database/eventTypes/ref.create": "google.firebase.database.document.v1.created",
44+
"providers/google.firebase.database/eventTypes/ref.write": "google.firebase.database.document.v1.written",
45+
"providers/google.firebase.database/eventTypes/ref.update": "google.firebase.database.document.v1.updated",
46+
"providers/google.firebase.database/eventTypes/ref.delete": "google.firebase.database.document.v1.deleted",
47+
"providers/cloud.storage/eventTypes/object.change": "google.cloud.storage.object.v1.finalized",
48+
}
49+
50+
# CloudEvent service names.
51+
_FIREBASE_AUTH_CE_SERVICE = "firebaseauth.googleapis.com"
52+
_FIREBASE_CE_SERVICE = "firebase.googleapis.com"
53+
_FIREBASE_DB_CE_SERVICE = "firebasedatabase.googleapis.com"
54+
_FIRESTORE_CE_SERVICE = "firestore.googleapis.com"
55+
_PUBSUB_CE_SERVICE = "pubsub.googleapis.com"
56+
_STORAGE_CE_SERVICE = "storage.googleapis.com"
57+
58+
# Maps background event services to their equivalent CloudEvent services.
59+
_SERVICE_BACKGROUND_TO_CE = {
60+
"providers/cloud.firestore/": _FIRESTORE_CE_SERVICE,
61+
"providers/google.firebase.analytics/": _FIREBASE_CE_SERVICE,
62+
"providers/firebase.auth/": _FIREBASE_AUTH_CE_SERVICE,
63+
"providers/google.firebase.database/": _FIREBASE_DB_CE_SERVICE,
64+
"providers/cloud.pubsub/": _PUBSUB_CE_SERVICE,
65+
"providers/cloud.storage/": _STORAGE_CE_SERVICE,
66+
"google.pubsub": _PUBSUB_CE_SERVICE,
67+
"google.storage": _STORAGE_CE_SERVICE,
68+
}
69+
70+
# Maps CloudEvent service strings to regular expressions used to split a background
71+
# event resource string into CloudEvent resource and subject strings. Each regex
72+
# must have exactly two capture groups: the first for the resource and the second
73+
# for the subject.
74+
_CE_SERVICE_TO_RESOURCE_RE = {
75+
_FIREBASE_CE_SERVICE: re.compile(r"^(projects/[^/]+)/(events/[^/]+)$"),
76+
_FIREBASE_DB_CE_SERVICE: re.compile(r"^(projects/[^/]/instances/[^/]+)/(refs/.+)$"),
77+
_FIRESTORE_CE_SERVICE: re.compile(
78+
r"^(projects/[^/]+/databases/\(default\))/(documents/.+)$"
79+
),
80+
_STORAGE_CE_SERVICE: re.compile(r"^(projects/[^/]/buckets/[^/]+)/(objects/.+)$"),
81+
}
82+
83+
# Maps Firebase Auth background event metadata field names to their equivalent
84+
# CloudEvent field names.
85+
_FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE = {
86+
"createdAt": "createTime",
87+
"lastSignedInAt": "lastSignInTime",
88+
}
89+
90+
91+
def background_event_to_cloudevent(request) -> CloudEvent:
92+
"""Converts a background event represented by the given HTTP request into a CloudEvent. """
93+
event_data = request.get_json()
94+
if not event_data:
95+
raise EventConversionException("Failed to parse JSON")
96+
97+
event_object = BackgroundEvent(**event_data)
98+
data = event_object.data
99+
context = Context(**event_object.context)
100+
101+
if context.event_type not in _BACKGROUND_TO_CE_TYPE:
102+
raise EventConversionException(
103+
f'Unable to find CloudEvent equivalent type for "{context.event_type}"'
104+
)
105+
new_type = _BACKGROUND_TO_CE_TYPE[context.event_type]
106+
107+
service, resource, subject = _split_resource(context)
108+
109+
# Handle Pub/Sub events.
110+
if service == _PUBSUB_CE_SERVICE:
111+
data = {"message": data}
112+
113+
# Handle Firebase Auth events.
114+
if service == _FIREBASE_AUTH_CE_SERVICE:
115+
if "metadata" in data:
116+
for old, new in _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE.items():
117+
if old in data["metadata"]:
118+
data["metadata"][new] = data["metadata"][old]
119+
del data["metadata"][old]
120+
if "uid" in data:
121+
uid = data["uid"]
122+
subject = f"users/{uid}"
123+
124+
metadata = {
125+
"id": context.event_id,
126+
"time": context.timestamp,
127+
"specversion": _CLOUDEVENT_SPEC_VERSION,
128+
"datacontenttype": "application/json",
129+
"type": new_type,
130+
"source": f"//{service}/{resource}",
131+
}
132+
133+
if subject:
134+
metadata["subject"] = subject
135+
136+
return CloudEvent(metadata, data)
137+
138+
139+
def _split_resource(context: Context) -> Tuple[str, str, str]:
140+
"""Splits a background event's resource into a CloudEvent service, resource, and subject."""
141+
service = ""
142+
resource = ""
143+
if isinstance(context.resource, dict):
144+
service = context.resource.get("service", "")
145+
resource = context.resource["name"]
146+
else:
147+
resource = context.resource
148+
149+
# If there's no service we'll choose an appropriate one based on the event type.
150+
if not service:
151+
for b_service, ce_service in _SERVICE_BACKGROUND_TO_CE.items():
152+
if context.event_type.startswith(b_service):
153+
service = ce_service
154+
break
155+
if not service:
156+
raise EventConversionException(
157+
"Unable to find CloudEvent equivalent service "
158+
f"for {context.event_type}"
159+
)
160+
161+
# If we don't need to split the resource string then we're done.
162+
if service not in _CE_SERVICE_TO_RESOURCE_RE:
163+
return service, resource, ""
164+
165+
# Split resource into resource and subject.
166+
match = _CE_SERVICE_TO_RESOURCE_RE[service].fullmatch(resource)
167+
if not match:
168+
raise EventConversionException("Resource regex did not match")
169+
170+
return service, match.group(1), match.group(2)

src/functions_framework/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ class MissingSourceException(FunctionsFrameworkException):
3131

3232
class MissingTargetException(FunctionsFrameworkException):
3333
pass
34+
35+
36+
class EventConversionException(FunctionsFrameworkException):
37+
pass

0 commit comments

Comments
 (0)