diff --git a/.changeset/cold-chairs-fold.md b/.changeset/cold-chairs-fold.md new file mode 100644 index 00000000000..0fb82429a87 --- /dev/null +++ b/.changeset/cold-chairs-fold.md @@ -0,0 +1,7 @@ +--- +"firebase": minor +"@firebase/app": patch +"@firebase/data-connect": minor +--- + +Included Data Connect product. diff --git a/.github/workflows/canary-deploy.yml b/.github/workflows/canary-deploy.yml index 74364b49cd5..cd720955640 100644 --- a/.github/workflows/canary-deploy.yml +++ b/.github/workflows/canary-deploy.yml @@ -52,6 +52,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/prerelease-manual-deploy.yml b/.github/workflows/prerelease-manual-deploy.yml index a4a96f12e61..57f31bf9c54 100644 --- a/.github/workflows/prerelease-manual-deploy.yml +++ b/.github/workflows/prerelease-manual-deploy.yml @@ -55,6 +55,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 910938c3903..7d6b4017406 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -65,6 +65,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index ee8eee1c0d1..4efd3065180 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -91,6 +91,7 @@ jobs: NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} NPM_TOKEN_COMPONENT: ${{secrets.NPM_TOKEN_COMPONENT}} + NPM_TOKEN_DATA_CONNECT: ${{secrets.NPM_TOKEN_DATA_CONNECT}} NPM_TOKEN_DATABASE: ${{secrets.NPM_TOKEN_DATABASE}} NPM_TOKEN_DATABASE_TYPES: ${{secrets.NPM_TOKEN_DATABASE_TYPES}} NPM_TOKEN_FIRESTORE: ${{secrets.NPM_TOKEN_FIRESTORE}} diff --git a/common/api-review/data-connect.api.md b/common/api-review/data-connect.api.md new file mode 100644 index 00000000000..b2c6fb01931 --- /dev/null +++ b/common/api-review/data-connect.api.md @@ -0,0 +1,234 @@ +## API Report File for "@firebase/data-connect" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; +import { FirebaseApp } from '@firebase/app'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { FirebaseError } from '@firebase/util'; +import { LogLevelString } from '@firebase/logger'; +import { Provider } from '@firebase/component'; + +// @public (undocumented) +export interface CancellableOperation extends PromiseLike<{ + data: T; +}> { + // (undocumented) + cancel: () => void; +} + +// @public +export function connectDataConnectEmulator(dc: DataConnect, host: string, port?: number, sslEnabled?: boolean): void; + +// @public +export interface ConnectorConfig { + // (undocumented) + connector: string; + // (undocumented) + location: string; + // (undocumented) + service: string; +} + +// @public +export class DataConnect { + constructor(app: FirebaseApp, dataConnectOptions: DataConnectOptions, _authProvider: Provider, _appCheckProvider: Provider); + // (undocumented) + readonly app: FirebaseApp; + // (undocumented) + enableEmulator(transportOptions: TransportOptions): void; + // (undocumented) + getSettings(): ConnectorConfig; + // (undocumented) + isEmulator: boolean; + // (undocumented) + setInitialized(): void; +} + +// @public +export interface DataConnectOptions extends ConnectorConfig { + // (undocumented) + projectId: string; +} + +// @public (undocumented) +export interface DataConnectResult extends OpResult { + // (undocumented) + ref: OperationRef; +} + +// @public +export interface DataConnectSubscription { + // (undocumented) + errCallback?: (e?: FirebaseError) => void; + // (undocumented) + unsubscribe: () => void; + // (undocumented) + userCallback: OnResultSubscription; +} + +// @public (undocumented) +export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; + +// @public +export function executeMutation(mutationRef: MutationRef): MutationPromise; + +// @public +export function executeQuery(queryRef: QueryRef): QueryPromise; + +// @public +export function getDataConnect(options: ConnectorConfig): DataConnect; + +// @public +export function getDataConnect(app: FirebaseApp, options: ConnectorConfig): DataConnect; + +// @public (undocumented) +export const MUTATION_STR = "mutation"; + +// @public +export interface MutationPromise extends PromiseLike> { +} + +// @public (undocumented) +export interface MutationRef extends OperationRef { + // (undocumented) + refType: typeof MUTATION_STR; +} + +// @public +export function mutationRef(dcInstance: DataConnect, mutationName: string): MutationRef; + +// @public (undocumented) +export function mutationRef(dcInstance: DataConnect, mutationName: string, variables: Variables): MutationRef; + +// @public +export interface MutationResult extends DataConnectResult { + // (undocumented) + ref: MutationRef; +} + +// @public +export type OnCompleteSubscription = () => void; + +// @public +export type OnErrorSubscription = (err?: FirebaseError) => void; + +// @public +export type OnResultSubscription = (res: QueryResult) => void; + +// @public (undocumented) +export interface OperationRef<_Data, Variables> { + // (undocumented) + dataConnect: DataConnect; + // (undocumented) + name: string; + // (undocumented) + refType: ReferenceType; + // (undocumented) + variables: Variables; +} + +// @public (undocumented) +export interface OpResult { + // (undocumented) + data: Data; + // (undocumented) + fetchTime: string; + // (undocumented) + source: DataSource; +} + +// @public (undocumented) +export const QUERY_STR = "query"; + +// @public +export interface QueryPromise extends PromiseLike> { +} + +// @public +export interface QueryRef extends OperationRef { + // (undocumented) + refType: typeof QUERY_STR; +} + +// @public +export function queryRef(dcInstance: DataConnect, queryName: string): QueryRef; + +// @public +export function queryRef(dcInstance: DataConnect, queryName: string, variables: Variables): QueryRef; + +// @public +export interface QueryResult extends DataConnectResult { + // (undocumented) + ref: QueryRef; + // (undocumented) + toJSON: () => SerializedRef; +} + +// @public +export type QueryUnsubscribe = () => void; + +// @public (undocumented) +export type ReferenceType = typeof QUERY_STR | typeof MUTATION_STR; + +// @public +export interface RefInfo { + // (undocumented) + connectorConfig: DataConnectOptions; + // (undocumented) + name: string; + // (undocumented) + variables: Variables; +} + +// @public +export interface SerializedRef extends OpResult { + // (undocumented) + refInfo: RefInfo; +} + +// @public (undocumented) +export function setLogLevel(logLevel: LogLevelString): void; + +// @public (undocumented) +export const SOURCE_CACHE = "CACHE"; + +// @public (undocumented) +export const SOURCE_SERVER = "SERVER"; + +// @public +export function subscribe(queryRefOrSerializedResult: QueryRef | SerializedRef, observer: SubscriptionOptions): QueryUnsubscribe; + +// @public +export function subscribe(queryRefOrSerializedResult: QueryRef | SerializedRef, onNext: OnResultSubscription, onError?: OnErrorSubscription, onComplete?: OnCompleteSubscription): QueryUnsubscribe; + +// @public +export interface SubscriptionOptions { + // (undocumented) + onComplete?: OnCompleteSubscription; + // (undocumented) + onErr?: OnErrorSubscription; + // (undocumented) + onNext?: OnResultSubscription; +} + +// @public +export function terminate(dataConnect: DataConnect): Promise; + +// @public +export function toQueryRef(serializedRef: SerializedRef): QueryRef; + +// @public +export interface TransportOptions { + // (undocumented) + host: string; + // (undocumented) + port?: number; + // (undocumented) + sslEnabled?: boolean; +} + + +``` diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 4a9ef4c0171..603e2349505 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -494,5 +494,4 @@ export class WriteBatch { // @public export function writeBatch(firestore: Firestore): WriteBatch; - ``` diff --git a/packages/app/src/constants.ts b/packages/app/src/constants.ts index 92102192e93..8ef4eada39c 100644 --- a/packages/app/src/constants.ts +++ b/packages/app/src/constants.ts @@ -24,6 +24,7 @@ import { name as appCheckName } from '../../../packages/app-check/package.json'; import { name as authName } from '../../../packages/auth/package.json'; import { name as authCompatName } from '../../../packages/auth-compat/package.json'; import { name as databaseName } from '../../../packages/database/package.json'; +import { name as dataconnectName } from '../../../packages/data-connect/package.json'; import { name as databaseCompatName } from '../../../packages/database-compat/package.json'; import { name as functionsName } from '../../../packages/functions/package.json'; import { name as functionsCompatName } from '../../../packages/functions-compat/package.json'; @@ -59,6 +60,7 @@ export const PLATFORM_LOG_STRING = { [authName]: 'fire-auth', [authCompatName]: 'fire-auth-compat', [databaseName]: 'fire-rtdb', + [dataconnectName]: 'fire-data-connect', [databaseCompatName]: 'fire-rtdb-compat', [functionsName]: 'fire-fn', [functionsCompatName]: 'fire-fn-compat', diff --git a/packages/data-connect/.eslintrc.js b/packages/data-connect/.eslintrc.js new file mode 100644 index 00000000000..faef63a0395 --- /dev/null +++ b/packages/data-connect/.eslintrc.js @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2024 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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.eslint.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + }, + plugins: ['import'], + ignorePatterns: ['compat/*'], + rules: { + 'no-console': ['error', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_', + args: 'none' + } + ], + 'import/order': [ + 'error', + { + 'groups': [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index' + ], + 'newlines-between': 'always', + 'alphabetize': { 'order': 'asc', 'caseInsensitive': true } + } + ], + 'no-restricted-globals': [ + 'error', + { + 'name': 'window', + 'message': 'Use `PlatformSupport.getPlatform().window` instead.' + }, + { + 'name': 'document', + 'message': 'Use `PlatformSupport.getPlatform().document` instead.' + } + ] + }, + overrides: [ + { + files: ['**/*.d.ts'], + rules: { + 'camelcase': 'off', + 'import/no-duplicates': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off' + } + }, + { + files: ['**/*.test.ts', '**/test/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error' + } + }, + { + files: ['scripts/*.ts'], + rules: { + 'import/no-extraneous-dependencies': 'off', + '@typescript-eslint/no-require-imports': 'off' + } + } + ] +}; diff --git a/packages/data-connect/.gitignore b/packages/data-connect/.gitignore new file mode 100644 index 00000000000..48a928da5dd --- /dev/null +++ b/packages/data-connect/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz \ No newline at end of file diff --git a/packages/data-connect/.npmignore b/packages/data-connect/.npmignore new file mode 100644 index 00000000000..c2969b2bf95 --- /dev/null +++ b/packages/data-connect/.npmignore @@ -0,0 +1,5 @@ +node_modules/ +rollup.config.mjs +package-lock.json +tsconfig.json +src/ \ No newline at end of file diff --git a/packages/data-connect/CHANGELOG.md b/packages/data-connect/CHANGELOG.md new file mode 100644 index 00000000000..4607da62337 --- /dev/null +++ b/packages/data-connect/CHANGELOG.md @@ -0,0 +1,10 @@ +## Unreleased +* Added app check support # @firebase/data-connect + +## 0.0.3 +* Updated reporting to use @firebase/data-connect instead of @firebase/connect. +* Added functionality to retry queries and mutations if the server responds with UNAUTHENTICATED. +* Moved `validateArgs` to core SDK. +* Updated errors to only show relevant details to the user. +* Added ability to track whether user is calling core sdk or generated sdk. + diff --git a/packages/data-connect/api-extractor.json b/packages/data-connect/api-extractor.json new file mode 100644 index 00000000000..deee6510e4b --- /dev/null +++ b/packages/data-connect/api-extractor.json @@ -0,0 +1,11 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/public.d.ts", + "apiReport": { + /** + * apiReport is handled by repo-scripts/prune-dts/extract-public-api.ts + */ + "enabled": false + } +} \ No newline at end of file diff --git a/packages/data-connect/karma.conf.js b/packages/data-connect/karma.conf.js new file mode 100644 index 00000000000..acb47c2ab3b --- /dev/null +++ b/packages/data-connect/karma.conf.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2024 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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = [`test/**/*.test.ts`]; + +module.exports = function (config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages/data-connect/package.json b/packages/data-connect/package.json new file mode 100644 index 00000000000..ba697fb9a76 --- /dev/null +++ b/packages/data-connect/package.json @@ -0,0 +1,80 @@ +{ + "name": "@firebase/data-connect", + "version": "0.0.3", + "description": "", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.node.cjs.js", + "browser": "dist/index.esm2017.js", + "module": "dist/index.esm2017.js", + "esm5": "dist/index.esm5.js", + "exports": { + ".": { + "types": "./dist/public.d.ts", + "node": { + "import": "./dist/node-esm/index.node.esm.js", + "require": "./dist/index.node.cjs.js" + }, + "esm5": "./dist/index.esm5.js", + "browser": { + "require": "./dist/index.cjs.js", + "import": "./dist/index.esm2017.js" + }, + "default": "./dist/index.esm2017.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore' --fix", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c rollup.config.js && yarn api-report", + "prettier": "prettier --write '*.js' '*.ts' '@(src|test)/**/*.ts'", + "build:deps": "lerna run --scope @firebase/'{app,data-connect}' --include-dependencies build", + "dev": "rollup -c -w", + "test": "run-p --npm-path npm test:emulator", + "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:emulator", + "test:all": "run-p --npm-path npm lint test:unit", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js", + "test:unit": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/unit/**/*.test.ts' --file src/index.node.ts --config ../../config/mocharc.node.js", + "test:emulator": "ts-node --compiler-options='{\"module\":\"commonjs\"}' ../../scripts/emulator-testing/dataconnect-test-runner.ts", + "api-report": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' ts-node ../../repo-scripts/prune-dts/extract-public-api.ts --package data-connect --packageRoot . --typescriptDts ./dist/src/index.d.ts --rollupDts ./dist/private.d.ts --untrimmedRollupDts ./dist/internal.d.ts --publicDts ./dist/public.d.ts && yarn api-report:api-json", + "api-report:api-json": "rm -rf temp && api-extractor run --local --verbose", + "doc": "api-documenter markdown --input temp --output docs", + "typings:public": "node ../../scripts/build/use_typings.js ./dist/public.d.ts" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app": "0.x" + }, + "dependencies": { + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + }, + "devDependencies": { + "@firebase/app": "0.10.11", + "rollup": "2.79.1", + "rollup-plugin-typescript2": "0.31.2", + "typescript": "4.7.4" + }, + "repository": { + "directory": "packages/data-connect", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} \ No newline at end of file diff --git a/packages/data-connect/rollup.config.js b/packages/data-connect/rollup.config.js new file mode 100644 index 00000000000..cb220911d69 --- /dev/null +++ b/packages/data-connect/rollup.config.js @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import json from '@rollup/plugin-json'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import replace from 'rollup-plugin-replace'; +import typescript from 'typescript'; +import { generateBuildTargetReplaceConfig } from '../../scripts/build/rollup_replace_build_target'; +import { emitModulePackageFile } from '../../scripts/build/rollup_emit_module_package_file'; +import pkg from './package.json'; + +const deps = [ + ...Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }), + '@firebase/app' +]; + +function onWarn(warning, defaultWarn) { + if (warning.code === 'CIRCULAR_DEPENDENCY') { + throw new Error(warning); + } + defaultWarn(warning); +} + +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + abortOnError: false + }), + json() +]; + +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + }, + abortOnError: false + }), + json({ preferConst: true }) +]; + +const browserBuilds = [ + { + input: 'src/index.ts', + output: [ + { + file: pkg.esm5, + format: 'es', + sourcemap: true + } + ], + plugins: [ + ...es5BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 5)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.ts', + output: [ + { + file: pkg.module, + format: 'es', + sourcemap: true + } + ], + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 2017)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.cjs.js', + format: 'cjs', + sourcemap: true + } + ], + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('cjs', 2017)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + } +]; + +const nodeBuilds = [ + { + input: 'src/index.node.ts', + output: { file: pkg.main, format: 'cjs', sourcemap: true }, + plugins: [ + ...es5BuildPlugins, + replace(generateBuildTargetReplaceConfig('cjs', 5)) + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + }, + { + input: 'src/index.node.ts', + output: { + file: pkg.exports['.'].node.import, + format: 'es', + sourcemap: true + }, + plugins: [ + ...es2017BuildPlugins, + replace(generateBuildTargetReplaceConfig('esm', 2017)), + emitModulePackageFile() + ], + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + onwarn: onWarn + } +]; + +export default [...browserBuilds, ...nodeBuilds]; diff --git a/packages/data-connect/src/api.browser.ts b/packages/data-connect/src/api.browser.ts new file mode 100644 index 00000000000..1ffcb8d1647 --- /dev/null +++ b/packages/data-connect/src/api.browser.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2024 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 { + OnCompleteSubscription, + OnErrorSubscription, + OnResultSubscription, + QueryRef, + QueryUnsubscribe, + SubscriptionOptions, + toQueryRef +} from './api/query'; +import { OpResult, SerializedRef } from './api/Reference'; +import { DataConnectError, Code } from './core/error'; + +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param observer observer object to use for subscribing. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + observer: SubscriptionOptions +): QueryUnsubscribe; +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param onNext Callback to call when result comes back. + * @param onError Callback to call when error gets thrown. + * @param onComplete Called when subscription completes. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + onNext: OnResultSubscription, + onError?: OnErrorSubscription, + onComplete?: OnCompleteSubscription +): QueryUnsubscribe; +/** + * Subscribe to a `QueryRef` + * @param queryRefOrSerializedResult query ref or serialized result. + * @param observerOrOnNext observer object or next function. + * @param onError Callback to call when error gets thrown. + * @param onComplete Called when subscription completes. + * @returns `SubscriptionOptions` + */ +export function subscribe( + queryRefOrSerializedResult: + | QueryRef + | SerializedRef, + observerOrOnNext: + | SubscriptionOptions + | OnResultSubscription, + onError?: OnErrorSubscription, + onComplete?: OnCompleteSubscription +): QueryUnsubscribe { + let ref: QueryRef; + let initialCache: OpResult | undefined; + if ('refInfo' in queryRefOrSerializedResult) { + const serializedRef: SerializedRef = + queryRefOrSerializedResult; + const { data, source, fetchTime } = serializedRef; + initialCache = { + data, + source, + fetchTime + }; + ref = toQueryRef(serializedRef); + } else { + ref = queryRefOrSerializedResult; + } + let onResult: OnResultSubscription | undefined = undefined; + if (typeof observerOrOnNext === 'function') { + onResult = observerOrOnNext; + } else { + onResult = observerOrOnNext.onNext; + onError = observerOrOnNext.onErr; + onComplete = observerOrOnNext.onComplete; + } + if (!onResult) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'Must provide onNext'); + } + return ref.dataConnect._queryManager.addSubscription( + ref, + onResult, + onError, + initialCache + ); +} diff --git a/packages/data-connect/src/api.node.ts b/packages/data-connect/src/api.node.ts new file mode 100644 index 00000000000..f8236ebe2d7 --- /dev/null +++ b/packages/data-connect/src/api.node.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2024 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. + */ + +export { subscribe } from './api.browser'; diff --git a/packages/data-connect/src/api/DataConnect.ts b/packages/data-connect/src/api/DataConnect.ts new file mode 100644 index 00000000000..27ab83660fd --- /dev/null +++ b/packages/data-connect/src/api/DataConnect.ts @@ -0,0 +1,289 @@ +/** + * @license + * Copyright 2024 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 { + FirebaseApp, + _getProvider, + _removeServiceInstance, + getApp +} from '@firebase/app'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; + +import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; +import { Code, DataConnectError } from '../core/error'; +import { + AuthTokenProvider, + FirebaseAuthProvider +} from '../core/FirebaseAuthProvider'; +import { QueryManager } from '../core/QueryManager'; +import { logDebug, logError } from '../logger'; +import { DataConnectTransport, TransportClass } from '../network'; +import { RESTTransport } from '../network/transport/rest'; + +import { MutationManager } from './Mutation'; + +/** + * Connector Config for calling Data Connect backend. + */ +export interface ConnectorConfig { + location: string; + connector: string; + service: string; +} + +/** + * Options to connect to emulator + */ +export interface TransportOptions { + host: string; + sslEnabled?: boolean; + port?: number; +} + +const FIREBASE_DATA_CONNECT_EMULATOR_HOST_VAR = + 'FIREBASE_DATA_CONNECT_EMULATOR_HOST'; + +/** + * + * @param fullHost + * @returns TransportOptions + * @internal + */ +export function parseOptions(fullHost: string): TransportOptions { + const [protocol, hostName] = fullHost.split('://'); + const isSecure = protocol === 'https'; + const [host, portAsString] = hostName.split(':'); + const port = Number(portAsString); + return { host, port, sslEnabled: isSecure }; +} +/** + * DataConnectOptions including project id + */ +export interface DataConnectOptions extends ConnectorConfig { + projectId: string; +} + +/** + * Class representing Firebase Data Connect + */ +export class DataConnect { + _queryManager!: QueryManager; + _mutationManager!: MutationManager; + isEmulator = false; + _initialized = false; + private _transport!: DataConnectTransport; + private _transportClass: TransportClass | undefined; + private _transportOptions?: TransportOptions; + private _authTokenProvider?: AuthTokenProvider; + _isUsingGeneratedSdk: boolean = false; + private _appCheckTokenProvider?: AppCheckTokenProvider; + // @internal + constructor( + public readonly app: FirebaseApp, + // TODO(mtewani): Replace with _dataConnectOptions in the future + private readonly dataConnectOptions: DataConnectOptions, + private readonly _authProvider: Provider, + private readonly _appCheckProvider: Provider + ) { + if (typeof process !== 'undefined' && process.env) { + const host = process.env[FIREBASE_DATA_CONNECT_EMULATOR_HOST_VAR]; + if (host) { + logDebug('Found custom host. Using emulator'); + this.isEmulator = true; + this._transportOptions = parseOptions(host); + } + } + } + // @internal + _useGeneratedSdk(): void { + if (!this._isUsingGeneratedSdk) { + this._isUsingGeneratedSdk = true; + } + } + _delete(): Promise { + _removeServiceInstance( + this.app, + 'data-connect', + JSON.stringify(this.getSettings()) + ); + return Promise.resolve(); + } + + // @internal + getSettings(): ConnectorConfig { + const copy = JSON.parse(JSON.stringify(this.dataConnectOptions)); + delete copy.projectId; + return copy; + } + + // @internal + setInitialized(): void { + if (this._initialized) { + return; + } + if (this._transportClass === undefined) { + logDebug('transportClass not provided. Defaulting to RESTTransport.'); + this._transportClass = RESTTransport; + } + + if (this._authProvider) { + this._authTokenProvider = new FirebaseAuthProvider( + this.app.name, + this.app.options, + this._authProvider + ); + } + if (this._appCheckProvider) { + this._appCheckTokenProvider = new AppCheckTokenProvider( + this.app.name, + this._appCheckProvider + ); + } + + this._initialized = true; + this._transport = new this._transportClass( + this.dataConnectOptions, + this.app.options.apiKey, + this.app.options.appId, + this._authTokenProvider, + this._appCheckTokenProvider, + undefined, + this._isUsingGeneratedSdk + ); + if (this._transportOptions) { + this._transport.useEmulator( + this._transportOptions.host, + this._transportOptions.port, + this._transportOptions.sslEnabled + ); + } + this._queryManager = new QueryManager(this._transport); + this._mutationManager = new MutationManager(this._transport); + } + + // @internal + enableEmulator(transportOptions: TransportOptions): void { + if (this._initialized) { + logError('enableEmulator called after initialization'); + throw new DataConnectError( + Code.ALREADY_INITIALIZED, + 'DataConnect instance already initialized!' + ); + } + this._transportOptions = transportOptions; + this.isEmulator = true; + } +} + +/** + * Connect to the DataConnect Emulator + * @param dc Data Connect instance + * @param host host of emulator server + * @param port port of emulator server + * @param sslEnabled use https + */ +export function connectDataConnectEmulator( + dc: DataConnect, + host: string, + port?: number, + sslEnabled = false +): void { + dc.enableEmulator({ host, port, sslEnabled }); +} + +/** + * Initialize DataConnect instance + * @param options ConnectorConfig + */ +export function getDataConnect(options: ConnectorConfig): DataConnect; +/** + * Initialize DataConnect instance + * @param app FirebaseApp to initialize to. + * @param options ConnectorConfig + */ +export function getDataConnect( + app: FirebaseApp, + options: ConnectorConfig +): DataConnect; +export function getDataConnect( + appOrOptions: FirebaseApp | ConnectorConfig, + optionalOptions?: ConnectorConfig +): DataConnect { + let app: FirebaseApp; + let dcOptions: ConnectorConfig; + if ('location' in appOrOptions) { + dcOptions = appOrOptions; + app = getApp(); + } else { + dcOptions = optionalOptions!; + app = appOrOptions; + } + + if (!app || Object.keys(app).length === 0) { + app = getApp(); + } + const provider = _getProvider(app, 'data-connect'); + const identifier = JSON.stringify(dcOptions); + if (provider.isInitialized(identifier)) { + const dcInstance = provider.getImmediate({ identifier }); + const options = provider.getOptions(identifier); + const optionsValid = Object.keys(options).length > 0; + if (optionsValid) { + logDebug('Re-using cached instance'); + return dcInstance; + } + } + validateDCOptions(dcOptions); + + logDebug('Creating new DataConnect instance'); + // Initialize with options. + return provider.initialize({ + instanceIdentifier: identifier, + options: dcOptions + }); +} + +/** + * + * @param dcOptions + * @returns {void} + * @internal + */ +export function validateDCOptions(dcOptions: ConnectorConfig): boolean { + const fields = ['connector', 'location', 'service']; + if (!dcOptions) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'DC Option Required'); + } + fields.forEach(field => { + if (dcOptions[field] === null || dcOptions[field] === undefined) { + throw new DataConnectError(Code.INVALID_ARGUMENT, `${field} Required`); + } + }); + return true; +} + +/** + * Delete DataConnect instance + * @param dataConnect DataConnect instance + * @returns + */ +export function terminate(dataConnect: DataConnect): Promise { + return dataConnect._delete(); + // TODO(mtewani): Stop pending tasks +} diff --git a/packages/data-connect/src/api/Mutation.ts b/packages/data-connect/src/api/Mutation.ts new file mode 100644 index 00000000000..ca2efdb7a30 --- /dev/null +++ b/packages/data-connect/src/api/Mutation.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2024 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 { DataConnectTransport } from '../network/transport'; + +import { DataConnect } from './DataConnect'; +import { + DataConnectResult, + MUTATION_STR, + OperationRef, + SOURCE_SERVER +} from './Reference'; + +export interface MutationRef + extends OperationRef { + refType: typeof MUTATION_STR; +} + +/** + * Creates a `MutationRef` + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string +): MutationRef; +/** + * + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + * @param variables variables to send with mutation + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string, + variables: Variables +): MutationRef; +/** + * + * @param dcInstance Data Connect instance + * @param mutationName name of mutation + * @param variables variables to send with mutation + * @returns `MutationRef` + */ +export function mutationRef( + dcInstance: DataConnect, + mutationName: string, + variables?: Variables +): MutationRef { + dcInstance.setInitialized(); + const ref: MutationRef = { + dataConnect: dcInstance, + name: mutationName, + refType: MUTATION_STR, + variables: variables as Variables + }; + return ref; +} + +/** + * @internal + */ +export class MutationManager { + private _inflight: Array> = []; + constructor(private _transport: DataConnectTransport) {} + executeMutation( + mutationRef: MutationRef + ): MutationPromise { + const result = this._transport.invokeMutation( + mutationRef.name, + mutationRef.variables + ); + const withRefPromise = result.then(res => { + const obj: MutationResult = { + ...res, // Double check that the result is result.data, not just result + source: SOURCE_SERVER, + ref: mutationRef, + fetchTime: Date.now().toLocaleString() + }; + return obj; + }); + this._inflight.push(result); + const removePromise = (): Array> => + (this._inflight = this._inflight.filter(promise => promise !== result)); + result.then(removePromise, removePromise); + return withRefPromise; + } +} + +/** + * Mutation Result from `executeMutation` + */ +export interface MutationResult + extends DataConnectResult { + ref: MutationRef; +} +/** + * Mutation return value from `executeMutation` + */ +export interface MutationPromise + extends PromiseLike> { + // reserved for special actions like cancellation +} + +/** + * Execute Mutation + * @param mutationRef mutation to execute + * @returns `MutationRef` + */ +export function executeMutation( + mutationRef: MutationRef +): MutationPromise { + return mutationRef.dataConnect._mutationManager.executeMutation(mutationRef); +} diff --git a/packages/data-connect/src/api/Reference.ts b/packages/data-connect/src/api/Reference.ts new file mode 100644 index 00000000000..f9d7687dd18 --- /dev/null +++ b/packages/data-connect/src/api/Reference.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2024 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 { DataConnect, DataConnectOptions } from './DataConnect'; +export const QUERY_STR = 'query'; +export const MUTATION_STR = 'mutation'; +export type ReferenceType = typeof QUERY_STR | typeof MUTATION_STR; + +export const SOURCE_SERVER = 'SERVER'; +export const SOURCE_CACHE = 'CACHE'; +export type DataSource = typeof SOURCE_CACHE | typeof SOURCE_SERVER; + +export interface OpResult { + data: Data; + source: DataSource; + fetchTime: string; +} + +export interface OperationRef<_Data, Variables> { + name: string; + variables: Variables; + refType: ReferenceType; + dataConnect: DataConnect; +} + +export interface DataConnectResult extends OpResult { + ref: OperationRef; + // future metadata +} + +/** + * Serialized RefInfo as a result of `QueryResult.toJSON().refInfo` + */ +export interface RefInfo { + name: string; + variables: Variables; + connectorConfig: DataConnectOptions; +} +/** + * Serialized Ref as a result of `QueryResult.toJSON()` + */ +export interface SerializedRef extends OpResult { + refInfo: RefInfo; +} diff --git a/packages/data-connect/src/api/index.ts b/packages/data-connect/src/api/index.ts new file mode 100644 index 00000000000..885dac5a923 --- /dev/null +++ b/packages/data-connect/src/api/index.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2024 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. + */ + +export * from '../network'; +export * from './DataConnect'; +export * from './Reference'; +export * from './Mutation'; +export * from './query'; +export { setLogLevel } from '../logger'; +export { validateArgs } from '../util/validateArgs'; diff --git a/packages/data-connect/src/api/query.ts b/packages/data-connect/src/api/query.ts new file mode 100644 index 00000000000..a4ab17b7ceb --- /dev/null +++ b/packages/data-connect/src/api/query.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2024 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 { DataConnectError } from '../core/error'; + +import { DataConnect, getDataConnect } from './DataConnect'; +import { + OperationRef, + QUERY_STR, + DataConnectResult, + SerializedRef +} from './Reference'; + +/** + * Signature for `OnResultSubscription` for `subscribe` + */ +export type OnResultSubscription = ( + res: QueryResult +) => void; +/** + * Signature for `OnErrorSubscription` for `subscribe` + */ +export type OnErrorSubscription = (err?: DataConnectError) => void; +/** + * Signature for unsubscribe from `subscribe` + */ +export type QueryUnsubscribe = () => void; +/** + * Representation of user provided subscription options. + */ +export interface DataConnectSubscription { + userCallback: OnResultSubscription; + errCallback?: (e?: DataConnectError) => void; + unsubscribe: () => void; +} + +/** + * QueryRef object + */ +export interface QueryRef + extends OperationRef { + refType: typeof QUERY_STR; +} +/** + * Result of `executeQuery` + */ +export interface QueryResult + extends DataConnectResult { + ref: QueryRef; + toJSON: () => SerializedRef; +} +/** + * Promise returned from `executeQuery` + */ +export interface QueryPromise + extends PromiseLike> { + // reserved for special actions like cancellation +} + +/** + * Execute Query + * @param queryRef query to execute. + * @returns `QueryPromise` + */ +export function executeQuery( + queryRef: QueryRef +): QueryPromise { + return queryRef.dataConnect._queryManager.executeQuery(queryRef); +} + +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string +): QueryRef; +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @param variables Variables to execute with + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string, + variables: Variables +): QueryRef; +/** + * Execute Query + * @param dcInstance Data Connect instance to use. + * @param queryName Query to execute + * @param variables Variables to execute with + * @param initialCache initial cache to use for client hydration + * @returns `QueryRef` + */ +export function queryRef( + dcInstance: DataConnect, + queryName: string, + variables?: Variables, + initialCache?: QueryResult +): QueryRef { + dcInstance.setInitialized(); + dcInstance._queryManager.track(queryName, variables, initialCache); + return { + dataConnect: dcInstance, + refType: QUERY_STR, + name: queryName, + variables: variables as Variables + }; +} +/** + * Converts serialized ref to query ref + * @param serializedRef ref to convert to `QueryRef` + * @returns `QueryRef` + */ +export function toQueryRef( + serializedRef: SerializedRef +): QueryRef { + const { + refInfo: { name, variables, connectorConfig } + } = serializedRef; + return queryRef(getDataConnect(connectorConfig), name, variables); +} +/** + * `OnCompleteSubscription` + */ +export type OnCompleteSubscription = () => void; +/** + * Representation of full observer options in `subscribe` + */ +export interface SubscriptionOptions { + onNext?: OnResultSubscription; + onErr?: OnErrorSubscription; + onComplete?: OnCompleteSubscription; +} diff --git a/packages/data-connect/src/core/AppCheckTokenProvider.ts b/packages/data-connect/src/core/AppCheckTokenProvider.ts new file mode 100644 index 00000000000..d9cdaeb6f39 --- /dev/null +++ b/packages/data-connect/src/core/AppCheckTokenProvider.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2024 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 { + AppCheckInternalComponentName, + AppCheckTokenListener, + AppCheckTokenResult, + FirebaseAppCheckInternal +} from '@firebase/app-check-interop-types'; +import { Provider } from '@firebase/component'; + +/** + * @internal + * Abstraction around AppCheck's token fetching capabilities. + */ +export class AppCheckTokenProvider { + private appCheck?: FirebaseAppCheckInternal; + constructor( + private appName_: string, + private appCheckProvider?: Provider + ) { + this.appCheck = appCheckProvider?.getImmediate({ optional: true }); + if (!this.appCheck) { + void appCheckProvider + ?.get() + .then(appCheck => (this.appCheck = appCheck)) + .catch(); + } + } + + getToken(forceRefresh?: boolean): Promise { + if (!this.appCheck) { + return new Promise((resolve, reject) => { + // Support delayed initialization of FirebaseAppCheck. This allows our + // customers to initialize the RTDB SDK before initializing Firebase + // AppCheck and ensures that all requests are authenticated if a token + // becomes available before the timoeout below expires. + setTimeout(() => { + if (this.appCheck) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); + } + return this.appCheck.getToken(forceRefresh); + } + + addTokenChangeListener(listener: AppCheckTokenListener): void { + void this.appCheckProvider + ?.get() + .then(appCheck => appCheck.addTokenListener(listener)); + } +} diff --git a/packages/data-connect/src/core/FirebaseAuthProvider.ts b/packages/data-connect/src/core/FirebaseAuthProvider.ts new file mode 100644 index 00000000000..a19b8a46d6c --- /dev/null +++ b/packages/data-connect/src/core/FirebaseAuthProvider.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2024 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 { FirebaseOptions } from '@firebase/app-types'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName, + FirebaseAuthTokenData +} from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; + +import { logDebug, logError } from '../logger'; + +// @internal +export interface AuthTokenProvider { + getToken(forceRefresh: boolean): Promise; + addTokenChangeListener(listener: AuthTokenListener): void; +} +export type AuthTokenListener = (token: string | null) => void; + +// @internal +export class FirebaseAuthProvider implements AuthTokenProvider { + private _auth: FirebaseAuthInternal; + constructor( + private _appName: string, + private _options: FirebaseOptions, + private _authProvider: Provider + ) { + this._auth = _authProvider.getImmediate({ optional: true })!; + if (!this._auth) { + _authProvider.onInit(auth => (this._auth = auth)); + } + } + getToken(forceRefresh: boolean): Promise { + if (!this._auth) { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (this._auth) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); + } + return this._auth.getToken(forceRefresh).catch(error => { + if (error && error.code === 'auth/token-not-initialized') { + logDebug( + 'Got auth/token-not-initialized error. Treating as null token.' + ); + return null; + } else { + logError( + 'Error received when attempting to retrieve token: ' + + JSON.stringify(error) + ); + return Promise.reject(error); + } + }); + } + addTokenChangeListener(listener: AuthTokenListener): void { + this._auth?.addAuthTokenListener(listener); + } + removeTokenChangeListener(listener: (token: string | null) => void): void { + this._authProvider + .get() + .then(auth => auth.removeAuthTokenListener(listener)) + .catch(err => logError(err)); + } +} diff --git a/packages/data-connect/src/core/QueryManager.ts b/packages/data-connect/src/core/QueryManager.ts new file mode 100644 index 00000000000..c82e0fee903 --- /dev/null +++ b/packages/data-connect/src/core/QueryManager.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2024 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 { + DataConnectSubscription, + OnErrorSubscription, + OnResultSubscription, + QueryPromise, + QueryRef, + QueryResult +} from '../api/query'; +import { + OperationRef, + QUERY_STR, + OpResult, + SerializedRef, + SOURCE_SERVER, + DataSource, + SOURCE_CACHE +} from '../api/Reference'; +import { logDebug } from '../logger'; +import { DataConnectTransport } from '../network'; +import { encoderImpl } from '../util/encoder'; +import { setIfNotExists } from '../util/map'; + +import { DataConnectError } from './error'; + +interface TrackedQuery { + ref: Omit, 'dataConnect'>; + subscriptions: Array>; + currentCache: OpResult | null; + lastError: DataConnectError | null; +} + +function getRefSerializer( + queryRef: QueryRef, + data: Data, + source: DataSource +) { + return function toJSON(): SerializedRef { + return { + data, + refInfo: { + name: queryRef.name, + variables: queryRef.variables, + connectorConfig: { + projectId: queryRef.dataConnect.app.options.projectId!, + ...queryRef.dataConnect.getSettings() + } + }, + fetchTime: Date.now().toLocaleString(), + source + }; + }; +} + +export class QueryManager { + _queries: Map>; + constructor(private transport: DataConnectTransport) { + this._queries = new Map(); + } + track( + queryName: string, + variables: Variables, + initialCache?: OpResult + ): TrackedQuery { + const ref: TrackedQuery['ref'] = { + name: queryName, + variables, + refType: QUERY_STR + }; + const key = encoderImpl(ref); + const newTrackedQuery: TrackedQuery = { + ref, + subscriptions: [], + currentCache: initialCache || null, + lastError: null + }; + // @ts-ignore + setIfNotExists(this._queries, key, newTrackedQuery); + return this._queries.get(key) as TrackedQuery; + } + addSubscription( + queryRef: OperationRef, + onResultCallback: OnResultSubscription, + onErrorCallback?: OnErrorSubscription, + initialCache?: OpResult + ): () => void { + const key = encoderImpl({ + name: queryRef.name, + variables: queryRef.variables, + refType: QUERY_STR + }); + const trackedQuery = this._queries.get(key) as TrackedQuery< + Data, + Variables + >; + const subscription = { + userCallback: onResultCallback, + errCallback: onErrorCallback + }; + const unsubscribe = (): void => { + const trackedQuery = this._queries.get(key)!; + trackedQuery.subscriptions = trackedQuery.subscriptions.filter( + sub => sub !== subscription + ); + }; + if (initialCache && trackedQuery.currentCache !== initialCache) { + logDebug('Initial cache found. Comparing dates.'); + if ( + !trackedQuery.currentCache || + (trackedQuery.currentCache && + compareDates( + trackedQuery.currentCache.fetchTime, + initialCache.fetchTime + )) + ) { + trackedQuery.currentCache = initialCache; + } + } + if (trackedQuery.currentCache !== null) { + const cachedData = trackedQuery.currentCache.data; + onResultCallback({ + data: cachedData, + source: SOURCE_CACHE, + ref: queryRef as QueryRef, + toJSON: getRefSerializer( + queryRef as QueryRef, + trackedQuery.currentCache.data, + SOURCE_CACHE + ), + fetchTime: trackedQuery.currentCache.fetchTime + }); + if (trackedQuery.lastError !== null && onErrorCallback) { + onErrorCallback(undefined); + } + } + + trackedQuery.subscriptions.push({ + userCallback: onResultCallback, + errCallback: onErrorCallback, + unsubscribe + }); + if (!trackedQuery.currentCache) { + logDebug( + `No cache available for query ${ + queryRef.name + } with variables ${JSON.stringify( + queryRef.variables + )}. Calling executeQuery.` + ); + const promise = this.executeQuery(queryRef as QueryRef); + // We want to ignore the error and let subscriptions handle it + promise.then(undefined, err => {}); + } + return unsubscribe; + } + executeQuery( + queryRef: QueryRef + ): QueryPromise { + const key = encoderImpl({ + name: queryRef.name, + variables: queryRef.variables, + refType: QUERY_STR + }); + const trackedQuery = this._queries.get(key)!; + const result = this.transport.invokeQuery( + queryRef.name, + queryRef.variables + ); + const newR = result.then( + res => { + const fetchTime = new Date().toString(); + const result: QueryResult = { + ...res, + source: SOURCE_SERVER, + ref: queryRef, + toJSON: getRefSerializer(queryRef, res.data, SOURCE_SERVER), + fetchTime + }; + trackedQuery.subscriptions.forEach(subscription => { + subscription.userCallback(result); + }); + trackedQuery.currentCache = { + data: res.data, + source: SOURCE_CACHE, + fetchTime + }; + return result; + }, + err => { + trackedQuery.lastError = err; + trackedQuery.subscriptions.forEach(subscription => { + if (subscription.errCallback) { + subscription.errCallback(err); + } + }); + throw err; + } + ); + + return newR; + } + enableEmulator(host: string, port: number): void { + this.transport.useEmulator(host, port); + } +} +function compareDates(str1: string, str2: string): boolean { + const date1 = new Date(str1); + const date2 = new Date(str2); + return date1.getTime() < date2.getTime(); +} diff --git a/packages/data-connect/src/core/error.ts b/packages/data-connect/src/core/error.ts new file mode 100644 index 00000000000..f0beb128afa --- /dev/null +++ b/packages/data-connect/src/core/error.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2024 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 { FirebaseError } from '@firebase/util'; + +export type DataConnectErrorCode = + | 'other' + | 'already-initialized' + | 'not-initialized' + | 'not-supported' + | 'invalid-argument' + | 'partial-error' + | 'unauthorized'; + +export type Code = DataConnectErrorCode; + +export const Code = { + OTHER: 'other' as DataConnectErrorCode, + ALREADY_INITIALIZED: 'already-initialized' as DataConnectErrorCode, + NOT_INITIALIZED: 'not-initialized' as DataConnectErrorCode, + NOT_SUPPORTED: 'not-supported' as DataConnectErrorCode, + INVALID_ARGUMENT: 'invalid-argument' as DataConnectErrorCode, + PARTIAL_ERROR: 'partial-error' as DataConnectErrorCode, + UNAUTHORIZED: 'unauthorized' as DataConnectErrorCode +}; + +/** An error returned by a DataConnect operation. */ +export class DataConnectError extends FirebaseError { + /** The stack of the error. */ + readonly stack?: string; + + /** @hideconstructor */ + constructor( + /** + * The backend error code associated with this error. + */ + readonly code: DataConnectErrorCode, + /** + * A custom error description. + */ + readonly message: string + ) { + super(code, message); + + // HACK: We write a toString property directly because Error is not a real + // class and so inheritance does not work correctly. We could alternatively + // do the same "back-door inheritance" trick that FirebaseError does. + this.toString = () => `${this.name}: [code=${this.code}]: ${this.message}`; + } +} diff --git a/packages/data-connect/src/core/version.ts b/packages/data-connect/src/core/version.ts new file mode 100644 index 00000000000..dd9e7850454 --- /dev/null +++ b/packages/data-connect/src/core/version.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 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. + */ + +/** The semver (www.semver.org) version of the SDK. */ +export let SDK_VERSION = ''; + +/** + * SDK_VERSION should be set before any database instance is created + * @internal + */ +export function setSDKVersion(version: string): void { + SDK_VERSION = version; +} diff --git a/packages/data-connect/src/index.node.ts b/packages/data-connect/src/index.node.ts new file mode 100644 index 00000000000..0a4970e4856 --- /dev/null +++ b/packages/data-connect/src/index.node.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2024 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 { initializeFetch } from './network/fetch'; +import { registerDataConnect } from './register'; + +export * from './api'; +export * from './api.node'; +initializeFetch(fetch); + +registerDataConnect('node'); diff --git a/packages/data-connect/src/index.ts b/packages/data-connect/src/index.ts new file mode 100644 index 00000000000..6963618400c --- /dev/null +++ b/packages/data-connect/src/index.ts @@ -0,0 +1,35 @@ +/** + * Firebase Data Connect + * + * @packageDocumentation + */ + +/** + * @license + * Copyright 2024 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 { DataConnect } from './api/DataConnect'; +import { registerDataConnect } from './register'; + +export * from './api'; +export * from './api.browser'; + +registerDataConnect(); + +declare module '@firebase/component' { + interface NameServiceMapping { + 'data-connect': DataConnect; + } +} diff --git a/packages/data-connect/src/logger.ts b/packages/data-connect/src/logger.ts new file mode 100644 index 00000000000..ee66e8796c3 --- /dev/null +++ b/packages/data-connect/src/logger.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2024 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 { Logger, LogLevelString } from '@firebase/logger'; + +import { SDK_VERSION } from './core/version'; + +const logger = new Logger('@firebase/data-connect'); +export function setLogLevel(logLevel: LogLevelString): void { + logger.setLogLevel(logLevel); +} +export function logDebug(msg: string): void { + logger.debug(`DataConnect (${SDK_VERSION}): ${msg}`); +} + +export function logError(msg: string): void { + logger.error(`DataConnect (${SDK_VERSION}): ${msg}`); +} diff --git a/packages/data-connect/src/network/fetch.ts b/packages/data-connect/src/network/fetch.ts new file mode 100644 index 00000000000..928b9f873cf --- /dev/null +++ b/packages/data-connect/src/network/fetch.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2024 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 { Code, DataConnectError } from '../core/error'; +import { SDK_VERSION } from '../core/version'; +import { logDebug, logError } from '../logger'; + +let connectFetch: typeof fetch | null = globalThis.fetch; +export function initializeFetch(fetchImpl: typeof fetch): void { + connectFetch = fetchImpl; +} +function getGoogApiClientValue(_isUsingGen: boolean): string { + let str = 'gl-js/ fire/' + SDK_VERSION; + if (_isUsingGen) { + str += ' web/gen'; + } + return str; +} +export function dcFetch( + url: string, + body: U, + { signal }: AbortController, + appId: string | null, + accessToken: string | null, + appCheckToken: string | null, + _isUsingGen: boolean +): Promise<{ data: T; errors: Error[] }> { + if (!connectFetch) { + throw new DataConnectError(Code.OTHER, 'No Fetch Implementation detected!'); + } + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Client': getGoogApiClientValue(_isUsingGen) + }; + if (accessToken) { + headers['X-Firebase-Auth-Token'] = accessToken; + } + if (appId) { + headers['x-firebase-gmpid'] = appId; + } + if (appCheckToken) { + headers['X-Firebase-AppCheck'] = appCheckToken; + } + const bodyStr = JSON.stringify(body); + logDebug(`Making request out to ${url} with body: ${bodyStr}`); + + return connectFetch(url, { + body: bodyStr, + method: 'POST', + headers, + signal + }) + .catch(err => { + throw new DataConnectError( + Code.OTHER, + 'Failed to fetch: ' + JSON.stringify(err) + ); + }) + .then(async response => { + let jsonResponse = null; + try { + jsonResponse = await response.json(); + } catch (e) { + throw new DataConnectError(Code.OTHER, JSON.stringify(e)); + } + const message = getMessage(jsonResponse); + if (response.status >= 400) { + logError( + 'Error while performing request: ' + JSON.stringify(jsonResponse) + ); + if (response.status === 401) { + throw new DataConnectError(Code.UNAUTHORIZED, message); + } + throw new DataConnectError(Code.OTHER, message); + } + return jsonResponse; + }) + .then(res => { + if (res.errors && res.errors.length) { + const stringified = JSON.stringify(res.errors); + logError('DataConnect error while performing request: ' + stringified); + throw new DataConnectError(Code.OTHER, stringified); + } + return res as { data: T; errors: Error[] }; + }); +} +interface MessageObject { + message?: string; +} +function getMessage(obj: MessageObject): string { + if ('message' in obj) { + return obj.message; + } + return JSON.stringify(obj); +} diff --git a/packages/data-connect/src/network/index.ts b/packages/data-connect/src/network/index.ts new file mode 100644 index 00000000000..33a2202d57f --- /dev/null +++ b/packages/data-connect/src/network/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2024 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. + */ + +export * from './transport'; diff --git a/packages/data-connect/src/network/transport/index.ts b/packages/data-connect/src/network/transport/index.ts new file mode 100644 index 00000000000..5518faa0f95 --- /dev/null +++ b/packages/data-connect/src/network/transport/index.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2024 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 { DataConnectOptions, TransportOptions } from '../../api/DataConnect'; +import { AppCheckTokenProvider } from '../../core/AppCheckTokenProvider'; +import { AuthTokenProvider } from '../../core/FirebaseAuthProvider'; + +/** + * @internal + */ +export interface DataConnectTransport { + invokeQuery( + queryName: string, + body?: U + ): PromiseLike<{ data: T; errors: Error[] }>; + invokeMutation( + queryName: string, + body?: U + ): PromiseLike<{ data: T; errors: Error[] }>; + useEmulator(host: string, port?: number, sslEnabled?: boolean): void; + onTokenChanged: (token: string | null) => void; +} + +export interface CancellableOperation extends PromiseLike<{ data: T }> { + cancel: () => void; +} + +/** + * @internal + */ +export type TransportClass = new ( + options: DataConnectOptions, + apiKey?: string, + appId?: string, + authProvider?: AuthTokenProvider, + appCheckProvider?: AppCheckTokenProvider, + transportOptions?: TransportOptions, + _isUsingGen?: boolean +) => DataConnectTransport; diff --git a/packages/data-connect/src/network/transport/rest.ts b/packages/data-connect/src/network/transport/rest.ts new file mode 100644 index 00000000000..85847868c5d --- /dev/null +++ b/packages/data-connect/src/network/transport/rest.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2024 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 { DataConnectOptions, TransportOptions } from '../../api/DataConnect'; +import { AppCheckTokenProvider } from '../../core/AppCheckTokenProvider'; +import { DataConnectError, Code } from '../../core/error'; +import { AuthTokenProvider } from '../../core/FirebaseAuthProvider'; +import { logDebug } from '../../logger'; +import { addToken, urlBuilder } from '../../util/url'; +import { dcFetch } from '../fetch'; + +import { DataConnectTransport } from '.'; + +export class RESTTransport implements DataConnectTransport { + private _host = ''; + private _port: number | undefined; + private _location = 'l'; + private _connectorName = ''; + private _secure = true; + private _project = 'p'; + private _serviceName: string; + private _accessToken: string | null = null; + private _appCheckToken: string | null = null; + private _lastToken: string | null = null; + constructor( + options: DataConnectOptions, + private apiKey?: string | undefined, + private appId?: string, + private authProvider?: AuthTokenProvider | undefined, + private appCheckProvider?: AppCheckTokenProvider | undefined, + transportOptions?: TransportOptions | undefined, + private _isUsingGen = false + ) { + if (transportOptions) { + if (typeof transportOptions.port === 'number') { + this._port = transportOptions.port; + } + if (typeof transportOptions.sslEnabled !== 'undefined') { + this._secure = transportOptions.sslEnabled; + } + this._host = transportOptions.host; + } + const { location, projectId: project, connector, service } = options; + if (location) { + this._location = location; + } + if (project) { + this._project = project; + } + this._serviceName = service; + if (!connector) { + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Connector Name required!' + ); + } + this._connectorName = connector; + this.authProvider?.addTokenChangeListener(token => { + logDebug(`New Token Available: ${token}`); + this._accessToken = token; + }); + this.appCheckProvider?.addTokenChangeListener(result => { + const { token } = result; + logDebug(`New App Check Token Available: ${token}`); + this._appCheckToken = token; + }); + } + get endpointUrl(): string { + return urlBuilder( + { + connector: this._connectorName, + location: this._location, + projectId: this._project, + service: this._serviceName + }, + { host: this._host, sslEnabled: this._secure, port: this._port } + ); + } + useEmulator(host: string, port?: number, isSecure?: boolean): void { + this._host = host; + if (typeof port === 'number') { + this._port = port; + } + if (typeof isSecure !== 'undefined') { + this._secure = isSecure; + } + } + onTokenChanged(newToken: string | null): void { + this._accessToken = newToken; + } + + async getWithAuth(forceToken = false): Promise { + let starterPromise: Promise = new Promise(resolve => + resolve(this._accessToken) + ); + if (this.appCheckProvider) { + this._appCheckToken = (await this.appCheckProvider.getToken())?.token; + } + if (this.authProvider) { + starterPromise = this.authProvider + .getToken(/*forceToken=*/ forceToken) + .then(data => { + if (!data) { + return null; + } + this._accessToken = data.accessToken; + return this._accessToken; + }); + } else { + starterPromise = new Promise(resolve => resolve('')); + } + return starterPromise; + } + + _setLastToken(lastToken: string | null): void { + this._lastToken = lastToken; + } + + withRetry( + promiseFactory: () => Promise<{ data: T; errors: Error[] }>, + retry = false + ): Promise<{ data: T; errors: Error[] }> { + let isNewToken = false; + return this.getWithAuth(retry) + .then(res => { + isNewToken = this._lastToken !== res; + this._lastToken = res; + return res; + }) + .then(promiseFactory) + .catch(err => { + // Only retry if the result is unauthorized and the last token isn't the same as the new one. + if ( + 'code' in err && + err.code === Code.UNAUTHORIZED && + !retry && + isNewToken + ) { + logDebug('Retrying due to unauthorized'); + return this.withRetry(promiseFactory, true); + } + throw err; + }); + } + + // TODO(mtewani): Update U to include shape of body defined in line 13. + invokeQuery: ( + queryName: string, + body?: U + ) => PromiseLike<{ data: T; errors: Error[] }> = ( + queryName: string, + body: U + ) => { + const abortController = new AbortController(); + // TODO(mtewani): Update to proper value + const withAuth = this.withRetry(() => + dcFetch( + addToken(`${this.endpointUrl}:executeQuery`, this.apiKey), + { + name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`, + operationName: queryName, + variables: body + } as unknown as U, // TODO(mtewani): This is a patch, fix this. + abortController, + this.appId, + this._accessToken, + this._appCheckToken, + this._isUsingGen + ) + ); + + return { + then: withAuth.then.bind(withAuth), + catch: withAuth.catch.bind(withAuth) + }; + }; + invokeMutation: ( + queryName: string, + body?: U + ) => PromiseLike<{ data: T; errors: Error[] }> = ( + mutationName: string, + body: U + ) => { + const abortController = new AbortController(); + const taskResult = this.withRetry(() => { + return dcFetch( + addToken(`${this.endpointUrl}:executeMutation`, this.apiKey), + { + name: `projects/${this._project}/locations/${this._location}/services/${this._serviceName}/connectors/${this._connectorName}`, + operationName: mutationName, + variables: body + } as unknown as U, + abortController, + this.appId, + this._accessToken, + this._appCheckToken, + this._isUsingGen + ); + }); + + return { + then: taskResult.then.bind(taskResult), + // catch: taskResult.catch.bind(taskResult), + // finally: taskResult.finally.bind(taskResult), + cancel: () => abortController.abort() + }; + }; +} diff --git a/packages/data-connect/src/register.ts b/packages/data-connect/src/register.ts new file mode 100644 index 00000000000..53b44f4e43d --- /dev/null +++ b/packages/data-connect/src/register.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2024 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. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { + _registerComponent, + registerVersion, + SDK_VERSION +} from '@firebase/app'; +import { Component, ComponentType } from '@firebase/component'; + +import { name, version } from '../package.json'; +import { setSDKVersion } from '../src/core/version'; + +import { DataConnect, ConnectorConfig } from './api/DataConnect'; +import { Code, DataConnectError } from './core/error'; + +export function registerDataConnect(variant?: string): void { + setSDKVersion(SDK_VERSION); + _registerComponent( + new Component( + 'data-connect', + (container, { instanceIdentifier: settings, options }) => { + const app = container.getProvider('app').getImmediate()!; + const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); + let newOpts = options as ConnectorConfig; + if (settings) { + newOpts = JSON.parse(settings); + } + if (!app.options.projectId) { + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + } + return new DataConnect( + app, + { ...newOpts, projectId: app.options.projectId! }, + authProvider, + appCheckProvider + ); + }, + ComponentType.PUBLIC + ).setMultipleInstances(true) + ); + registerVersion(name, version, variant); + // BUILD_TARGET will be replaced by values like esm5, esm2017, cjs5, etc during the compilation + registerVersion(name, version, '__BUILD_TARGET__'); +} diff --git a/packages/data-connect/src/util/encoder.ts b/packages/data-connect/src/util/encoder.ts new file mode 100644 index 00000000000..55aff801d22 --- /dev/null +++ b/packages/data-connect/src/util/encoder.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2024 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. + */ + +export type HmacImpl = (obj: unknown) => string; +export let encoderImpl: HmacImpl; +export function setEncoder(encoder: HmacImpl): void { + encoderImpl = encoder; +} +setEncoder(o => JSON.stringify(o)); diff --git a/packages/data-connect/src/util/map.ts b/packages/data-connect/src/util/map.ts new file mode 100644 index 00000000000..5b96eb2f3dc --- /dev/null +++ b/packages/data-connect/src/util/map.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2024 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. + */ + +export function setIfNotExists( + map: Map, + key: string, + val: T +): void { + if (!map.has(key)) { + map.set(key, val); + } +} diff --git a/packages/data-connect/src/util/url.ts b/packages/data-connect/src/util/url.ts new file mode 100644 index 00000000000..b979ec19eb5 --- /dev/null +++ b/packages/data-connect/src/util/url.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2024 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 { DataConnectOptions, TransportOptions } from '../api/DataConnect'; +import { Code, DataConnectError } from '../core/error'; +import { logError } from '../logger'; + +export function urlBuilder( + projectConfig: DataConnectOptions, + transportOptions: TransportOptions +): string { + const { connector, location, projectId: project, service } = projectConfig; + const { host, sslEnabled, port } = transportOptions; + const protocol = sslEnabled ? 'https' : 'http'; + const realHost = host || `firebasedataconnect.googleapis.com`; + let baseUrl = `${protocol}://${realHost}`; + if (typeof port === 'number') { + baseUrl += `:${port}`; + } else if (typeof port !== 'undefined') { + logError('Port type is of an invalid type'); + throw new DataConnectError( + Code.INVALID_ARGUMENT, + 'Incorrect type for port passed in!' + ); + } + return `${baseUrl}/v1beta/projects/${project}/locations/${location}/services/${service}/connectors/${connector}`; +} +export function addToken(url: string, apiKey?: string): string { + if (!apiKey) { + return url; + } + const newUrl = new URL(url); + newUrl.searchParams.append('key', apiKey); + return newUrl.toString(); +} diff --git a/packages/data-connect/src/util/validateArgs.ts b/packages/data-connect/src/util/validateArgs.ts new file mode 100644 index 00000000000..15d1effa3da --- /dev/null +++ b/packages/data-connect/src/util/validateArgs.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2024 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 { + ConnectorConfig, + DataConnect, + getDataConnect +} from '../api/DataConnect'; +import { Code, DataConnectError } from '../core/error'; +interface ParsedArgs { + dc: DataConnect; + vars: Variables; +} + +/** + * The generated SDK will allow the user to pass in either the variable or the data connect instance with the variable, + * and this function validates the variables and returns back the DataConnect instance and variables based on the arguments passed in. + * @param connectorConfig + * @param dcOrVars + * @param vars + * @param validateVars + * @returns {DataConnect} and {Variables} instance + * @internal + */ +export function validateArgs( + connectorConfig: ConnectorConfig, + dcOrVars?: DataConnect | Variables, + vars?: Variables, + validateVars?: boolean +): ParsedArgs { + let dcInstance: DataConnect; + let realVars: Variables; + if (dcOrVars && 'enableEmulator' in dcOrVars) { + dcInstance = dcOrVars as DataConnect; + realVars = vars; + } else { + dcInstance = getDataConnect(connectorConfig); + realVars = dcOrVars as Variables; + } + if (!dcInstance || (!realVars && validateVars)) { + throw new DataConnectError(Code.INVALID_ARGUMENT, 'Variables required.'); + } + return { dc: dcInstance, vars: realVars }; +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql new file mode 100755 index 00000000000..8c472f99a6e --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/input.gql @@ -0,0 +1,49 @@ +scalar Movie_Key +input Movie_Data { + id: String + id_expr: String_Expr + description: String + description_expr: String_Expr + genre: String + genre_expr: String_Expr + name: String + name_expr: String_Expr + test: String + test_expr: String_Expr +} +input Movie_Filter { + _and: [Movie_Filter!] + _not: Movie_Filter + _or: [Movie_Filter!] + id: String_Filter + description: String_Filter + genre: String_Filter + name: String_Filter + test: String_Filter +} +input Movie_ListFilter { + count: Int_Filter + exist: Movie_Filter +} +input Movie_ListUpdate { + append: [Movie_Data!] + delete: Int + i: Int + prepend: [Movie_Data!] + set: [Movie_Data!] + update: [Movie_Update!] +} +input Movie_Order { + id: OrderDirection + description: OrderDirection + genre: OrderDirection + name: OrderDirection + test: OrderDirection +} +input Movie_Update { + id: [String_Update!] + description: [String_Update!] + genre: [String_Update!] + name: [String_Update!] + test: [String_Update!] +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql new file mode 100755 index 00000000000..b6896486e4a --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/mutation.gql @@ -0,0 +1,8 @@ +extend type Mutation { + movie_insert(data: Movie_Data!): Movie_Key! + movie_upsert(data: Movie_Data!, update: Movie_Update): Movie_Key! + movie_update(id: String, id_expr: String_Expr, key: Movie_Key, data: Movie_Data, update: Movie_Update): Movie_Key + movie_updateMany(where: Movie_Filter, all: Boolean = false, data: Movie_Data, update: Movie_Update): Int! + movie_delete(id: String, id_expr: String_Expr, key: Movie_Key): Movie_Key + movie_deleteMany(where: Movie_Filter, all: Boolean = false): Int! +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql new file mode 100755 index 00000000000..53ee30ce8ad --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/main/query.gql @@ -0,0 +1,4 @@ +extend type Query { + movie(id: String, id_expr: String_Expr, key: Movie_Key): Movie + movies(where: Movie_Filter, orderBy: [Movie_Order!], limit: Int = 100): [Movie!] +} diff --git a/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql b/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql new file mode 100755 index 00000000000..4007a693025 --- /dev/null +++ b/packages/data-connect/test/dataconnect/.dataconnect/schema/prelude.gql @@ -0,0 +1,953 @@ +"Conditions on a string value" +input String_Filter { + isNull: Boolean + eq: String + eq_expr: String_Expr + ne: String + ne_expr: String_Expr + in: [String!] + nin: [String!] + gt: String + ge: String + lt: String + le: String + contains: String + startsWith: String + endsWith: String + pattern: String_Pattern +} + +""" +The pattern match condition on a string. Specify either like or regex. +https://www.postgresql.org/docs/current/functions-matching.html +""" +input String_Pattern { + "the LIKE expression to use" + like: String + "the POSIX regular expression" + regex: String + "when true, it's case-insensitive. In Postgres: ILIKE, ~*" + ignoreCase: Boolean + "when true, invert the condition. In Postgres: NOT LIKE, !~" + invert: Boolean +} + +"Conditions on a string list" +input String_ListFilter { + includes: String + excludes: String + includesAll: [String!] + excludesAll: [String!] +} + +"Conditions on a UUID value" +input UUID_Filter { + isNull: Boolean + eq: UUID + ne: UUID + in: [UUID!] + nin: [UUID!] +} + +"Conditions on a UUID list" +input UUID_ListFilter { + includes: UUID + excludes: UUID + includesAll: [UUID!] + excludesAll: [UUID!] +} + +"Conditions on an Int value" +input Int_Filter { + isNull: Boolean + eq: Int + ne: Int + in: [Int!] + nin: [Int!] + gt: Int + ge: Int + lt: Int + le: Int +} + +"Conditions on an Int list" +input Int_ListFilter { + includes: Int + excludes: Int + includesAll: [Int!] + excludesAll: [Int!] +} + +"Conditions on an Int64 value" +input Int64_Filter { + isNull: Boolean + eq: Int64 + ne: Int64 + in: [Int64!] + nin: [Int64!] + gt: Int64 + ge: Int64 + lt: Int64 + le: Int64 +} + +"Conditions on an Int64 list" +input Int64_ListFilter { + includes: Int64 + excludes: Int64 + includesAll: [Int64!] + excludesAll: [Int64!] +} + +"Conditions on a Float value" +input Float_Filter { + isNull: Boolean + eq: Float + ne: Float + in: [Float!] + nin: [Float!] + gt: Float + ge: Float + lt: Float + le: Float +} + +"Conditions on a Float list" +input Float_ListFilter { + includes: Float + excludes: Float + includesAll: [Float!] + excludesAll: [Float!] +} + +"Conditions on a Boolean value" +input Boolean_Filter { + isNull: Boolean + eq: Boolean + ne: Boolean + in: [Boolean!] + nin: [Boolean!] +} + +"Conditions on a Boolean list" +input Boolean_ListFilter { + includes: Boolean + excludes: Boolean + includesAll: [Boolean!] + excludesAll: [Boolean!] +} + +"Conditions on a Date value" +input Date_Filter { + isNull: Boolean + eq: Date + ne: Date + in: [Date!] + nin: [Date!] + gt: Date + ge: Date + lt: Date + le: Date + """ + Offset the date filters by a fixed duration. + last 3 months is {ge: {today: true}, offset: {months: -3}} + """ + offset: Date_Offset +} + +"Duration to offset a date value" +input Date_Offset { + days: Int + months: Int + years: Int +} + +"Conditions on a Date list" +input Date_ListFilter { + includes: Date + excludes: Date + includesAll: [Date!] + excludesAll: [Date!] +} + +"Conditions on an Timestamp value" +input Timestamp_Filter { + isNull: Boolean + eq: Timestamp + eq_expr: Timestamp_Expr + ne: Timestamp + ne_expr: Timestamp_Expr + in: [Timestamp!] + nin: [Timestamp!] + gt: Timestamp + gt_expr: Timestamp_Expr + ge: Timestamp + ge_expr: Timestamp_Expr + lt: Timestamp + lt_expr: Timestamp_Expr + le: Timestamp + le_expr: Timestamp_Expr + + """ + Offset timestamp input by a fixed duration. + in 12h is {le: {now: true}, offset: {hours: 12}} + """ + offset: Timestamp_Offset @deprecated +} + +"Duration to offset a timestamp value" +input Timestamp_Offset @fdc_deprecated { + milliseconds: Int + seconds: Int + minutes: Int + hours: Int + days: Int + months: Int + years: Int +} + +"Conditions on a Timestamp list" +input Timestamp_ListFilter { + includes: Timestamp + includes_expr: Timestamp_Expr + excludes: Timestamp + excludes_expr: Timestamp_Expr + includesAll: [Timestamp!] + excludesAll: [Timestamp!] +} + +"Conditions on an Any value" +input Any_Filter { + isNull: Boolean + eq: Any + ne: Any + in: [Any!] + nin: [Any!] +} + +"Conditions on a Any list" +input Any_ListFilter { + includes: Any + excludes: Any + includesAll: [Any!] + excludesAll: [Any!] +} + +"Conditions on an AuthUID value" +input AuthUID_Filter @fdc_deprecated { + eq: AuthUID + ne: AuthUID + in: [AuthUID!] + nin: [AuthUID!] + isNull: Boolean +} + +input AuthUID_ListFilter @fdc_deprecated { + "When true, will match if the list includes the id of the current user." + includes: AuthUID + excludes: AuthUID + includesAll: [AuthUID!] + excludesAll: [AuthUID!] +} + +"Conditions on an Vector value" +input Vector_Filter { + eq: Vector + ne: Vector + in: [Vector!] + nin: [Vector!] + isNull: Boolean +} + +input Vector_ListFilter { + "When true, will match if the list includes the supplied vector." + includes: Vector + excludes: Vector + includesAll: [Vector!] + excludesAll: [Vector!] +} +type Query { + _service: _Service! +} + +type Mutation { + # This is just a dummy field so that Mutation is always non-empty. + _firebase: Void @fdc_deprecated(reason: "dummy field -- does nothing useful") +} + +type _Service { + sdl: String! +} + +"(Internal) Added to things that may be removed from FDC and will soon be no longer usable in schema or operations." +directive @fdc_deprecated(reason: String = "No longer supported") on + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +"(Internal) Added to scalars representing quoted CEL expressions." +directive @fdc_celExpression( + "The expected CEL type that the expression should evaluate to." + returnType: String +) on SCALAR + +"(Internal) Added to scalars representing quoted SQL expressions." +directive @fdc_sqlExpression( + "The expected SQL type that the expression should evaluate to." + dataType: String +) on SCALAR + +"(Internal) Added to types that may not be used as variables." +directive @fdc_forbiddenAsVariableType on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"(Internal) Added to types that may not be used as fields in schema." +directive @fdc_forbiddenAsFieldType on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"Provides a frequently used example for this type / field / argument." +directive @fdc_example( + "A GraphQL literal value (verbatim) whose type matches the target." + value: Any + "A human-readable text description of what `value` means in this context." + description: String +) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +"(Internal) Marks this field / argument as conflicting with others in the same group." +directive @fdc_oneOf( + "The group name where fields / arguments conflict with each other." + group: String! = "" + "If true, exactly one field / argument in the group must be specified." + required: Boolean! = false +) repeatable on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +"AccessLevel specifies coarse access policies for common situations." +enum AccessLevel { + """ + This operation can be executed by anyone with or without authentication. + Equivalent to @auth(expr: "true") + """ + PUBLIC + + """ + This operation can only be executed with a valid Firebase Auth ID token. + Note: it allows anonymous auth and unverified accounts, so may be subjected to abuses. + It’s equivalent to @auth(expr: "auth.uid != nil") + """ + USER_ANON + + """ + This operation can only be executed by a non-anonymous Firebase Auth account. + It’s equivalent to @auth(expr: "auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'")" + """ + USER + + """ + This operation can only be executed by a verified Firebase Auth account. + It’s equivalent to @auth(expr: "auth.uid != nil && auth.token.email_verified")" + """ + USER_EMAIL_VERIFIED + + """ + This operation can not be executed with no IAM credentials. + It’s equivalent to @auth(expr: "false") + """ + NO_ACCESS +} + +""" +Defines the auth policy for a query or mutation. This directive must be added to +any operation you wish to be accessible from a client application. If left +unspecified, defaults to `@auth(level: NO_ACCESS)`. +""" +directive @auth( + "The minimal level of access required to perform this operation." + level: AccessLevel @fdc_oneOf(required: true) + """ + A CEL expression that allows access to this operation if the expression + evaluates to `true`. + """ + expr: Boolean_Expr @fdc_oneOf(required: true) +) on QUERY | MUTATION +""" +Mark this field as a customized resolver. +It may takes customized input arguments and return customized types. + +TODO(b/315857408): Funnel this through API review. + +See: +- go/firemat:custom-resolvers +- go/custom-resolvers-hackweek +""" +directive @resolver on FIELD_DEFINITION +scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122") +scalar Int64 +scalar Date +scalar Timestamp @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") +scalar Any +scalar Void +""" +AuthUID is a string representing a Firebase Auth uid. When passing a literal +value for an AuthUID in a query, you may instead pass `{current: true}` and the +currently signed in user's uid will be injected instead. For example: + +```gql +type Order { + customerId: AuthUID! + # ... +} + +query myOrders { + orders: (where: { + customerId: {eq: {current: true}} + }) { customerId } +} +``` +""" +scalar AuthUID @fdc_deprecated +scalar Vector +"Define the intervals used in timestamps and dates (subset)" +enum TimestampInterval @fdc_deprecated { + second + minute + hour + day + week + month + year +} + +input Timestamp_Sentinel @fdc_deprecated { + "Return the current time." + now: Boolean, + "Defines a timestamp relative to the current time. Offset values can be positive or negative." + fromNow: Timestamp_Offset + "Truncate the current/offset time to the specified interval." + truncateTo: TimestampInterval +} + +""" +A Common Expression Language (CEL) expression that returns a boolean at runtime. + +The expression can reference the `auth` variable, which is null if Firebase Auth +is not used. Otherwise, it contains the following fields: + + - `auth.uid`: The current user ID. + - `auth.token`: A map of all token fields (i.e. "claims"). +""" +scalar Boolean_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "bool") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "auth != null", description: "Allow only if a Firebase Auth user is present.") + +""" +A Common Expression Language (CEL) expression that returns a string at runtime. + +Limitation: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +scalar String_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "string") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "auth.uid", description: "The ID of the currently logged in user in Firebase Auth. (Errors if not logged in.)") + @fdc_example(value: "uuidV4()", description: "Generates a new random UUID (version 4) string, formatted as 32 lower-case hex digits without delimiters.") + +""" +A Common Expression Language (CEL) expression that returns a Timestamp at runtime. + +Limitation: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +scalar Timestamp_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "google.protobuf.Timestamp") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "request.time", description: "The timestamp when the request is received (with microseconds precision).") + +""" +A Common Expression Language (CEL) expression that returns a UUID string at runtime. + +Limitation: Right now, only a few expressions are supported. Those are listed +using the @fdc_example directive on this scalar. +""" +scalar UUID_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression(returnType: "string") + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "uuidV4()", description: "Generates a new random UUID (version 4) every time.") + +""" +A Common Expression Language (CEL) expression whose return type is unspecified. + +Limitation: Only a limited set of expressions are supported for now for each +type. For type XXX, see the @fdc_example directives on XXX_Expr for a full list. +""" +scalar Any_Expr + @specifiedBy(url: "https://github.com/google/cel-spec") + @fdc_celExpression + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType + @fdc_example(value: "auth.uid", description: "The ID of the currently logged in user in Firebase Auth. (Errors if not logged in.)") + @fdc_example(value: "uuidV4()", description: "Generates a new random UUID version 4 (formatted as 32 lower-case hex digits without delimiters if result type is String).") + @fdc_example(value: "request.time", description: "The timestamp when the request is received (with microseconds precision).") + +""" +A PostgreSQL value expression whose return type is unspecified. +""" +scalar Any_SQL + @specifiedBy(url: "https://www.postgresql.org/docs/current/sql-expressions.html") + @fdc_sqlExpression + @fdc_forbiddenAsVariableType + @fdc_forbiddenAsFieldType +""" +Defines a relational database table. + +Given `type TableName @table`, + + - `TableName` is the GQL type name. + - `tableName` is the singular name. Override with `@table(singular)`. + - `tableNames` is the plural name. Override with `@table(plural)`. + - `table_name` is the SQL table id. Override with `@table(name)`. + +Only a table type can be configured further with: + + - Customized data types. See `@col`. + - Index. See `@index` + - Unique constraint. See `@unqiue` + - Relation. See `@ref` + - Embedded Json. See `@embed` + +A scalar field map to a SQL database column. +An object field (like `type TableName @table { field: AnotherType }`) are either + + - a relation reference field if `AnotherType` is annotated with `@table`. + - an embedded json field if `field` is annotated with `@embed`. + +""" +directive @table( + "Override the SQL database table name. Defaults to ." + name: String + "Override the singular name. Default is the camel case of the type name." + singular: String + "Override the plural name. Default is generated based on English patterns." + plural: String + "The primary key of the table. Defaults to a single field `id: UUID!`. Generate if missing." + key: [String!] +) on OBJECT + +""" +Defines a relational database view. + +Given `type ViewName @view`, + - `ViewName` is the GQL type name. + - `viewName` is the singular name. Override with `@view(singular)`. + - `viewNames` is the plural name. Override with `@view(plural)`. + - `view_name` is the SQL view id. Override with `@view(name)`. + When `@view(sql)` is defined, it uses the given raw SQL as the view instead. + +A view type can be used just as a table type with queries. +A view type may have a nullable `@ref` field to another table, but cannot be +referenced in a `@ref`. + +WARNING: Firebase Data Connect does not validate the SQL of the view or +evaluate whether it matches the defined fields. + +If the SQL view is invalid or undefined, related requests may fail. +If the SQL view return incompatible types. Firebase Data Connect will surface +an error in the response. +""" +directive @view( + """ + The SQL view name. If no `name` or `sql` are provided, defaults to + snake_case of the singular type name. + """ + name: String @fdc_oneOf + """ + SQL SELECT statement to use as the basis for this type. Note that all SQL + identifiers should be snake_case and all GraphQL identifiers should be + camelCase. + """ + sql: String @fdc_oneOf + "Override the singular name. Default is the camel case of the type name." + singular: String + "Override the plural name. Default is generated based on English patterns." + plural: String +) on OBJECT + +""" +Specify additional column options. + +Given `type TableName @table { fieldName: Int } ` + + - `field_name` is the SQL column name. Override with `@col(name)`. + +""" +directive @col( + "The SQL database column name. Defaults to ." + name: String + """ + Override SQL columns data type. + Each GraphQL type could map to many SQL data types. + Refer to Postgres supported data types and mappings to GQL. + """ + dataType: String + """ + Defines a fixed column size for certain scalar types. + + - For Vector, size is required. It establishes the length of the vector. + - For String, size converts `text` type to `varchar(size)`. + """ + size: Int +) on FIELD_DEFINITION + + +""" +Define an embedded JSON field represented as Postgres `jsonb` (or `json`). + +Given `type TableName @table { fieldName: EmbeddedType @embed }` +`EmbeddedType` must NOT have `@table`. + + - Store JSON object if `EmbeddedType`. Required column if `EmbeddedType!`. + - Store JSON array if `[EmbeddedType]`. Required column if `[EmbeddedType]!`. + +""" +directive @embed on FIELD_DEFINITION + +""" +Define a reference field to another table. + +Given `type TableName @table { refField: AnotherTableName }`, it defines a foreign-key constraint + + - with id `table_name_ref_field_fkey` (override with `@ref(constraintName)`) + - from `table_name.ref_field` (override with `@ref(fields)`) + - to `another_table_name.id` (override with `@ref(references)`) + +Does not support `[AnotherTableName]` because array fields cannot have foreign-key constraints. +Nullability determines whether the reference is required. + + - `refField: AnotherTableName`: optional reference, SET_NULL on delete. + - `refField: AnotherTableName!`: required reference, CASCADE on delete. + +Consider all types of SQL relations: + + - many-to-one relations involve a reference field on the many-side. + - many-to-maybe-one if `refField: AnotherTableName`. + - many-to-exactly-one if `refField: AnotherTableName!`. + - one-to-one relations involve a unique reference field on one side. + - maybe-one-to-maybe-one if `refField: AnotherTableName @unique`. + - maybe-one-to-exact-one if `refField: AnotherTableName! @unique`. + - exact-one-to-exact-one shall be represented as a single table instead. + - many-to-many relations involve a join table. + - Its primary keys must be two non-null reference fields to tables bridged together to guarantee at most one relation per pair. + +type TableNameToAnotherTableName @table(key: ["refField", "anotherRefField"]) { + refField: TableName! + anotherRefField: AnotherTableName! +} + +""" +directive @ref( + "The SQL database foreign key constraint name. Default to __fkey." + constraintName: String + """ + Foreign key fields. Default to . + """ + fields: [String!] + "The fields that the foreign key references in the other table. Default to the primary key." + references: [String!] +) on FIELD_DEFINITION + +enum IndexFieldOrder { ASC DESC } + +""" +Defines a database index to optimize query performance. + +Given `type TableName @table @index(fields: [“fieldName”, “secondFieldName”])`, +`table_name_field_name_second_field_name_aa_idx` is the SQL index id. +`table_name_field_name_second_field_name_ad_idx` if `order: [ASC DESC]`. +`table_name_field_name_second_field_name_dd_idx` if `order: [DESC DESC]`. + +Given `type TableName @table { fieldName: Int @index } ` +`table_name_field_name_idx` is the SQL index id. +`order` matters less for single field indexes because they can be scanned in both ways. + +Override with `@index(name)` in case of index name conflicts. +""" +directive @index( + "The SQL database index id. Defaults to __idx." + name: String + """ + Only allowed and required when used on OBJECT. + The fields to create an index on. + """ + fields: [String!] + """ + Only allowed when used on OBJECT. + Index order of each column. Default to all ASC. + """ + order: [IndexFieldOrder!] +) repeatable on FIELD_DEFINITION | OBJECT + +""" +Defines a unique constraint. + +Given `type TableName @table @unique(fields: [“fieldName”, “secondFieldName”])`, +`table_name_field_name_second_field_name_uidx` is the SQL unique index id. +Given `type TableName @table { fieldName: Int @unique } ` +`table_name_field_name_idx` is the SQL unique index id. + +Override with `@unique(indexName)` in case of index name conflicts. +""" +directive @unique( + "The SQL database unique index name. Defaults to __uidx." + indexName: String + """ + Only allowed and required when used on OBJECT. + The fields to create a unique constraint on. + """ + fields: [String!] +) repeatable on FIELD_DEFINITION | OBJECT + +"Define the direction of an orderby query" +enum OrderDirection { + ASC + DESC +} + +""" +Defines what siliarlity function to use for fetching vectors. +Details here: https://github.com/pgvector/pgvector?tab=readme-ov-file#vector-functions +""" +enum VectorSimilarityMethod { + L2 + COSINE + INNER_PRODUCT +} + +enum ColDefault @fdc_deprecated { + """ + Generates a random UUID (v4) as the default column value. + Compatible with String or UUID typed fields. + """ + UUID + """ + Generates an auto-incrementing sequence as the default column value. + Compatible with Int and Int64 typed fields. + """ + SEQUENCE + """ + Populates the default column value with the current date or timestamp. + Compatible with Date and Timestamp typed fields. + """ + NOW +} + +""" +Specify the default column value. + +The supported arguments vary based on the field type. +""" +directive @default( + "A constant value. Validated against the field GraphQL type at compile-time." + value: Any @fdc_oneOf(required: true) + "(Deprecated) Built-in common ways to generate initial value." + generate: ColDefault @fdc_oneOf(required: true) @deprecated + "A CEL expression, whose return value must match the field data type." + expr: Any_Expr @fdc_oneOf(required: true) + """ + A raw SQL expression, whose SQL data type must match the underlying column. + + The value is any variable-free expression (in particular, cross-references to + other columns in the current table are not allowed). Subqueries are not allowed either. + https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-DEFAULT + """ + sql: Any_SQL @fdc_oneOf(required: true) +) on FIELD_DEFINITION +"Update input of a String value" +input String_Update { + set: String @fdc_oneOf(group: "set") + set_expr: String_Expr @fdc_oneOf(group: "set") +} + +"Update input of a String list value" +input String_ListUpdate { + set: [String!] + append: [String!] + prepend: [String!] + delete: Int + i: Int + update: String +} + +"Update input of a UUID value" +input UUID_Update { + set: UUID @fdc_oneOf(group: "set") + set_expr: UUID_Expr @fdc_oneOf(group: "set") +} + +"Update input of an ID list value" +input UUID_ListUpdate { + set: [UUID!] + append: [UUID!] + prepend: [UUID!] + delete: Int + i: Int + update: UUID +} + +"Update input of an Int value" +input Int_Update { + set: Int + inc: Int + dec: Int +} + +"Update input of an Int list value" +input Int_ListUpdate { + set: [Int!] + append: [Int!] + prepend: [Int!] + delete: Int + i: Int + update: Int +} + +"Update input of an Int64 value" +input Int64_Update { + set: Int64 + inc: Int64 + dec: Int64 +} + +"Update input of an Int64 list value" +input Int64_ListUpdate { + set: [Int64!] + append: [Int64!] + prepend: [Int64!] + delete: Int + i: Int + update: Int64 +} + +"Update input of a Float value" +input Float_Update { + set: Float + inc: Float + dec: Float +} + +"Update input of a Float list value" +input Float_ListUpdate { + set: [Float!] + append: [Float!] + prepend: [Float!] + delete: Int + i: Int + update: Float +} + +"Update input of a Boolean value" +input Boolean_Update { + set: Boolean +} + +"Update input of a Boolean list value" +input Boolean_ListUpdate { + set: [Boolean!] + append: [Boolean!] + prepend: [Boolean!] + delete: Int + i: Int + update: Boolean +} + +"Update input of a Date value" +input Date_Update { + set: Date + inc: Date_Offset + dec: Date_Offset +} + +"Update input of a Date list value" +input Date_ListUpdate { + set: [Date!] + append: [Date!] + prepend: [Date!] + delete: Int + i: Int + update: Date +} + +"Update input of a Timestamp value" +input Timestamp_Update { + set: Timestamp @fdc_oneOf(group: "set") + set_expr: Timestamp_Expr @fdc_oneOf(group: "set") + inc: Timestamp_Offset + dec: Timestamp_Offset +} + +"Update input of a Timestamp list value" +input Timestamp_ListUpdate { + set: [Timestamp!] + append: [Timestamp!] + prepend: [Timestamp!] + delete: Int + i: Int + update: Timestamp +} + +"Update input of an Any value" +input Any_Update { + set: Any +} + +"Update input of an Any list value" +input Any_ListUpdate { + set: [Any!] + append: [Any!] + prepend: [Any!] + delete: Int + i: Int + update: Any +} + +"Update input of an AuthUID value" +input AuthUID_Update @fdc_deprecated { + set: AuthUID +} + +"Update input of an AuthUID list value" +input AuthUID_ListUpdate @fdc_deprecated { + set: [AuthUID] + append: [AuthUID] + prepend: [AuthUID] + delete: Int + i: Int + update: AuthUID +} + +"Update input of an Vector value" +input Vector_Update { + set: Vector +} + +"Update input of a Vector list value" +input Vector_ListUpdate { + set: [Vector] + append: [Vector] + prepend: [Vector] + delete: Int + i: Int + update: Vector +} diff --git a/packages/data-connect/test/dataconnect/connector/connector.yaml b/packages/data-connect/test/dataconnect/connector/connector.yaml new file mode 100644 index 00000000000..064d9c2c184 --- /dev/null +++ b/packages/data-connect/test/dataconnect/connector/connector.yaml @@ -0,0 +1,6 @@ +connectorId: "movies" +authMode: "PUBLIC" +generate: + javascriptSdk: + outputDir: "./gen/web" + package: "@movie-app-ssr/movies" diff --git a/packages/data-connect/test/dataconnect/connector/mutations.gql b/packages/data-connect/test/dataconnect/connector/mutations.gql new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/data-connect/test/dataconnect/connector/queries.gql b/packages/data-connect/test/dataconnect/connector/queries.gql new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/data-connect/test/dataconnect/dataconnect.yaml b/packages/data-connect/test/dataconnect/dataconnect.yaml new file mode 100644 index 00000000000..442e98e5592 --- /dev/null +++ b/packages/data-connect/test/dataconnect/dataconnect.yaml @@ -0,0 +1,11 @@ +specVersion: "v1beta" +serviceId: "dataconnect" +location: "us-west2" +schema: + source: "./schema" + datasource: + postgresql: + database: "dataconnect-test" + cloudSql: + instanceId: "local" +connectorDirs: ["./connector"] diff --git a/packages/data-connect/test/dataconnect/index.esm.js b/packages/data-connect/test/dataconnect/index.esm.js new file mode 100644 index 00000000000..6c7c8f8a49a --- /dev/null +++ b/packages/data-connect/test/dataconnect/index.esm.js @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2024 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 { + getDataConnect, + queryRef, + mutationRef, + executeQuery, + executeMutation +} from 'firebase/data-connect'; + +export const connectorConfig = { + connector: 'test', + service: 'dataconnect', + location: 'us-central1' +}; + +function validateArgs(dcOrVars, vars, validateVars) { + let dcInstance; + let realVars; + // TODO(mtewani); Check what happens if this is undefined. + if (dcOrVars && 'dataConnectOptions' in dcOrVars) { + dcInstance = dcOrVars; + realVars = vars; + } else { + dcInstance = getDataConnect(connectorConfig); + realVars = dcOrVars; + } + if (!dcInstance || (!realVars && validateVars)) { + throw new Error('You didn\t pass in the vars!'); + } + return { dc: dcInstance, vars: realVars }; +} diff --git a/packages/data-connect/test/dataconnect/logAddMovieVariables.json b/packages/data-connect/test/dataconnect/logAddMovieVariables.json new file mode 100644 index 00000000000..92e237b649f --- /dev/null +++ b/packages/data-connect/test/dataconnect/logAddMovieVariables.json @@ -0,0 +1 @@ +{"Name":"addMovie","Kind":"mutation","Variables":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"name","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"genre","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""},{"Name":"description","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"Response":[{"Name":"movie_insert","TypeName":"Movie_Key","TypeInfo":{"Name":"Movie_Key","Kind":"TypeKey","Fields":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":{"Name":"Movie_Key","Kind":"TypeKey","Fields":[{"Name":"id","TypeName":"String","TypeInfo":{"Name":"String","Kind":"NativeScalar","Fields":null,"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"FullTypeInfo":null,"Attribute":"NonNull","DefaultValue":null,"Description":""}],"KeyTypeInfo":null,"HasPrimaryKeyFields":false,"Description":""},"Attribute":"NonNull","DefaultValue":null,"Description":""}],"Description":"# Example mutations\n# TODO: Replace with a really good illustrative example from devrel!\nmutation createOrder($name: String!) {\n order_insert(data : {name: $name})\n}"} \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json b/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json new file mode 100644 index 00000000000..ec747fa47dd --- /dev/null +++ b/packages/data-connect/test/dataconnect/logListAllMoviesMovies.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json b/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json new file mode 100644 index 00000000000..ec747fa47dd --- /dev/null +++ b/packages/data-connect/test/dataconnect/logListMovieIdsMovies.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/movies.tools.json b/packages/data-connect/test/dataconnect/movies.tools.json new file mode 100644 index 00000000000..f6938f8c163 --- /dev/null +++ b/packages/data-connect/test/dataconnect/movies.tools.json @@ -0,0 +1,6 @@ +{ + "connector": "movies", + "location": "us-central1", + "service": "dataconnect", + "tools": [] +} \ No newline at end of file diff --git a/packages/data-connect/test/dataconnect/schema/schema.gql b/packages/data-connect/test/dataconnect/schema/schema.gql new file mode 100644 index 00000000000..1b9ca01d832 --- /dev/null +++ b/packages/data-connect/test/dataconnect/schema/schema.gql @@ -0,0 +1,23 @@ +# # Example schema +# # TODO: Replace with a really good illustrative example from devrel! +# type Product @table { +# name: String! +# price: Int! +# } + +# type Order @table { +# name: String! +# } + +# type OrderItem @table(key: ["order", "product"]) { +# order: Order! +# product: Product! +# quantity: Int! +# } +type Movie @table { + id: String! + name: String! + genre: String! + description: String! + test: String +} diff --git a/packages/data-connect/test/dataconnect/test.tools.json b/packages/data-connect/test/dataconnect/test.tools.json new file mode 100644 index 00000000000..a048e8da310 --- /dev/null +++ b/packages/data-connect/test/dataconnect/test.tools.json @@ -0,0 +1,6 @@ +{ + "connector": "test", + "location": "us-central1", + "service": "dataconnect", + "tools": [] +} \ No newline at end of file diff --git a/packages/data-connect/test/emulatorSeeder.ts b/packages/data-connect/test/emulatorSeeder.ts new file mode 100644 index 00000000000..1517deb90f8 --- /dev/null +++ b/packages/data-connect/test/emulatorSeeder.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2024 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 fs from 'fs'; +import * as path from 'path'; + +import { ReferenceType } from '../src'; + +import { EMULATOR_PORT } from './util'; + +export interface SeedInfo { + type: ReferenceType; + name: string; +} +export async function setupQueries( + schema: string, + seedInfoArray: SeedInfo[] +): Promise { + const schemaPath = path.resolve(__dirname, schema); + const schemaFileContents = fs.readFileSync(schemaPath).toString(); + const toWrite = { + 'service_id': 'l', + 'schema': { + 'files': [ + { + 'path': `schema/${schema}`, + 'content': schemaFileContents + } + ] + }, + 'connectors': { + 'c': { + 'files': seedInfoArray.map(seedInfo => { + const fileName = seedInfo.name + '.gql'; + const operationFilePath = path.resolve(__dirname, fileName); + const operationFileContents = fs + .readFileSync(operationFilePath) + .toString(); + return { + path: `operations/${seedInfo.name}.gql`, + content: operationFileContents + }; + }) + } + }, + // eslint-disable-next-line camelcase + connection_string: + 'postgresql://postgres:secretpassword@localhost:5432/postgres?sslmode=disable' + }; + return fetch(`http://localhost:${EMULATOR_PORT}/setupSchema`, { + method: 'POST', + body: JSON.stringify(toWrite) + }); +} diff --git a/packages/data-connect/test/integration/.graphqlrc.yaml b/packages/data-connect/test/integration/.graphqlrc.yaml new file mode 100644 index 00000000000..4953f9bd343 --- /dev/null +++ b/packages/data-connect/test/integration/.graphqlrc.yaml @@ -0,0 +1,9 @@ +schema: + - ./dataconnect/schema/**/*.gql + - ./dataconnect/.dataconnect/**/*.gql +documents: + - ./dataconnect/connector/**/*.gql +extensions: + endpoints: + default: + url: http://127.0.0.1:8080/__/graphql diff --git a/packages/data-connect/test/mutations.gql b/packages/data-connect/test/mutations.gql new file mode 100644 index 00000000000..a826a39529a --- /dev/null +++ b/packages/data-connect/test/mutations.gql @@ -0,0 +1,6 @@ +mutation seedDatabase($id: UUID!, $content: String!) @auth(level: PUBLIC) { + post: post_insert(data: {id: $id, content: $content}) +} +mutation removePost($id: UUID!) @auth(level: PUBLIC) { + post: post_delete(id: $id) +} \ No newline at end of file diff --git a/packages/data-connect/test/mutations.mutation.graphql b/packages/data-connect/test/mutations.mutation.graphql new file mode 100644 index 00000000000..9e86f1c1eae --- /dev/null +++ b/packages/data-connect/test/mutations.mutation.graphql @@ -0,0 +1,4 @@ +mutation seeddatabase @auth(level: PUBLIC) { + post1: post_insert(data: {content: "do dishes"}) + post2: post_insert(data: {content: "schedule appointment"}) +} \ No newline at end of file diff --git a/packages/data-connect/test/post.gql b/packages/data-connect/test/post.gql new file mode 100644 index 00000000000..d483ec10130 --- /dev/null +++ b/packages/data-connect/test/post.gql @@ -0,0 +1,18 @@ +query getPost($id: UUID!) @auth(level: PUBLIC) { + post(id: $id) { + content + } +} +query listPosts @auth(level: PUBLIC) { + posts { + id, + content + } +} +query listPosts2 { + posts { + id, + content + } +} + diff --git a/packages/data-connect/test/queries.schema.gql b/packages/data-connect/test/queries.schema.gql new file mode 100644 index 00000000000..eb2c8ba86e0 --- /dev/null +++ b/packages/data-connect/test/queries.schema.gql @@ -0,0 +1 @@ +type Post @table {content: String!} \ No newline at end of file diff --git a/packages/data-connect/test/queries.test.ts b/packages/data-connect/test/queries.test.ts new file mode 100644 index 00000000000..dd7e4e6c9e3 --- /dev/null +++ b/packages/data-connect/test/queries.test.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2024 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 { uuidv4 } from '@firebase/util'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { + connectDataConnectEmulator, + DataConnect, + executeMutation, + executeQuery, + getDataConnect, + mutationRef, + QueryRef, + queryRef, + QueryResult, + SerializedRef, + subscribe, + terminate, + SOURCE_CACHE, + SOURCE_SERVER +} from '../src'; + +import { setupQueries } from './emulatorSeeder'; +import { getConnectionConfig, initDatabase, PROJECT_ID } from './util'; + +use(chaiAsPromised); + +interface Task { + id: string; + content: string; +} +interface TaskListResponse { + posts: Task[]; +} + +const SEEDED_DATA = [ + { + id: uuidv4(), + content: 'task 1' + }, + { + id: uuidv4(), + content: 'task 2' + } +]; +const REAL_DATA = SEEDED_DATA.map(obj => ({ + ...obj, + id: obj.id.replace(/-/g, '') +})); +function seedDatabase(instance: DataConnect): Promise { + // call mutation query that adds SEEDED_DATA to database + return new Promise((resolve, reject) => { + async function run(): Promise { + let idx = 0; + while (idx < SEEDED_DATA.length) { + const data = SEEDED_DATA[idx]; + const ref = mutationRef(instance, 'seedDatabase', data); + await executeMutation(ref); + idx++; + } + } + run().then(resolve, reject); + }); +} +async function deleteDatabase(instance: DataConnect): Promise { + for (let i = 0; i < SEEDED_DATA.length; i++) { + const data = SEEDED_DATA[i]; + const ref = mutationRef(instance, 'removePost', { id: data.id }); + await executeMutation(ref); + } +} + +describe('DataConnect Tests', async () => { + let dc: DataConnect; + beforeEach(async () => { + dc = initDatabase(); + await setupQueries('queries.schema.gql', [ + { type: 'query', name: 'post' }, + { type: 'mutation', name: 'mutations' } + ]); + await seedDatabase(dc); + }); + afterEach(async () => { + await deleteDatabase(dc); + await terminate(dc); + }); + it('Can get all posts', async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const taskListRes = await executeQuery(taskListQuery); + expect(taskListRes.data).to.deep.eq({ + posts: REAL_DATA + }); + }); + it(`instantly executes a query if one hasn't been subscribed to`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const promise = new Promise>( + (resolve, reject) => { + const unsubscribe = subscribe(taskListQuery, { + onNext: res => { + unsubscribe(); + resolve(res); + }, + onErr: () => { + unsubscribe(); + reject(res); + } + }); + } + ); + const res = await promise; + expect(res.data).to.deep.eq({ + posts: REAL_DATA + }); + expect(res.source).to.eq(SOURCE_SERVER); + }); + it(`returns the result source as cache when data already exists`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + const queryResult = await executeQuery(taskListQuery); + const result = await waitForFirstEvent(taskListQuery); + expect(result.data).to.eq(queryResult.data); + expect(result.source).to.eq(SOURCE_CACHE); + }); + it(`returns the proper JSON when calling .toJSON()`, async () => { + const taskListQuery = queryRef(dc, 'listPosts'); + await executeQuery(taskListQuery); + const result = await waitForFirstEvent(taskListQuery); + const serializedRef: SerializedRef = { + data: { + posts: REAL_DATA + }, + fetchTime: Date.now().toLocaleString(), + refInfo: { + connectorConfig: { + ...getConnectionConfig(), + projectId: PROJECT_ID + }, + name: taskListQuery.name, + variables: undefined + }, + source: SOURCE_CACHE + }; + expect(result.toJSON()).to.deep.eq(serializedRef); + expect(result.source).to.deep.eq(SOURCE_CACHE); + }); + it(`throws an error when the user can't connect to the server`, async () => { + // You can't point an existing data connect instance to a new emulator port, so we have to create a new one + const fakeInstance = getDataConnect({ + connector: 'wrong', + location: 'wrong', + service: 'wrong' + }); + connectDataConnectEmulator(fakeInstance, 'localhost', 3512); + const taskListQuery = queryRef(fakeInstance, 'listPosts'); + await expect(executeQuery(taskListQuery)).to.eventually.be.rejectedWith( + 'ECONNREFUSED' + ); + }); + it('throws an error with just the message when the server responds with an error', async () => { + const invalidTaskListQuery = queryRef(dc, 'listPosts2'); + const message = + 'unauthorized: you are not authorized to perform this operation'; + await expect( + executeQuery(invalidTaskListQuery) + ).to.eventually.be.rejectedWith(message); + }); +}); +async function waitForFirstEvent( + query: QueryRef +): Promise> { + return new Promise<{ + result: QueryResult; + unsubscribe: () => void; + }>((resolve, reject) => { + const onResult: (result: QueryResult) => void = ( + result: QueryResult + ) => { + setTimeout(() => { + resolve({ + result, + unsubscribe + }); + }); + }; + const unsubscribe = subscribe(query, { + onNext: onResult, + onErr: e => { + reject({ e, unsubscribe }); + } + }); + }).then( + ({ result, unsubscribe }) => { + unsubscribe(); + return result; + }, + ({ e, unsubscribe }) => { + unsubscribe(); + throw e; + } + ); +} diff --git a/packages/data-connect/test/unit/dataconnect.test.ts b/packages/data-connect/test/unit/dataconnect.test.ts new file mode 100644 index 00000000000..314c8a068dc --- /dev/null +++ b/packages/data-connect/test/unit/dataconnect.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2024 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 { deleteApp, initializeApp } from '@firebase/app'; +import { expect } from 'chai'; + +import { ConnectorConfig, getDataConnect } from '../../src'; + +describe('Data Connect Test', () => { + it('should throw an error if `projectId` is not provided', async () => { + const app = initializeApp({ projectId: undefined }, 'a'); + expect(() => + getDataConnect(app, { connector: 'c', location: 'l', service: 's' }) + ).to.throw( + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + await deleteApp(app); + }); + it('should not throw an error if `projectId` is provided', () => { + const projectId = 'p'; + initializeApp({ projectId }); + expect(() => + getDataConnect({ connector: 'c', location: 'l', service: 's' }) + ).to.not.throw( + 'Project ID must be provided. Did you pass in a proper projectId to initializeApp?' + ); + const dc = getDataConnect({ connector: 'c', location: 'l', service: 's' }); + expect(dc.app.options.projectId).to.eq(projectId); + }); + it('should throw an error if `connectorConfig` is not provided', () => { + const projectId = 'p'; + initializeApp({ projectId }); + expect(() => getDataConnect({} as ConnectorConfig)).to.throw( + 'DC Option Required' + ); + const dc = getDataConnect({ connector: 'c', location: 'l', service: 's' }); + expect(dc.app.options.projectId).to.eq(projectId); + }); +}); diff --git a/packages/data-connect/test/unit/fetch.test.ts b/packages/data-connect/test/unit/fetch.test.ts new file mode 100644 index 00000000000..a50ac188724 --- /dev/null +++ b/packages/data-connect/test/unit/fetch.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2024 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 { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; + +import { dcFetch, initializeFetch } from '../../src/network/fetch'; +use(chaiAsPromised); +function mockFetch(json: object): void { + const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) + ); + initializeFetch(fakeFetchImpl); +} +describe('fetch', () => { + it('should throw an error with just the message when the server responds with an error with a message property in the body', async () => { + const message = 'Failed to connect to Postgres instance'; + mockFetch({ + code: 401, + message + }); + await expect( + dcFetch( + 'http://localhost', + {}, + {} as AbortController, + null, + null, + null, + false + ) + ).to.eventually.be.rejectedWith(message); + }); + it('should throw a stringified message when the server responds with an error without a message property in the body', async () => { + const message = 'Failed to connect to Postgres instance'; + const json = { + code: 401, + message1: message + }; + mockFetch(json); + await expect( + dcFetch( + 'http://localhost', + {}, + {} as AbortController, + null, + null, + null, + false + ) + ).to.eventually.be.rejectedWith(JSON.stringify(json)); + }); +}); diff --git a/packages/data-connect/test/unit/gmpid.test.ts b/packages/data-connect/test/unit/gmpid.test.ts new file mode 100644 index 00000000000..77b9f8bcac4 --- /dev/null +++ b/packages/data-connect/test/unit/gmpid.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2024 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 { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; +import { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { DataConnect, executeQuery, getDataConnect, queryRef } from '../../src'; +import { initializeFetch } from '../../src/network/fetch'; + +use(sinonChai); +const json = { + message: 'unauthorized' +}; +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); + +describe('GMPID Tests', () => { + let dc: DataConnect; + let app: FirebaseApp; + const APPID = 'MYAPPID'; + beforeEach(() => { + initializeFetch(fakeFetchImpl); + app = initializeApp({ projectId: 'p', appId: APPID }, 'fdsasdf'); // TODO(mtewani): Replace with util function + dc = getDataConnect(app, { connector: 'c', location: 'l', service: 's' }); + }); + afterEach(async () => { + await dc._delete(); + await deleteApp(app); + }); + it('should send a request with the corresponding gmpid if using the app id is specified', async () => { + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['x-firebase-gmpid']: APPID + } + } + ); + }); + it('should send a request with no gmpid if using the app id is not specified', async () => { + const app2 = initializeApp({ projectId: 'p' }, 'def'); // TODO(mtewani): Replace with util function + const dc2 = getDataConnect(app2, { + connector: 'c', + location: 'l', + service: 's' + }); + // @ts-ignore + await executeQuery(queryRef(dc2, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['x-firebase-gmpid']: APPID + } + } + ); + await dc2._delete(); + await deleteApp(app2); + }); +}); diff --git a/packages/data-connect/test/unit/queries.test.ts b/packages/data-connect/test/unit/queries.test.ts new file mode 100644 index 00000000000..68bd96268a6 --- /dev/null +++ b/packages/data-connect/test/unit/queries.test.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2024 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 { FirebaseAuthTokenData } from '@firebase/auth-interop-types'; +import { expect } from 'chai'; +import * as chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; + +import { DataConnectOptions } from '../../src'; +import { + AuthTokenListener, + AuthTokenProvider +} from '../../src/core/FirebaseAuthProvider'; +import { initializeFetch } from '../../src/network/fetch'; +import { RESTTransport } from '../../src/network/transport/rest'; +chai.use(chaiAsPromised); +const options: DataConnectOptions = { + connector: 'c', + location: 'l', + projectId: 'p', + service: 's' +}; +const INITIAL_TOKEN = 'initial token'; +class FakeAuthProvider implements AuthTokenProvider { + private token: string | null = INITIAL_TOKEN; + addTokenChangeListener(listener: AuthTokenListener): void {} + getToken(forceRefresh: boolean): Promise { + if (!forceRefresh) { + return Promise.resolve({ accessToken: this.token! }); + } + return Promise.resolve({ accessToken: 'testToken' }); + } + setToken(_token: string | null): void { + this.token = _token; + } +} +const json = { + message: 'unauthorized' +}; + +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); +describe('Queries', () => { + afterEach(() => { + fakeFetchImpl.resetHistory(); + }); + it('[QUERY] should retry auth whenever the fetcher returns with unauthorized', async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + await expect(rt.invokeQuery('test', null)).to.eventually.be.rejectedWith( + json.message + ); + expect(fakeFetchImpl.callCount).to.eq(2); + }); + it('[MUTATION] should retry auth whenever the fetcher returns with unauthorized', async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + await expect(rt.invokeMutation('test', null)).to.eventually.be.rejectedWith( + json.message + ); + expect(fakeFetchImpl.callCount).to.eq(2); + }); + it("should not retry auth whenever the fetcher returns with unauthorized and the token doesn't change", async () => { + initializeFetch(fakeFetchImpl); + const authProvider = new FakeAuthProvider(); + const rt = new RESTTransport(options, undefined, undefined, authProvider); + rt._setLastToken('initial token'); + await expect( + rt.invokeQuery('test', null) as Promise + ).to.eventually.be.rejectedWith(json.message); + expect(fakeFetchImpl.callCount).to.eq(1); + }); +}); diff --git a/packages/data-connect/test/unit/userAgent.test.ts b/packages/data-connect/test/unit/userAgent.test.ts new file mode 100644 index 00000000000..d218969fb75 --- /dev/null +++ b/packages/data-connect/test/unit/userAgent.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2024 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 { deleteApp, initializeApp, FirebaseApp } from '@firebase/app'; +import { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; + +import { DataConnect, executeQuery, getDataConnect, queryRef } from '../../src'; +import { SDK_VERSION } from '../../src/core/version'; +import { initializeFetch } from '../../src/network/fetch'; + +use(sinonChai); +const json = { + message: 'unauthorized' +}; +const fakeFetchImpl = sinon.stub().returns( + Promise.resolve({ + json: () => { + return Promise.resolve(json); + }, + status: 401 + } as Response) +); + +describe('User Agent Tests', () => { + let dc: DataConnect; + let app: FirebaseApp; + beforeEach(() => { + initializeFetch(fakeFetchImpl); + app = initializeApp({ projectId: 'p' }, 'abc'); // TODO(mtewani): Replace with util function + dc = getDataConnect(app, { connector: 'c', location: 'l', service: 's' }); + }); + afterEach(async () => { + await dc._delete(); + await deleteApp(app); + }); + it('should send a request with the corresponding user agent if using the generated SDK', async () => { + dc._useGeneratedSdk(); + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['X-Goog-Api-Client']: 'gl-js/ fire/' + SDK_VERSION + ' web/gen' + } + } + ); + }); + it('should send a request with the corresponding user agent if not using the generated SDK', async () => { + // @ts-ignore + await executeQuery(queryRef(dc, '')).catch(() => {}); + expect(fakeFetchImpl).to.be.calledWithMatch( + 'https://firebasedataconnect.googleapis.com/v1beta/projects/p/locations/l/services/s/connectors/c:executeQuery', + { + headers: { + ['X-Goog-Api-Client']: 'gl-js/ fire/' + SDK_VERSION + } + } + ); + }); +}); diff --git a/packages/data-connect/test/unit/utils.test.ts b/packages/data-connect/test/unit/utils.test.ts new file mode 100644 index 00000000000..c69c1c8f511 --- /dev/null +++ b/packages/data-connect/test/unit/utils.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2024 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 { expect } from 'chai'; + +import { getDataConnect } from '../../src'; +import { validateArgs } from '../../src/util/validateArgs'; +describe('Utils', () => { + it('[Vars required: true] should throw if no arguments are provided', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + expect(() => + validateArgs(connectorConfig, undefined, undefined, true) + ).to.throw('Variables required'); + }); + it('[vars required: false, vars provided: false] should return data connect instance and no variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + expect(validateArgs(connectorConfig)).to.deep.eq({ dc, vars: undefined }); + }); + it('[vars required: false, vars provided: false, data connect provided: true] should return data connect instance and no variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + expect(validateArgs(connectorConfig, dc)).to.deep.eq({ + dc, + vars: undefined + }); + }); + it('[vars required: true, vars provided: true, data connect provided: true] should return data connect instance and variables', () => { + const connectorConfig = { connector: 'c', location: 'l', service: 's' }; + const dc = getDataConnect(connectorConfig); + const vars = { a: 1 }; + expect(validateArgs(connectorConfig, dc, vars)).to.deep.eq({ dc, vars }); + }); +}); diff --git a/packages/data-connect/test/util.ts b/packages/data-connect/test/util.ts new file mode 100644 index 00000000000..cd9149ed41e --- /dev/null +++ b/packages/data-connect/test/util.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2024 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 { initializeApp } from '@firebase/app'; + +import { + connectDataConnectEmulator, + ConnectorConfig, + DataConnect, + getDataConnect +} from '../src'; + +export const USE_EMULATOR = true; +export const EMULATOR_PORT = process.env.DC_EMULATOR_PORT; +// export const EMULATOR_PROJECT = process.env.PROJECT; +export const CONNECTOR_NAME = 'c'; +export const LOCATION_NAME = 'l'; +export const SERVICE_NAME = 'l'; +export const PROJECT_ID = 'p'; +export function getConnectionConfig(): ConnectorConfig { + return { + connector: CONNECTOR_NAME, + location: LOCATION_NAME, + service: SERVICE_NAME + }; +} + +export const app = initializeApp({ + projectId: PROJECT_ID +}); + +// Seed the database to have the proper fields to query, such as a list of tasks. +export function initDatabase(): DataConnect { + const instance = getDataConnect(getConnectionConfig()); + if (!instance.isEmulator) { + connectDataConnectEmulator(instance, 'localhost', Number(EMULATOR_PORT)); + } + return instance; +} diff --git a/packages/data-connect/tsconfig.eslint.json b/packages/data-connect/tsconfig.eslint.json new file mode 100644 index 00000000000..09f747b4d46 --- /dev/null +++ b/packages/data-connect/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/data-connect/tsconfig.json b/packages/data-connect/tsconfig.json new file mode 100644 index 00000000000..838f5c0d3c3 --- /dev/null +++ b/packages/data-connect/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "strict": false + }, + "exclude": [ + "dist/**/*", + "test/**/*" + ] +} diff --git a/packages/firebase/data-connect/index.ts b/packages/firebase/data-connect/index.ts new file mode 100644 index 00000000000..cc3ce65c24a --- /dev/null +++ b/packages/firebase/data-connect/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2024 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. + */ + +export * from '@firebase/data-connect'; diff --git a/packages/firebase/data-connect/package.json b/packages/firebase/data-connect/package.json new file mode 100644 index 00000000000..950ea5670a5 --- /dev/null +++ b/packages/firebase/data-connect/package.json @@ -0,0 +1,7 @@ +{ + "name": "firebase/data-connect", + "main": "dist/index.cjs.js", + "browser": "dist/esm/index.esm.js", + "module": "dist/esm/index.esm.js", + "typings": "dist/data-connect/index.d.ts" +} \ No newline at end of file diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 6d1e8b618b8..2982609af40 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -107,6 +107,18 @@ }, "default": "./database/dist/esm/index.esm.js" }, + "./data-connect": { + "types": "./data-connect/dist/data-connect/index.d.ts", + "node": { + "require": "./data-connect/dist/index.cjs.js", + "import": "./data-connect/dist/index.mjs" + }, + "browser": { + "require": "./data-connect/dist/index.cjs.js", + "import": "./data-connect/dist/esm/index.esm.js" + }, + "default": "./data-connect/dist/esm/index.esm.js" + }, "./firestore": { "types": "./firestore/dist/firestore/index.d.ts", "node": { @@ -392,6 +404,7 @@ "@firebase/app-types": "0.9.2", "@firebase/auth": "1.7.9", "@firebase/auth-compat": "0.5.14", + "@firebase/data-connect": "0.0.3", "@firebase/database": "1.0.8", "@firebase/database-compat": "1.0.8", "@firebase/firestore": "4.7.2", @@ -446,6 +459,7 @@ "messaging", "messaging/sw", "database", + "data-connect", "vertexai-preview" ], "typings": "empty.d.ts" diff --git a/scripts/docgen/docgen.ts b/scripts/docgen/docgen.ts index 8e9dfa87cab..3b3b10c8714 100644 --- a/scripts/docgen/docgen.ts +++ b/scripts/docgen/docgen.ts @@ -37,7 +37,12 @@ https://github.com/firebase/firebase-js-sdk `; const tmpDir = `${projectRoot}/temp`; -const EXCLUDED_PACKAGES = ['app-compat', 'util', 'rules-unit-testing']; +const EXCLUDED_PACKAGES = [ + 'app-compat', + 'util', + 'rules-unit-testing', + 'data-connect' +]; /** * When ordering functions, will prioritize these first params at diff --git a/scripts/emulator-testing/dataconnect-test-runner.ts b/scripts/emulator-testing/dataconnect-test-runner.ts new file mode 100644 index 00000000000..e362ef59cbe --- /dev/null +++ b/scripts/emulator-testing/dataconnect-test-runner.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2024 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 { DataConnectEmulator } from './emulators/dataconnect-emulator'; +import { spawn } from 'child-process-promise'; +import * as path from 'path'; +function runTest(port: number) { + console.log( + 'path: ' + path.resolve(__dirname, '../../packages/data-connect') + ); + const options = { + cwd: path.resolve(__dirname, '../../packages/data-connect'), + env: Object.assign({}, process.env, { + DC_EMULATOR_PORT: port + }), + stdio: 'inherit' as const + }; + return spawn('yarn', ['test:all'], options); +} +async function run(): Promise { + const emulator = new DataConnectEmulator(); + try { + await emulator.download(); + await emulator.setUp(); + await runTest(emulator.port); + } finally { + await emulator.tearDown(); + } +} +run().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/scripts/emulator-testing/emulators/dataconnect-emulator.ts b/scripts/emulator-testing/emulators/dataconnect-emulator.ts new file mode 100644 index 00000000000..efe5bdbe52c --- /dev/null +++ b/scripts/emulator-testing/emulators/dataconnect-emulator.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2024 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 { platform } from 'os'; +import { Emulator } from './emulator'; + +const DATABASE_EMULATOR_VERSION = '1.3.7'; + +export class DataConnectEmulator extends Emulator { + // namespace: string; + + constructor(port = 3628) { + const os = platform(); + let urlString = ''; + switch (os) { + case 'darwin': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-macos-v1.3.7?alt=media&token=2cf32435-d479-4929-b963-a97ae1ac3f0b'; + break; + case 'linux': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-linux-v1.3.7?alt=media&token=fd33b4fc-2e27-4874-893a-2d1f0ecbf116'; + break; + case 'win32': + urlString = + 'https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/emulator%2Fdataconnect-emulator-windows-v1.3.7?alt=media&token=bd6e60b0-50b4-46db-aa6c-5fcc6e991f39'; + break; + default: + throw new Error( + `We are unable to support your environment ${os} at this time.` + ); + } + super( + `cli-v${DATABASE_EMULATOR_VERSION}`, + // Use locked version of emulator for test to be deterministic. + // The latest version can be found from database emulator doc: + // https://firebase.google.com/docs/database/security/test-rules-emulator + urlString, + port + ); + this.isDataConnect = true; + } +} diff --git a/scripts/emulator-testing/emulators/emulator.ts b/scripts/emulator-testing/emulators/emulator.ts index 1295d413e4b..6f88c9769e4 100644 --- a/scripts/emulator-testing/emulators/emulator.ts +++ b/scripts/emulator-testing/emulators/emulator.ts @@ -16,7 +16,11 @@ */ // @ts-ignore -import { spawn } from 'child-process-promise'; +import { + ChildProcessPromise, + spawn, + SpawnPromiseResult +} from 'child-process-promise'; import { ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -32,6 +36,8 @@ export abstract class Emulator { cacheDirectory: string; cacheBinaryPath: string; + isDataConnect = false; + constructor( private binaryName: string, private binaryUrl: string, @@ -89,19 +95,29 @@ export abstract class Emulator { if (!this.binaryPath) { throw new Error('You must call download() before setUp()'); } - const promise = spawn( - 'java', - [ - '-jar', - path.basename(this.binaryPath), - '--port', - this.port.toString() - ], - { - cwd: path.dirname(this.binaryPath), - stdio: 'inherit' - } - ); + let promise: ChildProcessPromise; + if (this.isDataConnect) { + promise = spawn(this.binaryPath, [ + 'dev', + '--local_connection_string', + "'postgresql://postgres:secretpassword@localhost:5432/postgres?sslmode=disable'" + ]); + } else { + promise = spawn( + 'java', + [ + '-jar', + path.basename(this.binaryPath), + '--port', + this.port.toString() + ], + { + cwd: path.dirname(this.binaryPath), + stdio: 'inherit' + } + ); + } + promise.catch(reject); this.emulator = promise.childProcess; diff --git a/yarn.lock b/yarn.lock index 477e784871e..92372aa94a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9582,7 +9582,14 @@ hasha@^5.0.0: is-stream "^2.0.0" type-fest "^0.8.0" -hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: +hasown@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa" + integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA== + dependencies: + function-bind "^1.1.2" + +hasown@^2.0.1, hasown@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -15133,7 +15140,7 @@ resolve@^1.22.0, resolve@^1.22.4: resolve@~1.17.0: version "1.17.0" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== dependencies: path-parse "^1.0.6"