Skip to content
Open
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
39 changes: 38 additions & 1 deletion src/data-connect/data-connect-api-client-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ export class DataConnectApiClient {
};
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(' ');
const allMessages = formatGraphqlErrors(resp.data.errors);
throw new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR,
message: allMessages,
Expand Down Expand Up @@ -435,6 +435,15 @@ export class DataConnectApiClient {
}

const data = response.data as any;
if (validator.isNonNullObject(data) && validator.isNonEmptyArray(data.errors)) {
return new FirebaseDataConnectError({
code: DATA_CONNECT_ERROR_CODE_MAPPING.QUERY_ERROR,
message: formatGraphqlErrors(data.errors),
httpResponse: toHttpResponse(response),
cause: err,
});
}

const error: ServerError = (validator.isNonNullObject(data) && validator.isNonNullObject(data.error))
? data.error
: (validator.isNonNullObject(data) ? data : {});
Expand Down Expand Up @@ -646,6 +655,34 @@ interface ServerError {
status?: string;
}

interface GraphqlErrorResponse {
message?: string;
extensions?: {
debugDetails?: string;
[key: string]: any;
};
[key: string]: any;
}

/**
* Formats GraphQL errors into a human-readable string, including debugDetails if present.
*
* @internal
*/
export function formatGraphqlErrors(errors: GraphqlErrorResponse[]): string {
return errors
.map((error) => {
// Defensive fallback for non-conforming or malformed error payloads.
if (!validator.isNonNullObject(error)) {
return String(error);
Comment thread
mtr002 marked this conversation as resolved.
}
const message = error.message || 'Unknown error';
const details = error.extensions?.debugDetails;
return details ? `${message}: ${details}` : message;
})
Comment thread
mtr002 marked this conversation as resolved.
.join('; ');
}

/**
* Extracts property keys from an object or array of objects as a space-separated string,
* including recursively nested object/array fields for the `@allow(fields: ...)` directive.
Expand Down
95 changes: 94 additions & 1 deletion test/unit/data-connect/data-connect-api-client-internal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@
*/

import * as _ from 'lodash';
import { expect } from 'chai';
import * as chai from 'chai';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';
import * as chaiAsPromised from 'chai-as-promised';

chai.should();
chai.use(sinonChai);
chai.use(chaiAsPromised);

const expect = chai.expect;
import {
AuthorizedHttpClient,
HttpClient,
Expand Down Expand Up @@ -252,6 +260,91 @@ describe('DataConnectApiClient', () => {
return apiClient.executeGraphql('query', {})
.should.eventually.be.rejected.and.deep.include(expected);
});

it('should reject when GraphQL errors with debugDetails are returned', async () => {
const gqlErrorResponse = {
errors: [
{
message: 'SQL execution failed',
path: ['content_update'],
extensions: {
code: 'INTERNAL',
debugDetails: 'Quota exceeded for quota metric Connect Queries'
}
}
]
};
sandbox
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(gqlErrorResponse, 200));

await expect(apiClient.executeGraphql('query', {}))
.to.be.rejectedWith(
FirebaseDataConnectError,
'SQL execution failed: Quota exceeded for quota metric Connect Queries'
);
});

it('should reject and format multiple GraphQL errors', async () => {
const gqlErrorResponse = {
errors: [
{ message: 'First error' },
{
message: 'Second error',
extensions: { debugDetails: 'detailed failure' }
}
]
};
sandbox
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(gqlErrorResponse, 200));

await expect(apiClient.executeGraphql('query', {}))
.to.be.rejectedWith(
FirebaseDataConnectError,
'First error; Second error: detailed failure'
);
});

it('should reject with formatted GraphQL errors on non-200 HTTP response', async () => {
const gqlErrorResponse = {
errors: [
{
message: 'Bad request',
extensions: { debugDetails: 'Field not found' }
}
]
};
const mockErr = utils.errorFrom(gqlErrorResponse, 400);
sandbox
.stub(HttpClient.prototype, 'send')
.rejects(mockErr);

await expect(apiClient.executeGraphql('query', {}))
.to.be.rejectedWith(
FirebaseDataConnectError,
'Bad request: Field not found'
);
});

it('should handle malformed error arrays containing null or primitive elements', async () => {
const gqlErrorResponse = {
errors: [
null,
'string error',
{ message: 'valid error', extensions: { debugDetails: 'extra' } }
]
};
sandbox
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(gqlErrorResponse, 200));

await expect(apiClient.executeGraphql('query', {}))
.to.be.rejectedWith(
FirebaseDataConnectError,
'null; string error; valid error: extra'
);
});
});

it('should resolve with the GraphQL response on success', async () => {
Expand Down
Loading