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..3bff83f0 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -27,7 +27,10 @@ from cloudevents.http import from_http, is_binary +from functions_framework import event_conversion +from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( + EventConversionException, FunctionsFrameworkException, InvalidConfigurationException, InvalidTargetTypeException, @@ -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(event_conversion.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 = BackgroundEvent(**event_data) data = event_object.data context = Context(**event_object.context) function(data, context) diff --git a/src/functions_framework/background_event.py b/src/functions_framework/background_event.py new file mode 100644 index 00000000..be01960b --- /dev/null +++ b/src/functions_framework/background_event.py @@ -0,0 +1,44 @@ +# 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 BackgroundEvent(object): + """BackgroundEvent is an event passed to GCF background event functions. + + 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, + 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/event_conversion.py b/src/functions_framework/event_conversion.py new file mode 100644 index 00000000..4d914802 --- /dev/null +++ b/src/functions_framework/event_conversion.py @@ -0,0 +1,170 @@ +# 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.background_event import BackgroundEvent +from functions_framework.exceptions import EventConversionException +from google.cloud.functions.context import Context + +_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", + "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", +} + +# CloudEvent service names. +_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" + +# 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, + "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, +} + +# 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/.+)$"), + _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: + """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 = BackgroundEvent(**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) + + +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): + 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..d3fe3e22 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,214 @@ +# 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 flask +import pytest + +from cloudevents.http import from_json + +from functions_framework import event_conversion +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", + }, +} + +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(): + 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 = event_conversion.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 = event_conversion.background_event_to_cloudevent(req) + 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 = event_conversion.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 = event_conversion.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 = event_conversion.background_event_to_cloudevent(req) + assert cloudevent == firebase_auth_cloudevent_output + + +@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 + ) + 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_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 = event_conversion._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: + event_conversion._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: + event_conversion._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) 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/*