From b01d7538c016ffb02c899c4cffdbf6e179486721 Mon Sep 17 00:00:00 2001 From: Martynas Date: Thu, 17 Mar 2022 14:19:23 +0200 Subject: [PATCH] feat: custom JSON serializer support (#324) --- README.md | 25 ++++++++++ src/createRequestBody.ts | 12 +++-- src/defaultJsonSerializer.ts | 6 +++ src/index.ts | 22 +++++---- src/types.dom.ts | 6 +++ tests/general.test.ts | 2 +- tests/json-serializer.test.ts | 87 +++++++++++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 src/defaultJsonSerializer.ts create mode 100644 tests/json-serializer.test.ts diff --git a/README.md b/README.md index a918b18ff..53651e6cb 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps - [Incrementally setting headers](#incrementally-setting-headers) - [Passing Headers in each request](#passing-headers-in-each-request) - [Passing more options to `fetch`](#passing-more-options-to-fetch) + - [Custom JSON serializer](#custom-json-serializer) - [Using GraphQL Document variables](#using-graphql-document-variables) - [GraphQL Mutations](#graphql-mutations) - [Error handling](#error-handling) @@ -246,6 +247,30 @@ main().catch((error) => console.error(error)) [TypeScript Source](examples/passing-more-options-to-fetch.ts) +### Custom JSON serializer + +If you want to use non-standard JSON types, you can use your own JSON serializer to replace `JSON.parse`/`JSON.stringify` used by the `GraphQLClient`. + +An original use case for this feature is `BigInt` support: + +```js +import JSONbig from 'json-bigint' +import { GraphQLClient, gql } from 'graphql-request' + +async function main() { + const jsonSerializer = JSONbig({ useNativeBigInt: true }) + const graphQLClient = new GraphQLClient(endpoint, { jsonSerializer }) + const data = await graphQLClient.request( + gql` + { + someBigInt + } + ` + ) + console.log(typeof data.someBigInt) // if >MAX_SAFE_INTEGER then 'bigint' else 'number' +} +``` + ### Using GraphQL Document variables ```js diff --git a/src/createRequestBody.ts b/src/createRequestBody.ts index 9719b317e..263006867 100644 --- a/src/createRequestBody.ts +++ b/src/createRequestBody.ts @@ -1,5 +1,6 @@ import { isExtractableFile, extractFiles, ExtractableFile } from 'extract-files' import FormDataNode from 'form-data' +import { defaultJsonSerializer } from './defaultJsonSerializer' import { Variables } from './types' @@ -19,13 +20,14 @@ const isExtractableFileEnhanced = (value: any): value is ExtractableFile | { pip export default function createRequestBody( query: string | string[], variables?: Variables | Variables[], - operationName?: string + operationName?: string, + jsonSerializer = defaultJsonSerializer ): string | FormData { const { clone, files } = extractFiles({ query, variables, operationName }, '', isExtractableFileEnhanced) if (files.size === 0) { if (!Array.isArray(query)) { - return JSON.stringify(clone) + return jsonSerializer.stringify(clone) } if (typeof variables !== 'undefined' && !Array.isArray(variables)) { @@ -41,21 +43,21 @@ export default function createRequestBody( [] ) - return JSON.stringify(payload) + return jsonSerializer.stringify(payload) } const Form = typeof FormData === 'undefined' ? FormDataNode : FormData const form = new Form() - form.append('operations', JSON.stringify(clone)) + form.append('operations', jsonSerializer.stringify(clone)) const map: { [key: number]: string[] } = {} let i = 0 files.forEach((paths) => { map[++i] = paths }) - form.append('map', JSON.stringify(map)) + form.append('map', jsonSerializer.stringify(map)) i = 0 files.forEach((paths, file) => { diff --git a/src/defaultJsonSerializer.ts b/src/defaultJsonSerializer.ts new file mode 100644 index 000000000..09ba3cab9 --- /dev/null +++ b/src/defaultJsonSerializer.ts @@ -0,0 +1,6 @@ +import { JsonSerializer } from "./types.dom"; + +export const defaultJsonSerializer: JsonSerializer = { + parse: JSON.parse, + stringify: JSON.stringify +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c381238b1..bd327b839 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { OperationDefinitionNode, DocumentNode } from 'graphql/language/ast' import { parse } from 'graphql/language/parser' import { print } from 'graphql/language/printer' import createRequestBody from './createRequestBody' +import { defaultJsonSerializer } from './defaultJsonSerializer' import { parseBatchRequestArgs, parseRawRequestArgs, @@ -71,8 +72,8 @@ const resolveHeaders = (headers: Dom.RequestInit['headers']): Record str.replace(/([\s,]|#[^\n\r]+)+/g, ' ').trim() type TBuildGetQueryParams = - | { query: string; variables: V | undefined; operationName: string | undefined } - | { query: string[]; variables: V[] | undefined; operationName: undefined } + | { query: string; variables: V | undefined; operationName: string | undefined; jsonSerializer: Dom.JsonSerializer } + | { query: string[]; variables: V[] | undefined; operationName: undefined; jsonSerializer: Dom.JsonSerializer } /** * Create query string for GraphQL request @@ -83,12 +84,12 @@ type TBuildGetQueryParams = * @param {string|undefined} param0.operationName the GraphQL operation name * @param {any|any[]} param0.variables the GraphQL variables to use */ -const buildGetQueryParams = ({ query, variables, operationName }: TBuildGetQueryParams): string => { +const buildGetQueryParams = ({ query, variables, operationName, jsonSerializer }: TBuildGetQueryParams): string => { if (!Array.isArray(query)) { const search: string[] = [`query=${encodeURIComponent(queryCleanner(query))}`] if (variables) { - search.push(`variables=${encodeURIComponent(JSON.stringify(variables))}`) + search.push(`variables=${encodeURIComponent(jsonSerializer.stringify(variables))}`) } if (operationName) { @@ -107,14 +108,14 @@ const buildGetQueryParams = ({ query, variables, operationName }: TBuildGetQu (accu, currentQuery, index) => { accu.push({ query: queryCleanner(currentQuery), - variables: variables ? JSON.stringify(variables[index]) : undefined, + variables: variables ? jsonSerializer.stringify(variables[index]) : undefined, }) return accu }, [] ) - return `query=${encodeURIComponent(JSON.stringify(payload))}` + return `query=${encodeURIComponent(jsonSerializer.stringify(payload))}` } /** @@ -137,7 +138,7 @@ const post = async ({ headers?: Dom.RequestInit['headers'] operationName?: string }) => { - const body = createRequestBody(query, variables, operationName) + const body = createRequestBody(query, variables, operationName, fetchOptions.jsonSerializer) return await fetch(url, { method: 'POST', @@ -174,6 +175,7 @@ const get = async ({ query, variables, operationName, + jsonSerializer: fetchOptions.jsonSerializer } as TBuildGetQueryParams) return await fetch(`${url}?${queryParams}`, { @@ -381,7 +383,7 @@ async function makeRequest({ fetch, fetchOptions, }) - const result = await getResult(response) + const result = await getResult(response, fetchOptions.jsonSerializer) const successfullyReceivedData = isBathchingQuery && Array.isArray(result) ? !result.some(({ data }) => !data) : !!result.data @@ -538,7 +540,7 @@ export default request /** * todo */ -function getResult(response: Dom.Response): Promise { +async function getResult(response: Dom.Response, jsonSerializer = defaultJsonSerializer): Promise { let contentType: string | undefined response.headers.forEach((value, key) => { @@ -548,7 +550,7 @@ function getResult(response: Dom.Response): Promise { }) if (contentType && contentType.toLowerCase().startsWith('application/json')) { - return response.json() + return jsonSerializer.parse(await response.text()) } else { return response.text() } diff --git a/src/types.dom.ts b/src/types.dom.ts index c5502181f..a6172d067 100644 --- a/src/types.dom.ts +++ b/src/types.dom.ts @@ -278,6 +278,11 @@ interface AbortSignal extends EventTarget { ): void } +export interface JsonSerializer { + stringify(obj: any): string; + parse(obj: string): unknown; +} + export interface RequestInit { body?: BodyInit | null cache?: RequestCache @@ -294,6 +299,7 @@ export interface RequestInit { timeout?: number window?: any fetch?: any + jsonSerializer?: JsonSerializer } interface Body { diff --git a/tests/general.test.ts b/tests/general.test.ts index ac6ce0ff9..4119c901d 100644 --- a/tests/general.test.ts +++ b/tests/general.test.ts @@ -178,7 +178,7 @@ test('case-insensitive content-type header for custom fetch', async () => { const client = new GraphQLClient(ctx.url, options) const result = await client.request('{ test }') - expect(result).toBe(testData.data) + expect(result).toEqual(testData.data) }) describe('operationName parsing', () => { diff --git a/tests/json-serializer.test.ts b/tests/json-serializer.test.ts new file mode 100644 index 000000000..fa1ea4bba --- /dev/null +++ b/tests/json-serializer.test.ts @@ -0,0 +1,87 @@ +import { createReadStream } from 'fs' +import { join } from 'path' +import { GraphQLClient } from '../src' +import { setupTestServer } from './__helpers' +import * as Dom from '../src/types.dom' + +const ctx = setupTestServer() + +describe('jsonSerializer option', () => { + let serializer: Dom.JsonSerializer; + const testData = { data: { test: { name: 'test' } } } + let fetch: any; + + beforeEach(() => { + serializer = { + stringify: jest.fn(JSON.stringify), + parse: jest.fn(JSON.parse) + } + fetch = (url: string) => Promise.resolve({ + headers: new Map([['Content-Type', 'application/json; charset=utf-8']]), + data: testData, + text: function () { + return JSON.stringify(testData) + }, + ok: true, + status: 200, + url, + }); + }) + + test('is used for parsing response body', async () => { + const options: Dom.RequestInit = { jsonSerializer: serializer, fetch }; + const client: GraphQLClient = new GraphQLClient(ctx.url, options); + + const result = await client.request('{ test { name } }') + expect(result).toEqual(testData.data) + expect(serializer.parse).toBeCalledTimes(1) + }) + + describe('is used for serializing variables', () => { + const document = 'query getTest($name: String!) { test(name: $name) { name } }' + const simpleVariable = { name: 'test' } + + let options: Dom.RequestInit + let client: GraphQLClient + + const testSingleQuery = (expectedNumStringifyCalls = 1, variables: any = simpleVariable) => async () => { + await client.request(document, variables) + expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) + } + + const testBatchQuery = (expectedNumStringifyCalls: number, variables: any = simpleVariable) => async () => { + await client.batchRequests([{document, variables}]) + expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) + } + + describe('request body', () => { + beforeEach(() => { + options = { jsonSerializer: serializer, fetch } + client = new GraphQLClient(ctx.url, options) + }) + + describe('without files', () => { + test('single query', testSingleQuery()) + test('batch query', testBatchQuery(1)) + }) + + describe('with files', () => { + const fileName = 'upload.test.ts' + const file = createReadStream(join(__dirname, fileName)) + + test('single query', testSingleQuery(2, {...simpleVariable, file})) + test('batch query', testBatchQuery(2, {...simpleVariable, file})) + }) + }) + + describe('query string', () => { + beforeEach(() => { + options = { jsonSerializer: serializer, fetch, method: 'GET' } + client = new GraphQLClient(ctx.url, options) + }) + + test('single query', testSingleQuery()) + test('batch query', testBatchQuery(2)) // once for variable and once for query batch array + }) + }) +})