-
Notifications
You must be signed in to change notification settings - Fork 392
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
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
6dc9809
feat(data-connect): Add Data Connect API
lahirumaramba 6791c5d
Add unit tests
lahirumaramba 3ccef11
Add DataConnect Service
lahirumaramba 7dc93b6
Add executeGraphqlRead()
lahirumaramba 1a0fc6a
Handle query errors
lahirumaramba cb3080c
Add docstrings
lahirumaramba 131ae21
Increase unit tests coverage
lahirumaramba ea4bd5f
Increase test coverage for executeGraphql
lahirumaramba 5ba66d3
Add emulator unit tests
lahirumaramba 22fe18f
Merge branch 'master' into lm-fdc
lahirumaramba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
maneesht marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* The name of the GraphQL operation. Required only if `query` contains multiple operations. | ||
*/ | ||
operationName?: string; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.