| 
 | 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 | +}  | 
0 commit comments