Skip to content

Commit 1885958

Browse files
feat: Add Data Connect API (#2701)
* feat(data-connect): Add Data Connect API * Add unit tests * Add DataConnect Service * Add executeGraphqlRead() * Handle query errors * Add docstrings * Increase unit tests coverage * Increase test coverage for executeGraphql * Add emulator unit tests
1 parent 137a0d9 commit 1885958

10 files changed

+993
-0
lines changed

entrypoints.json

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"typings": "./lib/database/index.d.ts",
2121
"dist": "./lib/database/index.js"
2222
},
23+
"firebase-admin/data-connect": {
24+
"typings": "./lib/data-connect/index.d.ts",
25+
"dist": "./lib/data-connect/index.js"
26+
},
2327
"firebase-admin/extensions": {
2428
"typings": "./lib/extensions/index.d.ts",
2529
"dist": "./lib/extensions/index.js"
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
## API Report File for "firebase-admin.data-connect"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
/// <reference types="node" />
8+
9+
import { Agent } from 'http';
10+
11+
// @public
12+
export interface ConnectorConfig {
13+
location: string;
14+
serviceId: string;
15+
}
16+
17+
// @public
18+
export class DataConnect {
19+
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
20+
//
21+
// (undocumented)
22+
readonly app: App;
23+
// (undocumented)
24+
readonly connectorConfig: ConnectorConfig;
25+
// @beta
26+
executeGraphql<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
27+
// @beta
28+
executeGraphqlRead<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
29+
}
30+
31+
// @public
32+
export interface ExecuteGraphqlResponse<GraphqlResponse> {
33+
data: GraphqlResponse;
34+
}
35+
36+
// @public
37+
export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect;
38+
39+
// @public
40+
export interface GraphqlOptions<Variables> {
41+
operationName?: string;
42+
variables?: Variables;
43+
}
44+
45+
```

package.json

+8
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080
"database": [
8181
"lib/database"
8282
],
83+
"data-connect": [
84+
"lib/data-connect"
85+
],
8386
"firestore": [
8487
"lib/firestore"
8588
],
@@ -134,6 +137,11 @@
134137
"require": "./lib/database/index.js",
135138
"import": "./lib/esm/database/index.js"
136139
},
140+
"./data-connect": {
141+
"types": "./lib/data-connect/index.d.ts",
142+
"require": "./lib/data-connect/index.js",
143+
"import": "./lib/esm/data-connect/index.js"
144+
},
137145
"./eventarc": {
138146
"types": "./lib/eventarc/index.d.ts",
139147
"require": "./lib/eventarc/index.js",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*!
2+
* @license
3+
* Copyright 2024 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { App } from '../app';
19+
import { FirebaseApp } from '../app/firebase-app';
20+
import {
21+
HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient
22+
} from '../utils/api-request';
23+
import { PrefixedFirebaseError } from '../utils/error';
24+
import * as utils from '../utils/index';
25+
import * as validator from '../utils/validator';
26+
import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data-connect-api';
27+
28+
// Data Connect backend constants
29+
const DATA_CONNECT_HOST = 'https://firebasedataconnect.googleapis.com';
30+
const DATA_CONNECT_API_URL_FORMAT =
31+
'{host}/v1alpha/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';
32+
33+
const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql';
34+
const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead';
35+
36+
const DATA_CONNECT_CONFIG_HEADERS = {
37+
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
38+
};
39+
40+
/**
41+
* Class that facilitates sending requests to the Firebase Data Connect backend API.
42+
*
43+
* @internal
44+
*/
45+
export class DataConnectApiClient {
46+
private readonly httpClient: HttpClient;
47+
private projectId?: string;
48+
49+
constructor(private readonly connectorConfig: ConnectorConfig, private readonly app: App) {
50+
if (!validator.isNonNullObject(app) || !('options' in app)) {
51+
throw new FirebaseDataConnectError(
52+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
53+
'First argument passed to getDataConnect() must be a valid Firebase app instance.');
54+
}
55+
this.httpClient = new AuthorizedHttpClient(app as FirebaseApp);
56+
}
57+
58+
/**
59+
* Execute arbitrary GraphQL, including both read and write queries
60+
*
61+
* @param query - The GraphQL string to be executed.
62+
* @param options - GraphQL Options
63+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
64+
*/
65+
public async executeGraphql<GraphqlResponse, Variables>(
66+
query: string,
67+
options?: GraphqlOptions<Variables>,
68+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
69+
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_ENDPOINT, options);
70+
}
71+
72+
/**
73+
* Execute arbitrary read-only GraphQL queries
74+
*
75+
* @param query - The GraphQL (read-only) string to be executed.
76+
* @param options - GraphQL Options
77+
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
78+
* @throws FirebaseDataConnectError
79+
*/
80+
public async executeGraphqlRead<GraphqlResponse, Variables>(
81+
query: string,
82+
options?: GraphqlOptions<Variables>,
83+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
84+
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options);
85+
}
86+
87+
private async executeGraphqlHelper<GraphqlResponse, Variables>(
88+
query: string,
89+
endpoint: string,
90+
options?: GraphqlOptions<Variables>,
91+
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
92+
if (!validator.isNonEmptyString(query)) {
93+
throw new FirebaseDataConnectError(
94+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
95+
'`query` must be a non-empty string.');
96+
}
97+
if (typeof options !== 'undefined') {
98+
if (!validator.isNonNullObject(options)) {
99+
throw new FirebaseDataConnectError(
100+
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
101+
'GraphqlOptions must be a non-null object');
102+
}
103+
}
104+
const host = (process.env.DATA_CONNECT_EMULATOR_HOST || DATA_CONNECT_HOST);
105+
const data = {
106+
query,
107+
...(options?.variables && { variables: options?.variables }),
108+
...(options?.operationName && { operationName: options?.operationName }),
109+
};
110+
return this.getUrl(host, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint)
111+
.then(async (url) => {
112+
const request: HttpRequestConfig = {
113+
method: 'POST',
114+
url,
115+
headers: DATA_CONNECT_CONFIG_HEADERS,
116+
data,
117+
};
118+
const resp = await this.httpClient.send(request);
119+
if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) {
120+
const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' ');
121+
throw new FirebaseDataConnectError(
122+
DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages);
123+
}
124+
return Promise.resolve({
125+
data: resp.data.data as GraphqlResponse,
126+
});
127+
})
128+
.then((resp) => {
129+
return resp;
130+
})
131+
.catch((err) => {
132+
throw this.toFirebaseError(err);
133+
});
134+
}
135+
136+
private async getUrl(host: string, locationId: string, serviceId: string, endpointId: string): Promise<string> {
137+
return this.getProjectId()
138+
.then((projectId) => {
139+
const urlParams = {
140+
host,
141+
projectId,
142+
locationId,
143+
serviceId,
144+
endpointId
145+
};
146+
const baseUrl = utils.formatString(DATA_CONNECT_API_URL_FORMAT, urlParams);
147+
return utils.formatString(baseUrl);
148+
});
149+
}
150+
151+
private getProjectId(): Promise<string> {
152+
if (this.projectId) {
153+
return Promise.resolve(this.projectId);
154+
}
155+
return utils.findProjectId(this.app)
156+
.then((projectId) => {
157+
if (!validator.isNonEmptyString(projectId)) {
158+
throw new FirebaseDataConnectError(
159+
DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN,
160+
'Failed to determine project ID. Initialize the '
161+
+ 'SDK with service account credentials or set project ID as an app option. '
162+
+ 'Alternatively, set the GOOGLE_CLOUD_PROJECT environment variable.');
163+
}
164+
this.projectId = projectId;
165+
return projectId;
166+
});
167+
}
168+
169+
private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError {
170+
if (err instanceof PrefixedFirebaseError) {
171+
return err;
172+
}
173+
174+
const response = err.response;
175+
if (!response.isJson()) {
176+
return new FirebaseDataConnectError(
177+
DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN,
178+
`Unexpected response with status: ${response.status} and body: ${response.text}`);
179+
}
180+
181+
const error: ServerError = (response.data as ErrorResponse).error || {};
182+
let code: DataConnectErrorCode = DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN;
183+
if (error.status && error.status in DATA_CONNECT_ERROR_CODE_MAPPING) {
184+
code = DATA_CONNECT_ERROR_CODE_MAPPING[error.status];
185+
}
186+
const message = error.message || `Unknown server error: ${response.text}`;
187+
return new FirebaseDataConnectError(code, message);
188+
}
189+
}
190+
191+
interface ErrorResponse {
192+
error?: ServerError;
193+
}
194+
195+
interface ServerError {
196+
code?: number;
197+
message?: string;
198+
status?: string;
199+
}
200+
201+
export const DATA_CONNECT_ERROR_CODE_MAPPING: { [key: string]: DataConnectErrorCode } = {
202+
ABORTED: 'aborted',
203+
INVALID_ARGUMENT: 'invalid-argument',
204+
INVALID_CREDENTIAL: 'invalid-credential',
205+
INTERNAL: 'internal-error',
206+
PERMISSION_DENIED: 'permission-denied',
207+
UNAUTHENTICATED: 'unauthenticated',
208+
NOT_FOUND: 'not-found',
209+
UNKNOWN: 'unknown-error',
210+
QUERY_ERROR: 'query-error',
211+
};
212+
213+
export type DataConnectErrorCode =
214+
'aborted'
215+
| 'invalid-argument'
216+
| 'invalid-credential'
217+
| 'internal-error'
218+
| 'permission-denied'
219+
| 'unauthenticated'
220+
| 'not-found'
221+
| 'unknown-error'
222+
| 'query-error';
223+
224+
/**
225+
* Firebase Data Connect error code structure. This extends PrefixedFirebaseError.
226+
*
227+
* @param code - The error code.
228+
* @param message - The error message.
229+
* @constructor
230+
*/
231+
export class FirebaseDataConnectError extends PrefixedFirebaseError {
232+
constructor(code: DataConnectErrorCode, message: string) {
233+
super('data-connect', code, message);
234+
235+
/* tslint:disable:max-line-length */
236+
// Set the prototype explicitly. See the following link for more details:
237+
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
238+
/* tslint:enable:max-line-length */
239+
(this as any).__proto__ = FirebaseDataConnectError.prototype;
240+
}
241+
}

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

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*!
2+
* @license
3+
* Copyright 2024 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Interface representing a Data Connect connector configuration.
20+
*/
21+
export interface ConnectorConfig {
22+
/**
23+
* Location ID of the Data Connect service.
24+
*/
25+
location: string;
26+
27+
/**
28+
* Service ID of the Data Connect service.
29+
*/
30+
serviceId: string;
31+
}
32+
33+
/**
34+
* Interface representing GraphQL response.
35+
*/
36+
export interface ExecuteGraphqlResponse<GraphqlResponse> {
37+
/**
38+
* Data payload of the GraphQL response.
39+
*/
40+
data: GraphqlResponse;
41+
}
42+
43+
/**
44+
* Interface representing GraphQL options.
45+
*/
46+
export interface GraphqlOptions<Variables> {
47+
/**
48+
* Values for GraphQL variables provided in this query or mutation.
49+
*/
50+
variables?: Variables;
51+
52+
/**
53+
* The name of the GraphQL operation. Required only if `query` contains multiple operations.
54+
*/
55+
operationName?: string;
56+
}

0 commit comments

Comments
 (0)