diff --git a/.changeset/little-news-sniff.md b/.changeset/little-news-sniff.md new file mode 100644 index 00000000000..79ec737d119 --- /dev/null +++ b/.changeset/little-news-sniff.md @@ -0,0 +1,6 @@ +--- +'@firebase/data-connect': minor +'firebase': minor +--- + +Add custom request headers based on the type of SDK (JS/TS, React, Angular, etc) that's invoking Data Connect requests. This will help us understand how users interact with Data Connect when using the Web SDK. \ No newline at end of file diff --git a/common/api-review/data-connect.api.md b/common/api-review/data-connect.api.md index 952d8b4dc10..1a698c229b4 100644 --- a/common/api-review/data-connect.api.md +++ b/common/api-review/data-connect.api.md @@ -11,6 +11,19 @@ import { FirebaseError } from '@firebase/util'; import { LogLevelString } from '@firebase/logger'; import { Provider } from '@firebase/component'; +// @public +export type CallerSdkType = 'Base' | 'Generated' | 'TanstackReactCore' | 'GeneratedReact' | 'TanstackAngularCore' | 'GeneratedAngular'; + +// @public (undocumented) +export const CallerSdkTypeEnum: { + readonly Base: "Base"; + readonly Generated: "Generated"; + readonly TanstackReactCore: "TanstackReactCore"; + readonly GeneratedReact: "GeneratedReact"; + readonly TanstackAngularCore: "TanstackAngularCore"; + readonly GeneratedAngular: "GeneratedAngular"; +}; + // @public export function connectDataConnectEmulator(dc: DataConnect, host: string, port?: number, sslEnabled?: boolean): void; diff --git a/packages/data-connect/src/api/DataConnect.ts b/packages/data-connect/src/api/DataConnect.ts index 53eb0c97ed5..dc170809143 100644 --- a/packages/data-connect/src/api/DataConnect.ts +++ b/packages/data-connect/src/api/DataConnect.ts @@ -33,7 +33,12 @@ import { } from '../core/FirebaseAuthProvider'; import { QueryManager } from '../core/QueryManager'; import { logDebug, logError } from '../logger'; -import { DataConnectTransport, TransportClass } from '../network'; +import { + CallerSdkType, + CallerSdkTypeEnum, + DataConnectTransport, + TransportClass +} from '../network'; import { RESTTransport } from '../network/transport/rest'; import { MutationManager } from './Mutation'; @@ -92,6 +97,7 @@ export class DataConnect { private _transportOptions?: TransportOptions; private _authTokenProvider?: AuthTokenProvider; _isUsingGeneratedSdk: boolean = false; + _callerSdkType: CallerSdkType = CallerSdkTypeEnum.Base; private _appCheckTokenProvider?: AppCheckTokenProvider; // @internal constructor( @@ -116,6 +122,12 @@ export class DataConnect { this._isUsingGeneratedSdk = true; } } + _setCallerSdkType(callerSdkType: CallerSdkType): void { + this._callerSdkType = callerSdkType; + if (this._initialized) { + this._transport._setCallerSdkType(callerSdkType); + } + } _delete(): Promise { _removeServiceInstance( this.app, @@ -164,7 +176,8 @@ export class DataConnect { this._authTokenProvider, this._appCheckTokenProvider, undefined, - this._isUsingGeneratedSdk + this._isUsingGeneratedSdk, + this._callerSdkType ); if (this._transportOptions) { this._transport.useEmulator( diff --git a/packages/data-connect/src/network/fetch.ts b/packages/data-connect/src/network/fetch.ts index d5d2a439432..166422ca14c 100644 --- a/packages/data-connect/src/network/fetch.ts +++ b/packages/data-connect/src/network/fetch.ts @@ -19,13 +19,23 @@ import { Code, DataConnectError } from '../core/error'; import { SDK_VERSION } from '../core/version'; import { logDebug, logError } from '../logger'; +import { CallerSdkType, CallerSdkTypeEnum } from './transport'; + let connectFetch: typeof fetch | null = globalThis.fetch; export function initializeFetch(fetchImpl: typeof fetch): void { connectFetch = fetchImpl; } -function getGoogApiClientValue(_isUsingGen: boolean): string { +function getGoogApiClientValue( + _isUsingGen: boolean, + _callerSdkType: CallerSdkType +): string { let str = 'gl-js/ fire/' + SDK_VERSION; - if (_isUsingGen) { + if ( + _callerSdkType !== CallerSdkTypeEnum.Base && + _callerSdkType !== CallerSdkTypeEnum.Generated + ) { + str += ' js/' + _callerSdkType.toLowerCase(); + } else if (_isUsingGen || _callerSdkType === CallerSdkTypeEnum.Generated) { str += ' js/gen'; } return str; @@ -42,14 +52,15 @@ export function dcFetch( appId: string | null, accessToken: string | null, appCheckToken: string | null, - _isUsingGen: boolean + _isUsingGen: boolean, + _callerSdkType: CallerSdkType ): 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) + 'X-Goog-Api-Client': getGoogApiClientValue(_isUsingGen, _callerSdkType) }; if (accessToken) { headers['X-Firebase-Auth-Token'] = accessToken; diff --git a/packages/data-connect/src/network/transport/index.ts b/packages/data-connect/src/network/transport/index.ts index f4bb801f9b3..8b106b4d636 100644 --- a/packages/data-connect/src/network/transport/index.ts +++ b/packages/data-connect/src/network/transport/index.ts @@ -19,6 +19,26 @@ import { DataConnectOptions, TransportOptions } from '../../api/DataConnect'; import { AppCheckTokenProvider } from '../../core/AppCheckTokenProvider'; import { AuthTokenProvider } from '../../core/FirebaseAuthProvider'; +/** + * enum representing different flavors of the SDK used by developers + * use the CallerSdkType for type-checking, and the CallerSdkTypeEnum for value-checking/assigning + */ +export type CallerSdkType = + | 'Base' // Core JS SDK + | 'Generated' // Generated JS SDK + | 'TanstackReactCore' // Tanstack non-generated React SDK + | 'GeneratedReact' // Generated React SDK + | 'TanstackAngularCore' // Tanstack non-generated Angular SDK + | 'GeneratedAngular'; // Generated Angular SDK +export const CallerSdkTypeEnum = { + Base: 'Base', // Core JS SDK + Generated: 'Generated', // Generated JS SDK + TanstackReactCore: 'TanstackReactCore', // Tanstack non-generated React SDK + GeneratedReact: 'GeneratedReact', // Tanstack non-generated Angular SDK + TanstackAngularCore: 'TanstackAngularCore', // Tanstack non-generated Angular SDK + GeneratedAngular: 'GeneratedAngular' // Generated Angular SDK +} as const; + /** * @internal */ @@ -33,6 +53,7 @@ export interface DataConnectTransport { ): Promise<{ data: T; errors: Error[] }>; useEmulator(host: string, port?: number, sslEnabled?: boolean): void; onTokenChanged: (token: string | null) => void; + _setCallerSdkType(callerSdkType: CallerSdkType): void; } /** @@ -45,5 +66,6 @@ export type TransportClass = new ( authProvider?: AuthTokenProvider, appCheckProvider?: AppCheckTokenProvider, transportOptions?: TransportOptions, - _isUsingGen?: boolean + _isUsingGen?: boolean, + _callerSdkType?: CallerSdkType ) => DataConnectTransport; diff --git a/packages/data-connect/src/network/transport/rest.ts b/packages/data-connect/src/network/transport/rest.ts index 7c8500b733d..0663bc026db 100644 --- a/packages/data-connect/src/network/transport/rest.ts +++ b/packages/data-connect/src/network/transport/rest.ts @@ -23,7 +23,7 @@ import { logDebug } from '../../logger'; import { addToken, urlBuilder } from '../../util/url'; import { dcFetch } from '../fetch'; -import { DataConnectTransport } from '.'; +import { CallerSdkType, CallerSdkTypeEnum, DataConnectTransport } from '.'; export class RESTTransport implements DataConnectTransport { private _host = ''; @@ -43,7 +43,8 @@ export class RESTTransport implements DataConnectTransport { private authProvider?: AuthTokenProvider | undefined, private appCheckProvider?: AppCheckTokenProvider | undefined, transportOptions?: TransportOptions | undefined, - private _isUsingGen = false + private _isUsingGen = false, + private _callerSdkType: CallerSdkType = CallerSdkTypeEnum.Base ) { if (transportOptions) { if (typeof transportOptions.port === 'number') { @@ -180,7 +181,8 @@ export class RESTTransport implements DataConnectTransport { this.appId, this._accessToken, this._appCheckToken, - this._isUsingGen + this._isUsingGen, + this._callerSdkType ) ); return withAuth; @@ -205,9 +207,14 @@ export class RESTTransport implements DataConnectTransport { this.appId, this._accessToken, this._appCheckToken, - this._isUsingGen + this._isUsingGen, + this._callerSdkType ); }); return taskResult; }; + + _setCallerSdkType(callerSdkType: CallerSdkType): void { + this._callerSdkType = callerSdkType; + } } diff --git a/packages/data-connect/test/unit/fetch.test.ts b/packages/data-connect/test/unit/fetch.test.ts index 3d9a9b04523..599260f8b10 100644 --- a/packages/data-connect/test/unit/fetch.test.ts +++ b/packages/data-connect/test/unit/fetch.test.ts @@ -20,25 +20,30 @@ import chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; import { dcFetch, initializeFetch } from '../../src/network/fetch'; +import { CallerSdkType, CallerSdkTypeEnum } from '../../src/network/transport'; use(chaiAsPromised); -function mockFetch(json: object): void { +function mockFetch(json: object, reject: boolean): sinon.SinonStub { const fakeFetchImpl = sinon.stub().returns( Promise.resolve({ json: () => { return Promise.resolve(json); }, - status: 401 + status: reject ? 401 : 200 } as Response) ); initializeFetch(fakeFetchImpl); + return 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 - }); + mockFetch( + { + code: 401, + message + }, + true + ); await expect( dcFetch( 'http://localhost', @@ -51,7 +56,8 @@ describe('fetch', () => { null, null, null, - false + false, + CallerSdkTypeEnum.Base ) ).to.eventually.be.rejectedWith(message); }); @@ -61,7 +67,7 @@ describe('fetch', () => { code: 401, message1: message }; - mockFetch(json); + mockFetch(json, true); await expect( dcFetch( 'http://localhost', @@ -74,8 +80,105 @@ describe('fetch', () => { null, null, null, - false + false, + CallerSdkTypeEnum.Base ) ).to.eventually.be.rejectedWith(JSON.stringify(json)); }); + it('should assign different values to custom headers based on the _callerSdkType argument (_isUsingGen is false)', async () => { + const json = { + code: 200, + message1: 'success' + }; + const fakeFetchImpl = mockFetch(json, false); + + for (const callerSdkType in CallerSdkTypeEnum) { + // this check is done to follow the best practices outlined by the "guard-for-in" eslint rule + if ( + Object.prototype.hasOwnProperty.call(CallerSdkTypeEnum, callerSdkType) + ) { + await dcFetch( + 'http://localhost', + { + name: 'n', + operationName: 'n', + variables: {} + }, + {} as AbortController, + null, + null, + null, + false, // _isUsingGen is false + callerSdkType as CallerSdkType + ); + + let expectedHeaderRegex: RegExp; + if (callerSdkType === CallerSdkTypeEnum.Base) { + // should not contain any "js/xxx" substring + expectedHeaderRegex = RegExp(/^((?!js\/\w).)*$/); + } else if (callerSdkType === CallerSdkTypeEnum.Generated) { + expectedHeaderRegex = RegExp(/js\/gen/); + } else { + expectedHeaderRegex = RegExp(`js\/${callerSdkType.toLowerCase()}`); + } + expect( + fakeFetchImpl.calledWithMatch( + 'http://localhost', + sinon.match.hasNested( + 'headers.X-Goog-Api-Client', + sinon.match(expectedHeaderRegex) + ) + ) + ).to.be.true; + } + } + }); + it('should assign custom headers based on _callerSdkType before checking to-be-deprecated _isUsingGen', async () => { + const json = { + code: 200, + message1: 'success' + }; + const fakeFetchImpl = mockFetch(json, false); + + for (const callerSdkType in CallerSdkTypeEnum) { + // this check is done to follow the best practices outlined by the "guard-for-in" eslint rule + if ( + Object.prototype.hasOwnProperty.call(CallerSdkTypeEnum, callerSdkType) + ) { + await dcFetch( + 'http://localhost', + { + name: 'n', + operationName: 'n', + variables: {} + }, + {} as AbortController, + null, + null, + null, + true, // _isUsingGen is true + callerSdkType as CallerSdkType + ); + + let expectedHeaderRegex: RegExp; + if ( + callerSdkType === CallerSdkTypeEnum.Generated || + callerSdkType === CallerSdkTypeEnum.Base + ) { + expectedHeaderRegex = RegExp(`js\/gen`); + } else { + expectedHeaderRegex = RegExp(`js\/${callerSdkType.toLowerCase()}`); + } + expect( + fakeFetchImpl.calledWithMatch( + 'http://localhost', + sinon.match.hasNested( + 'headers.X-Goog-Api-Client', + sinon.match(expectedHeaderRegex) + ) + ) + ).to.be.true; + } + } + }); });