Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions NOTE.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BEFORE SUBMITTING:

Don't forget to undo changes made to database-debug.log, firestore-debug.log, and api-request.ts!
11 changes: 11 additions & 0 deletions database-debug.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/Users/stephenarosaj/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
16:49:54.775 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
16:49:54.866 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
16:50:04.160 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 86ms, init: 0ms
16:50:05.737 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
16:50:05.747 [NamespaceSystem-akka.actor.default-dispatcher-9] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: fake-project-id
16:50:05.751 [NamespaceSystem-akka.actor.default-dispatcher-10] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for fake-project-id
16:50:05.754 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.
22 changes: 22 additions & 0 deletions firestore-debug.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by io.netty.util.internal.PlatformDependent0$4 (file:/Users/stephenarosaj/.cache/firebase/emulators/cloud-firestore-emulator-v1.19.8.jar)
WARNING: Please consider reporting this to the maintainers of class io.netty.util.internal.PlatformDependent0$4
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
Aug 19, 2025 4:49:53 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start
INFO: Started WebSocket server on ws://127.0.0.1:9150
API endpoint: http://127.0.0.1:8080
If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run:

export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080

If you are running a Firestore in Datastore Mode project, run:

export DATASTORE_EMULATOR_HOST=127.0.0.1:8080

Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues.
Dev App Server is now running.

Aug 19, 2025 4:50:04 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead
INFO: Detected HTTP/2 connection.
*** shutting down gRPC server since JVM is shutting down
*** server shut down
223 changes: 181 additions & 42 deletions src/data-connect/data-connect-api-client-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,28 @@ import { ConnectorConfig, ExecuteGraphqlResponse, GraphqlOptions } from './data-

const API_VERSION = 'v1alpha';

/** The Firebase Data Connect backend base URL format. */
const FIREBASE_DATA_CONNECT_BASE_URL_FORMAT =
'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';
/** The Firebase Data Connect backend service URL format. */
const FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT =
'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';

/** Firebase Data Connect base URl format when using the Data Connect emultor. */
const FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT =
/** The Firebase Data Connect backend connector URL format. */
const FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT =
'https://firebasedataconnect.googleapis.com/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}';

/** Firebase Data Connect service URL format when using the Data Connect emulator. */
const FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT =
'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}:{endpointId}';

/** Firebase Data Connect connector URL format when using the Data Connect emulator. */
const FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT =
'http://{host}/{version}/projects/{projectId}/locations/{locationId}/services/{serviceId}/connectors/{connectorId}:{endpointId}';

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

const IMPERSONATE_QUERY_ENDPOINT = 'impersonateQuery';
const IMPERSONATE_MUTATION_ENDPOINT = 'impersonateMutation';

const DATA_CONNECT_CONFIG_HEADERS = {
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
};
Expand Down Expand Up @@ -89,6 +100,15 @@ export class DataConnectApiClient {
return this.executeGraphqlHelper(query, EXECUTE_GRAPH_QL_READ_ENDPOINT, options);
}


/**
* A helper function to execute GraphQL queries.
*
* @param query - The arbitrary GraphQL query to execute.
* @param endpoint - The endpoint to call.
* @param options - The GraphQL options.
* @returns A promise that fulfills with the GraphQL response, or throws an error.
*/
private async executeGraphqlHelper<GraphqlResponse, Variables>(
query: string,
endpoint: string,
Expand All @@ -112,24 +132,8 @@ export class DataConnectApiClient {
...(options?.operationName && { operationName: options?.operationName }),
...(options?.impersonate && { extensions: { impersonate: options?.impersonate } }),
};
return this.getUrl(API_VERSION, 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,
});
})
const url = await this.getUrl(API_VERSION, this.connectorConfig.location, this.connectorConfig.serviceId, endpoint);
return this.makeGqlRequest<GraphqlResponse>(url, data)
.then((resp) => {
return resp;
})
Expand All @@ -138,28 +142,137 @@ export class DataConnectApiClient {
});
}

private async getUrl(version: string, locationId: string, serviceId: string, endpointId: string): Promise<string> {
return this.getProjectId()
.then((projectId) => {
const urlParams = {
version,
projectId,
locationId,
serviceId,
endpointId
};
let urlFormat: string;
if (useEmulator()) {
urlFormat = utils.formatString(FIREBASE_DATA_CONNECT_EMULATOR_BASE_URL_FORMAT, {
host: emulatorHost()
});
} else {
urlFormat = FIREBASE_DATA_CONNECT_BASE_URL_FORMAT;
}
return utils.formatString(urlFormat, urlParams);
/**
* Executes a GraphQL query with impersonation.
*
* @param options - The GraphQL options, including impersonation details.
* @returns A promise that fulfills with the GraphQL response.
*/
public async impersonateQuery<GraphqlResponse, Variables>(
options: GraphqlOptions<Variables>
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.impersonateHelper(IMPERSONATE_QUERY_ENDPOINT, options);
}

/**
* Executes a GraphQL mutation with impersonation.
*
* @param options - The GraphQL options, including impersonation details.
* @returns A promise that fulfills with the GraphQL response.
*/
public async impersonateMutation<GraphqlResponse, Variables>(
options: GraphqlOptions<Variables>
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.impersonateHelper(IMPERSONATE_MUTATION_ENDPOINT, options);
}

/**
* A helper function to make impersonated GraphQL requests.
*
* @param endpoint - The endpoint to call.
* @param options - The GraphQL options, including impersonation details.
* @returns A promise that fulfills with the GraphQL response.
*/
private async impersonateHelper<GraphqlResponse, Variables>(
endpoint: string,
options: GraphqlOptions<Variables>
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
if (
typeof options.operationName === 'undefined' ||
!validator.isNonEmptyString(options.operationName)
) {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
'`options.operationName` must be a non-empty string.'
);
}
if (
typeof options.impersonate === 'undefined' ||
!validator.isNonNullObject(options?.impersonate)
) {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
'`options.impersonate` must be a non-null object.'
);
}

if (this.connectorConfig.connector === undefined || this.connectorConfig.connector === '') {
throw new FirebaseDataConnectError(
DATA_CONNECT_ERROR_CODE_MAPPING.INVALID_ARGUMENT,
'`connectorConfig.connector` field used to instantiate Data Connect instance \
must be a non-empty string (the connectorId) when calling impersonate APIs.');
}

const data = {
...(options.variables && { variables: options?.variables }),
operationName: options.operationName,
extensions: { impersonate: options.impersonate },
};
const url = await this.getUrl(
API_VERSION,
this.connectorConfig.location,
this.connectorConfig.serviceId,
endpoint,
this.connectorConfig.connector,
);
return this.makeGqlRequest<GraphqlResponse>(url, data)
.then((resp) => {
return resp;
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

/**
* Constructs the URL for a Data Connect backend request.
*
* If no connectorId is provided, will direct the request to an endpoint under services:
* .../services/{serviceId}:endpoint
*
* If connectorId is provided, will direct the request to an endpoint under connectors:
* .../services/{serviceId}/connectors/{connectorId}:endpoint
*
* @param version - The API version.
* @param locationId - The location of the Data Connect service.
* @param serviceId - The ID of the Data Connect service.
* @param endpointId - The endpoint to call.
* @param connectorId - The ID of the connector, if applicable.
* @returns A promise that fulfills with the constructed URL.
*/
private async getUrl(
version: string,
locationId: string,
serviceId: string,
endpointId: string,
connectorId?: string,
): Promise<string> {
const projectId = await this.getProjectId();
const urlParams = {
version,
projectId,
locationId,
serviceId,
endpointId,
connectorId
};
let urlFormat: string;
if (useEmulator()) {
(urlParams as any).host = emulatorHost();
urlFormat = connectorId === undefined || connectorId === ''
? FIREBASE_DATA_CONNECT_EMULATOR_SERVICES_URL_FORMAT
: FIREBASE_DATA_CONNECT_EMULATOR_CONNECTORS_URL_FORMAT;
} else {
urlFormat = connectorId === undefined || connectorId === ''
? FIREBASE_DATA_CONNECT_SERVICES_URL_FORMAT
: FIREBASE_DATA_CONNECT_CONNECTORS_URL_FORMAT;
}
if (connectorId) {
(urlParams as any).connectorId = connectorId;
}
return utils.formatString(urlFormat, urlParams);
}

private getProjectId(): Promise<string> {
if (this.projectId) {
return Promise.resolve(this.projectId);
Expand All @@ -178,6 +291,32 @@ export class DataConnectApiClient {
});
}

/**
* Makes a GraphQL request to the specified url.
*
* @param url - The URL to send the request to.
* @param data - The GraphQL request payload.
* @returns A promise that fulfills with the GraphQL response, or throws an error.
*/
private async makeGqlRequest<GraphqlResponse>(url: string, data: object):
Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
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,
});
}

private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError {
if (err instanceof PrefixedFirebaseError) {
return err;
Expand Down
11 changes: 10 additions & 1 deletion src/data-connect/data-connect-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface ConnectorConfig {
* Service ID of the Data Connect service.
*/
serviceId: string;

/**
* Name of the Data Connect connector.
* Required for operations that interact with connectors, such as impersonateQuery and impersonateMutation.
*/
connector?: string;
}

/**
Expand All @@ -52,7 +58,10 @@ export interface GraphqlOptions<Variables> {
variables?: Variables;

/**
* The name of the GraphQL operation. Required only if `query` contains multiple operations.
* The name of the GraphQL operation.
* Required for operations that interact with connectors, such as impersonateQuery and impersonateMutation.
* Required for operations that interact with services, such as executeGraphql, if
* `query` contains multiple operations.
*/
operationName?: string;

Expand Down
42 changes: 35 additions & 7 deletions src/data-connect/data-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,48 @@ export class DataConnect {
}

/**
* Execute an arbitrary read-only GraphQL query
*
* @param query - The GraphQL read-only query.
* @param options - Optional {@link GraphqlOptions} when executing a read-only GraphQL query.
*
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
*/
* Execute an arbitrary read-only GraphQL query
*
* @param query - The GraphQL read-only query.
* @param options - Optional {@link GraphqlOptions} when executing a read-only GraphQL query.
*
* @returns A promise that fulfills with a `ExecuteGraphqlResponse`.
*/
public executeGraphqlRead<GraphqlResponse, Variables>(
query: string,
options?: GraphqlOptions<Variables>,
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.client.executeGraphqlRead(query, options);
}

/**
* Executes a pre-defined GraphQL query with impersonation
*
* The query must be defined in your Data Connect GQL files.
*
* @param options - The GraphQL options, must include impersonation details.
* @returns A promise that fulfills with the GraphQL response.
*/
public async impersonateQuery<GraphqlResponse, Variables>(
options: GraphqlOptions<Variables>
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.client.impersonateQuery(options);
}

/**
* Executes a pre-defined GraphQL mutation with impersonation.
*
* The mutation must be defined in your Data Connect GQL files.
*
* @param options - The GraphQL options, must include impersonation details.
* @returns A promise that fulfills with the GraphQL response.
*/
public async impersonateMutation<GraphqlResponse, Variables>(
options: GraphqlOptions<Variables>
): Promise<ExecuteGraphqlResponse<GraphqlResponse>> {
return this.client.impersonateMutation(options);
}

/**
* Insert a single row into the specified table.
*
Expand Down
1 change: 1 addition & 0 deletions src/data-connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
* const connectorConfig: ConnectorConfig = {
* location: 'us-west2',
* serviceId: 'my-service',
* connectorName: 'my-connector',
* };
*
* // Get the `DataConnect` service for the default app
Expand Down
Loading
Loading