diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f6cf4a29..db99a121 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -35,7 +35,7 @@ jobs: command: composer install - name: Run HTTP conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.7 env: FUNCTION_TARGET: 'httpFunc' FUNCTION_SIGNATURE_TYPE: 'http' @@ -46,7 +46,7 @@ jobs: cmd: "'php -S localhost:8080 router.php'" - name: Run CloudEvent conformance tests - uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.6 + uses: GoogleCloudPlatform/functions-framework-conformance/action@v0.3.7 env: FUNCTION_TARGET: 'cloudEventFunc' FUNCTION_SIGNATURE_TYPE: 'cloudevent' @@ -54,5 +54,5 @@ jobs: with: functionType: 'cloudevent' useBuildpacks: false - validateMapping: false + validateMapping: true cmd: "'php -S localhost:8080 router.php'" diff --git a/src/LegacyEventMapper.php b/src/LegacyEventMapper.php index ce259e1a..23f81118 100644 --- a/src/LegacyEventMapper.php +++ b/src/LegacyEventMapper.php @@ -19,6 +19,66 @@ class LegacyEventMapper { + // 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 + private static $ceTypeMap = [ + '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. + private const FIREBASE_AUTH_CE_SERVICE = 'firebaseauth.googleapis.com'; + private const FIREBASE_CE_SERVICE = 'firebase.googleapis.com'; + private const FIREBASE_DB_CE_SERVICE = 'firebasedatabase.googleapis.com'; + private const FIRESTORE_CE_SERVICE = 'firestore.googleapis.com'; + private const PUBSUB_CE_SERVICE = 'pubsub.googleapis.com'; + private const STORAGE_CE_SERVICE = 'storage.googleapis.com'; + + // Maps background event services to their equivalent CloudEvent services. + private static $ceServiceMap = [ + 'providers/cloud.firestore/' => self::FIRESTORE_CE_SERVICE, + 'providers/google.firebase.analytics/' => self::FIREBASE_CE_SERVICE, + 'providers/firebase.auth/' => self::FIREBASE_AUTH_CE_SERVICE, + 'providers/google.firebase.database/' => self::FIREBASE_DB_CE_SERVICE, + 'providers/cloud.pubsub/' => self::PUBSUB_CE_SERVICE, + 'providers/cloud.storage/' => self::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. + private static $ceResourceRegexMap = [ + self::FIREBASE_CE_SERVICE => '#^(projects/[^/]+)/(events/[^/]+)$#', + self::FIREBASE_DB_CE_SERVICE => '#^(projects/_/instances/[^/]+)/(refs/.+)$#', + self::FIRESTORE_CE_SERVICE => '#^(projects/[^/]+/databases/\(default\))/(documents/.+)$#', + self::STORAGE_CE_SERVICE => '#^(projects/_/buckets/[^/]+)/(objects/.+)$#', + ]; + + // Maps Firebase Auth background event metadata field names to their equivalent + // CloudEvent field names. + private static $firebaseAuthMetadataFieldMap = [ + 'createdAt' => 'createTime', + 'lastSignedInAt' => 'lastSignInTime', + ]; + public function fromJsonData(array $jsonData): CloudEvent { list($context, $data) = $this->getLegacyEventContextAndData($jsonData); @@ -28,27 +88,44 @@ public function fromJsonData(array $jsonData): CloudEvent $ceId = $context->getEventId(); - // mapped from eventType + // Mapped from eventType. $ceType = $this->ceType($eventType); - // from context/resource/service, or mapped from eventType + // From context/resource/service, or mapped from eventType. $ceService = $context->getService() ?: $this->ceService($eventType); - // mapped from eventType & resource name - $ceSource = $this->ceSource($eventType, $ceService, $resourceName); - - $ceSubject = $this->ceSubject($eventType, $resourceName); + // Split the background event resource into a CloudEvent resource and subject. + list($ceResource, $ceSubject) = $this->ceResourceAndSubject($ceService, $resourceName); $ceTime = $context->getTimestamp(); + if ($ceService === self::PUBSUB_CE_SERVICE) { + // Handle Pub/Sub events. + $data = ['message' => $data]; + } elseif ($ceService === self::FIREBASE_AUTH_CE_SERVICE) { + // Handle Firebase Auth events. + if (array_key_exists('metadata', $data)) { + foreach (self::$firebaseAuthMetadataFieldMap as $old => $new) { + if (array_key_exists($old, $data['metadata'])) { + $data['metadata'][$new] = $data['metadata'][$old]; + unset($data['metadata'][$old]); + } + } + } + + if (array_key_exists('uid', $data)) { + $ceSubject = sprintf('users/%s', $data['uid']); + } + } + return CloudEvent::fromArray([ 'id' => $ceId, - 'source' => $ceSource, + 'source' => sprintf('//%s/%s', $ceService, $ceResource), 'specversion' => '1.0', 'type' => $ceType, 'datacontenttype' => 'application/json', 'dataschema' => null, - 'subject' => $ceSubject, // only present for storage events + 'subject' => $ceSubject, 'time' => $ceTime, 'data' => $data, ]); @@ -72,76 +149,39 @@ private function getLegacyEventContextAndData(array $jsonData): array private function ceType(string $eventType): string { - $ceTypeMap = [ - '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', - ]; - - if (isset($ceTypeMap[$eventType])) { - return $ceTypeMap[$eventType]; + if (isset(self::$ceTypeMap[$eventType])) { + return self::$ceTypeMap[$eventType]; } - // Defaut to the legacy event type if no mapping is found + // Default to the legacy event type if no mapping is found. return $eventType; } private function ceService(string $eventType): string { - $ceServiceMap = [ - 'providers/cloud.firestore/' => 'firestore.googleapis.com', - 'providers/google.firebase.analytics/' => 'firebase.googleapis.com', - 'providers/firebase.auth/' => 'firebase.googleapis.com', - 'providers/google.firebase.database/' => 'firebase.googleapis.com', - 'providers/cloud.pubsub/' => 'pubsub.googleapis.com', - 'providers/cloud.storage/' => 'storage.googleapis.com', - ]; - - foreach ($ceServiceMap as $prefix => $ceService) { + foreach (self::$ceServiceMap as $prefix => $ceService) { if (0 === strpos($eventType, $prefix)) { return $ceService; } } - // Defaut to the legacy event type if no service mapping is found + // Default to the legacy event type if no service mapping is found. return $eventType; } - private function ceSource( - string $eventType, - string $service, string - $resourceName - ): string { - if (0 === strpos($eventType, 'google.storage')) { - if (null !== $pos = strpos($resourceName, '/objects/')) { - $resourceName = substr($resourceName, 0, $pos); - } + private function ceResourceAndSubject(string $ceService, string $resource): array + { + if (!array_key_exists($ceService, self::$ceResourceRegexMap)) { + return [$resource, null]; } - return sprintf('//%s/%s', $service, $resourceName); - } - private function ceSubject(string $eventType, string $resourceName): ?string - { - if (0 === strpos($eventType, 'google.storage')) { - if (null !== $pos = strpos($resourceName, 'objects/')) { - return substr($resourceName, $pos); - } + $ret = preg_match(self::$ceResourceRegexMap[$ceService], $resource, $matches); + if (!$ret) { + throw new \RuntimeException( + $ret === 0 ? 'Resource regex did not match' : 'Failed while matching resource regex' + ); } - return null; + + return [$matches[1], $matches[2]]; } } diff --git a/tests/LegacyEventMapperTest.php b/tests/LegacyEventMapperTest.php index 947ba21d..0a1d534b 100644 --- a/tests/LegacyEventMapperTest.php +++ b/tests/LegacyEventMapperTest.php @@ -56,6 +56,9 @@ public function testWithContextProperty() $this->assertEquals('application/json', $cloudevent->getDataContentType()); $this->assertEquals(null, $cloudevent->getDataSchema()); $this->assertEquals(null, $cloudevent->getSubject()); + + // Verify Pub/Sub-specific data transformation. + $this->assertEquals(['message' => 'foo'], $cloudevent->getData()); } public function testWithoutContextProperty() @@ -87,6 +90,9 @@ public function testWithoutContextProperty() $this->assertEquals(null, $cloudevent->getDataSchema()); $this->assertEquals(null, $cloudevent->getSubject()); $this->assertEquals('2020-12-08T20:03:19.162Z', $cloudevent->getTime()); + + // Verify Pub/Sub-specific data transformation. + $this->assertEquals(['message' => 'foo'], $cloudevent->getData()); } public function testResourceAsString() @@ -115,6 +121,9 @@ public function testResourceAsString() $this->assertEquals(null, $cloudevent->getDataSchema()); $this->assertEquals(null, $cloudevent->getSubject()); $this->assertEquals('2020-12-08T20:03:19.162Z', $cloudevent->getTime()); + + // Verify Pub/Sub-specific data transformation. + $this->assertEquals(['message' => 'foo'], $cloudevent->getData()); } public function testCloudStorage() @@ -151,5 +160,54 @@ public function testCloudStorage() $cloudevent->getSubject() ); $this->assertEquals('2020-12-08T20:03:19.162Z', $cloudevent->getTime()); + $this->assertEquals('foo', $cloudevent->getData()); + } + + public function testFirebaseAuth() + { + $mapper = new LegacyEventMapper(); + $jsonData = [ + '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' => new \stdClass, + 'resource' => 'projects/my-project-id', + 'timestamp' => '2020-09-29T11:32:00.000Z', + ]; + $cloudevent = $mapper->fromJsonData($jsonData); + + $this->assertEquals('aaaaaa-1111-bbbb-2222-cccccccccccc', $cloudevent->getId()); + $this->assertEquals( + '//firebaseauth.googleapis.com/projects/my-project-id', + $cloudevent->getSource() + ); + $this->assertEquals('1.0', $cloudevent->getSpecVersion()); + $this->assertEquals( + 'google.firebase.auth.user.v1.created', + $cloudevent->getType() + ); + $this->assertEquals('application/json', $cloudevent->getDataContentType()); + $this->assertEquals(null, $cloudevent->getDataSchema()); + $this->assertEquals( + 'users/UUpby3s4spZre6kHsgVSPetzQ8l2', + $cloudevent->getSubject() + ); + $this->assertEquals('2020-09-29T11:32:00.000Z', $cloudevent->getTime()); + $this->assertEquals('2020-05-26T10:42:27Z', $cloudevent->getData()['metadata']['createTime']); + $this->assertEquals('2020-10-24T11:00:00Z', $cloudevent->getData()['metadata']['lastSignInTime']); } }