diff --git a/.gitignore b/.gitignore
index 2acfe351..f50f680a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.vscode
node_modules/
*.env
+*.env.local
firebase-debug.log
.DS_Store
mods-test-data/key.json
@@ -11,4 +12,5 @@ build
local.properties
coverage
package-lock.json
-yarn.lock
\ No newline at end of file
+yarn.lock
+*.log
\ No newline at end of file
diff --git a/_emulator/.firebaserc b/_emulator/.firebaserc
new file mode 100644
index 00000000..d2ad98e2
--- /dev/null
+++ b/_emulator/.firebaserc
@@ -0,0 +1,5 @@
+{
+ "projects": {
+ "default": "demo-test"
+ }
+}
diff --git a/_emulator/firebase.json b/_emulator/firebase.json
new file mode 100644
index 00000000..a0e92533
--- /dev/null
+++ b/_emulator/firebase.json
@@ -0,0 +1,32 @@
+{
+ "extensions": {
+ "firegraphql-extension": "../firegraphql-extension"
+ },
+ "storage": {
+ "rules": "storage.rules"
+ },
+ "emulators": {
+ "hub": {
+ "port": 4000
+ },
+ "storage": {
+ "port": 9199
+ },
+ "auth": {
+ "port": 9099
+ },
+ "pubsub": {
+ "port": 8085
+ },
+ "functions": {
+ "port": 5001
+ },
+ "ui": {
+ "enabled": true
+ }
+ },
+ "firestore": {
+ "rules": "firestore.rules",
+ "indexes": "firestore.indexes.json"
+ }
+}
diff --git a/_emulator/firestore.indexes.json b/_emulator/firestore.indexes.json
new file mode 100644
index 00000000..415027e5
--- /dev/null
+++ b/_emulator/firestore.indexes.json
@@ -0,0 +1,4 @@
+{
+ "indexes": [],
+ "fieldOverrides": []
+}
diff --git a/_emulator/firestore.rules b/_emulator/firestore.rules
new file mode 100644
index 00000000..4a44b27d
--- /dev/null
+++ b/_emulator/firestore.rules
@@ -0,0 +1,16 @@
+service cloud.firestore {
+ match /databases/{database}/documents {
+ match /{document=**} {
+ // This rule allows anyone with your database reference to view, edit,
+ // and delete all data in your database. It is useful for getting
+ // started, but it is configured to expire after 30 days because it
+ // leaves your app open to attackers. At that time, all client
+ // requests to your database will be denied.
+ //
+ // Make sure to write security rules for your app before that time, or
+ // else all client requests to your database will be denied until you
+ // update your rules.
+ allow read, write: if true;
+ }
+ }
+}
diff --git a/_emulator/import/auth_export/accounts.json b/_emulator/import/auth_export/accounts.json
new file mode 100644
index 00000000..135e08db
--- /dev/null
+++ b/_emulator/import/auth_export/accounts.json
@@ -0,0 +1 @@
+{"kind":"identitytoolkit#DownloadAccountResponse","users":[]}
\ No newline at end of file
diff --git a/_emulator/import/auth_export/config.json b/_emulator/import/auth_export/config.json
new file mode 100644
index 00000000..8f77af98
--- /dev/null
+++ b/_emulator/import/auth_export/config.json
@@ -0,0 +1 @@
+{"signIn":{"allowDuplicateEmails":false}}
\ No newline at end of file
diff --git a/_emulator/import/firebase-export-metadata.json b/_emulator/import/firebase-export-metadata.json
new file mode 100644
index 00000000..40c53cea
--- /dev/null
+++ b/_emulator/import/firebase-export-metadata.json
@@ -0,0 +1,16 @@
+{
+ "version": "11.9.0",
+ "firestore": {
+ "version": "1.14.4",
+ "path": "firestore_export",
+ "metadata_file": "firestore_export/firestore_export.overall_export_metadata"
+ },
+ "auth": {
+ "version": "11.9.0",
+ "path": "auth_export"
+ },
+ "storage": {
+ "version": "11.9.0",
+ "path": "storage_export"
+ }
+}
\ No newline at end of file
diff --git a/_emulator/import/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/_emulator/import/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata
new file mode 100644
index 00000000..41cea4e8
Binary files /dev/null and b/_emulator/import/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ
diff --git a/_emulator/import/firestore_export/all_namespaces/all_kinds/output-0 b/_emulator/import/firestore_export/all_namespaces/all_kinds/output-0
new file mode 100644
index 00000000..4387d8b8
Binary files /dev/null and b/_emulator/import/firestore_export/all_namespaces/all_kinds/output-0 differ
diff --git a/_emulator/import/firestore_export/firestore_export.overall_export_metadata b/_emulator/import/firestore_export/firestore_export.overall_export_metadata
new file mode 100644
index 00000000..c3f124a1
Binary files /dev/null and b/_emulator/import/firestore_export/firestore_export.overall_export_metadata differ
diff --git a/_emulator/import/storage_export/blobs/c5720248-816c-4ade-b076-ae1daabddfdf b/_emulator/import/storage_export/blobs/c5720248-816c-4ade-b076-ae1daabddfdf
new file mode 100644
index 00000000..a7f5f5b1
--- /dev/null
+++ b/_emulator/import/storage_export/blobs/c5720248-816c-4ade-b076-ae1daabddfdf
@@ -0,0 +1,9 @@
+type Test {
+ foo: String!
+}
+
+type Query {
+ testsCollection: [Test]! @firestoreQuery(collection: "tests")
+ testsCollectionWithCustomId: [Test]! @firestoreQuery(collection: "tests", idField: "customId")
+ testsDocument(id: ID!): Test @firestoreDoc(collection: "tests")
+}
\ No newline at end of file
diff --git a/_emulator/import/storage_export/buckets.json b/_emulator/import/storage_export/buckets.json
new file mode 100644
index 00000000..b49159e6
--- /dev/null
+++ b/_emulator/import/storage_export/buckets.json
@@ -0,0 +1,10 @@
+{
+ "buckets": [
+ {
+ "id": "demo-experimental.appspot.com"
+ },
+ {
+ "id": "extensions-testing.appspot.com"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/_emulator/import/storage_export/metadata/c5720248-816c-4ade-b076-ae1daabddfdf.json b/_emulator/import/storage_export/metadata/c5720248-816c-4ade-b076-ae1daabddfdf.json
new file mode 100644
index 00000000..fa1f0c3a
--- /dev/null
+++ b/_emulator/import/storage_export/metadata/c5720248-816c-4ade-b076-ae1daabddfdf.json
@@ -0,0 +1,19 @@
+{
+ "name": "firegraphql-ext-schema.gql",
+ "bucket": "demo-experimental.appspot.com",
+ "metageneration": 1,
+ "generation": 1663321798620,
+ "contentType": "application/octet-stream",
+ "storageClass": "STANDARD",
+ "contentDisposition": "inline",
+ "downloadTokens": [
+ "c73185b4-6034-49fd-a436-fb04fe4aaf19"
+ ],
+ "etag": "lBh57QypEvVlY77fVvooxkd66T0",
+ "customMetadata": {},
+ "timeCreated": "2022-09-16T09:49:58.620Z",
+ "updated": "2022-09-16T09:49:58.620Z",
+ "size": 271,
+ "md5Hash": "LX9evbwKAcukpur9IKJiOA==",
+ "crc32c": "340707379"
+}
\ No newline at end of file
diff --git a/_emulator/storage.rules b/_emulator/storage.rules
new file mode 100644
index 00000000..d0e06b61
--- /dev/null
+++ b/_emulator/storage.rules
@@ -0,0 +1,8 @@
+rules_version = '2';
+service firebase.storage {
+ match /b/{bucket}/o {
+ match /{allPaths=**} {
+ allow read, write: if true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/firegraphql-extension/.gitignore b/firegraphql-extension/.gitignore
new file mode 100644
index 00000000..b3942c4c
--- /dev/null
+++ b/firegraphql-extension/.gitignore
@@ -0,0 +1,3 @@
+test-params.env
+firestore-debug.log
+ui-debug.log
\ No newline at end of file
diff --git a/firegraphql-extension/CHANGELOG.md b/firegraphql-extension/CHANGELOG.md
new file mode 100644
index 00000000..e69de29b
diff --git a/firegraphql-extension/LICENSE b/firegraphql-extension/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/firegraphql-extension/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/firegraphql-extension/POSTINSTALL.md b/firegraphql-extension/POSTINSTALL.md
new file mode 100644
index 00000000..196417c2
--- /dev/null
+++ b/firegraphql-extension/POSTINSTALL.md
@@ -0,0 +1,9 @@
+### See it in action
+
+
+
+### Using the extension
+
+
+
+### Monitoring
diff --git a/firegraphql-extension/PREINSTALL.md b/firegraphql-extension/PREINSTALL.md
new file mode 100644
index 00000000..4c33a5f4
--- /dev/null
+++ b/firegraphql-extension/PREINSTALL.md
@@ -0,0 +1,8 @@
+
+
+#### Additional setup
+
+
+#### Billing
+
+
diff --git a/firegraphql-extension/README.md b/firegraphql-extension/README.md
new file mode 100644
index 00000000..e69de29b
diff --git a/firegraphql-extension/contributing.md b/firegraphql-extension/contributing.md
new file mode 100644
index 00000000..f1f7ef73
--- /dev/null
+++ b/firegraphql-extension/contributing.md
@@ -0,0 +1,1215 @@
+
+
+
We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
Contributor License Agreement
+
Contributions to this project must be accompanied by a Contributor License
+Agreement. You (or your employer) retain the copyright to your contribution;
+this simply gives us permission to use and redistribute your contributions as
+part of the project. Head over to https://cla.developers.google.com/ to see
+your current agreements on file or to sign a new one.
+
You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
Code reviews
+
All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+GitHub Help for more
+information on using pull requests.
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You signed in with another tab or window. Reload to refresh your session.
+ You signed out in another tab or window. Reload to refresh your session.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firegraphql-extension/extension.yaml b/firegraphql-extension/extension.yaml
new file mode 100644
index 00000000..15cc967a
--- /dev/null
+++ b/firegraphql-extension/extension.yaml
@@ -0,0 +1,120 @@
+# Copyright 2020 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
+#
+# https://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.
+
+name: firegraphql-extension
+version: 0.1.1
+specVersion: v1beta
+
+displayName: FiregraphQL
+
+description: >-
+ Use this extension to set up an endpoint to make GraphQL queries against resources on your Firebase project (Firebase Firestore, RTDB and Storage).
+
+license: Apache-2.0
+
+sourceUrl: https://github.com/FirebaseExtended/experimental-extensions
+
+author:
+ authorName: Firebase
+ url: https://firebase.google.com
+
+contributors:
+ - authorName: Invertase
+ email: oss@invertase.io
+ url: https://github.com/invertase
+
+billingRequired: true
+
+roles:
+ - role: storage.objectViewer
+ reason: Allows the extension to download the GraphQL Schema file from the Cloud Storage bucket.
+ - role: firebaseauth.viewer
+ reason: Allows the extension to get the project Web App credentials.
+
+resources:
+ - name: executeQuery
+ type: firebaseextensions.v1beta.function
+ properties:
+ location: ${LOCATION}
+ httpsTrigger: {}
+ runtime: "nodejs14"
+
+params:
+ - param: LOCATION
+ label: Cloud Functions location
+ description: >-
+ Where do you want to deploy the functions created for this extension?
+ For help selecting a location, refer to the [location selection
+ guide](https://firebase.google.com/docs/functions/locations).
+ type: select
+ options:
+ - label: Iowa (us-central1)
+ value: us-central1
+ - label: South Carolina (us-east1)
+ value: us-east1
+ - label: Northern Virginia (us-east4)
+ value: us-east4
+ - label: Belgium (europe-west1)
+ value: europe-west1
+ - label: London (europe-west2)
+ value: europe-west2
+ - label: Frankfurt (europe-west3)
+ value: europe-west3
+ - label: Hong Kong (asia-east2)
+ value: asia-east2
+ - label: Tokyo (asia-northeast1)
+ value: asia-northeast1
+ default: us-central1
+ required: true
+ immutable: true
+
+ - param: STORAGE_BUCKET
+ label: Cloud Storage bucket for GQL schemas
+ description: >
+ To which Cloud Storage bucket will you upload the GQL schemas?
+ type: selectResource
+ resourceType: storage.googleapis.com/Bucket
+ example: my-project-12345.appspot.com
+ validationRegex: ^([0-9a-z_.-]*)$
+ validationErrorMessage: Invalid storage bucket
+ default: ${STORAGE_BUCKET}
+ required: true
+
+ - param: SCHEMA_PATH
+ label: GQL Schema path
+ description: >
+ A path to a file in Cloud Storage representing the GraphQL schema.
+ required: true
+
+ - param: FIREBASE_WEB_APP_ID
+ label: Firebase Web App ID
+ description: >
+ The ID of the Firebase web app which will be used to make client requests. If not provided, the extension will attempt to use
+ one of the Firebase web apps in your project.
+ example: 1:11111111111:web:xxxxxxxxxxxxxxxxxxxxxx
+ required: false
+
+ - param: FIREBASE_APPS_LRU_MAX
+ label: Max Firebase Apps LRU Cache Size
+ description: >
+ The maximum number of Firebase app instances to store in the LRU cache.
+ example: 100
+ required: false
+
+ - param: FIREBASE_APPS_LRU_TTL
+ label: Firebase Apps LRU Cache TTL
+ description: >
+ The number of milliseconds to keep Firebase app instances in the LRU cache.
+ example: 600000
+ required: false
diff --git a/firegraphql-extension/functions/.gitignore b/firegraphql-extension/functions/.gitignore
new file mode 100644
index 00000000..aa0b6b37
--- /dev/null
+++ b/firegraphql-extension/functions/.gitignore
@@ -0,0 +1,6 @@
+# Typescript v1 declaration files
+typings/
+
+node_modules/
+
+lib/
\ No newline at end of file
diff --git a/firegraphql-extension/functions/__tests__/functions.test.ts b/firegraphql-extension/functions/__tests__/functions.test.ts
new file mode 100644
index 00000000..6f871861
--- /dev/null
+++ b/firegraphql-extension/functions/__tests__/functions.test.ts
@@ -0,0 +1,82 @@
+/**
+ * Copyright 2020 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 * as admin from "firebase-admin";
+import fetch from "node-fetch";
+
+process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";
+process.env.FIREBASE_FIRESTORE_EMULATOR_ADDRESS = "localhost:8080";
+process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099";
+process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:9199";
+process.env.FIREBASE_DATABASE_EMULATOR_HOST = "localhost:9000";
+process.env.PUBSUB_EMULATOR_HOST = "localhost:8085";
+process.env.GOOGLE_CLOUD_PROJECT = "demo-experimental";
+
+if (admin.apps.length === 0) {
+ admin.initializeApp({ projectId: "demo-experimental" });
+}
+
+const extName = "ext-firegraphql-extension-executeQuery";
+const url = `http://0.0.0.0:5001/demo-experimental/us-central1/${extName}`;
+
+async function postReq(query: string, variables?: any) {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query,
+ variables,
+ }),
+ });
+
+ return await res.json();
+}
+
+describe("FiregraphQL Extension", () => {
+ it("returns collection data", async () => {
+ const res = await postReq(`query { testsCollection }`);
+
+ expect(res.errors).toBeUndefined();
+ expect(res.data.testsCollection.length).toBe(3);
+
+ res.data.testsCollection.forEach((doc: any) => {
+ expect(doc.foo).toBeDefined();
+ expect(doc.id).toBeDefined();
+ });
+ });
+
+ it("returns collection data with a custom id", async () => {
+ const res = await postReq(`query { testsCollectionWithCustomId }`);
+
+ expect(res.errors).toBeUndefined();
+ expect(res.data.testsCollectionWithCustomId.length).toBe(3);
+
+ res.data.testsCollectionWithCustomId.forEach((doc: any) => {
+ expect(doc.foo).toBeDefined();
+ expect(doc.customId).toBeDefined();
+ });
+ });
+
+ it("returns a specific document", async () => {
+ const res = await postReq(`query { testsDocument(id: "9vg0QuyugnYAtNrxziiP") }`);
+
+ expect(res.errors).toBeUndefined();
+ expect(res.data.testsDocument.foo).toBeDefined();
+ expect(res.data.testsDocument.id).toBeDefined();
+ });
+});
diff --git a/firegraphql-extension/functions/__tests__/tsconfig.json b/firegraphql-extension/functions/__tests__/tsconfig.json
new file mode 100644
index 00000000..cd1fe18a
--- /dev/null
+++ b/firegraphql-extension/functions/__tests__/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "lib"
+ },
+ "include": ["**/*"]
+}
diff --git a/firegraphql-extension/functions/firegraphql-0.0.2.tgz b/firegraphql-extension/functions/firegraphql-0.0.2.tgz
new file mode 100644
index 00000000..63c4a250
Binary files /dev/null and b/firegraphql-extension/functions/firegraphql-0.0.2.tgz differ
diff --git a/firestore-shorten-urls-dynamic-links/functions/lib/config.js b/firegraphql-extension/functions/jest.config.js
similarity index 50%
rename from firestore-shorten-urls-dynamic-links/functions/lib/config.js
rename to firegraphql-extension/functions/jest.config.js
index 75fe8b51..5aea3dff 100644
--- a/firestore-shorten-urls-dynamic-links/functions/lib/config.js
+++ b/firegraphql-extension/functions/jest.config.js
@@ -1,12 +1,11 @@
-"use strict";
-/*
- * Copyright 2019 Google LLC
+/**
+ * Copyright 2020 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
*
- * https://www.apache.org/licenses/LICENSE-2.0
+ * 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,
@@ -14,12 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.default = {
- dynamicLinkUrlPrefix: process.env.DYNAMIC_LINKS_URL_PREFIX,
- dynamicLinkSuffixLength: process.env.DYNAMIC_LINKS_SUFFIX_LENGTH,
- collectionPath: process.env.COLLECTION_PATH,
- location: process.env.LOCATION,
- shortUrlFieldName: process.env.SHORT_URL_FIELD_NAME,
- urlFieldName: process.env.URL_FIELD_NAME,
+
+module.exports = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ testMatch: ["**/__tests__/*.test.ts"],
};
diff --git a/firegraphql-extension/functions/package.json b/firegraphql-extension/functions/package.json
new file mode 100644
index 00000000..8f0a9d1b
--- /dev/null
+++ b/firegraphql-extension/functions/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "firestore-graphql",
+ "description": "TODO",
+ "main": "lib/index.js",
+ "license": "Apache-2.0",
+ "scripts": {
+ "prepare": "npm run build",
+ "build": "npm run clean && npm run compile",
+ "format": "prettier --write \"**/*.{md,yml,ts,json,yaml}\"",
+ "lint": "prettier --list-different \"**/*.{md,yml,ts,json,yaml}\"",
+ "clean": "rimraf lib",
+ "compile": "tsc",
+ "test": "jest",
+ "generate-readme": "firebase ext:info .. --markdown > ../README.md"
+ },
+ "dependencies": {
+ "firebase": "^9.9.4",
+ "firebase-admin": "^11.0.1",
+ "firebase-functions": "^3.6.1",
+ "firegraphql": "file:firegraphql-0.0.2.tgz",
+ "googleapis": "^107.0.0",
+ "rimraf": "^3.0.2",
+ "typescript": "^4.6.4"
+ },
+ "devDependencies": {
+ "@types/jest": "^26.0.4",
+ "@types/node-fetch": "^2.6.2",
+ "axios": "^0.27.2",
+ "jest": "^29.0.3",
+ "mocked-env": "^1.3.2",
+ "node-fetch": "^2.6.7",
+ "prettier": "1.15.3",
+ "ts-jest": "^29.0.1"
+ },
+ "private": true
+}
diff --git a/firegraphql-extension/functions/src/api.ts b/firegraphql-extension/functions/src/api.ts
new file mode 100644
index 00000000..68b1b107
--- /dev/null
+++ b/firegraphql-extension/functions/src/api.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 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
+ *
+ * https://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 { FirebaseOptions } from "firebase/app";
+import { google } from "googleapis";
+import config from "./config";
+
+const firebase = google.firebase("v1beta1");
+
+const auth = new google.auth.GoogleAuth({
+ scopes: ["https://www.googleapis.com/auth/firebase.readonly"],
+});
+
+export async function getWebConfigByAppId(
+ appId: string
+): Promise {
+ const res = await firebase.projects.webApps.getConfig({
+ auth: await auth.getClient(),
+ name: `projects/${config.projectId}/webApps/${appId}/config`,
+ });
+
+ return res.data;
+}
+
+export async function getWebConfigByList(): Promise {
+ const res = await firebase.projects.webApps.list({
+ auth: await auth.getClient(),
+ pageSize: 1,
+ parent: `projects/${config.projectId}`,
+ });
+
+ if (res.data.apps.length === 0) {
+ throw Error("This Firebase project has no Web Applications configured. Please visit https://firebase.google.com/docs/web/setup#register-app to create one.");
+ }
+
+ return res.data.apps[0];
+}
diff --git a/firegraphql-extension/functions/src/config.ts b/firegraphql-extension/functions/src/config.ts
new file mode 100644
index 00000000..23f5e5b3
--- /dev/null
+++ b/firegraphql-extension/functions/src/config.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020 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
+ *
+ * https://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 { FirebaseOptions } from "firebase/app";
+
+export default {
+ projectId: process.env.PROJECT_ID,
+ location: process.env.LOCATION,
+ webAppId: process.env.FIREBASE_WEB_APP_ID,
+ storageBucket: process.env.STORAGE_BUCKET,
+ schemaPath: process.env.SCHEMA_PATH,
+ firebaseOptions: parseFirebaseOptions(process.env.FIREBASE_OPTIONS),
+ firestoreEmulator: parseEmulatorHost(process.env.FIRESTORE_EMULATOR_HOST),
+ databaseEmulator: parseEmulatorHost(process.env.DATABASE_EMULATOR_HOST),
+ storageEmulator: parseEmulatorHost(process.env.STORAGE_EMULATOR_HOST),
+};
+
+function parseEmulatorHost(uri: string): { host: string; port: number } | undefined {
+ if (!uri) return undefined;
+ const url = new URL(uri.startsWith('http') ? uri : `http://${uri}`);
+ return { host: url.hostname, port: Number(url.port) };
+}
+
+function parseFirebaseOptions(jsonString: string): FirebaseOptions | undefined {
+ try {
+ return JSON.parse(jsonString);
+ } catch (e) {
+ return undefined;
+ }
+}
diff --git a/firegraphql-extension/functions/src/index.ts b/firegraphql-extension/functions/src/index.ts
new file mode 100644
index 00000000..0071a418
--- /dev/null
+++ b/firegraphql-extension/functions/src/index.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 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
+ *
+ * https://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 * as admin from "firebase-admin";
+import * as functions from "firebase-functions";
+import { FirebaseApolloFunction, makeExecutableSchema } from "firegraphql";
+
+import config from "./config";
+import * as logs from "./logs";
+
+import { getWebConfigByAppId, getWebConfigByList } from "./api";
+
+const app = admin.initializeApp();
+let server: FirebaseApolloFunction;
+
+logs.init();
+
+export const executeQuery = functions.handler.https.onRequest(
+ async (req, res) => {
+ if (!server) {
+ let firebaseOptions = config.firebaseOptions;
+ let typeDef: string;
+
+ // During integration tests, we can't mock this call to GoogleApis easily,
+ // and since tests are running in the emulator, options are not needed.
+ if (!firebaseOptions) {
+ try {
+ if (config.webAppId) {
+ firebaseOptions = await getWebConfigByAppId(config.webAppId);
+ } else {
+ firebaseOptions = await getWebConfigByList();
+ }
+ } catch (e: any) {
+ logs.webConfigError(e);
+ return res.status(400).send(e?.message ?? e ?? '400 Bad Request');
+ }
+ }
+
+ try {
+ typeDef = await downloadSchema();
+ } catch (e) {
+ logs.downloadSchemaError(e);
+ return res.status(400).send(e?.message ?? e ?? '400 Bad Request');
+ }
+
+ server = new FirebaseApolloFunction({
+ schema: makeExecutableSchema({
+ typeDefs: [typeDef],
+ }),
+ options: {
+ firebaseAdminAppInstance: app,
+ firebaseOptions,
+ connectFirestoreEmulator: config.firestoreEmulator,
+ connectDatabaseEmulator: config.databaseEmulator,
+ connectStorageEmulator: config.storageEmulator,
+ },
+ });
+ }
+
+ return server.createHandler()(req, res);
+ }
+);
+
+// Downloads a file from the storage bucket at the provided ref.
+async function downloadSchema(): Promise {
+ const download = await app
+ .storage()
+ .bucket(config.storageBucket)
+ .file(config.schemaPath)
+ .download({
+ // https://github.com/googleapis/google-cloud-node/issues/654#issuecomment-987123067
+ validation: !process.env.FUNCTIONS_EMULATOR,
+ });
+
+ return download.toString();
+}
diff --git a/firegraphql-extension/functions/src/logs.ts b/firegraphql-extension/functions/src/logs.ts
new file mode 100644
index 00000000..a145567b
--- /dev/null
+++ b/firegraphql-extension/functions/src/logs.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 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
+ *
+ * https://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 { logger } from "firebase-functions";
+import config from "./config";
+
+export const obfuscatedConfig = {
+ ...config,
+};
+
+export const init = () => {
+ logger.log("Initializing extension with configuration", obfuscatedConfig);
+};
+
+export const webConfigError = (err: Error) => {
+ if (config.webAppId) {
+ logger.error(
+ `Error fetching web config, do you have access to the Web App with ID ${
+ config.webAppId
+ }?`,
+ err
+ );
+ } else {
+ logger.error(
+ `Error fetching a Web App configuration, have you created one?`,
+ err
+ );
+ }
+};
+
+export const downloadSchemaError = (e: Error) => {
+ logger.error(
+ `Error downloading schema on Bucket "${config.storageBucket}" at path "${
+ config.schemaPath
+ }"`,
+ e
+ );
+};
diff --git a/firegraphql-extension/functions/tsconfig.json b/firegraphql-extension/functions/tsconfig.json
new file mode 100644
index 00000000..1915eff3
--- /dev/null
+++ b/firegraphql-extension/functions/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "lib": ["esnext.asynciterable", "es2020", "es6"],
+ "outDir": "lib",
+ "module": "commonjs",
+ "noImplicitReturns": true,
+ "sourceMap": false,
+ "target": "es6",
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "compileOnSave": true,
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
+
diff --git a/firestore-shorten-urls-dynamic-links/functions/lib/abstract-shortener.js b/firestore-shorten-urls-dynamic-links/functions/lib/abstract-shortener.js
deleted file mode 100644
index a8b3d4d5..00000000
--- a/firestore-shorten-urls-dynamic-links/functions/lib/abstract-shortener.js
+++ /dev/null
@@ -1,105 +0,0 @@
-"use strict";
-/*
- * Copyright 2019 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
- *
- * https://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.
- */
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
- return new (P || (P = Promise))(function (resolve, reject) {
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
- step((generator = generator.apply(thisArg, _arguments || [])).next());
- });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.FirestoreUrlShortener = void 0;
-const admin = require("firebase-admin");
-const logs = require("./logs");
-class FirestoreUrlShortener {
- constructor(urlFieldName, shortUrlFieldName) {
- this.urlFieldName = urlFieldName;
- this.shortUrlFieldName = shortUrlFieldName;
- this.logs = logs;
- this.urlFieldName = urlFieldName;
- this.shortUrlFieldName = shortUrlFieldName;
- // Initialize the Firebase Admin SDK
- admin.initializeApp();
- }
- onDocumentCreate(snapshot) {
- return __awaiter(this, void 0, void 0, function* () {
- this.logs.start();
- if (this.urlFieldName === this.shortUrlFieldName) {
- this.logs.fieldNamesNotDifferent();
- return;
- }
- yield this.handleCreateDocument(snapshot);
- this.logs.complete();
- });
- }
- onDocumentUpdate(change) {
- return __awaiter(this, void 0, void 0, function* () {
- this.logs.start();
- if (this.urlFieldName === this.shortUrlFieldName) {
- this.logs.fieldNamesNotDifferent();
- return;
- }
- yield this.handleUpdateDocument(change.before, change.after);
- this.logs.complete();
- });
- }
- extractUrl(snapshot) {
- return snapshot.get(this.urlFieldName);
- }
- handleCreateDocument(snapshot) {
- return __awaiter(this, void 0, void 0, function* () {
- const url = this.extractUrl(snapshot);
- if (url) {
- this.logs.documentCreatedWithUrl();
- yield this.shortenUrl(snapshot);
- }
- else {
- this.logs.documentCreatedNoUrl();
- }
- });
- }
- handleUpdateDocument(before, after) {
- return __awaiter(this, void 0, void 0, function* () {
- const urlAfter = this.extractUrl(after);
- const urlBefore = this.extractUrl(before);
- if (urlAfter === urlBefore) {
- this.logs.documentUpdatedUnchangedUrl();
- }
- else if (urlAfter) {
- this.logs.documentUpdatedChangedUrl();
- yield this.shortenUrl(after);
- }
- else if (urlBefore) {
- this.logs.documentUpdatedDeletedUrl();
- yield this.updateShortUrl(after, admin.firestore.FieldValue.delete());
- }
- else {
- this.logs.documentUpdatedNoUrl();
- }
- });
- }
- updateShortUrl(snapshot, url) {
- return __awaiter(this, void 0, void 0, function* () {
- this.logs.updateDocument(snapshot.ref.path);
- yield snapshot.ref.update(this.shortUrlFieldName, url);
- this.logs.updateDocumentComplete(snapshot.ref.path);
- });
- }
-}
-exports.FirestoreUrlShortener = FirestoreUrlShortener;
diff --git a/firestore-shorten-urls-dynamic-links/functions/lib/index.js b/firestore-shorten-urls-dynamic-links/functions/lib/index.js
deleted file mode 100644
index a0069e77..00000000
--- a/firestore-shorten-urls-dynamic-links/functions/lib/index.js
+++ /dev/null
@@ -1,112 +0,0 @@
-"use strict";
-/*
- * Copyright 2019 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
- *
- * https://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.
- */
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
- return new (P || (P = Promise))(function (resolve, reject) {
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
- step((generator = generator.apply(thisArg, _arguments || [])).next());
- });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.shorten_update = exports.shorten_create = void 0;
-const url_1 = require("url");
-const functions = require("firebase-functions");
-const node_fetch_1 = require("node-fetch");
-const abstract_shortener_1 = require("./abstract-shortener");
-const config_1 = require("./config");
-const logs = require("./logs");
-class ServiceAccountCredential {
- constructor() {
- this.metadataServiceUri = new url_1.URL("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token");
- this.requiredScopes = "https://www.googleapis.com/auth/firebase";
- const searchParams = new url_1.URLSearchParams({ scopes: this.requiredScopes });
- this.metadataServiceUri.search = searchParams.toString();
- }
- getAccessToken() {
- return __awaiter(this, void 0, void 0, function* () {
- const now = Math.floor(Date.now() / 1000);
- const nowish = now + 30; // 30s leeway
- if (typeof this.accessToken === "undefined" ||
- typeof this.tokenExpiration === "undefined" ||
- this.tokenExpiration < nowish) {
- const metadataResponse = yield (0, node_fetch_1.default)(this.metadataServiceUri, {
- headers: {
- "Metadata-Flavor": "Google",
- },
- });
- const { access_token, expires_in } = yield metadataResponse.json();
- this.accessToken = access_token;
- this.tokenExpiration = now + expires_in;
- }
- return this.accessToken;
- });
- }
-}
-class FirestoreDynamicLinksUrlShortener extends abstract_shortener_1.FirestoreUrlShortener {
- constructor(urlFieldName, shortUrlFieldName, dynamicLinkUrlPrefix, dynamicLinkSuffixLength) {
- super(urlFieldName, shortUrlFieldName);
- this.dynamicLinkUrlPrefix = dynamicLinkUrlPrefix;
- this.dynamicLinkSuffixLength = dynamicLinkSuffixLength;
- this.dynamicLinksApiUrl = "https://firebasedynamiclinks.googleapis.com/v1/shortLinks";
- this.credentials = new ServiceAccountCredential();
- this.logs = logs;
- this.logs.init();
- }
- shortenUrl(snapshot) {
- return __awaiter(this, void 0, void 0, function* () {
- const url = this.extractUrl(snapshot);
- this.logs.shortenUrl(url);
- const accessToken = yield this.credentials.getAccessToken();
- try {
- const requestBody = {
- dynamicLinkInfo: {
- domainUriPrefix: this.dynamicLinkUrlPrefix,
- link: url,
- },
- suffix: { option: this.dynamicLinkSuffixLength },
- };
- const response = yield (0, node_fetch_1.default)(this.dynamicLinksApiUrl, {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- },
- method: "POST",
- body: JSON.stringify(requestBody),
- });
- const responseBody = yield response.json();
- if (!response.ok) {
- this.logs.error(responseBody.error.message);
- return;
- }
- const shortUrl = responseBody.shortLink;
- this.logs.shortenUrlComplete(shortUrl);
- yield this.updateShortUrl(snapshot, shortUrl);
- }
- catch (err) {
- this.logs.error(err.body);
- }
- });
- }
-}
-const urlShortener = new FirestoreDynamicLinksUrlShortener(config_1.default.urlFieldName, config_1.default.shortUrlFieldName, config_1.default.dynamicLinkUrlPrefix, config_1.default.dynamicLinkSuffixLength);
-exports.shorten_create = functions.handler.firestore.document.onCreate((snapshot) => __awaiter(void 0, void 0, void 0, function* () {
- return urlShortener.onDocumentCreate(snapshot);
-}));
-exports.shorten_update = functions.handler.firestore.document.onUpdate((change) => __awaiter(void 0, void 0, void 0, function* () {
- return urlShortener.onDocumentUpdate(change);
-}));
diff --git a/firestore-shorten-urls-dynamic-links/functions/lib/logs.js b/firestore-shorten-urls-dynamic-links/functions/lib/logs.js
deleted file mode 100644
index e57e9479..00000000
--- a/firestore-shorten-urls-dynamic-links/functions/lib/logs.js
+++ /dev/null
@@ -1,80 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.updateDocumentComplete = exports.updateDocument = exports.start = exports.shortenUrlComplete = exports.shortenUrl = exports.init = exports.fieldNamesNotDifferent = exports.error = exports.documentUpdatedUnchangedUrl = exports.documentUpdatedNoUrl = exports.documentUpdatedDeletedUrl = exports.documentUpdatedChangedUrl = exports.documentCreatedWithUrl = exports.documentCreatedNoUrl = exports.complete = void 0;
-/*
- * Copyright 2019 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
- *
- * https://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.
- */
-const functions = require("firebase-functions");
-const config_1 = require("./config");
-const complete = () => {
- functions.logger.log("Completed execution of extension");
-};
-exports.complete = complete;
-const documentCreatedNoUrl = () => {
- functions.logger.log("Document was created without a URL, so no processing is required");
-};
-exports.documentCreatedNoUrl = documentCreatedNoUrl;
-const documentCreatedWithUrl = () => {
- functions.logger.log("Document was created with a URL");
-};
-exports.documentCreatedWithUrl = documentCreatedWithUrl;
-const documentUpdatedChangedUrl = () => {
- functions.logger.log("Document was updated: URL has changed");
-};
-exports.documentUpdatedChangedUrl = documentUpdatedChangedUrl;
-const documentUpdatedDeletedUrl = () => {
- functions.logger.log("Document was updated: URL was deleted");
-};
-exports.documentUpdatedDeletedUrl = documentUpdatedDeletedUrl;
-const documentUpdatedNoUrl = () => {
- functions.logger.log("Document was updated: no URL exists, so no processing is required");
-};
-exports.documentUpdatedNoUrl = documentUpdatedNoUrl;
-const documentUpdatedUnchangedUrl = () => {
- functions.logger.log("Document was updated: URL has not changed, so no processing is required");
-};
-exports.documentUpdatedUnchangedUrl = documentUpdatedUnchangedUrl;
-const error = (err) => {
- functions.logger.error("Error when shortening URL", err);
-};
-exports.error = error;
-const fieldNamesNotDifferent = () => {
- functions.logger.error("The `URL` and `Short URL` field names must be different");
-};
-exports.fieldNamesNotDifferent = fieldNamesNotDifferent;
-const init = () => {
- functions.logger.log("Initializing extension with configuration", config_1.default);
-};
-exports.init = init;
-const shortenUrl = (url) => {
- functions.logger.log(`Shortening URL: '${url}'`);
-};
-exports.shortenUrl = shortenUrl;
-const shortenUrlComplete = (shortUrl) => {
- functions.logger.log(`Finished shortening URL to: '${shortUrl}'`);
-};
-exports.shortenUrlComplete = shortenUrlComplete;
-const start = () => {
- functions.logger.log("Started execution of extension with configuration", config_1.default);
-};
-exports.start = start;
-const updateDocument = (path) => {
- functions.logger.log(`Updating Cloud Firestore document: '${path}'`);
-};
-exports.updateDocument = updateDocument;
-const updateDocumentComplete = (path) => {
- functions.logger.log(`Finished updating Cloud Firestore document: '${path}'`);
-};
-exports.updateDocumentComplete = updateDocumentComplete;
diff --git a/package.json b/package.json
index 32f8fdf4..45369186 100644
--- a/package.json
+++ b/package.json
@@ -22,11 +22,13 @@
"url": "https://github.com/FirebaseExtended/experimental-extensions/issues"
},
"devDependencies": {
+ "@types/jest": "^29.0.3",
"firebase-tools": "^9.9.0",
"husky": "^7.0.4",
"lerna": "^3.4.3",
"lint-staged": "^12.4.0",
"prettier": "^2.2.1",
+ "ts-jest": "^29.0.1",
"typescript": "^4.1.3"
},
"lint-staged": {
diff --git a/storage-mirror-firestore/functions/lib/config.js b/storage-mirror-firestore/functions/lib/config.js
deleted file mode 100644
index d27971b9..00000000
--- a/storage-mirror-firestore/functions/lib/config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-const config = {
- bucket: process.env.BUCKET,
- location: process.env.LOCATION,
- firestoreRoot: process.env.FIRESTORE_ROOT,
- itemsSubcollectionName: process.env.ITEMS_SUBCOLLECTION_NAME || "items",
- itemsTombstoneSubcollectionName: process.env.ITEMS_TOMBSTONES_NAME || "items-tombstones",
- prefixesSubcollectionName: process.env.PREFIXES_SUBCOLLECTION_NAME || "prefixes",
- prefixesTombstoneSubcollectionName: process.env.PREFIXES_TOMBSTONES_NAME || "prefixes-tombstones",
- customMetadataFieldFilter: new RegExp(process.env.CUSTOM_METADATA_FILTER || ".*"),
- metadataFieldFilter: new RegExp(process.env.METADATA_FIELD_FILTER || ".*"),
- objectNameFilter: new RegExp(process.env.OBJECT_NAME_FILTER || ".*"),
-};
-exports.default = config;
diff --git a/storage-mirror-firestore/functions/lib/constants.js b/storage-mirror-firestore/functions/lib/constants.js
deleted file mode 100644
index eca4df25..00000000
--- a/storage-mirror-firestore/functions/lib/constants.js
+++ /dev/null
@@ -1,21 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.Constants = void 0;
-class Constants {
-}
-exports.Constants = Constants;
-// Extension Firestore Document Fields
-Constants.lastEventField = "lastEvent";
-Constants.childRefField = "childRef";
-Constants.gcsMetadataField = "gcsMetadata";
-// GCS Object Metadata Fields
-Constants.objectCustomMetadataField = "metadata";
-// Extension Constants
-Constants.queryLimit = 5;
-Constants.transactionAttempts = 5;
-Constants.numberFields = ["size", "generation", "metageneration"];
-Constants.dateFields = [
- "timeCreated",
- "timeStorageClassUpdated",
- "updated",
-];
diff --git a/storage-mirror-firestore/functions/lib/index.js b/storage-mirror-firestore/functions/lib/index.js
deleted file mode 100644
index 67dd69e2..00000000
--- a/storage-mirror-firestore/functions/lib/index.js
+++ /dev/null
@@ -1,43 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
- return new (P || (P = Promise))(function (resolve, reject) {
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
- step((generator = generator.apply(thisArg, _arguments || [])).next());
- });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.mirrorObjectPathHttp = exports.mirrorArchive = exports.mirrorDelete = exports.mirrorMetadataUpdate = exports.mirrorFinalize = void 0;
-const admin = require("firebase-admin");
-const functions = require("firebase-functions");
-const logs = require("./logs");
-const mirror_1 = require("./mirror");
-// Initialize the Firebase Admin SDK
-admin.initializeApp();
-const genericHandler = (object, context) => __awaiter(void 0, void 0, void 0, function* () {
- logs.start();
- logs.metadata(object);
- logs.context(context);
- yield (0, mirror_1.onObjectChange)(object, context.eventType);
-});
-exports.mirrorFinalize = functions.storage
- .object()
- .onFinalize(genericHandler);
-exports.mirrorMetadataUpdate = functions.storage
- .object()
- .onMetadataUpdate(genericHandler);
-exports.mirrorDelete = functions.storage.object().onDelete(genericHandler);
-exports.mirrorArchive = functions.storage
- .object()
- .onArchive(genericHandler);
-exports.mirrorObjectPathHttp = functions.handler.https.onRequest((req, res) => __awaiter(void 0, void 0, void 0, function* () {
- if (req.method != "POST") {
- res.status(403).send("Forbidden!");
- return;
- }
- const path = req.body.path;
- yield (0, mirror_1.mirrorObjectPath)(path);
- res.sendStatus(200);
-}));
diff --git a/storage-mirror-firestore/functions/lib/logs.js b/storage-mirror-firestore/functions/lib/logs.js
deleted file mode 100644
index ca023cdf..00000000
--- a/storage-mirror-firestore/functions/lib/logs.js
+++ /dev/null
@@ -1,104 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.missingLastEventField = exports.invalidPrefixDocument = exports.transactionDeleteAttempt = exports.transactionWriteAttempt = exports.transactionReads = exports.context = exports.invalidObjectName = exports.skippedMissingPath = exports.skippedObject = exports.skippedOverwrite = exports.transactionFailure = exports.docDoesNotExist = exports.eventNotStale = exports.staleTieBreak = exports.abortingStaleEvent = exports.startingTransaction = exports.metadata = exports.startHttpEvent = exports.start = exports.error = void 0;
-const config_1 = require("./config");
-const constants_1 = require("./constants");
-const error = (err, action) => {
- const message = action ? `Error while ${action}:` : "Error:";
- console.error(message, err);
-};
-exports.error = error;
-const start = () => {
- console.log("Started Function execution with Configuration:", config_1.default);
-};
-exports.start = start;
-const startHttpEvent = () => {
- console.log("Started Http Function execution with Configuration:", config_1.default);
-};
-exports.startHttpEvent = startHttpEvent;
-const metadata = (m) => {
- console.log(`Event triggered with Object metadata: ${JSON.stringify(m)}`);
-};
-exports.metadata = metadata;
-const startingTransaction = (path, attemptNo) => {
- console.log(`Starting Transaction attempt number ${attemptNo} for Item Document: ${path}`);
-};
-exports.startingTransaction = startingTransaction;
-const abortingStaleEvent = (path, eventTime, existingTime) => {
- console.log("Aborting update of " +
- path +
- " because incoming timestamp (" +
- eventTime +
- ") is older than existing timestamp (" +
- existingTime +
- ")");
-};
-exports.abortingStaleEvent = abortingStaleEvent;
-const staleTieBreak = (path, eventTime, existingTime) => {
- console.log("Aborting update of " +
- path +
- " because incoming timestamp (" +
- eventTime +
- ") is equal to existing timestamp (" +
- existingTime +
- ")");
-};
-exports.staleTieBreak = staleTieBreak;
-const eventNotStale = (path, eventTime, existingTime) => {
- console.log("Update for " +
- path +
- "is stale because incoming timestamp (" +
- eventTime +
- ") is newer than existing timestamp (" +
- existingTime +
- ")");
-};
-exports.eventNotStale = eventNotStale;
-const docDoesNotExist = (path) => {
- console.log(`${path} is not stale because it does not yet exist.`);
-};
-exports.docDoesNotExist = docDoesNotExist;
-const transactionFailure = (path, reason) => {
- console.error(`Transaction to update ${path} failed because:`, reason);
-};
-exports.transactionFailure = transactionFailure;
-const skippedOverwrite = (name, eventType) => {
- console.log(`Skipped write for ${eventType} event on ${name} because the object exists. This was likely an overwrite of the object.`);
-};
-exports.skippedOverwrite = skippedOverwrite;
-const skippedObject = (name) => {
- console.log(`Skipping Object with name not matching filter: ${name}`);
-};
-exports.skippedObject = skippedObject;
-const skippedMissingPath = (name) => {
- console.log(`Skipping mirroring of path "${name}" because no data was found in Firestore or GCS`);
-};
-exports.skippedMissingPath = skippedMissingPath;
-const invalidObjectName = (name) => {
- console.log(`Skipping Object with invalid name: ${name}`);
-};
-exports.invalidObjectName = invalidObjectName;
-const context = (m) => {
- console.log("Event Context", m);
-};
-exports.context = context;
-const transactionReads = (paths) => {
- console.log(`This transaction read ${paths.length} documents: ${paths}`);
-};
-exports.transactionReads = transactionReads;
-const transactionWriteAttempt = (num, paths) => {
- console.log(`Attempting to write ${num} documents: ${paths}`);
-};
-exports.transactionWriteAttempt = transactionWriteAttempt;
-const transactionDeleteAttempt = (num, paths) => {
- console.log(`Attempting to delete ${num} documents: ${paths}`);
-};
-exports.transactionDeleteAttempt = transactionDeleteAttempt;
-const invalidPrefixDocument = (id) => {
- console.log(`Missing fields in Prefix Document with id: ${id} treating the Document as requiring deletion.`);
-};
-exports.invalidPrefixDocument = invalidPrefixDocument;
-const missingLastEventField = (id) => {
- console.log(`Missing ${constants_1.Constants.lastEventField} field in Document with id: ${id}, treating as stale.`);
-};
-exports.missingLastEventField = missingLastEventField;
diff --git a/storage-mirror-firestore/functions/lib/mirror.js b/storage-mirror-firestore/functions/lib/mirror.js
deleted file mode 100644
index c9859715..00000000
--- a/storage-mirror-firestore/functions/lib/mirror.js
+++ /dev/null
@@ -1,403 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
- return new (P || (P = Promise))(function (resolve, reject) {
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
- step((generator = generator.apply(thisArg, _arguments || [])).next());
- });
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.isStaleEvent = exports.onObjectChange = exports.mirrorObjectPath = void 0;
-const firestore_1 = require("@google-cloud/firestore");
-const admin = require("firebase-admin");
-const config_1 = require("./config");
-const constants_1 = require("./constants");
-const logs = require("./logs");
-const util_1 = require("./util");
-/**
- * Get the metadata for an object, returning undefined if the object does not exist.
- * This is a wrapper around `getMetadata` that returns undefined if the error is a 404.
- *
- * @param objectName Path to the GCS object (without the bucket) to get metadata for.
- */
-function getCurrentMetadata(objectName) {
- return __awaiter(this, void 0, void 0, function* () {
- try {
- const [gcsMetadata, _] = yield admin
- .storage()
- .bucket(config_1.default.bucket)
- .file(objectName)
- .getMetadata();
- return firestoreDocumentData(gcsMetadata, "google.storage.object.finalize");
- }
- catch (e) {
- if (e.code === 404) {
- return undefined;
- }
- else {
- logs.error(e, `getting metadata for ${objectName}`);
- throw e;
- }
- }
- });
-}
-/**
- * Mirror the gcs state of the given object to Firestore.
- * This works by reading from GCS and Firestore and simulating an event.
- * @param objectName The name of the object (without the bucket).
- */
-function mirrorObjectPath(objectName) {
- return __awaiter(this, void 0, void 0, function* () {
- /**
- * Step 1: Skip this if the path isn't valid.
- * Step 2: Read the Firestore doc.
- * Step 3: Read the GCS Metadata (note this needs to be done after the Firestore read to handle delete timestamps correctly).
- * If they don't match, call onObjectChange.
- * - If the GCS metadata doesn't exist, simulate a deletion event using the metadata from the Firestore document.
- *
- * Note that this function is meant to handle dropped events, not necessarily arbirary modifications.
- * It is also not going to be able to called on a prefix path.
- */
- if (!(0, util_1.shouldMirrorObject)(objectName)) {
- return logs.skippedObject(objectName);
- }
- const paths = (0, util_1.objectNameToFirestorePaths)(objectName);
- // Check if the generated Item Document is valid.
- if (!(0, util_1.isValidDocumentName)(paths.itemPath)) {
- return logs.invalidObjectName(objectName);
- }
- // Check if every generated Firestore Document will have a valid id.
- if (!objectName.split("/").every(util_1.isValidDocumentId)) {
- return logs.invalidObjectName(objectName);
- }
- const existingSnapshot = (yield admin
- .firestore()
- .doc(paths.itemPath)
- .get());
- const currentData = yield getCurrentMetadata(objectName);
- if (currentData) {
- yield updateFirestore(paths, currentData, false);
- }
- else if (existingSnapshot.exists) {
- var lastEvent = new firestore_1.Timestamp(0, 0);
- if (existingSnapshot.data().hasOwnProperty(constants_1.Constants.lastEventField)) {
- lastEvent = existingSnapshot.data()[constants_1.Constants.lastEventField];
- }
- yield updateFirestore(paths, { lastEvent }, true);
- }
- else {
- logs.skippedMissingPath(objectName);
- }
- });
-}
-exports.mirrorObjectPath = mirrorObjectPath;
-/**
- * Handler for GCS Object Change events. Will validate the generated Documents before updating Firestore accordingly.
- * @param object The GCS Object Metadata object.
- * @param eventType The GCS Event type.
- */
-function onObjectChange(object, eventType) {
- return __awaiter(this, void 0, void 0, function* () {
- const isDeletion = (0, util_1.isDeletionEventType)(eventType);
- const objectName = object.name;
- if (!(0, util_1.shouldMirrorObject)(objectName)) {
- return logs.skippedObject(objectName);
- }
- const paths = (0, util_1.objectNameToFirestorePaths)(objectName);
- // Check if the generated Item Document is valid.
- if (!(0, util_1.isValidDocumentName)(paths.itemPath)) {
- return logs.invalidObjectName(objectName);
- }
- // Check if every generated Firestore Document will have a valid id.
- if (!objectName.split("/").every(util_1.isValidDocumentId)) {
- return logs.invalidObjectName(objectName);
- }
- if (isDeletion) {
- // When an object is overwritten, it will fire a deletion/archive event for the original, followed by a finalize
- // event for the new one. We try to avoid updating firestore for the first event, so that firestore doesn't
- // temporarily show that the document doesn't exist.
- const [objectExists] = yield admin
- .storage()
- .bucket(object.bucket)
- .file(objectName)
- .exists();
- if (objectExists) {
- return logs.skippedOverwrite(objectName, eventType);
- }
- }
- const data = firestoreDocumentData(object, eventType);
- yield updateFirestore(paths, data, isDeletion);
- });
-}
-exports.onObjectChange = onObjectChange;
-/**
- * Update the Item Document in Firestore with the provided data and perform
- * maintenance (creation/deletion) on it's Prefix Documents if necessary.
- * @param paths The Item Document path and Prefix Document paths (sorted from root to the parent of the Item Document).
- * @param data The data to write to the Item Document (and Prefix Documents if it is a Tombstone).
- */
-function updateFirestore(paths, data, isDeletion) {
- return __awaiter(this, void 0, void 0, function* () {
- const prefixRefs = paths.prefixPaths.map((prefixPath) => admin.firestore().doc(prefixPath));
- const itemRef = admin.firestore().doc(paths.itemPath);
- const references = [...prefixRefs, itemRef];
- const timestamp = data[constants_1.Constants.lastEventField];
- let attemptNumber = 0;
- return admin
- .firestore()
- .runTransaction((t) => __awaiter(this, void 0, void 0, function* () {
- attemptNumber += 1;
- logs.startingTransaction(itemRef.path, attemptNumber);
- // Can only write Documents after all reads have been done in the Transaction.
- const docsToWrite = [];
- const docsToDelete = [];
- const transactionReads = [];
- // Read the Item Document and it's Tombstone under the Transaction.
- const itemTombstoneRef = admin
- .firestore()
- .doc((0, util_1.mirrorDocumentPathToTombstonePath)(itemRef.path));
- const [itemSnapshot, itemTombstoneSnapshot] = (yield Promise.all([
- t.get(itemRef),
- t.get(itemTombstoneRef),
- ]));
- transactionReads.push(itemSnapshot.ref.path, itemTombstoneSnapshot.ref.path);
- if (isStaleEvent(itemSnapshot, data, isDeletion) ||
- isStaleEvent(itemTombstoneSnapshot, data, isDeletion)) {
- // Skip if the event is older than what is in Firestore.
- return;
- }
- if (!isDeletion) {
- // Write the Item Document for create/update events
- docsToWrite.push({ ref: itemRef, data: data });
- if (itemTombstoneSnapshot.exists) {
- docsToDelete.push({ ref: itemTombstoneSnapshot.ref });
- }
- }
- else {
- // Tombstone the Item Document for delete/archive events
- docsToWrite.push({
- ref: itemTombstoneSnapshot.ref,
- data: data,
- });
- if (itemSnapshot.exists) {
- docsToDelete.push({ ref: itemRef });
- }
- }
- // Move up the Prefixes from deepest to shallowest until the root.
- // Read each Document and queue up the Documents that we need to modify.
- for (let i = references.length - 2; i >= 0; i--) {
- const prefixRef = references[i];
- const prefixTombstoneRef = admin
- .firestore()
- .doc((0, util_1.mirrorDocumentPathToTombstonePath)(prefixRef.path));
- // Read the Prefix Document and it's Tombstone under the Transaction.
- const [prefixSnapshot, prefixTombstoneSnapshot] = (yield Promise.all([
- t.get(prefixRef),
- t.get(prefixTombstoneRef),
- ]));
- transactionReads.push(prefixSnapshot.ref.path, prefixTombstoneSnapshot.ref.path);
- const child = references[i + 1];
- // Prefix Maintenance, create any Prefix Documents that need to exist, delete
- // any that no longer have a child underneath it.
- // A reference to an "existing" (non-Tombstone) child is kept on each Prefix
- // Document, this is used to skip a Firestore query to determine whether the
- // Prefix Document should be pruned when one of it's children is deleted or archived.
- if (!isDeletion) {
- // Prefix Maintenance for create/update events.
- if (prefixTombstoneSnapshot.exists) {
- docsToDelete.push({ ref: prefixTombstoneSnapshot.ref });
- }
- if (prefixSnapshot.exists) {
- // Because we create Prefix Documents from the deepest path to shallowest, once we find a Document
- // that has already been created we are guaranteed all remaining Prefix Documents also exist.
- break;
- }
- docsToWrite.push({
- ref: prefixRef,
- data: {
- [constants_1.Constants.childRefField]: (0, util_1.pathHash)(child.path),
- [constants_1.Constants.lastEventField]: timestamp,
- },
- });
- }
- else {
- // Prefix Maintenance for delete/archive events.
- // The Prefix Document has been Tombstoned or never existed in the first place.
- if (!prefixSnapshot.exists)
- break;
- // Treat the Prefix Document as one requiring deletion if it is not what we're expecting/invalid.
- else if (!isValidPrefixDocument(prefixSnapshot)) {
- logs.invalidPrefixDocument(prefixSnapshot.id);
- }
- // If it already is a Tombstone we can stop.
- else if (prefixTombstoneSnapshot.exists)
- break;
- // This Document doesn't point to a child being deleted, can stop checking parents.
- else if (prefixSnapshot.data()[constants_1.Constants.childRefField] !== (0, util_1.pathHash)(child.path)) {
- break;
- }
- // Reference points to Document being deleted, check if the Prefix Document should
- // still exist. If it should still exist, update it's child reference.
- const items = prefixRef.collection(config_1.default.itemsSubcollectionName);
- const prefixes = prefixRef.collection(config_1.default.prefixesSubcollectionName);
- const subcollections = [prefixes, items];
- const childDocs = [];
- // Try to find "existing" (non-Tombstone) child Documents to replace the child reference.
- // A Prefix Document is preferred for the new child reference because they are less likely to be deleted.
- for (let i = 0; i < subcollections.length; i++) {
- const subcollection = subcollections[i];
- const query = (yield subcollection
- .limit(constants_1.Constants.queryLimit - childDocs.length)
- .get());
- // New child reference cannot be the old one because its being deleted.
- const results = query.docs.filter((doc) => doc.ref.path !== child.path);
- childDocs.push(...results);
- }
- if (childDocs.length === 0) {
- // No existing child Documents found. Add a tombstone and continue up.
- // Re-using the same Tombstone object that is used to replace Item Documents.
- docsToWrite.push({
- ref: prefixTombstoneSnapshot.ref,
- data: data,
- });
- if (prefixSnapshot.exists) {
- docsToDelete.push({ ref: prefixSnapshot.ref });
- }
- continue;
- }
- let newChildPath = null;
- // Find a child reference that hasn't been deleted since we ran the query and read under the transaction.
- // Gets are transactional whereas the Query performed earlier was not, prevents concurrent transactions
- // from deleting the document we've chosen as our new child reference before this transaction is done.
- for (let i = 0; i < childDocs.length; i++) {
- const newChild = childDocs[i];
- const childSnapshot = (yield t.get(admin.firestore().doc(newChild.ref.path)));
- transactionReads.push(childSnapshot.ref.path);
- if (childSnapshot.exists) {
- newChildPath = childSnapshot.ref.path;
- break;
- }
- }
- if (newChildPath === null) {
- throw "All Query results have since been Tombstoned. Retrying transaction...";
- }
- else {
- // Update reference to the new child prefix/object under it.
- docsToWrite.push({
- ref: prefixRef,
- data: {
- [constants_1.Constants.childRefField]: (0, util_1.pathHash)(newChildPath),
- [constants_1.Constants.lastEventField]: timestamp,
- },
- });
- break;
- }
- }
- }
- logs.transactionReads(transactionReads);
- // Finished transaction reads. Attempt to write to each queued up document.
- logs.transactionWriteAttempt(docsToWrite.length, docsToWrite.map((d) => d.ref.path));
- docsToWrite.forEach((doc) => {
- t.set(doc.ref, doc.data);
- });
- logs.transactionDeleteAttempt(docsToDelete.length, docsToDelete.map((d) => d.ref.path));
- docsToDelete.forEach((doc) => {
- t.delete(doc.ref);
- });
- }), {
- maxAttempts: constants_1.Constants.transactionAttempts,
- })
- .catch((reason) => {
- logs.transactionFailure(itemRef.path, reason);
- throw reason;
- });
- });
-}
-/**
- * Returns whether an GCS event is stale based on what is currently in Firestore.
- * @param existingSnapshot Current Firestore snapshot.
- * @param eventData Data to write for the GCS event.
- */
-function isStaleEvent(existingSnapshot, eventData, isDeletion) {
- // Timestamp on the incoming Firestore document.
- const newDocumentTime = eventData[constants_1.Constants.lastEventField];
- if (existingSnapshot.exists) {
- // Treat the Document as non-existent if it doesn't have a last-event field.
- if (!existingSnapshot.data().hasOwnProperty(constants_1.Constants.lastEventField)) {
- logs.missingLastEventField(existingSnapshot.id);
- return false;
- }
- const firestoreTime = (existingSnapshot.data()[constants_1.Constants.lastEventField]);
- if (firestoreTime.valueOf() > newDocumentTime.valueOf()) {
- logs.abortingStaleEvent(existingSnapshot.ref.path, newDocumentTime, firestoreTime);
- // This event is older than what is in Firestore.
- return true;
- }
- else if (firestoreTime.isEqual(newDocumentTime) && !isDeletion) {
- logs.staleTieBreak(existingSnapshot.ref.path, newDocumentTime, firestoreTime);
- // Break ties in favor of deletion event taking precedence.
- return true;
- }
- logs.eventNotStale(existingSnapshot.ref.path, newDocumentTime, firestoreTime);
- return false;
- }
- logs.docDoesNotExist(existingSnapshot.ref.path);
- return false;
-}
-exports.isStaleEvent = isStaleEvent;
-/**
- * Construct the Firestore Document fields for the Prefixes Documents & Item Document.
- * @param metadata GCS metadata for the relevant object.
- * @param eventType The event type.
- */
-function firestoreDocumentData(metadata, eventType) {
- const timestamp = firestore_1.Timestamp.fromDate(new Date(metadata.updated));
- if ((0, util_1.isDeletionEventType)(eventType)) {
- // Instead of deleting Documents when the corresponding Object is deleted, replace it
- // with a "Tombstone" to deal with out-of-order function execution.
- return {
- [constants_1.Constants.lastEventField]: timestamp,
- };
- }
- const documentData = {
- [constants_1.Constants.lastEventField]: timestamp,
- [constants_1.Constants.gcsMetadataField]: {},
- };
- const fields = (0, util_1.filterObjectFields)(metadata, config_1.default.metadataFieldFilter);
- fields.forEach(({ key, value }) => {
- let fieldValue;
- if (key === constants_1.Constants.objectCustomMetadataField) {
- // Set custom metadata fields
- fieldValue = {};
- (0, util_1.filterObjectFields)(value, config_1.default.customMetadataFieldFilter).forEach(({ key, value }) => {
- fieldValue[key] = value;
- });
- }
- else if (constants_1.Constants.numberFields.includes(key)) {
- fieldValue = parseInt(value);
- }
- else if (constants_1.Constants.dateFields.includes(key)) {
- fieldValue = new Date(value);
- }
- else {
- fieldValue = value;
- }
- documentData[constants_1.Constants.gcsMetadataField][key] = fieldValue;
- });
- return documentData;
-}
-/**
- * Returns whether a Prefix Document in Firestore has all the valid extension fields.
- * @param snapshot The Firestore snapshot.
- */
-function isValidPrefixDocument(snapshot) {
- const fields = [constants_1.Constants.lastEventField, constants_1.Constants.childRefField];
- for (let i = 0; i < fields.length; i++) {
- const field = fields[i];
- if (!snapshot.data().hasOwnProperty(field))
- return false;
- }
- return true;
-}
diff --git a/storage-mirror-firestore/functions/lib/util.js b/storage-mirror-firestore/functions/lib/util.js
deleted file mode 100644
index 9b511b93..00000000
--- a/storage-mirror-firestore/functions/lib/util.js
+++ /dev/null
@@ -1,128 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.pathHash = exports.mirrorDocumentPathToTombstonePath = exports.objectNameToFirestorePaths = exports.shouldMirrorObject = exports.isValidDocumentId = exports.isValidDocumentName = exports.isDeletionEventType = exports.filterObjectFields = void 0;
-const config_1 = require("./config");
-const crypto = require("crypto");
-/**
- * Returns all the key-value pairs in an Object that match the given filter RegEx.
- * @param o The Object to filter.
- * @param filter The RegEx expression to match with.
- */
-function filterObjectFields(o, filter) {
- const ret = [];
- Object.entries(o).forEach((e) => {
- const key = e[0];
- const value = e[1];
- if (key.match(filter) !== null) {
- ret.push({ key, value });
- }
- });
- return ret;
-}
-exports.filterObjectFields = filterObjectFields;
-/**
- * Returns whether the event is an Object Archiving or Object Deletion.
- * @param eventType The event type.
- */
-function isDeletionEventType(eventType) {
- return (eventType === "google.storage.object.delete" ||
- eventType === "google.storage.object.archive");
-}
-exports.isDeletionEventType = isDeletionEventType;
-/**
- * Returns whether an a Document name (includes Prefixes) is valid in Firestore.
- * https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
- * @param name The name to validate. e.g. `gcs/foo/bar.jpg`
- */
-function isValidDocumentName(name) {
- // Cannot be more than 100 subcollections deep.
- if (name.split("/").length > 100)
- return false;
- // Cannot be larger than 6 KiB.
- if (new Buffer(name).byteLength > 6144)
- return false;
- return true;
-}
-exports.isValidDocumentName = isValidDocumentName;
-/**
- * Returns whether a id is valid for a Document in Firestore.
- * https://firebase.google.com/docs/firestore/quotas#collections_documents_and_fields
- * @param id The id to validate. e.g. `image.jpg`
- */
-function isValidDocumentId(id) {
- // Cannot be empty.
- if (id.length === 0)
- return false;
- // Must be no longer than 1500 bytes.
- if (new Buffer(id).byteLength > 1500)
- return false;
- // Cannot solely consist of a single or double period.
- if (id === "." || id === "..")
- return false;
- // Cannot match this regex.
- if (id.match(new RegExp("__.*__")))
- return false;
- return true;
-}
-exports.isValidDocumentId = isValidDocumentId;
-/**
- * Returns whether an Object should be mirrored (matches the configured RegEx).
- * @param name The name of the Object.
- */
-function shouldMirrorObject(name) {
- return name.match(config_1.default.objectNameFilter) !== null;
-}
-exports.shouldMirrorObject = shouldMirrorObject;
-/**
- * Returns the Firestore Paths to all the Prefix Documents and the Item Document
- * for a given GCS Object name. `prefixPaths` is sorted from the root to the parent
- * prefix just before the item path.
- * @param name The name of the Object.
- */
-function objectNameToFirestorePaths(name) {
- const parts = name.split("/");
- const fileName = parts[parts.length - 1];
- // Build array of prefixes for the Object.
- let prefix = `${config_1.default.firestoreRoot}`;
- const prefixes = [];
- for (let i = 0; i < parts.length - 1; i++) {
- const part = parts[i];
- prefix += `/${config_1.default.prefixesSubcollectionName}/${part}`;
- prefixes.push(prefix);
- }
- return {
- prefixPaths: prefixes,
- itemPath: `${prefix}/${config_1.default.itemsSubcollectionName}/${fileName}`,
- };
-}
-exports.objectNameToFirestorePaths = objectNameToFirestorePaths;
-/**
- * Returns the relevant Tombstone path for any Item Document or Prefix Document path.
- * @param path The Item Document or Prefix Document path. This is assumed to be a valid Document path
- * that was generated by the extension (through the `objectNameToFirestorePaths` function or similar).
- */
-function mirrorDocumentPathToTombstonePath(path) {
- const parts = path.split("/");
- if (parts[parts.length - 2] === config_1.default.itemsSubcollectionName) {
- // Tombstone path for an Item Document.
- parts[parts.length - 2] = config_1.default.itemsTombstoneSubcollectionName;
- }
- else if (parts[parts.length - 2] === config_1.default.prefixesSubcollectionName) {
- // Tombstone path for an Prefix Document.
- parts[parts.length - 2] = config_1.default.prefixesTombstoneSubcollectionName;
- }
- else {
- throw "Invalid Mirror Document Path.";
- }
- return parts.join("/");
-}
-exports.mirrorDocumentPathToTombstonePath = mirrorDocumentPathToTombstonePath;
-/**
- * Returns a hash of the Firestore Document path.
- * @param path The Document path
- */
-function pathHash(path) {
- const hash = crypto.createHash("md5").update(path).digest("hex");
- return hash;
-}
-exports.pathHash = pathHash;