From 0b02674a7c78ebb29ca7b3402c8e930eaa27f377 Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 13:43:57 -0800 Subject: [PATCH 1/7] Add support for background to CloudEvent conversion --- .github/workflows/conformance.yml | 8 +- src/functions_framework/__init__.py | 66 +++---- src/functions_framework/background.py | 38 ++++ src/functions_framework/convert.py | 159 +++++++++++++++ src/functions_framework/exceptions.py | 4 + tests/test_cloudevent_functions.py | 34 +++- tests/test_convert.py | 185 ++++++++++++++++++ .../firebase-auth-cloudevent-output.json | 24 +++ .../test_data/firebase-auth-legacy-input.json | 23 +++ .../pubsub_text-cloudevent-output.json | 17 ++ tests/test_data/pubsub_text-legacy-input.json | 19 ++ .../cloudevents/converted_background_event.py | 51 +++++ 12 files changed, 577 insertions(+), 51 deletions(-) create mode 100644 src/functions_framework/background.py create mode 100644 src/functions_framework/convert.py create mode 100644 tests/test_convert.py create mode 100644 tests/test_data/firebase-auth-cloudevent-output.json create mode 100644 tests/test_data/firebase-auth-legacy-input.json create mode 100644 tests/test_data/pubsub_text-cloudevent-output.json create mode 100644 tests/test_data/pubsub_text-legacy-input.json create mode 100644 tests/test_functions/cloudevents/converted_background_event.py diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b981b3fa..5eb1be78 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -24,7 +24,7 @@ jobs: go-version: '1.13' - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.2 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.7 with: functionType: 'http' useBuildpacks: false @@ -32,7 +32,7 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_http --signature-type http'" - name: Run event conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.2 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.7 with: functionType: 'legacyevent' useBuildpacks: false @@ -40,9 +40,9 @@ jobs: cmd: "'functions-framework --source tests/conformance/main.py --target write_legacy_event --signature-type event'" - name: Run cloudevent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.2 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.7 with: functionType: 'cloudevent' useBuildpacks: false - validateMapping: false + validateMapping: true cmd: "'functions-framework --source tests/conformance/main.py --target write_cloud_event --signature-type cloudevent'" diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index c44773f3..00651885 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -27,12 +27,15 @@ from cloudevents.http import from_http, is_binary +from functions_framework.background import Event +from functions_framework import convert from functions_framework.exceptions import ( FunctionsFrameworkException, InvalidConfigurationException, InvalidTargetTypeException, MissingSourceException, MissingTargetException, + EventConversionException, ) from google.cloud.functions.context import Context @@ -43,30 +46,7 @@ _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" _CRASH = "crash" - -class _Event(object): - """Event passed to background functions.""" - - # Supports both v1beta1 and v1beta2 event formats. - def __init__( - self, - context=None, - data="", - eventId="", - timestamp="", - eventType="", - resource="", - **kwargs, - ): - self.context = context - if not self.context: - self.context = { - "eventId": eventId, - "timestamp": timestamp, - "eventType": eventType, - "resource": resource, - } - self.data = data +_CLOUDEVENT_MIME_TYPE = "application/cloudevents+json" class _LoggingHandler(io.TextIOWrapper): @@ -97,26 +77,32 @@ def _run_cloudevent(function, request): def _cloudevent_view_func_wrapper(function, request): def view_func(path): + ce_exception = None + event = None try: - _run_cloudevent(function, request) - except cloud_exceptions.MissingRequiredFields as e: - flask.abort( - 400, - 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: + event = from_http(request.headers, request.get_data()) + except ( + cloud_exceptions.MissingRequiredFields, + cloud_exceptions.InvalidRequiredFields, + ) as e: + ce_exception = e + + if not ce_exception: + function(event) + return "OK" + + # Not a CloudEvent. Try converting to a CloudEvent. + try: + function(convert.background_event_to_cloudevent(request)) + except EventConversionException 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}" + " parsing CloudEvent failed and converting from background event to" + f" CloudEvent also failed.\nGot HTTP headers: {request.headers}\nGot" + f" data: {request.get_data()}\nGot CloudEvent exception: {repr(ce_exception)}" + f"\nGot background event conversion exception: {repr(e)}" ), ) return "OK" @@ -143,7 +129,7 @@ def view_func(path): event_data = request.get_json() if not event_data: flask.abort(400) - event_object = _Event(**event_data) + event_object = Event(**event_data) data = event_object.data context = Context(**event_object.context) function(data, context) diff --git a/src/functions_framework/background.py b/src/functions_framework/background.py new file mode 100644 index 00000000..f3a00767 --- /dev/null +++ b/src/functions_framework/background.py @@ -0,0 +1,38 @@ +# Copyright 2021 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. + + +class Event(object): + """Event passed to background functions.""" + + # Supports both v1beta1 and v1beta2 event formats. + def __init__( + self, + context=None, + data="", + eventId="", + timestamp="", + eventType="", + resource="", + **kwargs, + ): + self.context = context + if not self.context: + self.context = { + "eventId": eventId, + "timestamp": timestamp, + "eventType": eventType, + "resource": resource, + } + self.data = data diff --git a/src/functions_framework/convert.py b/src/functions_framework/convert.py new file mode 100644 index 00000000..8ed2abd1 --- /dev/null +++ b/src/functions_framework/convert.py @@ -0,0 +1,159 @@ +# Copyright 2021 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 re +from typing import Tuple + +from cloudevents.http import CloudEvent + +from functions_framework import background +from functions_framework.exceptions import EventConversionException +from google.cloud.functions.context import Context + +_CLOUDEVENT_SPEC_VERSION = "1.0" + +_BACKGROUND_TO_CE_TYPE = { + "google.pubsub.topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", + "providers/cloud.pubsub/eventTypes/topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", + "google.storage.object.finalize": "google.cloud.storage.object.v1.finalized", + "google.storage.object.delete": "google.cloud.storage.object.v1.deleted", + "google.storage.object.archive": "google.cloud.storage.object.v1.archived", + "google.storage.object.metadataUpdate": "google.cloud.storage.object.v1.metadataUpdated", + "providers/cloud.firestore/eventTypes/document.write": "google.cloud.firestore.document.v1.written", + "providers/cloud.firestore/eventTypes/document.create": "google.cloud.firestore.document.v1.created", + "providers/cloud.firestore/eventTypes/document.update": "google.cloud.firestore.document.v1.updated", + "providers/cloud.firestore/eventTypes/document.delete": "google.cloud.firestore.document.v1.deleted", + "providers/firebase.auth/eventTypes/user.create": "google.firebase.auth.user.v1.created", + "providers/firebase.auth/eventTypes/user.delete": "google.firebase.auth.user.v1.deleted", + "providers/google.firebase.analytics/eventTypes/event.log": "google.firebase.analytics.log.v1.written", + "providers/google.firebase.database/eventTypes/ref.create": "google.firebase.database.document.v1.created", + "providers/google.firebase.database/eventTypes/ref.write": "google.firebase.database.document.v1.written", + "providers/google.firebase.database/eventTypes/ref.update": "google.firebase.database.document.v1.updated", + "providers/google.firebase.database/eventTypes/ref.delete": "google.firebase.database.document.v1.deleted", + "providers/cloud.storage/eventTypes/object.change": "google.cloud.storage.object.v1.finalized", +} + +_FIREBASE_AUTH_CE_SERVICE = "firebaseauth.googleapis.com" +_FIREBASE_CE_SERVICE = "firebase.googleapis.com" +_FIREBASE_DB_CE_SERVICE = "firebasedatabase.googleapis.com" +_FIRESTORE_CE_SERVICE = "firestore.googleapis.com" +_PUBSUB_CE_SERVICE = "pubsub.googleapis.com" +_STORAGE_CE_SERVICE = "storage.googleapis.com" + +_SERVICE_BACKGROUND_TO_CE = { + "providers/cloud.firestore/": _FIRESTORE_CE_SERVICE, + "providers/google.firebase.analytics/": _FIREBASE_CE_SERVICE, + "providers/firebase.auth/": _FIREBASE_AUTH_CE_SERVICE, + "providers/google.firebase.database/": _FIREBASE_DB_CE_SERVICE, + "providers/cloud.pubsub/": _PUBSUB_CE_SERVICE, + "providers/cloud.storage/": _STORAGE_CE_SERVICE, + "google.pubsub": _PUBSUB_CE_SERVICE, + "google.storage": _STORAGE_CE_SERVICE, +} + +_CE_SERVICE_TO_RESOURCE_RE = { + _FIREBASE_CE_SERVICE: re.compile(r"^(projects/[^/]+)/(events/[^/]+)$"), + _FIREBASE_DB_CE_SERVICE: re.compile(r"^(projects/[^/]/instances/[^/]+)/(refs/.+)$"), + _FIRESTORE_CE_SERVICE: re.compile( + r"^(projects/[^/]+/databases/\(default\))/(documents/.+)$" + ), + _STORAGE_CE_SERVICE: re.compile(r"^(projects/[^/]/buckets/[^/]+)/(objects/.+)$"), +} + +# Maps Firebase Auth background event metadata field names to their equivalent +# CloudEvent field names. +_FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE = { + "createdAt": "createTime", + "lastSignedInAt": "lastSignInTime", +} + + +def background_event_to_cloudevent(request) -> CloudEvent: + event_data = request.get_json() + if not event_data: + raise EventConversionException("Failed to parse JSON") + + event_object = background.Event(**event_data) + data = event_object.data + context = Context(**event_object.context) + + if context.event_type not in _BACKGROUND_TO_CE_TYPE: + raise EventConversionException( + f'Unable to find CloudEvent equivalent type for "{context.event_type}"' + ) + new_type = _BACKGROUND_TO_CE_TYPE[context.event_type] + + service, resource, subject = _split_resource(context) + + # Handle Pub/Sub events. + if service == _PUBSUB_CE_SERVICE: + data = {"message": data} + + # Handle Firebase Auth events. + if service == _FIREBASE_AUTH_CE_SERVICE: + if "metadata" in data: + for old, new in _FIREBASE_AUTH_METADATA_FIELDS_BACKGROUND_TO_CE.items(): + if old in data["metadata"]: + data["metadata"][new] = data["metadata"][old] + del data["metadata"][old] + if "uid" in data: + uid = data["uid"] + subject = f"users/{uid}" + + metadata = { + "id": context.event_id, + "time": context.timestamp, + "specversion": _CLOUDEVENT_SPEC_VERSION, + "datacontenttype": "application/json", + "type": new_type, + "source": f"//{service}/{resource}", + } + + if subject: + metadata["subject"] = subject + + return CloudEvent(metadata, data) + + +# Splits a background event's resource into a CloudEvent service, resource, and subject. +def _split_resource(context: Context) -> Tuple[str, str, str]: + service = "" + resource = "" + if isinstance(context.resource, dict): + service = context.resource.get("service", "") + resource = context.resource["name"] + else: + resource = context.resource + + # If there's no service we'll choose an appropriate one based on the event type. + if not service: + for b_service, ce_service in _SERVICE_BACKGROUND_TO_CE.items(): + if context.event_type.startswith(b_service): + service = ce_service + break + if not service: + raise EventConversionException( + "Unable to find CloudEvent equivalent service " + f"for {context.event_type}" + ) + + # If we don't need to split the resource string then we're done. + if service not in _CE_SERVICE_TO_RESOURCE_RE: + return service, resource, "" + + # Split resource into resource and subject. + match = _CE_SERVICE_TO_RESOURCE_RE[service].fullmatch(resource) + if not match: + raise EventConversionException("Resource regex did not match") + + return service, match.group(1), match.group(2) diff --git a/src/functions_framework/exceptions.py b/src/functions_framework/exceptions.py index 970da5f4..671a28a4 100644 --- a/src/functions_framework/exceptions.py +++ b/src/functions_framework/exceptions.py @@ -31,3 +31,7 @@ class MissingSourceException(FunctionsFrameworkException): class MissingTargetException(FunctionsFrameworkException): pass + + +class EventConversionException(FunctionsFrameworkException): + pass diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py index 90cebe05..061bb945 100644 --- a/tests/test_cloudevent_functions.py +++ b/tests/test_cloudevent_functions.py @@ -21,6 +21,7 @@ from functions_framework import LazyWSGIApp, create_app, exceptions TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" # Python 3.5: ModuleNotFoundError does not exist try: @@ -82,6 +83,12 @@ def create_structured_data(): } +@pytest.fixture +def background_event(): + with open(TEST_DATA_DIR / "pubsub_text-legacy-input.json", "r") as f: + return json.load(f) + + @pytest.fixture def client(): source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" @@ -96,6 +103,13 @@ def empty_client(): return create_app(target, source, "cloudevent").test_client() +@pytest.fixture +def converted_background_event_client(): + source = TEST_FUNCTIONS_DIR / "cloudevents" / "converted_background_event.py" + target = "function" + return create_app(target, source, "cloudevent").test_client() + + def test_event(client, cloudevent_1_0): headers, data = to_structured(cloudevent_1_0) resp = client.post("/", headers=headers, data=data) @@ -142,9 +156,7 @@ def test_cloudevent_missing_required_binary_fields( resp = client.post("/", headers=invalid_headers, json=data_payload) assert resp.status_code == 400 - assert ( - "cloudevents.exceptions.MissingRequiredFields" in resp.get_data().decode() - ) + assert "MissingRequiredFields" in resp.get_data().decode() @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -162,7 +174,7 @@ def test_cloudevent_missing_required_structured_fields( resp = client.post("/", headers=headers, json=invalid_data) assert resp.status_code == 400 - assert "cloudevents.exceptions.MissingRequiredFields" in resp.data.decode() + assert "MissingRequiredFields" in resp.data.decode() def test_invalid_fields_binary(client, create_headers_binary, data_payload): @@ -171,15 +183,14 @@ def test_invalid_fields_binary(client, create_headers_binary, data_payload): 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() + assert "InvalidRequiredFields" in resp.data.decode() def test_unparsable_cloudevent(client): resp = client.post("/", headers={}, data="") assert resp.status_code == 400 - assert "cloudevents.exceptions.MissingRequiredFields" in resp.data.decode() + assert "MissingRequiredFields" in resp.data.decode() @pytest.mark.parametrize("specversion", ["0.3", "1.0"]) @@ -209,3 +220,12 @@ def test_no_mime_type_structured(empty_client, specversion, create_structured_da assert resp.status_code == 200 assert resp.get_data() == b"OK" + + +def test_background_event(converted_background_event_client, background_event): + resp = converted_background_event_client.post( + "/", headers={}, json=background_event + ) + + assert resp.status_code == 200 + assert resp.get_data() == b"OK" diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 00000000..47b51066 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,185 @@ +# Copyright 2021 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 pytest + +from cloudevents.http import from_json +import flask + +from functions_framework import convert +from functions_framework.background import Event +from functions_framework.exceptions import EventConversionException +from google.cloud.functions.context import Context + +TEST_DATA_DIR = pathlib.Path(__file__).resolve().parent / "test_data" + + +PUBSUB_BACKGROUND_EVENT = { + "context": { + "eventId": "1215011316659232", + "timestamp": "2020-05-18T12:13:19Z", + "eventType": "google.pubsub.topic.publish", + "resource": { + "service": "pubsub.googleapis.com", + "name": "projects/sample-project/topics/gcf-test", + "type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + }, + }, + "data": { + "data": "10", + }, +} + +PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT = { + "eventId": "1215011316659232", + "timestamp": "2020-05-18T12:13:19Z", + "eventType": "providers/cloud.pubsub/eventTypes/topic.publish", + "resource": "projects/sample-project/topics/gcf-test", + "data": { + "data": "10", + }, +} + + +@pytest.fixture +def pubsub_cloudevent_output(): + event = { + "specversion": "1.0", + "id": "1215011316659232", + "source": "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "time": "2020-05-18T12:13:19Z", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "datacontenttype": "application/json", + "data": { + "message": { + "data": "10", + }, + }, + } + + return from_json(json.dumps(event)) + + +@pytest.fixture +def firebase_auth_background_input(): + with open(TEST_DATA_DIR / "firebase-auth-legacy-input.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def firebase_auth_cloudevent_output(): + with open(TEST_DATA_DIR / "firebase-auth-cloudevent-output.json", "r") as f: + return from_json(f.read()) + + +@pytest.mark.parametrize( + "event", [PUBSUB_BACKGROUND_EVENT, PUBSUB_BACKGROUND_EVENT_WITHOUT_CONTEXT] +) +def test_pubsub_event_to_cloudevent(event, pubsub_cloudevent_output): + req = flask.Request.from_values(json=event) + cloudevent = convert.background_event_to_cloudevent(req) + assert cloudevent == pubsub_cloudevent_output + + +def test_firebase_auth_event_to_cloudevent( + firebase_auth_background_input, firebase_auth_cloudevent_output +): + req = flask.Request.from_values(json=firebase_auth_background_input) + cloudevent = convert.background_event_to_cloudevent(req) + assert cloudevent == firebase_auth_cloudevent_output + + +def test_split_resource(): + background_resource = { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", + } + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + service, resource, subject = convert._split_resource(context) + assert service == "storage.googleapis.com" + assert resource == "projects/_/buckets/some-bucket" + assert subject == "objects/folder/Test.cs" + + +def test_split_resource_without_service(): + background_resource = { + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", + } + # This event type will be successfully mapped to an equivalent CloudEvent type. + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + service, resource, subject = convert._split_resource(context) + assert service == "storage.googleapis.com" + assert resource == "projects/_/buckets/some-bucket" + assert subject == "objects/folder/Test.cs" + + +def test_split_resource_string_resource(): + background_resource = "projects/_/buckets/some-bucket/objects/folder/Test.cs" + # This event type will be successfully mapped to an equivalent CloudEvent type. + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + service, resource, subject = convert._split_resource(context) + assert service == "storage.googleapis.com" + assert resource == "projects/_/buckets/some-bucket" + assert subject == "objects/folder/Test.cs" + + +def test_split_resource_unknown_service_and_event_type(): + # With both an unknown service and an unknown event type, we won't attempt any + # event type mapping or resource/subject splitting. + background_resource = { + "service": "not_a_known_service", + "name": "projects/_/my/stuff/at/test.txt", + "type": "storage#object", + } + context = Context(eventType="not_a_known_event_type", resource=background_resource) + service, resource, subject = convert._split_resource(context) + assert service == "not_a_known_service" + assert resource == "projects/_/my/stuff/at/test.txt" + assert subject == "" + + +def test_split_resource_without_service_unknown_event_type(): + background_resource = { + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", + } + # This event type cannot be mapped to an equivalent CloudEvent type. + context = Context(eventType="not_a_known_event_type", resource=background_resource) + with pytest.raises(EventConversionException) as exc_info: + convert._split_resource(context) + assert "Unable to find CloudEvent equivalent service" in exc_info.value.args[0] + + +def test_split_resource_no_resource_regex_match(): + background_resource = { + "service": "storage.googleapis.com", + # This name will not match the regex associated with the service. + "name": "foo/bar/baz", + "type": "storage#object", + } + context = Context( + eventType="google.storage.object.finalize", resource=background_resource + ) + with pytest.raises(EventConversionException) as exc_info: + convert._split_resource(context) + assert "Resource regex did not match" in exc_info.value.args[0] diff --git a/tests/test_data/firebase-auth-cloudevent-output.json b/tests/test_data/firebase-auth-cloudevent-output.json new file mode 100644 index 00000000..329c483b --- /dev/null +++ b/tests/test_data/firebase-auth-cloudevent-output.json @@ -0,0 +1,24 @@ +{ + "specversion": "1.0", + "type": "google.firebase.auth.user.v1.created", + "source": "//firebaseauth.googleapis.com/projects/my-project-id", + "subject": "users/UUpby3s4spZre6kHsgVSPetzQ8l2", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.000Z", + "datacontenttype": "application/json", + "data": { + "email": "test@nowhere.com", + "metadata": { + "createTime": "2020-05-26T10:42:27Z", + "lastSignInTime": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + } +} diff --git a/tests/test_data/firebase-auth-legacy-input.json b/tests/test_data/firebase-auth-legacy-input.json new file mode 100644 index 00000000..1119ea7c --- /dev/null +++ b/tests/test_data/firebase-auth-legacy-input.json @@ -0,0 +1,23 @@ +{ + "data": { + "email": "test@nowhere.com", + "metadata": { + "createdAt": "2020-05-26T10:42:27Z", + "lastSignedInAt": "2020-10-24T11:00:00Z" + }, + "providerData": [ + { + "email": "test@nowhere.com", + "providerId": "password", + "uid": "test@nowhere.com" + } + ], + "uid": "UUpby3s4spZre6kHsgVSPetzQ8l2" + }, + "eventId": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "eventType": "providers/firebase.auth/eventTypes/user.create", + "notSupported": { + }, + "resource": "projects/my-project-id", + "timestamp": "2020-09-29T11:32:00.000Z" +} diff --git a/tests/test_data/pubsub_text-cloudevent-output.json b/tests/test_data/pubsub_text-cloudevent-output.json new file mode 100644 index 00000000..48204933 --- /dev/null +++ b/tests/test_data/pubsub_text-cloudevent-output.json @@ -0,0 +1,17 @@ +{ + "specversion": "1.0", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "source": "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test", + "id": "aaaaaa-1111-bbbb-2222-cccccccccccc", + "time": "2020-09-29T11:32:00.000Z", + "datacontenttype": "application/json", + "data": { + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } + } +} diff --git a/tests/test_data/pubsub_text-legacy-input.json b/tests/test_data/pubsub_text-legacy-input.json new file mode 100644 index 00000000..6028d090 --- /dev/null +++ b/tests/test_data/pubsub_text-legacy-input.json @@ -0,0 +1,19 @@ +{ + "context": { + "eventId":"aaaaaa-1111-bbbb-2222-cccccccccccc", + "timestamp":"2020-09-29T11:32:00.000Z", + "eventType":"google.pubsub.topic.publish", + "resource":{ + "service":"pubsub.googleapis.com", + "name":"projects/sample-project/topics/gcf-test", + "type":"type.googleapis.com/google.pubsub.v1.PubsubMessage" + } + }, + "data": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1":"attr1-value" + }, + "data": "dGVzdCBtZXNzYWdlIDM=" + } +} diff --git a/tests/test_functions/cloudevents/converted_background_event.py b/tests/test_functions/cloudevents/converted_background_event.py new file mode 100644 index 00000000..be0d6c52 --- /dev/null +++ b/tests/test_functions/cloudevents/converted_background_event.py @@ -0,0 +1,51 @@ +# Copyright 2021 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 CloudEvent 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 CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + data = { + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "attributes": { + "attr1": "attr1-value", + }, + "data": "dGVzdCBtZXNzYWdlIDM=", + }, + } + + valid_event = ( + cloudevent["id"] == "aaaaaa-1111-bbbb-2222-cccccccccccc" + and cloudevent.data == data + and cloudevent["source"] + == "//pubsub.googleapis.com/projects/sample-project/topics/gcf-test" + and cloudevent["type"] == "google.cloud.pubsub.topic.v1.messagePublished" + and cloudevent["time"] == "2020-09-29T11:32:00.000Z" + ) + + if not valid_event: + flask.abort(500) From c8f0615f949149f30a9254b40347129b760329f2 Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 13:55:47 -0800 Subject: [PATCH 2/7] Remove deprecated isort flag `-rc` from tox.ini See https://pycqa.github.io/isort/docs/upgrade_guides/5.0.0/ --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e4a251bd..b9ba72f9 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,6 @@ deps = isort commands = black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py - isort -rc -c src tests setup.py conftest.py + isort -c src tests setup.py conftest.py python setup.py --quiet sdist bdist_wheel twine check dist/* From b6f31ca5d725937bab245e8dc21c68d1d5af8565 Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 14:13:09 -0800 Subject: [PATCH 3/7] Fix import sorting --- src/functions_framework/__init__.py | 4 ++-- src/functions_framework/convert.py | 1 + tests/test_convert.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 00651885..2eece1ea 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -27,15 +27,15 @@ from cloudevents.http import from_http, is_binary -from functions_framework.background import Event from functions_framework import convert +from functions_framework.background import Event from functions_framework.exceptions import ( + EventConversionException, FunctionsFrameworkException, InvalidConfigurationException, InvalidTargetTypeException, MissingSourceException, MissingTargetException, - EventConversionException, ) from google.cloud.functions.context import Context diff --git a/src/functions_framework/convert.py b/src/functions_framework/convert.py index 8ed2abd1..ddc878c8 100644 --- a/src/functions_framework/convert.py +++ b/src/functions_framework/convert.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import re + from typing import Tuple from cloudevents.http import CloudEvent diff --git a/tests/test_convert.py b/tests/test_convert.py index 47b51066..0928b116 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -13,10 +13,11 @@ # limitations under the License. import json import pathlib + +import flask import pytest from cloudevents.http import from_json -import flask from functions_framework import convert from functions_framework.background import Event From 924b6948ba5c9beb931116a95f13d7a984d42e8a Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 15:20:24 -0800 Subject: [PATCH 4/7] Add tests to get coverage to 100% --- tests/test_convert.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_convert.py b/tests/test_convert.py index 0928b116..efe48d90 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -102,6 +102,46 @@ def test_firebase_auth_event_to_cloudevent( assert cloudevent == firebase_auth_cloudevent_output +def test_firebase_auth_event_to_cloudevent_no_metadata( + firebase_auth_background_input, firebase_auth_cloudevent_output +): + # Remove metadata from the events to verify conversion still works. + del firebase_auth_background_input["data"]["metadata"] + del firebase_auth_cloudevent_output.data["metadata"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloudevent = convert.background_event_to_cloudevent(req) + assert cloudevent == firebase_auth_cloudevent_output + + +def test_firebase_auth_event_to_cloudevent_no_metadata_timestamps( + firebase_auth_background_input, firebase_auth_cloudevent_output +): + # Remove metadata timestamps from the events to verify conversion still works. + del firebase_auth_background_input["data"]["metadata"]["createdAt"] + del firebase_auth_background_input["data"]["metadata"]["lastSignedInAt"] + del firebase_auth_cloudevent_output.data["metadata"]["createTime"] + del firebase_auth_cloudevent_output.data["metadata"]["lastSignInTime"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloudevent = convert.background_event_to_cloudevent(req) + assert cloudevent == firebase_auth_cloudevent_output + + +def test_firebase_auth_event_to_cloudevent_no_uid( + firebase_auth_background_input, firebase_auth_cloudevent_output +): + # Remove UIDs from the events to verify conversion still works. The UID is mapped + # to the subject in the CloudEvent so remove that from the expected CloudEvent. + del firebase_auth_background_input["data"]["uid"] + del firebase_auth_cloudevent_output.data["uid"] + del firebase_auth_cloudevent_output["subject"] + + req = flask.Request.from_values(json=firebase_auth_background_input) + cloudevent = convert.background_event_to_cloudevent(req) + assert cloudevent == firebase_auth_cloudevent_output + + def test_split_resource(): background_resource = { "service": "storage.googleapis.com", From 9e7555bf2623cd4a4db6793e4ed945241402e5ca Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 21:14:18 -0800 Subject: [PATCH 5/7] Address review comments --- src/functions_framework/__init__.py | 4 ++-- src/functions_framework/background.py | 12 +++++++++--- src/functions_framework/convert.py | 14 ++++++++++++-- tests/test_convert.py | 1 - 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 2eece1ea..1f8d6db1 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -28,7 +28,7 @@ from cloudevents.http import from_http, is_binary from functions_framework import convert -from functions_framework.background import Event +from functions_framework.background import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, FunctionsFrameworkException, @@ -129,7 +129,7 @@ def view_func(path): event_data = request.get_json() if not event_data: flask.abort(400) - event_object = Event(**event_data) + event_object = BackgroundEvent(**event_data) data = event_object.data context = Context(**event_object.context) function(data, context) diff --git a/src/functions_framework/background.py b/src/functions_framework/background.py index f3a00767..be01960b 100644 --- a/src/functions_framework/background.py +++ b/src/functions_framework/background.py @@ -13,10 +13,16 @@ # limitations under the License. -class Event(object): - """Event passed to background functions.""" +class BackgroundEvent(object): + """BackgroundEvent is an event passed to GCF background event functions. - # Supports both v1beta1 and v1beta2 event formats. + Background event functions take data and context as parameters, both of + which this class represents. By contrast, CloudEvent functions take a + single CloudEvent object as their parameter. This class does not represent + CloudEvents. + """ + + # Supports v1beta1, v1beta2, and v1 event formats. def __init__( self, context=None, diff --git a/src/functions_framework/convert.py b/src/functions_framework/convert.py index ddc878c8..d71cb797 100644 --- a/src/functions_framework/convert.py +++ b/src/functions_framework/convert.py @@ -23,6 +23,9 @@ _CLOUDEVENT_SPEC_VERSION = "1.0" +# Maps background/legacy event types to their equivalent CloudEvent types. +# For more info on event mappings see +# https://github.com/GoogleCloudPlatform/functions-framework-conformance/blob/master/docs/mapping.md _BACKGROUND_TO_CE_TYPE = { "google.pubsub.topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", "providers/cloud.pubsub/eventTypes/topic.publish": "google.cloud.pubsub.topic.v1.messagePublished", @@ -44,6 +47,7 @@ "providers/cloud.storage/eventTypes/object.change": "google.cloud.storage.object.v1.finalized", } +# CloudEvent service names. _FIREBASE_AUTH_CE_SERVICE = "firebaseauth.googleapis.com" _FIREBASE_CE_SERVICE = "firebase.googleapis.com" _FIREBASE_DB_CE_SERVICE = "firebasedatabase.googleapis.com" @@ -51,6 +55,7 @@ _PUBSUB_CE_SERVICE = "pubsub.googleapis.com" _STORAGE_CE_SERVICE = "storage.googleapis.com" +# Maps background event services to their equivalent CloudEvent services. _SERVICE_BACKGROUND_TO_CE = { "providers/cloud.firestore/": _FIRESTORE_CE_SERVICE, "providers/google.firebase.analytics/": _FIREBASE_CE_SERVICE, @@ -62,6 +67,10 @@ "google.storage": _STORAGE_CE_SERVICE, } +# Maps CloudEvent service strings to regular expressions used to split a background +# event resource string into CloudEvent resource and subject strings. Each regex +# must have exactly two capture groups: the first for the resource and the second +# for the subject. _CE_SERVICE_TO_RESOURCE_RE = { _FIREBASE_CE_SERVICE: re.compile(r"^(projects/[^/]+)/(events/[^/]+)$"), _FIREBASE_DB_CE_SERVICE: re.compile(r"^(projects/[^/]/instances/[^/]+)/(refs/.+)$"), @@ -80,11 +89,12 @@ def background_event_to_cloudevent(request) -> CloudEvent: + """Converts a background event represented by the given HTTP request into a CloudEvent. """ event_data = request.get_json() if not event_data: raise EventConversionException("Failed to parse JSON") - event_object = background.Event(**event_data) + event_object = background.BackgroundEvent(**event_data) data = event_object.data context = Context(**event_object.context) @@ -126,8 +136,8 @@ def background_event_to_cloudevent(request) -> CloudEvent: return CloudEvent(metadata, data) -# Splits a background event's resource into a CloudEvent service, resource, and subject. def _split_resource(context: Context) -> Tuple[str, str, str]: + """Splits a background event's resource into a CloudEvent service, resource, and subject.""" service = "" resource = "" if isinstance(context.resource, dict): diff --git a/tests/test_convert.py b/tests/test_convert.py index efe48d90..fff9ee9c 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -20,7 +20,6 @@ from cloudevents.http import from_json from functions_framework import convert -from functions_framework.background import Event from functions_framework.exceptions import EventConversionException from google.cloud.functions.context import Context From 06ce9c7dcc269fa7806c585a3e629c1177bad02d Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 21:23:13 -0800 Subject: [PATCH 6/7] Rename files --- src/functions_framework/__init__.py | 6 ++--- .../{background.py => background_event.py} | 0 .../{convert.py => event_conversion.py} | 4 ++-- tests/test_convert.py | 24 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) rename src/functions_framework/{background.py => background_event.py} (100%) rename src/functions_framework/{convert.py => event_conversion.py} (98%) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 1f8d6db1..3bff83f0 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -27,8 +27,8 @@ from cloudevents.http import from_http, is_binary -from functions_framework import convert -from functions_framework.background import BackgroundEvent +from functions_framework import event_conversion +from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, FunctionsFrameworkException, @@ -93,7 +93,7 @@ def view_func(path): # Not a CloudEvent. Try converting to a CloudEvent. try: - function(convert.background_event_to_cloudevent(request)) + function(event_conversion.background_event_to_cloudevent(request)) except EventConversionException as e: flask.abort( 400, diff --git a/src/functions_framework/background.py b/src/functions_framework/background_event.py similarity index 100% rename from src/functions_framework/background.py rename to src/functions_framework/background_event.py diff --git a/src/functions_framework/convert.py b/src/functions_framework/event_conversion.py similarity index 98% rename from src/functions_framework/convert.py rename to src/functions_framework/event_conversion.py index d71cb797..4d914802 100644 --- a/src/functions_framework/convert.py +++ b/src/functions_framework/event_conversion.py @@ -17,7 +17,7 @@ from cloudevents.http import CloudEvent -from functions_framework import background +from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import EventConversionException from google.cloud.functions.context import Context @@ -94,7 +94,7 @@ def background_event_to_cloudevent(request) -> CloudEvent: if not event_data: raise EventConversionException("Failed to parse JSON") - event_object = background.BackgroundEvent(**event_data) + event_object = BackgroundEvent(**event_data) data = event_object.data context = Context(**event_object.context) diff --git a/tests/test_convert.py b/tests/test_convert.py index fff9ee9c..b9df005a 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -19,7 +19,7 @@ from cloudevents.http import from_json -from functions_framework import convert +from functions_framework import event_conversion from functions_framework.exceptions import EventConversionException from google.cloud.functions.context import Context @@ -89,7 +89,7 @@ def firebase_auth_cloudevent_output(): ) def test_pubsub_event_to_cloudevent(event, pubsub_cloudevent_output): req = flask.Request.from_values(json=event) - cloudevent = convert.background_event_to_cloudevent(req) + cloudevent = event_conversion.background_event_to_cloudevent(req) assert cloudevent == pubsub_cloudevent_output @@ -97,7 +97,7 @@ def test_firebase_auth_event_to_cloudevent( firebase_auth_background_input, firebase_auth_cloudevent_output ): req = flask.Request.from_values(json=firebase_auth_background_input) - cloudevent = convert.background_event_to_cloudevent(req) + cloudevent = event_conversion.background_event_to_cloudevent(req) assert cloudevent == firebase_auth_cloudevent_output @@ -109,7 +109,7 @@ def test_firebase_auth_event_to_cloudevent_no_metadata( del firebase_auth_cloudevent_output.data["metadata"] req = flask.Request.from_values(json=firebase_auth_background_input) - cloudevent = convert.background_event_to_cloudevent(req) + cloudevent = event_conversion.background_event_to_cloudevent(req) assert cloudevent == firebase_auth_cloudevent_output @@ -123,7 +123,7 @@ def test_firebase_auth_event_to_cloudevent_no_metadata_timestamps( del firebase_auth_cloudevent_output.data["metadata"]["lastSignInTime"] req = flask.Request.from_values(json=firebase_auth_background_input) - cloudevent = convert.background_event_to_cloudevent(req) + cloudevent = event_conversion.background_event_to_cloudevent(req) assert cloudevent == firebase_auth_cloudevent_output @@ -137,7 +137,7 @@ def test_firebase_auth_event_to_cloudevent_no_uid( del firebase_auth_cloudevent_output["subject"] req = flask.Request.from_values(json=firebase_auth_background_input) - cloudevent = convert.background_event_to_cloudevent(req) + cloudevent = event_conversion.background_event_to_cloudevent(req) assert cloudevent == firebase_auth_cloudevent_output @@ -150,7 +150,7 @@ def test_split_resource(): context = Context( eventType="google.storage.object.finalize", resource=background_resource ) - service, resource, subject = convert._split_resource(context) + service, resource, subject = event_conversion._split_resource(context) assert service == "storage.googleapis.com" assert resource == "projects/_/buckets/some-bucket" assert subject == "objects/folder/Test.cs" @@ -165,7 +165,7 @@ def test_split_resource_without_service(): context = Context( eventType="google.storage.object.finalize", resource=background_resource ) - service, resource, subject = convert._split_resource(context) + service, resource, subject = event_conversion._split_resource(context) assert service == "storage.googleapis.com" assert resource == "projects/_/buckets/some-bucket" assert subject == "objects/folder/Test.cs" @@ -177,7 +177,7 @@ def test_split_resource_string_resource(): context = Context( eventType="google.storage.object.finalize", resource=background_resource ) - service, resource, subject = convert._split_resource(context) + service, resource, subject = event_conversion._split_resource(context) assert service == "storage.googleapis.com" assert resource == "projects/_/buckets/some-bucket" assert subject == "objects/folder/Test.cs" @@ -192,7 +192,7 @@ def test_split_resource_unknown_service_and_event_type(): "type": "storage#object", } context = Context(eventType="not_a_known_event_type", resource=background_resource) - service, resource, subject = convert._split_resource(context) + service, resource, subject = event_conversion._split_resource(context) assert service == "not_a_known_service" assert resource == "projects/_/my/stuff/at/test.txt" assert subject == "" @@ -206,7 +206,7 @@ def test_split_resource_without_service_unknown_event_type(): # This event type cannot be mapped to an equivalent CloudEvent type. context = Context(eventType="not_a_known_event_type", resource=background_resource) with pytest.raises(EventConversionException) as exc_info: - convert._split_resource(context) + event_conversion._split_resource(context) assert "Unable to find CloudEvent equivalent service" in exc_info.value.args[0] @@ -221,5 +221,5 @@ def test_split_resource_no_resource_regex_match(): eventType="google.storage.object.finalize", resource=background_resource ) with pytest.raises(EventConversionException) as exc_info: - convert._split_resource(context) + event_conversion._split_resource(context) assert "Resource regex did not match" in exc_info.value.args[0] From d1c8d63b3596a2a68b3074c5cd8f39e4c46a240e Mon Sep 17 00:00:00 2001 From: Michael Traver Date: Mon, 8 Mar 2021 23:19:26 -0800 Subject: [PATCH 7/7] Parametrize some tests --- tests/test_convert.py | 55 +++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/tests/test_convert.py b/tests/test_convert.py index b9df005a..d3fe3e22 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -52,6 +52,19 @@ }, } +BACKGROUND_RESOURCE = { + "service": "storage.googleapis.com", + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", +} + +BACKGROUND_RESOURCE_WITHOUT_SERVICE = { + "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", + "type": "storage#object", +} + +BACKGROUND_RESOURCE_STRING = "projects/_/buckets/some-bucket/objects/folder/Test.cs" + @pytest.fixture def pubsub_cloudevent_output(): @@ -141,39 +154,15 @@ def test_firebase_auth_event_to_cloudevent_no_uid( assert cloudevent == firebase_auth_cloudevent_output -def test_split_resource(): - background_resource = { - "service": "storage.googleapis.com", - "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", - "type": "storage#object", - } - context = Context( - eventType="google.storage.object.finalize", resource=background_resource - ) - service, resource, subject = event_conversion._split_resource(context) - assert service == "storage.googleapis.com" - assert resource == "projects/_/buckets/some-bucket" - assert subject == "objects/folder/Test.cs" - - -def test_split_resource_without_service(): - background_resource = { - "name": "projects/_/buckets/some-bucket/objects/folder/Test.cs", - "type": "storage#object", - } - # This event type will be successfully mapped to an equivalent CloudEvent type. - context = Context( - eventType="google.storage.object.finalize", resource=background_resource - ) - service, resource, subject = event_conversion._split_resource(context) - assert service == "storage.googleapis.com" - assert resource == "projects/_/buckets/some-bucket" - assert subject == "objects/folder/Test.cs" - - -def test_split_resource_string_resource(): - background_resource = "projects/_/buckets/some-bucket/objects/folder/Test.cs" - # This event type will be successfully mapped to an equivalent CloudEvent type. +@pytest.mark.parametrize( + "background_resource", + [ + BACKGROUND_RESOURCE, + BACKGROUND_RESOURCE_WITHOUT_SERVICE, + BACKGROUND_RESOURCE_STRING, + ], +) +def test_split_resource(background_resource): context = Context( eventType="google.storage.object.finalize", resource=background_resource )