Skip to content

Commit 9fbc2f6

Browse files
convert cloud events to background events (GoogleCloudPlatform#124)
* convert cloud events to background events * review feedback
1 parent df48822 commit 9fbc2f6

File tree

7 files changed

+369
-39
lines changed

7 files changed

+369
-39
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ __pycache__/
55
build/
66
dist/
77
.coverage
8+
.vscode/

src/functions_framework/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ def view_func(path):
112112

113113
def _event_view_func_wrapper(function, request):
114114
def view_func(path):
115-
if is_binary(request.headers):
115+
if event_conversion.is_convertable_cloudevent(request):
116+
# Convert this CloudEvent to the equivalent background event data and context.
117+
data, context = event_conversion.cloudevent_to_background_event(request)
118+
function(data, context)
119+
elif is_binary(request.headers):
116120
# Support CloudEvents in binary content mode, with data being the
117121
# whole request body and context attributes retrieved from request
118122
# headers.

src/functions_framework/event_conversion.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
import re
1515

1616
from datetime import datetime
17-
from typing import Optional, Tuple
17+
from typing import Any, Optional, Tuple
1818

19-
from cloudevents.http import CloudEvent
19+
from cloudevents.exceptions import MissingRequiredFields
20+
from cloudevents.http import CloudEvent, from_http, is_binary
2021

2122
from functions_framework.background_event import BackgroundEvent
2223
from functions_framework.exceptions import EventConversionException
@@ -48,6 +49,19 @@
4849
"providers/cloud.storage/eventTypes/object.change": "google.cloud.storage.object.v1.finalized",
4950
}
5051

52+
# _BACKGROUND_TO_CE_TYPE contains duplicate values for some keys. This set contains the duplicates
53+
# that should be dropped when generating the inverse mapping _CE_TO_BACKGROUND_TYPE
54+
_NONINVERTALBE_CE_TYPES = {
55+
"providers/cloud.pubsub/eventTypes/topic.publish",
56+
"providers/cloud.storage/eventTypes/object.change",
57+
}
58+
59+
# Maps CloudEvent types to the equivalent background/legacy event types (inverse
60+
# of _BACKGROUND_TO_CE_TYPE)
61+
_CE_TO_BACKGROUND_TYPE = {
62+
v: k for k, v in _BACKGROUND_TO_CE_TYPE.items() if k not in _NONINVERTALBE_CE_TYPES
63+
}
64+
5165
# CloudEvent service names.
5266
_FIREBASE_AUTH_CE_SERVICE = "firebaseauth.googleapis.com"
5367
_FIREBASE_CE_SERVICE = "firebase.googleapis.com"
@@ -93,6 +107,11 @@
93107
"createdAt": "createTime",
94108
"lastSignedInAt": "lastSignInTime",
95109
}
110+
# Maps Firebase Auth CloudEvent metadata field names to their equivalent
111+
# background event field names (inverse of _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE).
112+
_FIREBASE_AUTH_METADATA_FIELDS_CE_TO_BACKGROUND = {
113+
v: k for k, v in _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE.items()
114+
}
96115

97116

98117
def background_event_to_cloudevent(request) -> CloudEvent:
@@ -143,6 +162,74 @@ def background_event_to_cloudevent(request) -> CloudEvent:
143162
return CloudEvent(metadata, data)
144163

145164

165+
def is_convertable_cloudevent(request) -> bool:
166+
"""Is the given request a known CloudEvent that can be converted to background event."""
167+
if is_binary(request.headers):
168+
event_type = request.headers.get("ce-type")
169+
event_source = request.headers.get("ce-source")
170+
return (
171+
event_source is not None
172+
and event_type is not None
173+
and event_type in _CE_TO_BACKGROUND_TYPE
174+
)
175+
return False
176+
177+
178+
def _split_ce_source(source) -> Tuple[str, str]:
179+
"""Splits a CloudEvent source string into resource and subject components."""
180+
regex = re.compile(r"\/\/([^/]+)\/(.+)")
181+
match = regex.fullmatch(source)
182+
if not match:
183+
raise EventConversionException("Unexpected CloudEvent source.")
184+
185+
return match.group(1), match.group(2)
186+
187+
188+
def cloudevent_to_background_event(request) -> Tuple[Any, Context]:
189+
"""Converts a background event represented by the given HTTP request into a CloudEvent."""
190+
try:
191+
event = from_http(request.headers, request.get_data())
192+
data = event.data
193+
service, name = _split_ce_source(event["source"])
194+
195+
if event["type"] not in _CE_TO_BACKGROUND_TYPE:
196+
raise EventConversionException(
197+
f'Unable to find background event equivalent type for "{event["type"]}"'
198+
)
199+
200+
if service == _PUBSUB_CE_SERVICE:
201+
resource = {"service": service, "name": name, "type": _PUBSUB_MESSAGE_TYPE}
202+
if "message" in data:
203+
data = data["message"]
204+
elif service == _FIREBASE_AUTH_CE_SERVICE:
205+
resource = name
206+
if "metadata" in data:
207+
for old, new in _FIREBASE_AUTH_METADATA_FIELDS_CE_TO_BACKGROUND.items():
208+
if old in data["metadata"]:
209+
data["metadata"][new] = data["metadata"][old]
210+
del data["metadata"][old]
211+
elif service == _STORAGE_CE_SERVICE:
212+
resource = {
213+
"name": f"{name}/{event['subject']}",
214+
"service": service,
215+
"type": data["kind"],
216+
}
217+
else:
218+
resource = f"{name}/{event['subject']}"
219+
220+
context = Context(
221+
eventId=event["id"],
222+
timestamp=event["time"],
223+
eventType=_CE_TO_BACKGROUND_TYPE[event["type"]],
224+
resource=resource,
225+
)
226+
return (data, context)
227+
except (AttributeError, KeyError, TypeError, MissingRequiredFields):
228+
raise EventConversionException(
229+
"Failed to convert CloudEvent to BackgroundEvent."
230+
)
231+
232+
146233
def _split_resource(context: Context) -> Tuple[str, str, str]:
147234
"""Splits a background event's resource into a CloudEvent service, resource, and subject."""
148235
service = ""

tests/test_convert.py

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import flask
1818
import pytest
1919

20-
from cloudevents.http import from_json
20+
from cloudevents.http import from_json, to_binary
2121

2222
from functions_framework import event_conversion
2323
from functions_framework.exceptions import EventConversionException
@@ -138,6 +138,18 @@ def firebase_auth_cloudevent_output():
138138
return from_json(f.read())
139139

140140

141+
@pytest.fixture
142+
def create_ce_headers():
143+
return lambda event_type, source: {
144+
"ce-id": "my-id",
145+
"ce-type": event_type,
146+
"ce-source": source,
147+
"ce-specversion": "1.0",
148+
"ce-subject": "my/subject",
149+
"ce-time": "2020-08-16T13:58:54.471765",
150+
}
151+
152+
141153
@pytest.mark.parametrize(
142154
"event", [PUBSUB_BACKGROUND_EVENT, PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT]
143155
)
@@ -329,3 +341,189 @@ def test_pubsub_emulator_request_with_invalid_message(
329341
with pytest.raises(EventConversionException) as exc_info:
330342
cloudevent = event_conversion.background_event_to_cloudevent(req)
331343
assert "Failed to convert Pub/Sub payload to event" in exc_info.value.args[0]
344+
345+
346+
@pytest.mark.parametrize(
347+
"ce_event_type, ce_source, expected_type, expected_resource",
348+
[
349+
(
350+
"google.firebase.database.document.v1.written",
351+
"//firebasedatabase.googleapis.com/projects/_/instances/my-project-id",
352+
"providers/google.firebase.database/eventTypes/ref.write",
353+
"projects/_/instances/my-project-id/my/subject",
354+
),
355+
(
356+
"google.cloud.pubsub.topic.v1.messagePublished",
357+
"//pubsub.googleapis.com/projects/sample-project/topics/gcf-test",
358+
"google.pubsub.topic.publish",
359+
{
360+
"service": "pubsub.googleapis.com",
361+
"name": "projects/sample-project/topics/gcf-test",
362+
"type": "type.googleapis.com/google.pubsub.v1.PubsubMessage",
363+
},
364+
),
365+
(
366+
"google.cloud.storage.object.v1.finalized",
367+
"//storage.googleapis.com/projects/_/buckets/some-bucket",
368+
"google.storage.object.finalize",
369+
{
370+
"service": "storage.googleapis.com",
371+
"name": "projects/_/buckets/some-bucket/my/subject",
372+
"type": "value",
373+
},
374+
),
375+
(
376+
"google.firebase.auth.user.v1.created",
377+
"//firebaseauth.googleapis.com/projects/my-project-id",
378+
"providers/firebase.auth/eventTypes/user.create",
379+
"projects/my-project-id",
380+
),
381+
],
382+
)
383+
def test_cloudevent_to_legacy_event(
384+
create_ce_headers,
385+
ce_event_type,
386+
ce_source,
387+
expected_type,
388+
expected_resource,
389+
):
390+
headers = create_ce_headers(ce_event_type, ce_source)
391+
req = flask.Request.from_values(headers=headers, json={"kind": "value"})
392+
393+
(res_data, res_context) = event_conversion.cloudevent_to_background_event(req)
394+
395+
assert res_context.event_id == "my-id"
396+
assert res_context.timestamp == "2020-08-16T13:58:54.471765"
397+
assert res_context.event_type == expected_type
398+
assert res_context.resource == expected_resource
399+
assert res_data == {"kind": "value"}
400+
401+
402+
def test_cloudevent_to_legacy_event_with_pubsub_message_payload(
403+
create_ce_headers,
404+
):
405+
headers = create_ce_headers(
406+
"google.cloud.pubsub.topic.v1.messagePublished",
407+
"//pubsub.googleapis.com/projects/sample-project/topics/gcf-test",
408+
)
409+
data = {"message": {"data": "fizzbuzz"}}
410+
req = flask.Request.from_values(headers=headers, json=data)
411+
412+
(res_data, res_context) = event_conversion.cloudevent_to_background_event(req)
413+
414+
assert res_context.event_type == "google.pubsub.topic.publish"
415+
assert res_data == {"data": "fizzbuzz"}
416+
417+
418+
def test_cloudevent_to_legacy_event_with_firebase_auth_ce(
419+
create_ce_headers,
420+
):
421+
headers = create_ce_headers(
422+
"google.firebase.auth.user.v1.created",
423+
"//firebaseauth.googleapis.com/projects/my-project-id",
424+
)
425+
data = {
426+
"metadata": {
427+
"createTime": "2020-05-26T10:42:27Z",
428+
"lastSignInTime": "2020-10-24T11:00:00Z",
429+
},
430+
"uid": "my-id",
431+
}
432+
req = flask.Request.from_values(headers=headers, json=data)
433+
434+
(res_data, res_context) = event_conversion.cloudevent_to_background_event(req)
435+
436+
assert res_context.event_type == "providers/firebase.auth/eventTypes/user.create"
437+
assert res_data == {
438+
"metadata": {
439+
"createdAt": "2020-05-26T10:42:27Z",
440+
"lastSignedInAt": "2020-10-24T11:00:00Z",
441+
},
442+
"uid": "my-id",
443+
}
444+
445+
446+
def test_cloudevent_to_legacy_event_with_firebase_auth_ce_empty_metadata(
447+
create_ce_headers,
448+
):
449+
headers = create_ce_headers(
450+
"google.firebase.auth.user.v1.created",
451+
"//firebaseauth.googleapis.com/projects/my-project-id",
452+
)
453+
data = {"metadata": {}, "uid": "my-id"}
454+
req = flask.Request.from_values(headers=headers, json=data)
455+
456+
(res_data, res_context) = event_conversion.cloudevent_to_background_event(req)
457+
458+
assert res_context.event_type == "providers/firebase.auth/eventTypes/user.create"
459+
assert res_data == data
460+
461+
462+
@pytest.mark.parametrize(
463+
"header_overrides, exception_message",
464+
[
465+
(
466+
{"ce-source": "invalid-source-format"},
467+
"Unexpected CloudEvent source",
468+
),
469+
(
470+
{"ce-source": None},
471+
"Failed to convert CloudEvent to BackgroundEvent",
472+
),
473+
(
474+
{"ce-subject": None},
475+
"Failed to convert CloudEvent to BackgroundEvent",
476+
),
477+
(
478+
{"ce-type": "unknown-type"},
479+
"Unable to find background event equivalent type for",
480+
),
481+
],
482+
)
483+
def test_cloudevent_to_legacy_event_with_invalid_event(
484+
create_ce_headers,
485+
header_overrides,
486+
exception_message,
487+
):
488+
headers = create_ce_headers(
489+
"google.firebase.database.document.v1.written",
490+
"//firebasedatabase.googleapis.com/projects/_/instances/my-project-id",
491+
)
492+
for k, v in header_overrides.items():
493+
if v is None:
494+
del headers[k]
495+
else:
496+
headers[k] = v
497+
498+
req = flask.Request.from_values(headers=headers, json={"some": "val"})
499+
500+
with pytest.raises(EventConversionException) as exc_info:
501+
event_conversion.cloudevent_to_background_event(req)
502+
503+
assert exception_message in exc_info.value.args[0]
504+
505+
506+
@pytest.mark.parametrize(
507+
"source,expected_service,expected_name",
508+
[
509+
(
510+
"//firebasedatabase.googleapis.com/projects/_/instances/my-project-id",
511+
"firebasedatabase.googleapis.com",
512+
"projects/_/instances/my-project-id",
513+
),
514+
(
515+
"//firebaseauth.googleapis.com/projects/my-project-id",
516+
"firebaseauth.googleapis.com",
517+
"projects/my-project-id",
518+
),
519+
(
520+
"//firestore.googleapis.com/projects/project-id/databases/(default)",
521+
"firestore.googleapis.com",
522+
"projects/project-id/databases/(default)",
523+
),
524+
],
525+
)
526+
def test_split_ce_source(source, expected_service, expected_name):
527+
service, name = event_conversion._split_ce_source(source)
528+
assert service == expected_service
529+
assert name == expected_name

0 commit comments

Comments
 (0)