Skip to content

Commit c9a3ee3

Browse files
feat(dc): Add executeQuery and executeMutation APIs to Data Connect (#2979)
# API Changes - Added `executeQuery()` and `executeMutation()` to `src/data-connect/data-connect.ts`. These allow users to call deployed operations with impersonated auth credentials. # Testing - New unit tests added which match the coverage of existing `executeGraphql*` APIs - New integration tests added which more than match the coverage of existing `executeGraphql*` APIs # Commits: * add in changes from stephenarosaj/fdc-impersonate * finish adding in changes from stephenarosaj/fdc-impersonate * update Google Inc. to Google LLC, run npm install; npm run build * run npm apidocs * remove public execute apis * convert executeOperation api to OperationRef(...).execute() api * remove internal client from operation refs * cleanup javadocs to address workflow failures * npm run apidocs * spread GraphqlOptions arguments in OperationRefs and executeOperation functions * convert unit tests to use spread args * convert integration tests to use spread args * add executeQuery test cases which do not provide impersonation options, bypassing auth policies * add executeMutation test cases which do not provide impersonation options, bypassing auth policies * run npm apidocs * address try/catch comment * address await and reject grouping comment * address getUrl comments * address insecureReason comment * convert autopush resources to prod * add RefOptions, [Operation,Query,Mutation]Ref, [Operation,Query,Mutation]Result to exported api * revert OperationRef.execute() API to executeOperation API * revert OperationRef.execute() API to executeOperation API * revert tests to use DataConnect.executeOperation() API instead of OperationRef.execute() API * revert package version * update executeOperation API to return executeOperationResponse * update comments * add invalidateAdminArgs to handle variadic JS executeOperation arguments * npm run apidocs for validateAdminArgs * update validateAdminArgs documentation * address validateAdminArgs and some test comments * update validate-admin-args and add tests, address existing test comments, revert package changes * update tests * update tests * address mutation test comments * address prod url comments * finally fixed unit tests * REALLY fixed unit tests * address comments, add DataConnect.executeQuery() and DataConnect.executeMutation() unit tests * make validateAdminArgs internal * address comments * address documentation comment * undo integration test changes * remove empty checks * remove length checks * remove foreach checks * address test comments * revert package version * revert package version to master * revert package version to master * update comments
1 parent 27c682a commit c9a3ee3

File tree

13 files changed

+2142
-350
lines changed

13 files changed

+2142
-350
lines changed

etc/firebase-admin.data-connect.api.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type AuthClaims = Partial<DecodedIdToken>;
1313

1414
// @public
1515
export interface ConnectorConfig {
16+
connector?: string;
1617
location: string;
1718
serviceId: string;
1819
}
@@ -27,6 +28,10 @@ export class DataConnect {
2728
readonly connectorConfig: ConnectorConfig;
2829
executeGraphql<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
2930
executeGraphqlRead<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
31+
executeMutation<Data>(name: string, options?: OperationOptions): Promise<ExecuteOperationResponse<Data>>;
32+
executeMutation<Data, Variables>(name: string, variables: Variables, options?: OperationOptions): Promise<ExecuteOperationResponse<Data>>;
33+
executeQuery<Data>(name: string, options?: OperationOptions): Promise<ExecuteOperationResponse<Data>>;
34+
executeQuery<Data, Variables>(name: string, variables: Variables, options?: OperationOptions): Promise<ExecuteOperationResponse<Data>>;
3035
insert<GraphQlResponse, Variables extends object>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
3136
insertMany<GraphQlResponse, Variables extends Array<unknown>>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
3237
upsert<GraphQlResponse, Variables extends object>(tableName: string, variables: Variables): Promise<ExecuteGraphqlResponse<GraphQlResponse>>;
@@ -38,6 +43,11 @@ export interface ExecuteGraphqlResponse<GraphqlResponse> {
3843
data: GraphqlResponse;
3944
}
4045

46+
// @public
47+
export interface ExecuteOperationResponse<GraphqlResponse> {
48+
data: GraphqlResponse;
49+
}
50+
4151
// @public
4252
export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect;
4353

@@ -60,4 +70,9 @@ export interface ImpersonateUnauthenticated {
6070
unauthenticated: true;
6171
}
6272

73+
// @public
74+
export interface OperationOptions {
75+
impersonate?: ImpersonateAuthenticated | ImpersonateUnauthenticated;
76+
}
77+
6378
```

src/data-connect/data-connect-api-client-internal.ts

Lines changed: 242 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,44 @@ import {
2323
import { PrefixedFirebaseError } from '../utils/error';
2424
import * as utils from '../utils/index';
2525
import * as validator from '../utils/validator';
26-
import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data-connect-api';
26+
import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions, OperationOptions } from './data-connect-api';
2727

2828
const API_VERSION = 'v1';
29-
30-
/** The Firebase Data Connect backend base URL format. */
31-
const FIREBASE_DATA_CONNECT_BASE_URL_FORMAT =
32-
'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';
33-
34-
/** Firebase Data Connect base URl format when using the Data Connect emultor. */
35-
const FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT =
29+
const FIREBASE_DATA_CONNECT_PROD_URL = 'https://firebasedataconnect.googleapis.com';
30+
31+
/** The Firebase Data Connect backend service URL format. */
32+
const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT =
33+
FIREBASE_DATA_CONNECT_PROD_URL +
34+
'/{version}' +
35+
'/projects/{projectId}' +
36+
'/locations/{locationId}' +
37+
'/services/{serviceId}' +
38+
':{endpointId}';
39+
40+
/** The Firebase Data Connect backend connector URL format. */
41+
const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT =
42+
FIREBASE_DATA_CONNECT_PROD_URL +
43+
'/{version}' +
44+
'/projects/{projectId}' +
45+
'/locations/{locationId}' +
46+
'/services/{serviceId}' +
47+
'/connectors/{connectorId}' +
48+
':{endpointId}';
49+
50+
/** Firebase Data Connect service URL format when using the Data Connect emulator. */
51+
const FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT =
3652
'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';
3753

54+
/** Firebase Data Connect connector URL format when using the Data Connect emulator. */
55+
const FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT =
56+
'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}';
57+
3858
const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql';
3959
const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead';
4060

61+
const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery';
62+
const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation';
63+
4164

4265
function getHeaders(isUsingGen: boolean): { [key: string]: string } {
4366
const headerValue = {
@@ -50,6 +73,27 @@ function getHeaders(isUsingGen: boolean): { [key: string]: string } {
5073
return headerValue;
5174
}
5275

76+
/**
77+
* URL params for requests to an endpoint under services:
78+
* .../services/{serviceId}:endpoint
79+
*/
80+
interface ServicesUrlParams {
81+
version: string;
82+
projectId: string;
83+
locationId: string;
84+
serviceId: string;
85+
endpointId: string;
86+
host?: string; // Present only when using the emulator
87+
}
88+
89+
/**
90+
* URL params for requests to an endpoint under connectors:
91+
* .../services/{serviceId}/connectors/{connectorId}:endpoint
92+
*/
93+
interface ConnectorsUrlParams extends ServicesUrlParams {
94+
connectorId: string;
95+
}
96+
5397
/**
5498
* Class that facilitates sending requests to the Firebase Data Connect backend API.
5599
*
@@ -106,6 +150,15 @@ export class DataConnectApiClient {
106150
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options);
107151
}
108152

153+
154+
/**
155+
* A helper function to execute GraphQL queries.
156+
*
157+
* @param query - The arbitrary GraphQL query to execute.
158+
* @param endpoint - The endpoint to call.
159+
* @param options - The GraphQL options.
160+
* @returns A promise that fulfills with the GraphQL response, or throws an error.
161+
*/
109162
private async executeGraphqlHelper<GraphqlResponse, Variables>(
110163
query: string,
111164
endpoint: string,
@@ -129,52 +182,163 @@ export class DataConnectApiClient {
129182
...(options?.operationName && { operationName: options?.operationName }),
130183
...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }),
131184
};
132-
return this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint)
133-
.then(async (url) => {
134-
const request: HttpRequestConfig = {
135-
method: 'POST',
136-
url,
137-
headers: getHeaders(this.isUsingGen),
138-
data,
139-
};
140-
const resp = await this.httpClient.send(request);
141-
if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) {
142-
const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' ');
143-
throw new FirebaseDataConnectError(
144-
DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages);
145-
}
146-
return Promise.resolve({
147-
data: resp.data.data as GraphqlResponse,
148-
});
149-
})
150-
.then((resp) => {
151-
return resp;
152-
})
153-
.catch((err) => {
154-
throw this.toFirebaseError(err);
155-
});
185+
const url = await this.getServicesUrl(
186+
API_VERSION,
187+
this.connectorConfig.location,
188+
this.connectorConfig.serviceId,
189+
endpoint
190+
);
191+
try {
192+
const resp = await this.makeGqlRequest<GraphqlResponse>(url, data);
193+
return resp;
194+
} catch (err: any) {
195+
throw this.toFirebaseError(err);
196+
}
156197
}
157198

158-
private async getUrl(version: string, locationId: string, serviceId: string, endpointId: string): Promise<string> {
159-
return this.getProjectId()
160-
.then((projectId) => {
161-
const urlParams = {
162-
version,
163-
projectId,
164-
locationId,
165-
serviceId,
166-
endpointId
167-
};
168-
let urlFormat: string;
169-
if (useEmulator()) {
170-
urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT, {
171-
host: emulatorHost()
172-
});
173-
} else {
174-
urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT;
175-
}
176-
return utils.formatString(urlFormat, urlParams);
177-
});
199+
/**
200+
* Executes a GraphQL query with impersonation.
201+
*
202+
* @param options - The GraphQL options. Must include impersonation details.
203+
* @returns A promise that fulfills with the GraphQL response.
204+
*/
205+
public async executeQuery<GraphqlResponse, Variables>(
206+
name: string,
207+
variables: Variables,
208+
options?: OperationOptions
209+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
210+
return this.executeOperationHelper(IMPERSONATE_QUERY_ENDPOINT, name, variables, options);
211+
}
212+
213+
/**
214+
* Executes a GraphQL mutation with impersonation.
215+
*
216+
* @param options - The GraphQL options. Must include impersonation details.
217+
* @returns A promise that fulfills with the GraphQL response.
218+
*/
219+
public async executeMutation<GraphqlResponse, Variables>(
220+
name: string,
221+
variables: Variables,
222+
options?: OperationOptions
223+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
224+
return this.executeOperationHelper(IMPERSONATE_MUTATION_ENDPOINT, name, variables, options);
225+
}
226+
227+
/**
228+
* A helper function to execute operations by making requests to FDC's impersonate
229+
* operations endpoints.
230+
*
231+
* @param endpoint - The endpoint to call.
232+
* @param options - The GraphQL options, including impersonation details.
233+
* @returns A promise that fulfills with the GraphQL response.
234+
*/
235+
private async executeOperationHelper<GraphqlResponse, Variables>(
236+
endpoint: string,
237+
name: string,
238+
variables: Variables,
239+
options?: OperationOptions
240+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
241+
if (
242+
typeof name === 'undefined' ||
243+
!validator.isNonEmptyString(name)
244+
) {
245+
throw new FirebaseDataConnectError(
246+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
247+
'`name` must be a non-empty string.'
248+
);
249+
}
250+
251+
if (this.connectorConfig.connector === undefined || this.connectorConfig.connector === '') {
252+
throw new FirebaseDataConnectError(
253+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
254+
`The 'connectorConfig.connector' field used to instantiate your Data Connect
255+
instance must be a non-empty string (the connectorId) when calling executeQuery or executeMutation.`);
256+
}
257+
258+
const data = {
259+
...(variables && { variables: variables }),
260+
operationName: name,
261+
extensions: { impersonate: options?.impersonate },
262+
};
263+
const url = await this.getConnectorsUrl(
264+
API_VERSION,
265+
this.connectorConfig.location,
266+
this.connectorConfig.serviceId,
267+
this.connectorConfig.connector,
268+
endpoint,
269+
);
270+
try {
271+
const resp = await this.makeGqlRequest<GraphqlResponse>(url, data);
272+
return resp;
273+
} catch (err: any) {
274+
throw this.toFirebaseError(err);
275+
}
276+
}
277+
278+
/**
279+
* Constructs the URL for a Data Connect request to a service endpoint.
280+
*
281+
* @param version - The API version.
282+
* @param locationId - The location of the Data Connect service.
283+
* @param serviceId - The ID of the Data Connect service.
284+
* @param endpointId - The endpoint to call.
285+
* @returns A promise which resolves to the formatted URL string.
286+
*/
287+
private async getServicesUrl(
288+
version: string,
289+
locationId: string,
290+
serviceId: string,
291+
endpointId: string,
292+
): Promise<string> {
293+
const projectId = await this.getProjectId();
294+
const params: ServicesUrlParams = {
295+
version,
296+
projectId,
297+
locationId,
298+
serviceId,
299+
endpointId,
300+
};
301+
let urlFormat = FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT;
302+
if (useEmulator()) {
303+
urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT;
304+
params.host = emulatorHost();
305+
}
306+
return utils.formatString(urlFormat, params);
307+
}
308+
309+
/**
310+
* Constructs the URL for a Data Connect request to a connector endpoint.
311+
*
312+
* @param version - The API version.
313+
* @param locationId - The location of the Data Connect service.
314+
* @param serviceId - The ID of the Data Connect service.
315+
* @param connectorId - The ID of the Connector.
316+
* @param endpointId - The endpoint to call.
317+
* @returns A promise which resolves to the formatted URL string.
318+
319+
*/
320+
private async getConnectorsUrl(
321+
version: string,
322+
locationId: string,
323+
serviceId: string,
324+
connectorId: string,
325+
endpointId: string,
326+
): Promise<string> {
327+
const projectId = await this.getProjectId();
328+
const params: ConnectorsUrlParams = {
329+
version,
330+
projectId,
331+
locationId,
332+
serviceId,
333+
connectorId,
334+
endpointId,
335+
};
336+
let urlFormat = FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT;
337+
if (useEmulator()) {
338+
urlFormat = FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT;
339+
params.host = emulatorHost();
340+
}
341+
return utils.formatString(urlFormat, params);
178342
}
179343

180344
private getProjectId(): Promise<string> {
@@ -195,6 +359,32 @@ export class DataConnectApiClient {
195359
});
196360
}
197361

362+
/**
363+
* Makes a GraphQL request to the specified url.
364+
*
365+
* @param url - The URL to send the request to.
366+
* @param data - The GraphQL request payload.
367+
* @returns A promise that fulfills with the GraphQL response, or throws an error.
368+
*/
369+
private async makeGqlRequest<GraphqlResponse>(url: string, data: object):
370+
Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
371+
const request: HttpRequestConfig = {
372+
method: 'POST',
373+
url,
374+
headers: getHeaders(this.isUsingGen),
375+
data,
376+
};
377+
const resp = await this.httpClient.send(request);
378+
if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) {
379+
const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' ');
380+
throw new FirebaseDataConnectError(
381+
DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages);
382+
}
383+
return Promise.resolve({
384+
data: resp.data.data as GraphqlResponse,
385+
});
386+
}
387+
198388
private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError {
199389
if (err instanceof PrefixedFirebaseError) {
200390
return err;

0 commit comments

Comments
 (0)