diff --git a/.changeset/unlucky-donuts-juggle.md b/.changeset/unlucky-donuts-juggle.md new file mode 100644 index 0000000000..094061862c --- /dev/null +++ b/.changeset/unlucky-donuts-juggle.md @@ -0,0 +1,21 @@ +--- +'@shopify/graphql-client': minor +'@shopify/shopify-app-remix': minor +'@shopify/shopify-api': minor +--- + +Return headers in responses from GraphQL client. + +Headers are now returned in the response object from the GraphQL client. + +In apps using the `@shopify/shopify-app-remix` package the headers can be access as follows: +```ts + const response = await admin.graphql( + ... + + const responseJson = await response.json(); + const responseHeaders = responseJson.headers + const xRequestID = responseHeaders? responseHeaders["X-Request-Id"] : ''; + console.log(responseHeaders); + console.log(xRequestID, 'x-request-id'); +``` diff --git a/packages/api-clients/graphql-client/src/graphql-client/graphql-client.ts b/packages/api-clients/graphql-client/src/graphql-client/graphql-client.ts index 834a5f3c0b..183cc6b605 100644 --- a/packages/api-clients/graphql-client/src/graphql-client/graphql-client.ts +++ b/packages/api-clients/graphql-client/src/graphql-client/graphql-client.ts @@ -77,13 +77,15 @@ export function generateClientLogger(logger?: Logger): Logger { } async function processJSONResponse( - response: any, + response: Response, ): Promise> { - const {errors, data, extensions} = await response.json(); + const {errors, data, extensions} = await response.json(); return { ...getKeyValueIfValid('data', data), ...getKeyValueIfValid('extensions', extensions), + headers: response.headers, + ...(errors || !data ? { errors: { diff --git a/packages/api-clients/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts b/packages/api-clients/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts index 3ab2858643..67495481b1 100644 --- a/packages/api-clients/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts +++ b/packages/api-clients/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts @@ -391,6 +391,30 @@ describe('GraphQL Client', () => { expect(response).toHaveProperty('data', mockResponseData.data); }); + it('includes headers if headers are included in the response.', async () => { + const headers = { + 'content-type': 'application/json', + 'x-request-id': '1234', + }; + const mockResponseData = { + data: {shop: {name: 'Test shop'}}, + headers, + }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers(headers), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, {variables}); + console.log(response, 'RESPONSE IN TEST'); + expect(response).toHaveProperty('headers', new Headers(headers)); + }); + it('includes an API extensions object if it is included in the response', async () => { const extensions = { context: { diff --git a/packages/api-clients/graphql-client/src/graphql-client/types.ts b/packages/api-clients/graphql-client/src/graphql-client/types.ts index 84aa71b78a..5499792ccb 100644 --- a/packages/api-clients/graphql-client/src/graphql-client/types.ts +++ b/packages/api-clients/graphql-client/src/graphql-client/types.ts @@ -11,7 +11,9 @@ type OperationVariables = Record; export type DataChunk = Buffer | Uint8Array; -export type Headers = Record; +type HeadersObject = Record; + +export type {HeadersObject as Headers}; export interface ResponseErrors { networkStatusCode?: number; @@ -25,6 +27,7 @@ export type GQLExtensions = Record; export interface FetchResponseBody { data?: TData; extensions?: GQLExtensions; + headers?: Headers; } export interface ClientResponse extends FetchResponseBody { @@ -70,7 +73,7 @@ export type Logger = ( ) => void; export interface ClientOptions { - headers: Headers; + headers: HeadersObject; url: string; customFetchApi?: CustomFetchApi; retries?: number; @@ -86,7 +89,7 @@ export interface ClientConfig { export interface RequestOptions { variables?: OperationVariables; url?: string; - headers?: Headers; + headers?: HeadersObject; retries?: number; } diff --git a/packages/apps/shopify-api/docs/reference/clients/Graphql.md b/packages/apps/shopify-api/docs/reference/clients/Graphql.md index 608698879f..26cf24bf79 100644 --- a/packages/apps/shopify-api/docs/reference/clients/Graphql.md +++ b/packages/apps/shopify-api/docs/reference/clients/Graphql.md @@ -91,7 +91,7 @@ const response = await client.request( }, }, ); -console.log(response.data, response.extensions); +console.log(response.data, response.extensions, response.headers); ``` > **Note**: If using TypeScript, you can pass in a type argument for the response body: @@ -158,7 +158,7 @@ The maximum number of times to retry the request. ### Return -`Promise` +`Promise` Returns an object containing: @@ -174,4 +174,8 @@ The [`data` component](https://shopify.dev/docs/api/admin/getting-started#graphq The [`extensions` component](https://shopify.dev/docs/api/admin-graphql#rate_limits) of the response. +#### Headers +`Record` +The headers from the response. + [Back to shopify.clients](./README.md) diff --git a/packages/apps/shopify-api/lib/clients/admin/graphql/client.ts b/packages/apps/shopify-api/lib/clients/admin/graphql/client.ts index f5a5e0a0fa..2a5d6c076c 100644 --- a/packages/apps/shopify-api/lib/clients/admin/graphql/client.ts +++ b/packages/apps/shopify-api/lib/clients/admin/graphql/client.ts @@ -2,7 +2,6 @@ import { AdminApiClient, AdminOperations, ApiClientRequestOptions, - ClientResponse, createAdminApiClient, ReturnData, } from '@shopify/admin-api-client'; @@ -14,11 +13,12 @@ import type { GraphqlParams, GraphqlClientParams, GraphqlQueryOptions, + GraphQLClientResponse, } from '../../types'; import {Session} from '../../../session/session'; import {logger} from '../../../logger'; import * as ShopifyErrors from '../../../error'; -import {abstractFetch} from '../../../../runtime'; +import {abstractFetch, canonicalizeHeaders} from '../../../../runtime'; import { clientLoggerFactory, getUserAgent, @@ -114,9 +114,14 @@ export class GraphqlClient { operation: Operation, options?: GraphqlQueryOptions, ): Promise< - ClientResponse : T> + GraphQLClientResponse< + T extends undefined ? ReturnData : T + > > { - const response = await this.client.request(operation, { + const response = await this.client.request< + T extends undefined ? ReturnData : T, + Operation + >(operation, { apiVersion: this.apiVersion || this.graphqlClass().config.apiVersion, ...(options as ApiClientRequestOptions), }); @@ -127,7 +132,14 @@ export class GraphqlClient { throwFailedRequest(response, (options?.retries ?? 0) > 0, fetchResponse); } - return response; + const headerObject = Object.fromEntries( + response.headers ? response.headers.entries() : [], + ); + + return { + ...response, + headers: canonicalizeHeaders(headerObject ?? {}), + }; } private graphqlClass() { diff --git a/packages/apps/shopify-api/lib/clients/admin/types.ts b/packages/apps/shopify-api/lib/clients/admin/types.ts index 4c089dd214..97ce0c1529 100644 --- a/packages/apps/shopify-api/lib/clients/admin/types.ts +++ b/packages/apps/shopify-api/lib/clients/admin/types.ts @@ -1,4 +1,4 @@ -import {SearchParams} from '@shopify/admin-api-client'; +import {ClientResponse, SearchParams} from '@shopify/admin-api-client'; import {ApiVersion} from '../../types'; import {Session} from '../../session/session'; @@ -28,3 +28,8 @@ export interface RestClientParams { session: Session; apiVersion?: ApiVersion; } + +export interface GraphQLClientResponse + extends Omit, 'headers'> { + headers?: Headers; +} diff --git a/packages/apps/shopify-api/lib/clients/graphql_proxy/__tests__/graphql_proxy.test.ts b/packages/apps/shopify-api/lib/clients/graphql_proxy/__tests__/graphql_proxy.test.ts index 3d21995b69..7e687ab3fe 100644 --- a/packages/apps/shopify-api/lib/clients/graphql_proxy/__tests__/graphql_proxy.test.ts +++ b/packages/apps/shopify-api/lib/clients/graphql_proxy/__tests__/graphql_proxy.test.ts @@ -15,6 +15,9 @@ const successResponse = { name: 'Shop', }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; const shopQuery = `{ shop { diff --git a/packages/apps/shopify-api/lib/webhooks/__tests__/responses.ts b/packages/apps/shopify-api/lib/webhooks/__tests__/responses.ts index 073e8d2f13..5caa44485a 100644 --- a/packages/apps/shopify-api/lib/webhooks/__tests__/responses.ts +++ b/packages/apps/shopify-api/lib/webhooks/__tests__/responses.ts @@ -132,6 +132,9 @@ export const successResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const eventBridgeSuccessResponse = { @@ -140,6 +143,9 @@ export const eventBridgeSuccessResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const pubSubSuccessResponse = { @@ -148,6 +154,9 @@ export const pubSubSuccessResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const successUpdateResponse = { @@ -156,6 +165,9 @@ export const successUpdateResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const eventBridgeSuccessUpdateResponse = { @@ -164,6 +176,9 @@ export const eventBridgeSuccessUpdateResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const pubSubSuccessUpdateResponse = { @@ -172,6 +187,9 @@ export const pubSubSuccessUpdateResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const successDeleteResponse = { @@ -180,6 +198,9 @@ export const successDeleteResponse = { userErrors: [], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; export const failResponse = { @@ -192,4 +213,7 @@ export const httpFailResponse = { userErrors: ['this is an error'], }, }, + headers: { + 'Content-Type': ['application/json'], + }, }; diff --git a/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json b/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json index c50901d3d7..592bf2eeed 100644 --- a/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json +++ b/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json @@ -4933,7 +4933,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLClient", "description": "", @@ -4968,7 +4969,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLQueryOptions", "description": "", @@ -6722,7 +6724,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLClient", "description": "", @@ -6757,7 +6760,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLQueryOptions", "description": "", @@ -7599,7 +7603,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLClient", "description": "", @@ -7634,7 +7639,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLQueryOptions", "description": "", @@ -10493,7 +10499,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLClient", "description": "", @@ -10528,7 +10535,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLQueryOptions", "description": "", @@ -13914,7 +13922,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLClient", "description": "", @@ -13949,7 +13958,8 @@ "FetchResponseBody": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ApiClientRequestOptions": "../../api-clients/admin-api-client/dist/ts/index.d.ts", "ResponseWithType": "../../api-clients/admin-api-client/dist/ts/index.d.ts", - "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts" + "ApiVersion": "../shopify-api/dist/ts/lib/index.d.ts", + "Headers": "../shopify-api/runtime/index.ts" }, "name": "GraphQLQueryOptions", "description": "", diff --git a/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-admin-api-client.ts b/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-admin-api-client.ts index fcdefe8023..d78d1e33bf 100644 --- a/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-admin-api-client.ts +++ b/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-admin-api-client.ts @@ -106,7 +106,10 @@ export function expectAdminApiClient( // THEN expect(response.status).toEqual(200); - expect(await response.json()).toEqual({data: {shop: {name: 'Test shop'}}}); + expect(await response.json()).toEqual({ + data: {shop: {name: 'Test shop'}}, + headers: {'Content-Type': ['application/json']}, + }); }); it('returns a session object as part of the context', async () => { diff --git a/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-storefront-api-client.ts b/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-storefront-api-client.ts index aa2c014cc9..d2e2d7b6d7 100644 --- a/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-storefront-api-client.ts +++ b/packages/apps/shopify-app-remix/src/server/__test-helpers/expect-storefront-api-client.ts @@ -16,7 +16,7 @@ export function expectStorefrontApiClient( it('Storefront client can perform GraphQL Requests', async () => { // GIVEN const {storefront, actualSession} = await factory(); - const apiResponse = {data: {blogs: {nodes: [{id: 1}]}}}; + const apiResponse = {data: {blogs: {nodes: [{id: 1}]}}, headers: {}}; await mockExternalRequest({ request: new Request( `https://${TEST_SHOP}/api/${LATEST_API_VERSION}/graphql.json`,