diff --git a/.gitignore b/.gitignore index 9331c650de..2005e95795 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ test/resources/appid.txt firebase-admin-*.tgz docgen/markdown/ +service_account.json +dataconnect/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fc2251b97e..bba8a0fd5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebase-admin", - "version": "13.4.0", + "version": "13.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebase-admin", - "version": "13.4.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", diff --git a/src/data-connect/data-connect-api-client-internal.ts b/src/data-connect/data-connect-api-client-internal.ts index e12005b8bb..d666d2b6b4 100644 --- a/src/data-connect/data-connect-api-client-internal.ts +++ b/src/data-connect/data-connect-api-client-internal.ts @@ -29,14 +29,24 @@ const API_VERSION = 'v1alpha'; /** The Firebase Data Connect backend base URL format. */ const FIREBASE_DATA_CONNECT_BASE_URL_FORMAT = - 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; + 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; -/** Firebase Data Connect base URl format when using the Data Connect emultor. */ +/** The Firebase Data Connect backend base URL format including a connector. */ +const FIREBASE_DATA_CONNECT_BASE_URL_FORMAT_WITH_CONNECTOR = + 'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connector}:{endpointId}'; + +/** Firebase Data Connect base URl format when using the Data Connect emulator. */ const FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT = 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}'; +/** Firebase Data Connect base URl format when using the Data Connect emulator including a connector. */ +const FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT_WITH_CONNECTOR = + 'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connector}:{endpointId}'; + const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql'; const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead'; +const EXECUTE_QUERY_ENDPOINT = 'executeQuery'; +const EXECUTE_MUTATION_ENDPOINT = 'executeMutation'; const DATA_CONNECT_CONFIG_HEADERS = { 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` @@ -87,6 +97,30 @@ export class DataConnectApiClient { options?: GraphqlOptions, ): Promise> { return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options); + // return this.executeHelper(EXECUTE_GRAPH_QL_READ_ENDPOINT,options, query); + } + + /** + * Execute pre-existing read-only queries > + * @param options - GraphQL Options + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @throws FirebaseDataConnectError + */ + public async executeQuery( + options: GraphqlOptions, + ): Promise> { + return this.executeOperationHelper(EXECUTE_QUERY_ENDPOINT, options); + } + /** + * Execute pre-existing read and write queries > + * @param options - GraphQL Options + * @returns A promise that fulfills with a `ExecuteGraphqlResponse`. + * @throws FirebaseDataConnectError + */ + public async executeMutation( + options: GraphqlOptions, + ): Promise> { + return this.executeOperationHelper(EXECUTE_MUTATION_ENDPOINT, options); } private async executeGraphqlHelper( @@ -106,13 +140,49 @@ export class DataConnectApiClient { 'GraphqlOptions must be a non-null object'); } } + return this.executeHelper(endpoint, options, query) + } + + private async executeOperationHelper( + endpoint: string, + options: GraphqlOptions, + ): Promise> { + if (typeof options == 'undefined') { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'GraphqlOptions should be a non-null object'); + } + if (typeof options !== 'undefined') { + if (!validator.isNonNullObject(options)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'GraphqlOptions must be a non-null object'); + } + } + + if (!("operationName" in options)) { + throw new FirebaseDataConnectError( + DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT, + 'GraphqlOptions must contain `operationName`.'); + } + + return this.executeHelper(endpoint, options) + } + + + private async executeHelper( + endpoint: string, + options?: GraphqlOptions, + gql?: string + ): Promise> { const data = { - query, + query: gql, + ...(!gql && { name: options?.operationName }), ...(options?.variables && { variables: options?.variables }), ...(options?.operationName && { operationName: options?.operationName }), ...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }), }; - return this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint) + return this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint, this.connectorConfig.connector) .then(async (url) => { const request: HttpRequestConfig = { method: 'POST', @@ -138,7 +208,7 @@ export class DataConnectApiClient { }); } - private async getUrl(version: string, locationId: string, serviceId: string, endpointId: string): Promise { + private async getUrl(version: string, locationId: string, serviceId: string, endpointId: string, connector?: string): Promise { return this.getProjectId() .then((projectId) => { const urlParams = { @@ -146,15 +216,28 @@ export class DataConnectApiClient { projectId, locationId, serviceId, - endpointId + endpointId, + ...(connector && { connector }) }; let urlFormat: string; if (useEmulator()) { - urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT, { - host: emulatorHost() - }); + if ('connector' in urlParams && (endpointId === EXECUTE_QUERY_ENDPOINT || endpointId === EXECUTE_MUTATION_ENDPOINT)) { + urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT_WITH_CONNECTOR, { + host: emulatorHost() + }); + } + else { + urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT, { + host: emulatorHost() + }); + } } else { - urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT; + if ('connector' in urlParams && (endpointId === EXECUTE_QUERY_ENDPOINT || endpointId === EXECUTE_MUTATION_ENDPOINT)) { + urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT_WITH_CONNECTOR + } + else { + urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT; + } } return utils.formatString(urlFormat, urlParams); }); @@ -226,13 +309,13 @@ export class DataConnectApiClient { // GraphQL object keys are typically unquoted. return `${key}: ${this.objectToString(val)}`; }); - + if (kvPairs.length === 0) { return '{}'; // Represent an object with no defined properties as {} } return `{ ${kvPairs.join(', ')} }`; } - + // If value is undefined (and not an object property, which is handled above, // e.g., if objectToString(undefined) is called directly or for an array element) // it should be represented as 'null'. @@ -255,7 +338,7 @@ export class DataConnectApiClient { } private handleBulkImportErrors(err: FirebaseDataConnectError): never { - if (err.code === `data-connect/${DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`){ + if (err.code === `data-connect/${DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR}`) { throw new FirebaseDataConnectError( DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, `${err.message}. Make sure that your table name passed in matches the type name in your GraphQL schema file.`); diff --git a/src/data-connect/data-connect-api.ts b/src/data-connect/data-connect-api.ts index 1073437032..2e6146302f 100644 --- a/src/data-connect/data-connect-api.ts +++ b/src/data-connect/data-connect-api.ts @@ -30,6 +30,11 @@ export interface ConnectorConfig { * Service ID of the Data Connect service. */ serviceId: string; + + /** + * Connector of the Data Connect service. + */ + connector?: string; } /** diff --git a/src/data-connect/data-connect.ts b/src/data-connect/data-connect.ts index aa7d9d7e19..7476db4157 100644 --- a/src/data-connect/data-connect.ts +++ b/src/data-connect/data-connect.ts @@ -16,7 +16,7 @@ */ import { App } from '../app'; -import { DataConnectApiClient } from './data-connect-api-client-internal'; +import { DATA_CONNECT_ERROR_CODE_MAPPING, DataConnectApiClient, FirebaseDataConnectError } from './data-connect-api-client-internal'; import { ConnectorConfig, @@ -157,4 +157,116 @@ export class DataConnect { ): Promise> { return this.client.upsertMany(tableName, variables); } + + /** + * Returns Query Reference + * @param name Name of Query + * @returns QueryRef + */ + public queryRef(name: string): QueryRef; + /** + * + * Returns Query Reference + * @param name Name of Query + * @param variables + * @returns QueryRef + */ + public queryRef(name: string, variables: Variables): QueryRef; + /** + * + * Returns Query Reference + * @param name Name of Query + * @param variables + * @returns QueryRef + */ + public queryRef(name: string, variables?: Variables): QueryRef { + if (!("connector" in this.connectorConfig)){ + throw new FirebaseDataConnectError(DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,'executeQuery requires a connector'); + } + return new QueryRef(this, name, variables as Variables, this.client); + } + /** + * Returns Mutation Reference + * @param name Name of Mutation + * @returns MutationRef + */ + public mutationRef(name: string): MutationRef; + /** + * + * Returns Mutation Reference + * @param name Name of Mutation + * @param variables + * @returns MutationRef + */ + public mutationRef(name: string, variables: Variables): MutationRef; + /** + * + * Returns Query Reference + * @param name Name of Mutation + * @param variables + * @returns MutationRef + */ + public mutationRef(name: string, variables?: Variables): MutationRef { + if (!("connector" in this.connectorConfig)){ + throw new FirebaseDataConnectError(DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,'executeMutation requires a connector'); + } + return new MutationRef(this, name, variables as Variables, this.client); + } +} + +abstract class OperationRef { + _data?: Data; + constructor(public readonly dataConnect: DataConnect, public readonly name: string, public readonly variables: Variables, protected readonly client: DataConnectApiClient) { + + } + abstract execute(): Promise>; +} + +interface OperationResult { + ref: OperationRef; + data: Data; + variables: Variables; + dataConnect: DataConnect; +} +export interface QueryResult extends OperationResult { + ref: QueryRef; +} +export interface MutationResult extends OperationResult { + ref: MutationRef; +} + +class QueryRef extends OperationRef { + option_params:GraphqlOptions; + async execute(): Promise> { + const option_params = { + variables: this.variables, + operationName: this.name + }; + const {data} = await this.client.executeQuery(option_params) + + return { + ref: this, + data: data, + variables: this.variables, + dataConnect: this.dataConnect + } + } +} + +class MutationRef extends OperationRef { + option_params:GraphqlOptions; + async execute(): Promise> { + const option_params = { + variables: this.variables, + operationName: this.name + }; + const {data} = await this.client.executeMutation(option_params) + + return { + ref: this, + data: data, + variables: this.variables, + dataConnect: this.dataConnect + } + } } diff --git a/src/data-connect/index.ts b/src/data-connect/index.ts index ab262682f2..1b1d71f66e 100644 --- a/src/data-connect/index.ts +++ b/src/data-connect/index.ts @@ -71,6 +71,11 @@ export { * @returns The default `DataConnect` service with the provided connector configuration * if no app is provided, or the `DataConnect` service associated with the provided app. */ +// export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect { +// if (typeof app === 'undefined') { +// app = getApp(); +// } + export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect { if (typeof app === 'undefined') { app = getApp(); diff --git a/test/unit/data-connect/data-connect-api-client-internal.spec.ts b/test/unit/data-connect/data-connect-api-client-internal.spec.ts index a5798703e5..a607491025 100644 --- a/test/unit/data-connect/data-connect-api-client-internal.spec.ts +++ b/test/unit/data-connect/data-connect-api-client-internal.spec.ts @@ -53,8 +53,8 @@ describe('DataConnectApiClient', () => { }; const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' - + 'account credentials or set project ID as an app option. Alternatively, set the ' - + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + 'account credentials or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; const TEST_RESPONSE = { data: { @@ -71,10 +71,20 @@ describe('DataConnectApiClient', () => { serviceId: 'my-service', }; + const connectorConfig_with_connector: ConnectorConfig = { + location: 'us-west2', + serviceId: 'my-service', + connector: 'mock-connector' + }; + const clientWithoutProjectId = new DataConnectApiClient( connectorConfig, mocks.mockCredentialApp()); + const clientWithoutProjectIdWithConnector = new DataConnectApiClient( + connectorConfig_with_connector, + mocks.mockCredentialApp()); + const mockOptions = { credential: new mocks.MockCredential(), projectId: 'test-project', @@ -83,12 +93,14 @@ describe('DataConnectApiClient', () => { let app: FirebaseApp; let apiClient: DataConnectApiClient; + let apiClientWithConnector: DataConnectApiClient; let sandbox: sinon.SinonSandbox; beforeEach(() => { sandbox = sinon.createSandbox(); app = mocks.appWithOptions(mockOptions); apiClient = new DataConnectApiClient(connectorConfig, app); + apiClientWithConnector = new DataConnectApiClient(connectorConfig_with_connector, app); }); afterEach(() => { @@ -110,6 +122,17 @@ describe('DataConnectApiClient', () => { it('should initialize httpClient with the provided app', () => { expect((apiClient as any).httpClient).to.be.an.instanceOf(AuthorizedHttpClient); }); + // Test for an app instance with a connector within the connector config + it('should throw an error if app is not a valid Firebase app instance', () => { + expect(() => new DataConnectApiClient(connectorConfig_with_connector, null as unknown as FirebaseApp)).to.throw( + FirebaseDataConnectError, + 'First argument passed to getDataConnect() must be a valid Firebase app instance.' + ); + }); + + it('should initialize httpClient with the provided app', () => { + expect((apiClientWithConnector as any).httpClient).to.be.an.instanceOf(AuthorizedHttpClient); + }); }); describe('executeGraphql', () => { @@ -229,6 +252,331 @@ describe('DataConnectApiClient', () => { }); }); }); + + it('should resolve with the GraphQL response on success when a connector is passed in', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeGraphql('query', {}) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebasedataconnect.googleapis.com/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}:executeGraphql`, + headers: EXPECTED_HEADERS, + data: { query: 'query' } + }); + }); + }); + + it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeGraphql('query', {}) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}:executeGraphql`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { query: 'query' } + }); + }); + }); + + }); + + describe('executeQuery', () => { + it('should reject when project id is not available', () => { + return clientWithoutProjectIdWithConnector.executeQuery({ operationName: 'getById' }) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should throw an error if no arguments are passed in', async () => { + await expect(apiClientWithConnector.executeQuery(undefined as any)).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions should be a non-null object' + ); + }); + + const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop]; + invalidOptions.forEach((invalidOption) => { + it('should throw given an invalid options object: ' + JSON.stringify(invalidOption), async () => { + await expect(apiClientWithConnector.executeQuery(invalidOption as any)).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions must be a non-null object' + ); + }); + }); + + it('should throw an error if there is no operationName', async () => { + await expect(apiClientWithConnector.executeQuery({})).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions must contain `operationName`.' + ); + }); + + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should resolve with the Query response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebasedataconnect.googleapis.com/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeQuery`, + headers: EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById' } + }); + }); + }); + + it('should resolve with the Query response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeQuery({ operationName: 'getById', variables: {} }) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebasedataconnect.googleapis.com/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeQuery`, + headers: EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById', variables: {} } + }); + }); + }); + + it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeQuery({ operationName: 'getById' }) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeQuery`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById' } + }); + }); + }); + }); + + describe('executeMutation', () => { + it('should reject when project id is not available', () => { + return clientWithoutProjectIdWithConnector.executeMutation({ operationName: 'getById' }) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should throw an error if no arguments are passed in', async () => { + await expect(apiClientWithConnector.executeMutation(undefined as any)).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions should be a non-null object' + ); + }); + + const invalidOptions = [null, NaN, 0, 1, true, false, [], _.noop]; + invalidOptions.forEach((invalidOption) => { + it('should throw given an invalid options object: ' + JSON.stringify(invalidOption), async () => { + await expect(apiClientWithConnector.executeMutation(invalidOption as any)).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions must be a non-null object' + ); + }); + }); + + it('should throw an error if there is no operationName', async () => { + await expect(apiClientWithConnector.executeMutation({})).to.be.rejectedWith( + FirebaseDataConnectError, + 'GraphqlOptions must contain `operationName`.' + ); + }); + + it('should reject when a full platform error response is received', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + const expected = new FirebaseDataConnectError('not-found', 'Requested entity not found'); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error when error code is not present', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + const expected = new FirebaseDataConnectError('unknown-error', 'Unknown server error: {}'); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject with unknown-error for non-json response', () => { + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + const expected = new FirebaseDataConnectError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseDataConnectError', () => { + const expected = new FirebaseDataConnectError('internal-error', 'socket hang up'); + sandbox + .stub(HttpClient.prototype, 'send') + .rejects(expected); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should resolve with the Mutation response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebasedataconnect.googleapis.com/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeMutation`, + headers: EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById' } + }); + }); + }); + + it('should resolve with the Mutation response on success', () => { + interface UsersResponse { + users: [ + user: { + id: string; + name: string; + address: string; + } + ]; + } + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeMutation({ operationName: 'getById', variables: {} }) + .then((resp) => { + expect(resp.data.users).to.be.not.empty; + expect(resp.data.users[0].name).to.be.not.undefined; + expect(resp.data.users[0].address).to.be.not.undefined; + expect(resp.data.users).to.deep.equal(TEST_RESPONSE.data.users); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `https://firebasedataconnect.googleapis.com/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeMutation`, + headers: EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById', variables: {} } + }); + }); + }); + + it('should use DATA_CONNECT_EMULATOR_HOST if set', () => { + process.env.DATA_CONNECT_EMULATOR_HOST = 'localhost:9399'; + const stub = sandbox + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom(TEST_RESPONSE, 200)); + return apiClientWithConnector.executeMutation({ operationName: 'getById' }) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: `http://localhost:9399/v1alpha/projects/test-project/locations/${connectorConfig_with_connector.location}/services/${connectorConfig_with_connector.serviceId}/connectors/${connectorConfig_with_connector.connector}:executeMutation`, + headers: EMULATOR_EXPECTED_HEADERS, + data: { query: undefined, name: 'getById', operationName: 'getById' } + }); + }); + }); }); }); @@ -360,11 +708,11 @@ describe('DataConnectApiClient CRUD helpers', () => { .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be a non-null object./); }); - it('should throw FirebaseDataConnectError for array data', async() => { + it('should throw FirebaseDataConnectError for array data', async () => { await expect(apiClient.insert(tableName, [])) .to.be.rejectedWith(FirebaseDataConnectError, /`data` must be an object, not an array, for single insert./); }); - + it('should amend the message for query errors', async () => { await expect(apiClientQueryError.insert(tableName, { data: 1 })) .to.be.rejectedWith(FirebaseDataConnectError, `${serverErrorString}. ${additionalErrorMessageForBulkImport}`);