diff --git a/.vscode/settings.json b/.vscode/settings.json index b017778bf..eafd9269a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,9 +8,8 @@ "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "editor.codeActionsOnSave": { - "source.addMissingImports": true, - "source.fixAll.eslint": true, - // Disable this because it will conflict with ESLint based import organizing. - "source.organizeImports": false + "source.addMissingImports": "explicit", + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" } } diff --git a/src/classes/ClientError.ts b/src/classes/ClientError.ts new file mode 100644 index 000000000..f879d4868 --- /dev/null +++ b/src/classes/ClientError.ts @@ -0,0 +1,29 @@ +import type { GraphQLRequestContext, GraphQLResponse } from '../helpers/types.js' + +export class ClientError extends Error { + public response: GraphQLResponse + public request: GraphQLRequestContext + + constructor(response: GraphQLResponse, request: GraphQLRequestContext) { + const message = `${ClientError.extractMessage(response)}: ${JSON.stringify({ + response, + request, + })}` + + super(message) + + Object.setPrototypeOf(this, ClientError.prototype) + + this.response = response + this.request = request + + // this is needed as Safari doesn't support .captureStackTrace + if (typeof Error.captureStackTrace === `function`) { + Error.captureStackTrace(this, ClientError) + } + } + + private static extractMessage(response: GraphQLResponse): string { + return response.errors?.[0]?.message ?? `GraphQL Error (Code: ${response.status})` + } +} diff --git a/src/classes/GraphQLClient.ts b/src/classes/GraphQLClient.ts index 1089299f9..d4311e81d 100644 --- a/src/classes/GraphQLClient.ts +++ b/src/classes/GraphQLClient.ts @@ -2,28 +2,16 @@ import type { BatchRequestDocument, BatchRequestsOptions, BatchResult } from '.. import { parseBatchRequestArgs } from '../functions/batchRequests.js' import { parseRawRequestArgs } from '../functions/rawRequest.js' import { parseRequestArgs } from '../functions/request.js' -import { defaultJsonSerializer } from '../helpers/defaultJsonSerializer.js' import { resolveRequestDocument } from '../helpers/resolveRequestDocument.js' -import type { - Fetch, - FetchOptions, - HTTPMethodInput, - JsonSerializer, - RequestDocument, - RequestMiddleware, - RequestOptions, - VariablesAndRequestHeadersArgs, -} from '../helpers/types.js' +import { runRequest } from '../helpers/runRequest.js' +import type { RequestDocument, RequestOptions, VariablesAndRequestHeadersArgs } from '../helpers/types.js' import { - ClientError, type GraphQLClientResponse, type RawRequestOptions, type RequestConfig, type Variables, } from '../helpers/types.js' -import { cleanQuery, isGraphQLContentType } from '../lib/graphql.js' -import { ACCEPT_HEADER, CONTENT_TYPE_GQL, CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON } from '../lib/http.js' -import { callOrIdentity, HeadersInstanceToPlainObject, uppercase } from '../lib/prelude.js' +import { callOrIdentity, HeadersInitToPlainObject } from '../lib/prelude.js' import type { TypedDocumentNode } from '@graphql-typed-document-node/core' /** @@ -38,12 +26,11 @@ export class GraphQLClient { /** * Send a GraphQL query to the server. */ - rawRequest: RawRequestMethod = async ( - ...args: RawRequestMethodArgs + rawRequest: RawRequestMethod = async ( + ...args: RawRequestMethodArgs<$Variables> ): Promise> => { const [queryOrOptions, variables, requestHeaders] = args - const rawRequestOptions = parseRawRequestArgs(queryOrOptions, variables, requestHeaders) - + const rawRequestOptions = parseRawRequestArgs<$Variables>(queryOrOptions, variables, requestHeaders) const { headers, fetch = globalThis.fetch, @@ -60,32 +47,33 @@ export class GraphQLClient { const { operationName } = resolveRequestDocument(rawRequestOptions.query, excludeOperationName) - return makeRequest({ + const response = await runRequest({ url, - query: rawRequestOptions.query, - variables: rawRequestOptions.variables as V, + request: { + _tag: `Single`, + operationName, + query: rawRequestOptions.query, + variables: rawRequestOptions.variables, + }, headers: { - ...resolveHeaders(callOrIdentity(headers)), - ...resolveHeaders(rawRequestOptions.requestHeaders), + ...HeadersInitToPlainObject(callOrIdentity(headers)), + ...HeadersInitToPlainObject(rawRequestOptions.requestHeaders), }, - operationName, fetch, method, fetchOptions, middleware: requestMiddleware, }) - .then((response) => { - if (responseMiddleware) { - responseMiddleware(response) - } - return response - }) - .catch((error) => { - if (responseMiddleware) { - responseMiddleware(error) - } - throw error - }) + + if (responseMiddleware) { + responseMiddleware(response) + } + + if (response instanceof Error) { + throw response + } + + return response } /** @@ -119,44 +107,45 @@ export class GraphQLClient { const { query, operationName } = resolveRequestDocument(requestOptions.document, excludeOperationName) - return makeRequest({ + const response = await runRequest({ url, - query, - variables: requestOptions.variables, + request: { + operationName, + _tag: `Single`, + query, + variables: requestOptions.variables, + }, headers: { - ...resolveHeaders(callOrIdentity(headers)), - ...resolveHeaders(requestOptions.requestHeaders), + ...HeadersInitToPlainObject(callOrIdentity(headers)), + ...HeadersInitToPlainObject(requestOptions.requestHeaders), }, - operationName, fetch, method, fetchOptions, middleware: requestMiddleware, }) - .then((response) => { - if (responseMiddleware) { - responseMiddleware(response) - } - return response.data - }) - .catch((error) => { - if (responseMiddleware) { - responseMiddleware(error) - } - throw error - }) + + if (responseMiddleware) { + responseMiddleware(response) + } + + if (response instanceof Error) { + throw response + } + + return response.data } /** * Send GraphQL documents in batch to the server. */ // prettier-ignore - batchRequests(documents: BatchRequestDocument[], requestHeaders?: HeadersInit): Promise + async batchRequests<$BatchResult extends BatchResult, $Variables extends Variables = Variables>(documents: BatchRequestDocument<$Variables>[], requestHeaders?: HeadersInit): Promise<$BatchResult> // prettier-ignore - batchRequests(options: BatchRequestsOptions): Promise + async batchRequests<$BatchResult extends BatchResult, $Variables extends Variables = Variables>(options: BatchRequestsOptions<$Variables>): Promise<$BatchResult> // prettier-ignore - batchRequests(documentsOrOptions: BatchRequestDocument[] | BatchRequestsOptions, requestHeaders?: HeadersInit): Promise { - const batchRequestOptions = parseBatchRequestArgs(documentsOrOptions, requestHeaders) + async batchRequests<$BatchResult extends BatchResult, $Variables extends Variables = Variables>(documentsOrOptions: BatchRequestDocument<$Variables>[] | BatchRequestsOptions<$Variables>, requestHeaders?: HeadersInit): Promise<$BatchResult> { + const batchRequestOptions = parseBatchRequestArgs<$Variables>(documentsOrOptions, requestHeaders) const { headers, excludeOperationName, ...fetchOptions } = this.requestConfig if (batchRequestOptions.signal !== undefined) { @@ -168,33 +157,33 @@ export class GraphQLClient { ) const variables = batchRequestOptions.documents.map(({ variables }) => variables) - return makeRequest({ + const response= await runRequest({ url: this.url, - query: queries, - // @ts-expect-error TODO reconcile batch variables into system. - variables, + request: { + _tag:`Batch`, + operationName: undefined, + query: queries, + variables, + }, headers: { - ...resolveHeaders(callOrIdentity(headers)), - ...resolveHeaders(batchRequestOptions.requestHeaders), + ...HeadersInitToPlainObject(callOrIdentity(headers)), + ...HeadersInitToPlainObject(batchRequestOptions.requestHeaders), }, - operationName: undefined, fetch: this.requestConfig.fetch ?? globalThis.fetch, method: this.requestConfig.method || `POST`, fetchOptions, middleware: this.requestConfig.requestMiddleware, }) - .then((response) => { - if (this.requestConfig.responseMiddleware) { - this.requestConfig.responseMiddleware(response) - } - return response.data - }) - .catch((error) => { - if (this.requestConfig.responseMiddleware) { - this.requestConfig.responseMiddleware(error) - } - throw error - }) + + if (this.requestConfig.responseMiddleware) { + this.requestConfig.responseMiddleware(response) + } + + if (response instanceof Error) { + throw response + } + + return response.data } setHeaders(headers: HeadersInit): GraphQLClient { @@ -238,240 +227,3 @@ interface RawRequestMethod { type RawRequestMethodArgs = | [query: string, variables?: V, requestHeaders?: HeadersInit] | [RawRequestOptions] - -/** - * Convert the given headers configuration into a plain object. - */ -const resolveHeaders = (headers?: HeadersInit): Record => { - let oHeaders: Record = {} - if (headers) { - if (headers instanceof Headers) { - oHeaders = HeadersInstanceToPlainObject(headers) - } else if (Array.isArray(headers)) { - headers.forEach(([name, value]) => { - if (name && value !== undefined) { - oHeaders[name] = value - } - }) - } else { - oHeaders = headers - } - } - - return oHeaders -} - -const makeRequest = async (params: { - url: string - query: string | string[] - variables?: V - headers?: HeadersInit - operationName?: string - fetch: Fetch - method?: HTTPMethodInput - fetchOptions: FetchOptions - middleware?: RequestMiddleware -}): Promise> => { - const { query, variables, fetchOptions } = params - const fetcher = createHttpMethodFetcher(uppercase(params.method ?? `post`)) - const isBatchingQuery = Array.isArray(params.query) - const response = await fetcher(params) - const result = await getResult(response, fetchOptions.jsonSerializer ?? defaultJsonSerializer) - - const successfullyReceivedData = Array.isArray(result) - ? !result.some(({ data }) => !data) - : Boolean(result.data) - - const successfullyPassedErrorPolicy = - Array.isArray(result) || - !result.errors || - (Array.isArray(result.errors) && !result.errors.length) || - fetchOptions.errorPolicy === `all` || - fetchOptions.errorPolicy === `ignore` - - if (response.ok && successfullyPassedErrorPolicy && successfullyReceivedData) { - // @ts-expect-error TODO fixme - const { errors: _, ...rest } = Array.isArray(result) ? result : result - const data = fetchOptions.errorPolicy === `ignore` ? rest : result - const dataEnvelope = isBatchingQuery ? { data } : data - - // @ts-expect-error TODO - return { - ...dataEnvelope, - headers: response.headers, - status: response.status, - } - } else { - const errorResult = - typeof result === `string` - ? { - error: result, - } - : result - throw new ClientError( - // @ts-expect-error TODO - { ...errorResult, status: response.status, headers: response.headers }, - { query, variables }, - ) - } -} - -const getResult = async ( - response: Response, - jsonSerializer: JsonSerializer, -): Promise< - | { data: object; errors: undefined }[] - | { data: object; errors: undefined } - | { data: undefined; errors: object } - | { data: undefined; errors: object[] } -> => { - const contentType = response.headers.get(CONTENT_TYPE_HEADER) - - if (contentType && isGraphQLContentType(contentType)) { - return jsonSerializer.parse(await response.text()) as any - } else { - return response.text() as any - } -} - -const createHttpMethodFetcher = - (method: 'GET' | 'POST') => - async (params: RequestVerbParams) => { - const { url, query, variables, operationName, fetch, fetchOptions, middleware } = params - - const headers = new Headers(params.headers) - let queryParams = `` - let body = undefined - - if (!headers.has(ACCEPT_HEADER)) { - headers.set(ACCEPT_HEADER, [CONTENT_TYPE_GQL, CONTENT_TYPE_JSON].join(`, `)) - } - - if (method === `POST`) { - body = createRequestBody(query, variables, operationName, fetchOptions.jsonSerializer) - if (typeof body === `string` && !headers.has(CONTENT_TYPE_HEADER)) { - headers.set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON) - } - } else { - // @ts-expect-error todo needs ADT for TS to understand the different states - queryParams = buildRequestConfig({ - query, - variables, - operationName, - jsonSerializer: fetchOptions.jsonSerializer ?? defaultJsonSerializer, - }) - } - - const init: RequestInit = { - method, - headers, - body, - ...fetchOptions, - } - - let urlResolved = url - let initResolved = init - if (middleware) { - const result = await Promise.resolve(middleware({ ...init, url, operationName, variables })) - const { url: urlNew, ...initNew } = result - urlResolved = urlNew - initResolved = initNew - } - if (queryParams) { - urlResolved = `${urlResolved}?${queryParams}` - } - return await fetch(urlResolved, initResolved) - } - -const createRequestBody = ( - query: string | string[], - variables?: Variables | Variables[], - operationName?: string, - jsonSerializer?: JsonSerializer, -): string => { - const jsonSerializer_ = jsonSerializer ?? defaultJsonSerializer - if (!Array.isArray(query)) { - return jsonSerializer_.stringify({ query, variables, operationName }) - } - - if (typeof variables !== `undefined` && !Array.isArray(variables)) { - throw new Error(`Cannot create request body with given variable type, array expected`) - } - - // Batch support - const payload = query.reduce<{ query: string; variables: Variables | undefined }[]>( - (acc, currentQuery, index) => { - acc.push({ query: currentQuery, variables: variables ? variables[index] : undefined }) - return acc - }, - [], - ) - - return jsonSerializer_.stringify(payload) -} - -/** - * Create query string for GraphQL request - */ -const buildRequestConfig = (params: BuildRequestConfigParams): string => { - if (!Array.isArray(params.query)) { - const params_ = params as BuildRequestConfigParamsSingle - const search: string[] = [`query=${encodeURIComponent(cleanQuery(params_.query))}`] - - if (params.variables) { - search.push(`variables=${encodeURIComponent(params_.jsonSerializer.stringify(params_.variables))}`) - } - - if (params_.operationName) { - search.push(`operationName=${encodeURIComponent(params_.operationName)}`) - } - - return search.join(`&`) - } - - if (typeof params.variables !== `undefined` && !Array.isArray(params.variables)) { - throw new Error(`Cannot create query with given variable type, array expected`) - } - - // Batch support - const params_ = params as BuildRequestConfigParamsBatch - const payload = params.query.reduce<{ query: string; variables: string | undefined }[]>( - (acc, currentQuery, index) => { - acc.push({ - query: cleanQuery(currentQuery), - variables: params_.variables ? params_.jsonSerializer.stringify(params_.variables[index]) : undefined, - }) - return acc - }, - [], - ) - - return `query=${encodeURIComponent(params_.jsonSerializer.stringify(payload))}` -} - -interface RequestVerbParams { - url: string - query: string | string[] - fetch: Fetch - fetchOptions: FetchOptions - variables?: V - headers?: HeadersInit - operationName?: string - middleware?: RequestMiddleware -} - -type BuildRequestConfigParams = BuildRequestConfigParamsSingle | BuildRequestConfigParamsBatch - -type BuildRequestConfigParamsBatch = { - query: string[] - variables: V[] | undefined - operationName: undefined - jsonSerializer: JsonSerializer -} - -type BuildRequestConfigParamsSingle = { - query: string - variables: V | undefined - operationName: string | undefined - jsonSerializer: JsonSerializer -} diff --git a/src/entrypoints/main.ts b/src/entrypoints/main.ts index 87ce8dffe..54ef1294f 100644 --- a/src/entrypoints/main.ts +++ b/src/entrypoints/main.ts @@ -1,3 +1,4 @@ +import { ClientError } from '../classes/ClientError.js' import { type BatchRequestDocument, type BatchRequestsExtendedOptions, @@ -6,13 +7,7 @@ import { import { RequestExtendedOptions } from '../functions/request.js' import { request } from '../functions/request.js' import type { GraphQLResponse, RequestMiddleware, ResponseMiddleware } from '../helpers/types.js' -import { - ClientError, - RawRequestOptions, - RequestDocument, - RequestOptions, - Variables, -} from '../helpers/types.js' +import { RawRequestOptions, RequestDocument, RequestOptions, Variables } from '../helpers/types.js' export { GraphQLClient } from '../classes/GraphQLClient.js' export { batchRequests } from '../functions/batchRequests.js' export { gql } from '../functions/gql.js' diff --git a/src/helpers/runRequest.ts b/src/helpers/runRequest.ts new file mode 100644 index 000000000..0d078dfad --- /dev/null +++ b/src/helpers/runRequest.ts @@ -0,0 +1,205 @@ +import { ClientError } from '../classes/ClientError.js' +import type { GraphQLExecutionResultSingle } from '../lib/graphql.js' +import { + cleanQuery, + isGraphQLContentType, + isRequestResultHaveErrors, + parseGraphQLExecutionResult, +} from '../lib/graphql.js' +import { ACCEPT_HEADER, CONTENT_TYPE_GQL, CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON } from '../lib/http.js' +import { casesExhausted, uppercase, zip } from '../lib/prelude.js' +import { defaultJsonSerializer } from './defaultJsonSerializer.js' +import type { + BatchVariables, + Fetch, + FetchOptions, + GraphQLClientResponse, + HTTPMethodInput, + JsonSerializer, + RequestMiddleware, + Variables, +} from './types.js' + +interface Params { + url: string + method: HTTPMethodInput + fetch: Fetch + fetchOptions: FetchOptions + headers?: HeadersInit + middleware?: RequestMiddleware + request: + | { + _tag: 'Single' + query: string + operationName?: string + variables?: Variables + } + | { + _tag: 'Batch' + query: string[] + operationName?: undefined + variables?: BatchVariables + } +} + +// @ts-expect-error todo +export const runRequest = async (params: Params): Promise> => { + const $params = { + ...params, + method: uppercase(params.method ?? `post`), + fetchOptions: { + ...params.fetchOptions, + errorPolicy: params.fetchOptions.errorPolicy ?? `none`, + }, + } + const fetcher = createFetcher($params.method) + const fetchResponse = await fetcher($params) + + if (!fetchResponse.ok) { + return new ClientError( + { status: fetchResponse.status, headers: fetchResponse.headers }, + { query: params.request.query, variables: params.request.variables }, + ) + } + + const result = await parseResultFromResponse( + fetchResponse, + params.fetchOptions.jsonSerializer ?? defaultJsonSerializer, + ) + + if (result instanceof Error) throw result // todo something better + + const clientResponseBase = { + status: fetchResponse.status, + headers: fetchResponse.headers, + } + + if (isRequestResultHaveErrors(result) && $params.fetchOptions.errorPolicy === `none`) { + // todo this client response on error is not consisitent with the data type for success + const clientResponse = + result._tag === `Batch` + ? { ...result.executionResults, ...clientResponseBase } + : { + ...result.executionResult, + ...clientResponseBase, + } + // @ts-expect-error todo + return new ClientError(clientResponse, { + query: params.request.query, + variables: params.request.variables, + }) + } + + if (result._tag === `Single`) { + // @ts-expect-error todo + return { + ...clientResponseBase, + ...executionResultClientResponseFields($params)(result.executionResult), + } + } + + if (result._tag === `Batch`) { + return { + ...clientResponseBase, + data: result.executionResults.map(executionResultClientResponseFields($params)), + } + } +} + +const executionResultClientResponseFields = + ($params: Params) => (executionResult: GraphQLExecutionResultSingle) => { + return { + extensions: executionResult.extensions, + data: executionResult.data, + errors: $params.fetchOptions.errorPolicy === `all` ? executionResult.errors : undefined, + } + } + +const parseResultFromResponse = async (response: Response, jsonSerializer: JsonSerializer) => { + const contentType = response.headers.get(CONTENT_TYPE_HEADER) + const text = await response.text() + if (contentType && isGraphQLContentType(contentType)) { + return parseGraphQLExecutionResult(jsonSerializer.parse(text)) + } else { + // todo what is this good for...? Seems very random/undefined + return parseGraphQLExecutionResult(text) + } +} + +const createFetcher = (method: 'GET' | 'POST') => async (params: Params) => { + const headers = new Headers(params.headers) + let queryParams = `` + let body = undefined + + if (!headers.has(ACCEPT_HEADER)) { + headers.set(ACCEPT_HEADER, [CONTENT_TYPE_GQL, CONTENT_TYPE_JSON].join(`, `)) + } + + if (method === `POST`) { + const $jsonSerializer = params.fetchOptions.jsonSerializer ?? defaultJsonSerializer + body = $jsonSerializer.stringify(buildBody(params)) + if (typeof body === `string` && !headers.has(CONTENT_TYPE_HEADER)) { + headers.set(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON) + } + } else { + queryParams = buildQueryParams(params) + } + + const init: RequestInit = { method, headers, body, ...params.fetchOptions } + + let urlResolved = params.url + let initResolved = init + if (params.middleware) { + const { + url, + request: { variables, operationName }, + } = params + const result = await Promise.resolve(params.middleware({ ...init, url, operationName, variables })) + const { url: urlNew, ...initNew } = result + urlResolved = urlNew + initResolved = initNew + } + if (queryParams) { + urlResolved = `${urlResolved}?${queryParams}` + } + const $fetch = params.fetch ?? fetch + return await $fetch(urlResolved, initResolved) +} + +const buildBody = (params: Params) => { + if (params.request._tag === `Single`) { + const { query, variables, operationName } = params.request + return { query, variables, operationName } + } else if (params.request._tag === `Batch`) { + return zip(params.request.query, params.request.variables ?? []).map(([query, variables]) => ({ + query, + variables, + })) + } else { + throw casesExhausted(params.request) + } +} + +const buildQueryParams = (params: Params): string => { + const $jsonSerializer = params.fetchOptions.jsonSerializer ?? defaultJsonSerializer + if (params.request._tag === `Single`) { + const search: string[] = [`query=${encodeURIComponent(cleanQuery(params.request.query))}`] + if (params.request.variables) { + search.push(`variables=${encodeURIComponent($jsonSerializer.stringify(params.request.variables))}`) + } + if (params.request.operationName) { + search.push(`operationName=${encodeURIComponent(params.request.operationName)}`) + } + return search.join(`&`) + } else if (params.request._tag === `Batch`) { + const variablesSerialized = params.request.variables?.map((v) => $jsonSerializer.stringify(v)) ?? [] + const queriesCleaned = params.request.query.map(cleanQuery) + const payload = zip(queriesCleaned, variablesSerialized).map(([query, variables]) => ({ + query, + variables, + })) + return `query=${encodeURIComponent($jsonSerializer.stringify(payload))}` + } else { + throw casesExhausted(params.request) + } +} diff --git a/src/helpers/types.ts b/src/helpers/types.ts index 0358523f7..86efd81b6 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,3 +1,4 @@ +import type { ClientError } from '../classes/ClientError.js' import type { MaybeLazy, RemoveIndex } from '../lib/prelude.js' import type { TypedDocumentNode } from '@graphql-typed-document-node/core' import type { GraphQLError } from 'graphql/error/GraphQLError.js' @@ -31,9 +32,8 @@ export interface FetchOptions extends RequestInit, AdditionalRequestOptions {} export type { GraphQLError } -export type Variables = Record - -export type BatchVariables = (Record | undefined)[] +export type Variables = object +export type BatchVariables = (Variables | undefined)[] export interface GraphQLResponse { data?: T @@ -48,34 +48,6 @@ export interface GraphQLRequestContext { variables?: V } -export class ClientError extends Error { - response: GraphQLResponse - request: GraphQLRequestContext - - constructor(response: GraphQLResponse, request: GraphQLRequestContext) { - const message = `${ClientError.extractMessage(response)}: ${JSON.stringify({ - response, - request, - })}` - - super(message) - - Object.setPrototypeOf(this, ClientError.prototype) - - this.response = response - this.request = request - - // this is needed as Safari doesn't support .captureStackTrace - if (typeof Error.captureStackTrace === `function`) { - Error.captureStackTrace(this, ClientError) - } - } - - private static extractMessage(response: GraphQLResponse): string { - return response.errors?.[0]?.message ?? `GraphQL Error (Code: ${response.status})` - } -} - export type RequestDocument = string | DocumentNode export interface GraphQLClientResponse { @@ -105,8 +77,8 @@ export type RawRequestOptions = { } & (V extends Record ? { variables?: V } : keyof RemoveIndex extends never - ? { variables?: V } - : { variables: V }) + ? { variables?: V } + : { variables: V }) export type RequestOptions = { document: RequestDocument | TypedDocumentNode @@ -115,8 +87,8 @@ export type RequestOptions = { } & (V extends Record ? { variables?: V } : keyof RemoveIndex extends never - ? { variables?: V } - : { variables: V }) + ? { variables?: V } + : { variables: V }) export type ResponseMiddleware = (response: GraphQLClientResponse | ClientError | Error) => void diff --git a/src/lib/graphql-ws.ts b/src/lib/graphql-ws.ts index c0d6112d3..b211ed5c4 100644 --- a/src/lib/graphql-ws.ts +++ b/src/lib/graphql-ws.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import { resolveRequestDocument } from '../helpers/resolveRequestDocument.js' import type { RequestDocument, Variables } from '../helpers/types.js' -import { ClientError } from '../helpers/types.js' +import { ClientError } from '../classes/ClientError.js' import { TypedDocumentNode } from '@graphql-typed-document-node/core' // import type WebSocket from 'ws' diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index e2bd247e9..3cb586067 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -1,4 +1,5 @@ import { CONTENT_TYPE_GQL, CONTENT_TYPE_JSON } from './http.js' +import { isPlainObject } from './prelude.js' /** * Clean a GraphQL document to send it via a GET query @@ -10,3 +11,80 @@ export const isGraphQLContentType = (contentType: string) => { return contentTypeLower.includes(CONTENT_TYPE_GQL) || contentTypeLower.includes(CONTENT_TYPE_JSON) } + +export type GraphQLRequestResult = GraphQLRequestResultBatch | GraphQLRequestResultSingle +export type GraphQLRequestResultBatch = { _tag: 'Batch'; executionResults: GraphQLExecutionResultBatch } +export type GraphQLRequestResultSingle = { _tag: 'Single'; executionResult: GraphQLExecutionResultSingle } + +export type GraphQLExecutionResult = GraphQLExecutionResultSingle | GraphQLExecutionResultBatch +export type GraphQLExecutionResultBatch = GraphQLExecutionResultSingle[] +export type GraphQLExecutionResultSingle = { + data: object | undefined + errors: undefined | object | object[] + extensions?: object +} + +export const parseGraphQLExecutionResult = (result: unknown): Error | GraphQLRequestResult => { + try { + if (Array.isArray(result)) { + return { + _tag: `Batch` as const, + executionResults: result.map(parseExecutionResult), + } + } else if (isPlainObject(result)) { + return { + _tag: `Single`, + executionResult: parseExecutionResult(result), + } + } else { + throw new Error(`Invalid execution result: result is not object or array. \nGot:\n${String(result)}`) + } + } catch (e) { + return e as Error + } +} + +export const parseExecutionResult = (result: unknown): GraphQLExecutionResultSingle => { + if (typeof result !== `object` || result === null) { + throw new Error(`Invalid execution result: result is not object`) + } + + let errors = undefined + let data = undefined + let extensions = undefined + + if (`errors` in result) { + if (!isPlainObject(result.errors) && !Array.isArray(result.errors)) { + throw new Error(`Invalid execution result: errors is not plain object OR array`) + } + errors = result.errors + } + + if (`data` in result) { + if (!isPlainObject(result.data)) { + throw new Error(`Invalid execution result: data is not plain object`) + } + data = result.data + } + + if (`extensions` in result) { + if (!isPlainObject(result.extensions)) { + throw new Error(`Invalid execution result: extensions is not plain object`) + } + extensions = result.extensions + } + + return { + data, + errors, + extensions, + } +} + +export const isRequestResultHaveErrors = (result: GraphQLRequestResult) => + result._tag === `Batch` + ? result.executionResults.some(isExecutionResultHaveErrors) + : isExecutionResultHaveErrors(result.executionResult) + +export const isExecutionResultHaveErrors = (result: GraphQLExecutionResultSingle) => + Array.isArray(result.errors) ? result.errors.length > 0 : Boolean(result.errors) diff --git a/src/lib/http.ts b/src/lib/http.ts index 63c7e859b..410f9919f 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -2,3 +2,6 @@ export const ACCEPT_HEADER = `Accept` export const CONTENT_TYPE_HEADER = `Content-Type` export const CONTENT_TYPE_JSON = `application/json` export const CONTENT_TYPE_GQL = `application/graphql-response+json` +export const statusCodes = { + success: 200, +} diff --git a/src/lib/prelude.ts b/src/lib/prelude.ts index 21a6c4264..65f59c9e7 100644 --- a/src/lib/prelude.ts +++ b/src/lib/prelude.ts @@ -4,9 +4,32 @@ export type RemoveIndex = { export const uppercase = (str: S): Uppercase => str.toUpperCase() as Uppercase -/** - * Convert Headers instance into regular object - */ +export const callOrIdentity = (value: MaybeLazy) => { + return typeof value === `function` ? (value as () => T)() : value +} + +export type MaybeLazy = T | (() => T) + +export const zip = (a: A[], b: B[]): [A, B | undefined][] => a.map((k, i) => [k, b[i]]) + +export const HeadersInitToPlainObject = (headers?: HeadersInit): Record => { + let oHeaders: Record = {} + + if (headers instanceof Headers) { + oHeaders = HeadersInstanceToPlainObject(headers) + } else if (Array.isArray(headers)) { + headers.forEach(([name, value]) => { + if (name && value !== undefined) { + oHeaders[name] = value + } + }) + } else if (headers) { + oHeaders = headers + } + + return oHeaders +} + export const HeadersInstanceToPlainObject = (headers: Response['headers']): Record => { const o: Record = {} headers.forEach((v, k) => { @@ -15,8 +38,48 @@ export const HeadersInstanceToPlainObject = (headers: Response['headers']): Reco return o } -export const callOrIdentity = (value: MaybeLazy) => { - return typeof value === `function` ? (value as () => T)() : value +export const tryCatch = <$Return, $Throw extends Error = Error>( + fn: () => $Return, +): $Return extends Promise ? Promise | $Throw> : $Return | $Throw => { + try { + const result = fn() as any // eslint-disable-line + if (isPromiseLikeValue(result)) { + return result.catch((error) => { + return errorFromMaybeError(error) + }) as any + } + return result + } catch (error) { + return errorFromMaybeError(error) as any + } } -export type MaybeLazy = T | (() => T) +/** + * Ensure that the given value is an error and return it. If it is not an error than + * wrap it in one, passing the given value as the error message. + */ +export const errorFromMaybeError = (maybeError: unknown): Error => { + if (maybeError instanceof Error) return maybeError + return new Error(String(maybeError)) +} + +export const isPromiseLikeValue = (value: unknown): value is Promise => { + return ( + typeof value === `object` && + value !== null && + `then` in value && + typeof value.then === `function` && + `catch` in value && + typeof value.catch === `function` && + `finally` in value && + typeof value.finally === `function` + ) +} + +export const casesExhausted = (value: never): never => { + throw new Error(`Unhandled case: ${String(value)}`) +} + +export const isPlainObject = (value: unknown): value is object => { + return typeof value === `object` && value !== null && !Array.isArray(value) +} diff --git a/tests/__helpers.ts b/tests/__helpers.ts index b062dc87d..4a453b51d 100644 --- a/tests/__helpers.ts +++ b/tests/__helpers.ts @@ -9,6 +9,16 @@ import { createServer } from 'http' import type { JsonArray, JsonObject } from 'type-fest' import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest' +export const errors = { + message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, + locations: [ + { + line: 1, + column: 1, + }, + ], +} + type CapturedRequest = Pick type Context = { diff --git a/tests/batching.test.ts b/tests/batching.test.ts index 5f25c7592..c7aa03732 100644 --- a/tests/batching.test.ts +++ b/tests/batching.test.ts @@ -1,6 +1,6 @@ import { batchRequests } from '../src/entrypoints/main.js' import type { MockSpecBatch } from './__helpers.js' -import { setupMockServer } from './__helpers.js' +import { errors, setupMockServer } from './__helpers.js' import { expect, test } from 'vitest' const mockServer = setupMockServer() @@ -21,22 +21,7 @@ test(`minimal double query`, async () => { }) test(`basic error`, async () => { - mockServer.res({ - body: [ - { - errors: { - message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, - locations: [ - { - line: 1, - column: 1, - }, - ], - }, - }, - ], - }) - + mockServer.res({ body: [{ errors }] }) await expect(batchRequests(mockServer.url, [{ document: `x` }])).rejects.toMatchInlineSnapshot( `[Error: GraphQL Error (Code: 200): {"response":{"0":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]}},"status":200,"headers":{}},"request":{"query":["x"],"variables":[null]}}]`, ) @@ -44,22 +29,8 @@ test(`basic error`, async () => { test(`successful query with another which make an error`, async () => { const firstResult = { data: { me: { id: `some-id` } } } - const secondResult = { - errors: { - message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, - locations: [ - { - line: 1, - column: 1, - }, - ], - }, - } - - mockServer.res({ - body: [firstResult, secondResult], - }) - + const secondResult = { errors } + mockServer.res({ body: [firstResult, secondResult] }) await expect( batchRequests(mockServer.url, [{ document: `{ me { id } }` }, { document: `x` }]), ).rejects.toMatchInlineSnapshot( diff --git a/tests/errorPolicy.test.ts b/tests/errorPolicy.test.ts index 8fb1ed08d..bbf687131 100644 --- a/tests/errorPolicy.test.ts +++ b/tests/errorPolicy.test.ts @@ -1,36 +1,35 @@ import { GraphQLClient } from '../src/entrypoints/main.js' -import { setupMockServer } from './__helpers.js' +import { errors, setupMockServer } from './__helpers.js' import { describe, expect, test } from 'vitest' const ctx = setupMockServer() -const errors = { - message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, - locations: [{ line: 1, column: 1 }], -} -describe(`should throw error`, () => { - test(`should throw error when error policy not set`, async () => { +const data = { test: {} } + +describe(`"none"`, () => { + test(`throws error`, async () => { ctx.res({ body: { data: {}, errors } }) await expect(() => new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow(`GraphQL Error`) }) - - test(`when error policy set to "none"`, async () => { + test(`is the default`, async () => { ctx.res({ body: { data: {}, errors } }) await expect(() => new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow(`GraphQL Error`) }) }) -describe(`should not throw error`, () => { - test(`when error policy set to "ignore" and return only data`, async () => { - ctx.res({ body: { data: { test: {} }, errors } }) +describe(`"ignore"`, () => { + test(`does not throw error, returns only data`, async () => { + ctx.res({ body: { data, errors } }) const res = await new GraphQLClient(ctx.url, { errorPolicy: `ignore` }).rawRequest(`x`) - expect(res).toEqual(expect.objectContaining({ data: { test: {} } })) + expect(res).toEqual(expect.objectContaining({ data })) expect(res).toEqual(expect.not.objectContaining({ errors })) }) +}) - test(`when error policy set to "all" and return both data and error`, async () => { - ctx.res({ body: { data: { test: {} }, errors } }) +describe(`"all"`, () => { + test(`does not throw, returns both data and error`, async () => { + ctx.res({ body: { data, errors } }) const res = await new GraphQLClient(ctx.url, { errorPolicy: `all` }).rawRequest(`x`) - expect(res).toEqual(expect.objectContaining({ data: { test: {} }, errors })) + expect(res).toEqual(expect.objectContaining({ data, errors })) }) }) diff --git a/tests/general.test.ts b/tests/general.test.ts index 30d25273b..1f12d9b5b 100644 --- a/tests/general.test.ts +++ b/tests/general.test.ts @@ -1,40 +1,22 @@ import { GraphQLClient, rawRequest, request } from '../src/entrypoints/main.js' -import { setupMockServer } from './__helpers.js' +import { errors, setupMockServer } from './__helpers.js' import { gql } from 'graphql-tag' import type { Mock } from 'vitest' import { beforeEach, describe, expect, it, test, vitest } from 'vitest' const ctx = setupMockServer() -test(`minimal query`, async () => { - const { data } = ctx.res({ - body: { - data: { - me: { - id: `some-id`, - }, - }, - }, - }).spec.body! +const data = { me: { id: `some-id` } } - expect(await request(ctx.url, `{ me { id } }`)).toEqual(data) +test(`minimal query`, async () => { + const mockRes = ctx.res({ body: { data } }).spec.body! + expect(await request(ctx.url, `{ me { id } }`)).toEqual(mockRes.data) }) test(`minimal raw query`, async () => { - const { extensions, data } = ctx.res({ - body: { - data: { - me: { - id: `some-id`, - }, - }, - extensions: { - version: `1`, - }, - }, - }).spec.body! + const mockRes = ctx.res({ body: { data, extensions: { version: `1` } } }).spec.body! const { headers: _, ...result } = await rawRequest(ctx.url, `{ me { id } }`) - expect(result).toEqual({ data, extensions, status: 200 }) + expect(result).toEqual({ data: mockRes.data, extensions: mockRes.extensions, status: 200 }) }) test(`minimal raw query with response headers`, async () => { @@ -44,59 +26,26 @@ test(`minimal raw query with response headers`, async () => { 'X-Custom-Header': `test-custom-header`, }, body: { - data: { - me: { - id: `some-id`, - }, - }, - extensions: { - version: `1`, - }, + data, + extensions: { version: `1` }, }, }).spec const { headers, ...result } = await rawRequest(ctx.url, `{ me { id } }`) - expect(result).toEqual({ ...body, status: 200 }) expect(headers.get(`X-Custom-Header`)).toEqual(reqHeaders![`X-Custom-Header`]) }) test(`basic error`, async () => { - ctx.res({ - body: { - errors: { - message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, - locations: [ - { - line: 1, - column: 1, - }, - ], - }, - }, - }) - + ctx.res({ body: { errors } }) const res = await request(ctx.url, `x`).catch((x) => x) - expect(res).toMatchInlineSnapshot( `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`, ) }) test(`basic error with raw request`, async () => { - ctx.res({ - body: { - errors: { - message: `Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n`, - locations: [ - { - line: 1, - column: 1, - }, - ], - }, - }, - }) + ctx.res({ body: { errors } }) const res: unknown = await rawRequest(ctx.url, `x`).catch((x) => x) expect(res).toMatchInlineSnapshot( `[Error: GraphQL Error (Code: 200): {"response":{"errors":{"message":"Syntax Error GraphQL request (1:1) Unexpected Name \\"x\\"\\n\\n1: x\\n ^\\n","locations":[{"line":1,"column":1}]},"status":200,"headers":{}},"request":{"query":"x"}}]`, @@ -373,15 +322,8 @@ describe(`excludeOperationName`, () => { }) test(`should not throw error when errors property is an empty array (occurred when using UltraGraphQL)`, async () => { - ctx.res({ - body: { - data: { test: `test` }, - errors: [], - }, - }) - + ctx.res({ body: { data: { test: `test` }, errors: [] } }) const res = await new GraphQLClient(ctx.url).request(`{ test }`) - expect(res).toEqual(expect.objectContaining({ test: `test` })) }) diff --git a/tests/json-serializer.test.ts b/tests/json-serializer.test.ts index 569aaecad..3362ac17e 100644 --- a/tests/json-serializer.test.ts +++ b/tests/json-serializer.test.ts @@ -1,5 +1,6 @@ import { GraphQLClient } from '../src/entrypoints/main.js' import type { Fetch, Variables } from '../src/helpers/types.js' +import { CONTENT_TYPE_HEADER, statusCodes } from '../src/lib/http.js' import { setupMockServer } from './__helpers.js' import { beforeEach, describe, expect, test, vitest } from 'vitest' @@ -14,10 +15,8 @@ const testData = { data: { test: { name: `test` } } } const createMockFetch = (): Fetch => () => { const response = new Response(JSON.stringify(testData), { - headers: new Headers({ - 'Content-Type': `application/json; charset=utf-8`, - }), - status: 200, + headers: new Headers({ [CONTENT_TYPE_HEADER]: `application/json; charset=utf-8` }), + status: statusCodes.success, }) return Promise.resolve(response) } diff --git a/tsconfig.json b/tsconfig.json index 673dc5feb..f4887aca8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,8 +23,8 @@ // Other "skipLibCheck": true, - "esModuleInterop": true + "esModuleInterop": true, }, "include": ["src", "tests", "examples"], - "exclude": ["build"] + "exclude": ["build"], }