Skip to content

Commit

Permalink
feat: add batching support with batchRequests (#285)
Browse files Browse the repository at this point in the history
  • Loading branch information
GeoffreyHervet authored Oct 12, 2021
1 parent a6d1365 commit 2d92782
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 40 deletions.
57 changes: 57 additions & 0 deletions examples/batching-requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { batchRequests } from '../src'
;(async function () {
const endpoint = 'https://api.spacex.land/graphql/'

const query1 = /* GraphQL */ `
query ($id: ID!) {
capsule(id: $id) {
id
landings
}
}
`
const variables1 = {
id: 'C105',
}

interface TData1 {
data: { capsule: { id: string; landings: number } }
}

const query2 = /* GraphQL */ `
{
rockets(limit: 10) {
active
}
}
`

interface TData2 {
data: { rockets: { active: boolean }[] }
}

const query3 = /* GraphQL */ `
query ($id: ID!) {
core(id: $id) {
id
block
original_launch
}
}
`

const variables3 = {
id: 'B1015',
}

interface TData3 {
data: { core: { id: string; block: number; original_launch: string } }
}

const data = await batchRequests<[TData1, TData2, TData3]>(endpoint, [
{ document: query1, variables: variables1 },
{ document: query2 },
{ document: query3, variables: variables3 },
])
console.log(JSON.stringify(data, undefined, 2))
})().catch((error) => console.error(error))
25 changes: 23 additions & 2 deletions src/createRequestBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,32 @@ const isExtractableFileEnhanced = (value: any): value is ExtractableFile | { pip
* (https://github.com/jaydenseric/graphql-multipart-request-spec)
* Otherwise returns JSON
*/
export default function createRequestBody(query: string, variables?: Variables, operationName?: string): string | FormData {
export default function createRequestBody(
query: string | string[],
variables?: Variables | Variables[],
operationName?: string
): string | FormData {
const { clone, files } = extractFiles({ query, variables, operationName }, '', isExtractableFileEnhanced)

if (files.size === 0) {
return JSON.stringify(clone)
if (!Array.isArray(query)) {
return JSON.stringify(clone)
}

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 }[]>(
(accu, currentQuery, index) => {
accu.push({ query: currentQuery, variables: variables ? variables[index] : undefined })
return accu
},
[]
)

return JSON.stringify(payload)
}

const Form = typeof FormData === 'undefined' ? FormDataNode : FormData
Expand Down
164 changes: 148 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import crossFetch, * as CrossFetch from 'cross-fetch'
import { OperationDefinitionNode } from 'graphql/language/ast'
import { print } from 'graphql/language/printer'
import createRequestBody from './createRequestBody'
import { ClientError, RequestDocument, Variables } from './types'
import { BatchRequestDocument, ClientError, RequestDocument, Variables } from './types'
import * as Dom from './types.dom'

export { ClientError } from './types'
Expand Down Expand Up @@ -30,6 +30,61 @@ const resolveHeaders = (headers: Dom.RequestInit['headers']): Record<string, str
return oHeaders
}

/**
* Clean a GraphQL document to send it via a GET query
*
* @param {string} str GraphQL query
* @returns {string} Cleaned query
*/
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 }

/**
* Create query string for GraphQL request
*
* @param {object} param0 -
*
* @param {string|string[]} param0.query the GraphQL document or array of document if it's a batch request
* @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 => {
if (!Array.isArray(query)) {
const search: string[] = [`query=${encodeURIComponent(queryCleanner(query))}`]

if (variables) {
search.push(`variables=${encodeURIComponent(JSON.stringify(variables))}`)
}

if (operationName) {
search.push(`operationName=${encodeURIComponent(operationName)}`)
}

return search.join('&')
}

if (typeof variables !== 'undefined' && !Array.isArray(variables)) {
throw new Error('Cannot create query with given variable type, array expected')
}

// Batch support
const payload = query.reduce<{ query: string; variables: string | undefined }[]>(
(accu, currentQuery, index) => {
accu.push({
query: queryCleanner(currentQuery),
variables: variables ? JSON.stringify(variables[index]) : undefined,
})
return accu
},
[]
)

return `query=${encodeURIComponent(JSON.stringify(payload))}`
}

/**
* Fetch data using POST method
*/
Expand All @@ -43,7 +98,7 @@ const post = async <V = Variables>({
fetchOptions,
}: {
url: string
query: string
query: string | string[]
fetch: any
fetchOptions: Dom.RequestInit
variables?: V
Expand Down Expand Up @@ -76,24 +131,20 @@ const get = async <V = Variables>({
fetchOptions,
}: {
url: string
query: string
query: string | string[]
fetch: any
fetchOptions: Dom.RequestInit
variables?: V
headers?: HeadersInit
operationName?: string
}) => {
const search: string[] = [`query=${encodeURIComponent(query.replace(/([\s,]|#[^\n\r]+)+/g, ' ').trim())}`]

if (variables) {
search.push(`variables=${encodeURIComponent(JSON.stringify(variables))}`)
}

if (operationName) {
search.push(`operationName=${encodeURIComponent(operationName)}`)
}
const queryParams = buildGetQueryParams<V>({
query,
variables,
operationName,
} as TBuildGetQueryParams<V>)

return await fetch(`${url}?${search.join('&')}`, {
return await fetch(`${url}?${queryParams}`, {
method: 'GET',
headers,
...fetchOptions,
Expand Down Expand Up @@ -165,6 +216,36 @@ export class GraphQLClient {
return data
}

/**
* Send a GraphQL document to the server.
*/
async batchRequests<T extends any = any, V = Variables>(
documents: BatchRequestDocument<V>[],
requestHeaders?: Dom.RequestInit['headers']
): Promise<T> {
let { headers, fetch = crossFetch, method = 'POST', ...fetchOptions } = this.options
let { url } = this

const queries = documents.map(({ document }) => resolveRequestDocument(document).query)
const variables = documents.map(({ variables }) => variables)

const { data } = await makeRequest<T, (V | undefined)[]>({
url,
query: queries,
variables,
headers: {
...resolveHeaders(headers),
...resolveHeaders(requestHeaders),
},
operationName: undefined,
fetch,
method,
fetchOptions,
})

return data
}

setHeaders(headers: Dom.RequestInit['headers']): GraphQLClient {
this.options.headers = headers
return this
Expand Down Expand Up @@ -199,7 +280,7 @@ async function makeRequest<T = any, V = Variables>({
fetchOptions,
}: {
url: string
query: string
query: string | string[]
variables?: V
headers?: Dom.RequestInit['headers']
operationName?: string
Expand All @@ -208,6 +289,7 @@ async function makeRequest<T = any, V = Variables>({
fetchOptions: Dom.RequestInit
}): Promise<{ data: T; extensions?: any; headers: Dom.Headers; status: number }> {
const fetcher = method.toUpperCase() === 'POST' ? post : get
const isBathchingQuery = Array.isArray(query)

const response = await fetcher({
url,
Expand All @@ -220,9 +302,16 @@ async function makeRequest<T = any, V = Variables>({
})
const result = await getResult(response)

if (response.ok && !result.errors && result.data) {
const successfullyReceivedData =
isBathchingQuery && Array.isArray(result) ? !result.some(({ data }) => !data) : !!result.data

if (response.ok && !result.errors && successfullyReceivedData) {
const { headers, status } = response
return { ...result, headers, status }
return {
...(isBathchingQuery ? { data: result } : result),
headers,
status,
}
} else {
const errorResult = typeof result === 'string' ? { error: result } : result
throw new ClientError(
Expand Down Expand Up @@ -289,6 +378,49 @@ export async function request<T = any, V = Variables>(
return client.request<T, V>(document, variables, requestHeaders)
}

/**
* Send a batch of GraphQL Document to the GraphQL server for exectuion.
*
* @example
*
* ```ts
* // You can pass a raw string
*
* await request('https://foo.bar/graphql', [
* {
* query: `
* {
* query {
* users
* }
* }`
* },
* {
* query: `
* {
* query {
* users
* }
* }`
* }])
*
* // You can also pass a GraphQL DocumentNode as query. Convenient if you
* // are using graphql-tag package.
*
* import gql from 'graphql-tag'
*
* await request('https://foo.bar/graphql', [{ query: gql`...` }])
* ```
*/
export async function batchRequests<T extends any = any, V = Variables>(
url: string,
documents: BatchRequestDocument<V>[],
requestHeaders?: Dom.RequestInit['headers']
): Promise<T> {
const client = new GraphQLClient(url)
return client.batchRequests<T, V>(documents, requestHeaders)
}

export default request

/**
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface GraphQLResponse<T = any> {
}

export interface GraphQLRequestContext<V = Variables> {
query: string
query: string | string[]
variables?: V
}

Expand Down Expand Up @@ -55,3 +55,8 @@ export class ClientError extends Error {
}

export type RequestDocument = string | DocumentNode

export type BatchRequestDocument<V = Variables> = {
document: RequestDocument
variables?: V
}
Loading

0 comments on commit 2d92782

Please sign in to comment.