diff --git a/.changeset/lively-otters-wander.md b/.changeset/lively-otters-wander.md new file mode 100644 index 000000000..67510bd59 --- /dev/null +++ b/.changeset/lively-otters-wander.md @@ -0,0 +1,19 @@ +--- +"@frontify/app-bridge-app": minor +--- + +feat(api): add `executeGraphQlWithFullResponse` and deprecate `executeGraphQl` + +`executeGraphQl` resolves only the `data` field of the GraphQL response, so the top-level `errors` array is not accessible to the app. The new `executeGraphQlWithFullResponse` method resolves the full response envelope, including `errors`. `executeGraphQl` is now deprecated. + +```ts +const response = await appBridge.api({ + name: 'executeGraphQlWithFullResponse', + payload: { + query: `query CurrentUser { currentUser { id email } }`, + }, +}); + +// response now includes both `data` and any top-level `errors` +const { data, errors } = response; +``` diff --git a/packages/app-bridge-app/src/AppBridgePlatformApp.ts b/packages/app-bridge-app/src/AppBridgePlatformApp.ts index 8f43b3225..4c6ce9d86 100644 --- a/packages/app-bridge-app/src/AppBridgePlatformApp.ts +++ b/packages/app-bridge-app/src/AppBridgePlatformApp.ts @@ -37,6 +37,7 @@ export type PlatformAppApiMethod = PlatformAppApiMethodNameValidator< | 'getSecureRequest' | 'getAccountId' | 'executeGraphQl' + | 'executeGraphQlWithFullResponse' | 'executeSecureRequest' > >; diff --git a/packages/app-bridge-app/src/registries/api/ApiMethodRegistry.ts b/packages/app-bridge-app/src/registries/api/ApiMethodRegistry.ts index f66c8a886..a104defcb 100644 --- a/packages/app-bridge-app/src/registries/api/ApiMethodRegistry.ts +++ b/packages/app-bridge-app/src/registries/api/ApiMethodRegistry.ts @@ -4,6 +4,10 @@ import { type PlatformAppApiMethodNameValidator } from '../../types/Api.ts'; import { type CreateAssetPayload, type CreateAssetResponse } from './CreateAsset'; import { type ExecuteGraphQlPayload, type ExecuteGraphQlResponse } from './ExecuteGraphQl.ts'; +import { + type ExecuteGraphQlWithFullResponse, + type ExecuteGraphQlWithFullResponsePayload, +} from './ExecuteGraphQlWithFullResponse.ts'; import { type ExecuteSecureRequestPayload, type ExecuteSecureRequestResponse } from './ExecuteSecureRequest.ts'; import { type GetAccountIdPayload, type GetAccountIdResponse } from './GetAccountId.ts'; import { @@ -23,5 +27,9 @@ export type ApiMethodRegistry = PlatformAppApiMethodNameValidator<{ getSecureRequest: { payload: GetSecureRequestPayload; response: GetSecureRequestResponse }; getAccountId: { payload: GetAccountIdPayload; response: GetAccountIdResponse }; executeGraphQl: { payload: ExecuteGraphQlPayload; response: ExecuteGraphQlResponse }; + executeGraphQlWithFullResponse: { + payload: ExecuteGraphQlWithFullResponsePayload; + response: ExecuteGraphQlWithFullResponse; + }; executeSecureRequest: { payload: ExecuteSecureRequestPayload; response: ExecuteSecureRequestResponse }; }>; diff --git a/packages/app-bridge-app/src/registries/api/ExecuteGraphQl.ts b/packages/app-bridge-app/src/registries/api/ExecuteGraphQl.ts index 2ab25ab63..e55f9a28a 100644 --- a/packages/app-bridge-app/src/registries/api/ExecuteGraphQl.ts +++ b/packages/app-bridge-app/src/registries/api/ExecuteGraphQl.ts @@ -13,6 +13,12 @@ export type ExecuteGraphQlPayload = { export type ExecuteGraphQlResponse = Record; +/** + * @deprecated Use `executeGraphQlWithFullResponse` instead. `executeGraphQl` resolves only the + * `data` field of the GraphQL response, so the top-level `errors` array is not accessible + * to the consuming app. `executeGraphQlWithFullResponse` resolves the full response envelope, + * including `errors`. + */ export const executeGraphQl = ( payload: ExecuteGraphQlPayload, ): { name: 'executeGraphQl'; payload: ExecuteGraphQlPayload } => ({ diff --git a/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.spec.ts b/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.spec.ts new file mode 100644 index 000000000..f839b4ef9 --- /dev/null +++ b/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.spec.ts @@ -0,0 +1,23 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { describe, expect, it } from 'vitest'; + +import { executeGraphQlWithFullResponse } from './ExecuteGraphQlWithFullResponse'; + +describe('ExecuteGraphQlWithFullResponse', () => { + it('should return correct method name', () => { + const TEST_QUERY = 'query SomeQuery {}'; + const TEST_VARIABLES = { someVariable: 'someValue' }; + + const graphQlCall = executeGraphQlWithFullResponse({ + query: TEST_QUERY, + variables: TEST_VARIABLES, + }); + + expect(graphQlCall.name).toBe('executeGraphQlWithFullResponse'); + expect(graphQlCall.payload).toStrictEqual({ + query: TEST_QUERY, + variables: TEST_VARIABLES, + }); + }); +}); diff --git a/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.ts b/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.ts new file mode 100644 index 000000000..58e31dcbb --- /dev/null +++ b/packages/app-bridge-app/src/registries/api/ExecuteGraphQlWithFullResponse.ts @@ -0,0 +1,30 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ExecuteGraphQlPayload } from './ExecuteGraphQl.ts'; + +export type ExecuteGraphQlWithFullResponsePayload = ExecuteGraphQlPayload; + +export type GraphQlError = { + message: string; + locations?: { line: number; column: number }[]; + path?: (string | number)[]; + extensions?: Record; +}; + +/** + * Unlike `executeGraphQl`, which resolves only the `data` field of the GraphQL response, + * this resolves the full GraphQL response envelope, including the top-level `errors` array + * (and `extensions`, if present). + */ +export type ExecuteGraphQlWithFullResponse = { + data?: Record | null; + errors?: GraphQlError[]; + extensions?: Record; +}; + +export const executeGraphQlWithFullResponse = ( + payload: ExecuteGraphQlWithFullResponsePayload, +): { name: 'executeGraphQlWithFullResponse'; payload: ExecuteGraphQlWithFullResponsePayload } => ({ + name: 'executeGraphQlWithFullResponse', + payload, +}); diff --git a/packages/app-bridge-app/src/registries/api/index.ts b/packages/app-bridge-app/src/registries/api/index.ts index 502aae59d..497ee7c29 100644 --- a/packages/app-bridge-app/src/registries/api/index.ts +++ b/packages/app-bridge-app/src/registries/api/index.ts @@ -6,4 +6,5 @@ export * from './GetAssetResourceInformation'; export * from './GetCurrentUser'; export * from './GetSecureRequest'; export * from './ExecuteGraphQl'; +export * from './ExecuteGraphQlWithFullResponse'; export * from './ExecuteSecureRequest'; diff --git a/packages/app-bridge-app/src/utilities/MessageBus.spec.ts b/packages/app-bridge-app/src/utilities/MessageBus.spec.ts index 780746085..07fae0b66 100644 --- a/packages/app-bridge-app/src/utilities/MessageBus.spec.ts +++ b/packages/app-bridge-app/src/utilities/MessageBus.spec.ts @@ -144,4 +144,26 @@ describe('MessageBus', () => { expect(result.headers.get('Content-Type')).toBe('application/json'); expect(await result.json()).toEqual({ message: 'test-message' }); }); + + it('should forward the full executeGraphQl response envelope, including top-level errors', async () => { + const channel = new MessageChannel(); + const messageBus = new MessageBus(channel.port1); + + const hostResponse = { + data: { brand: { id: '1' } }, + errors: [{ message: 'Cannot query field "x" on type "Brand".' }], + }; + + channel.port2.onmessage = (event) => { + const { token } = event.data; + channel.port2.postMessage({ message: hostResponse, token }); + }; + + const result = await messageBus.post({ + parameter: { name: 'executeGraphQl', payload: { query: 'query SomeQuery {}' } }, + }); + + expect(result).toEqual(hostResponse); + expect((result as typeof hostResponse).errors).toBeDefined(); + }); });