Skip to content

feat: Add Data Connect API #2701

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions entrypoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"typings": "./lib/database/index.d.ts",
"dist": "./lib/database/index.js"
},
"firebase-admin/data-connect": {
"typings": "./lib/data-connect/index.d.ts",
"dist": "./lib/data-connect/index.js"
},
"firebase-admin/extensions": {
"typings": "./lib/extensions/index.d.ts",
"dist": "./lib/extensions/index.js"
Expand Down
45 changes: 45 additions & 0 deletions etc/firebase-admin.data-connect.api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## API Report File for "firebase-admin.data-connect"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

/// <reference types="node" />

import { Agent } from 'http';

// @public
export interface ConnectorConfig {
location: string;
serviceId: string;
}

// @public
export class DataConnect {
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
//
// (undocumented)
readonly app: App;
// (undocumented)
readonly connectorConfig: ConnectorConfig;
// @beta
executeGraphql<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
// @beta
executeGraphqlRead<GraphqlResponse, Variables>(query: string, options?: GraphqlOptions<Variables>): Promise<ExecuteGraphqlResponse<GraphqlResponse>>;
}

// @public
export interface ExecuteGraphqlResponse<GraphqlResponse> {
data: GraphqlResponse;
}

// @public
export function getDataConnect(connectorConfig: ConnectorConfig, app?: App): DataConnect;

// @public
export interface GraphqlOptions<Variables> {
operationName?: string;
variables?: Variables;
}

```
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
"database": [
"lib/database"
],
"data-connect": [
"lib/data-connect"
],
"firestore": [
"lib/firestore"
],
Expand Down Expand Up @@ -134,6 +137,11 @@
"require": "./lib/database/index.js",
"import": "./lib/esm/database/index.js"
},
"./data-connect": {
"types": "./lib/data-connect/index.d.ts",
"require": "./lib/data-connect/index.js",
"import": "./lib/esm/data-connect/index.js"
},
"./eventarc": {
"types": "./lib/eventarc/index.d.ts",
"require": "./lib/eventarc/index.js",
Expand Down
241 changes: 241 additions & 0 deletions src/data-connect/data-connect-api-client-internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*!
* @license
* Copyright 2024 Google Inc.
*
* 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 { App } from '../app';
import { FirebaseApp } from '../app/firebase-app';
import {
HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient
} from '../utils/api-request';
import { PrefixedFirebaseError } from '../utils/error';
import * as utils from '../utils/index';
import * as validator from '../utils/validator';
import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data-connect-api';

// Data Connect backend constants
const DATA_CONNECT_HOST = 'https://firebasedataconnect.googleapis.com';
const DATA_CONNECT_API_URL_FORMAT =
'{host}/v1alpha/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';

const EXECUTE_GRAPH_QL_ENDPOINT = 'executeGraphql';
const EXECUTE_GRAPH_QL_READ_ENDPOINT = 'executeGraphqlRead';

const DATA_CONNECT_CONFIG_HEADERS = {
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
};

/**
* Class that facilitates sending requests to the Firebase Data Connect backend API.
*
* @internal
*/
export class DataConnectApiClient {
private readonly httpClient: HttpClient;
private projectId?: string;

constructor(private readonly connectorConfig: ConnectorConfig, private readonly app: App) {
if (!validator.isNonNullObject(app) || !('options' in app)) {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
'First argument passed to getDataConnect() must be a valid Firebase app instance.');
}
this.httpClient = new AuthorizedHttpClient(app as FirebaseApp);
}

/**
* Execute arbitrary GraphQL, including both read and write queries
*
* @param query - The GraphQL string to be executed.
* @param options - GraphQL Options
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
*/
public async executeGraphql<GraphqlResponse, Variables>(
query: string,
options?: GraphqlOptions<Variables>,
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_ENDPOINT, options);
}

/**
* Execute arbitrary read-only GraphQL queries
*
* @param query - The GraphQL (read-only) string to be executed.
* @param options - GraphQL Options
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
* @throws FirebaseDataConnectError
*/
public async executeGraphqlRead<GraphqlResponse, Variables>(
query: string,
options?: GraphqlOptions<Variables>,
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options);
}

private async executeGraphqlHelper<GraphqlResponse, Variables>(
query: string,
endpoint: string,
options?: GraphqlOptions<Variables>,
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
if (!validator.isNonEmptyString(query)) {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
'`query` must be a non-empty string.');
}
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');
}
}
const host = (process.env.DATA_CONNECT_EMULATOR_HOST || DATA_CONNECT_HOST);
const data = {
query,
...(options?.variables && { variables: options?.variables }),
...(options?.operationName && { operationName: options?.operationName }),
};
return this.getUrl(host, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint)
.then(async (url) => {
const request: HttpRequestConfig = {
method: 'POST',
url,
headers: DATA_CONNECT_CONFIG_HEADERS,
data,
};
const resp = await this.httpClient.send(request);
if (resp.data.errors && validator.isNonEmptyArray(resp.data.errors)) {
const allMessages = resp.data.errors.map((error: { message: any; }) => error.message).join(' ');
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR, allMessages);
}
return Promise.resolve({
data: resp.data.data as GraphqlResponse,
});
})
.then((resp) => {
return resp;
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

private async getUrl(host: string, locationId: string, serviceId: string, endpointId: string): Promise<string> {
return this.getProjectId()
.then((projectId) => {
const urlParams = {
host,
projectId,
locationId,
serviceId,
endpointId
};
const baseUrl = utils.formatString(DATA_CONNECT_API_URL_FORMAT, urlParams);
return utils.formatString(baseUrl);
});
}

private getProjectId(): Promise<string> {
if (this.projectId) {
return Promise.resolve(this.projectId);
}
return utils.findProjectId(this.app)
.then((projectId) => {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN,
'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.');
}
this.projectId = projectId;
return projectId;
});
}

private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError {
if (err instanceof PrefixedFirebaseError) {
return err;
}

const response = err.response;
if (!response.isJson()) {
return new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN,
`Unexpected response with status: ${response.status} and body: ${response.text}`);
}

const error: ServerError = (response.data as ErrorResponse).error || {};
let code: DataConnectErrorCode = DATA_CONNECT_ERROR_CODE_MAPPING.UNKNOWN;
if (error.status && error.status in DATA_CONNECT_ERROR_CODE_MAPPING) {
code = DATA_CONNECT_ERROR_CODE_MAPPING[error.status];
}
const message = error.message || `Unknown server error: ${response.text}`;
return new FirebaseDataConnectError(code, message);
}
}

interface ErrorResponse {
error?: ServerError;
}

interface ServerError {
code?: number;
message?: string;
status?: string;
}

export const DATA_CONNECT_ERROR_CODE_MAPPING: { [key: string]: DataConnectErrorCode } = {
ABORTED: 'aborted',
INVALID_ARGUMENT: 'invalid-argument',
INVALID_CREDENTIAL: 'invalid-credential',
INTERNAL: 'internal-error',
PERMISSION_DENIED: 'permission-denied',
UNAUTHENTICATED: 'unauthenticated',
NOT_FOUND: 'not-found',
UNKNOWN: 'unknown-error',
QUERY_ERROR: 'query-error',
};

export type DataConnectErrorCode =
'aborted'
| 'invalid-argument'
| 'invalid-credential'
| 'internal-error'
| 'permission-denied'
| 'unauthenticated'
| 'not-found'
| 'unknown-error'
| 'query-error';

/**
* Firebase Data Connect error code structure. This extends PrefixedFirebaseError.
*
* @param code - The error code.
* @param message - The error message.
* @constructor
*/
export class FirebaseDataConnectError extends PrefixedFirebaseError {
constructor(code: DataConnectErrorCode, message: string) {
super('data-connect', code, message);

/* tslint:disable:max-line-length */
// Set the prototype explicitly. See the following link for more details:
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
/* tslint:enable:max-line-length */
(this as any).__proto__ = FirebaseDataConnectError.prototype;
}
}
56 changes: 56 additions & 0 deletions src/data-connect/data-connect-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*!
* @license
* Copyright 2024 Google Inc.
*
* 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.
*/

/**
* Interface representing a Data Connect connector configuration.
*/
export interface ConnectorConfig {
/**
* Location ID of the Data Connect service.
*/
location: string;

/**
* Service ID of the Data Connect service.
*/
serviceId: string;
}

/**
* Interface representing GraphQL response.
*/
export interface ExecuteGraphqlResponse<GraphqlResponse> {
/**
* Data payload of the GraphQL response.
*/
data: GraphqlResponse;
}

/**
* Interface representing GraphQL options.
*/
export interface GraphqlOptions<Variables> {
/**
* Values for GraphQL variables provided in this query or mutation.
*/
variables?: Variables;

/**
* The name of the GraphQL operation. Required only if `query` contains multiple operations.
*/
operationName?: string;
}
Loading