Skip to content

Commit

Permalink
feat: custom JSON serializer support (#324)
Browse files Browse the repository at this point in the history
  • Loading branch information
mkazlauskas authored Mar 17, 2022
1 parent d71028c commit b01d753
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 16 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions src/createRequestBody.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isExtractableFile, extractFiles, ExtractableFile } from 'extract-files'
import FormDataNode from 'form-data'
import { defaultJsonSerializer } from './defaultJsonSerializer'

import { Variables } from './types'

Expand All @@ -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)) {
Expand All @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions src/defaultJsonSerializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { JsonSerializer } from "./types.dom";

export const defaultJsonSerializer: JsonSerializer = {
parse: JSON.parse,
stringify: JSON.stringify
}
22 changes: 12 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,8 +72,8 @@ const resolveHeaders = (headers: Dom.RequestInit['headers']): Record<string, str
const queryCleanner = (str: string): string => str.replace(/([\s,]|#[^\n\r]+)+/g, ' ').trim()

type TBuildGetQueryParams<V> =
| { 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
Expand All @@ -83,12 +84,12 @@ type TBuildGetQueryParams<V> =
* @param {string|undefined} param0.operationName the GraphQL operation name
* @param {any|any[]} param0.variables the GraphQL variables to use
*/
const buildGetQueryParams = <V>({ query, variables, operationName }: TBuildGetQueryParams<V>): string => {
const buildGetQueryParams = <V>({ query, variables, operationName, jsonSerializer }: TBuildGetQueryParams<V>): 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) {
Expand All @@ -107,14 +108,14 @@ const buildGetQueryParams = <V>({ 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))}`
}

/**
Expand All @@ -137,7 +138,7 @@ const post = async <V = Variables>({
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',
Expand Down Expand Up @@ -174,6 +175,7 @@ const get = async <V = Variables>({
query,
variables,
operationName,
jsonSerializer: fetchOptions.jsonSerializer
} as TBuildGetQueryParams<V>)

return await fetch(`${url}?${queryParams}`, {
Expand Down Expand Up @@ -381,7 +383,7 @@ async function makeRequest<T = any, V = Variables>({
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
Expand Down Expand Up @@ -538,7 +540,7 @@ export default request
/**
* todo
*/
function getResult(response: Dom.Response): Promise<any> {
async function getResult(response: Dom.Response, jsonSerializer = defaultJsonSerializer): Promise<any> {
let contentType: string | undefined

response.headers.forEach((value, key) => {
Expand All @@ -548,7 +550,7 @@ function getResult(response: Dom.Response): Promise<any> {
})

if (contentType && contentType.toLowerCase().startsWith('application/json')) {
return response.json()
return jsonSerializer.parse(await response.text())
} else {
return response.text()
}
Expand Down
6 changes: 6 additions & 0 deletions src/types.dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -294,6 +299,7 @@ export interface RequestInit {
timeout?: number
window?: any
fetch?: any
jsonSerializer?: JsonSerializer
}

interface Body {
Expand Down
2 changes: 1 addition & 1 deletion tests/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
87 changes: 87 additions & 0 deletions tests/json-serializer.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})

0 comments on commit b01d753

Please sign in to comment.