diff --git a/README.md b/README.md index c6f5c3ca..67bb6bfa 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,78 @@ console.log('Listening to port 4000'); +
+🔗 Server handler usage with graphql-upload and http + +```js +import http from 'http'; +import { createHandler } from 'graphql-http/lib/use/http'; +import processRequest from 'graphql-upload/processRequest.mjs'; // yarn add graphql-upload +import { schema } from './my-graphql'; + +const handler = createHandler({ + schema, + async parseRequestParams(req) { + const params = await processRequest(req.raw, req.context.res); + if (Array.isArray(params)) { + throw new Error('Batching is not supported'); + } + return { + ...params, + // variables must be an object as per the GraphQL over HTTP spec + variables: Object(params.variables), + }; + }, +}); + +const server = http.createServer((req, res) => { + if (req.url.startsWith('/graphql')) { + handler(req, res); + } else { + res.writeHead(404).end(); + } +}); + +server.listen(4000); +console.log('Listening to port 4000'); +``` + +
+ +
+🔗 Server handler usage with graphql-upload and express + +```js +import express from 'express'; // yarn add express +import { createHandler } from 'graphql-http/lib/use/express'; +import processRequest from 'graphql-upload/processRequest.mjs'; // yarn add graphql-upload +import { schema } from './my-graphql'; + +const app = express(); +app.all( + '/graphql', + createHandler({ + schema, + async parseRequestParams(req) { + const params = await processRequest(req.raw, req.context.res); + if (Array.isArray(params)) { + throw new Error('Batching is not supported'); + } + return { + ...params, + // variables must be an object as per the GraphQL over HTTP spec + variables: Object(params.variables), + }; + }, + }), +); + +app.listen({ port: 4000 }); +console.log('Listening to port 4000'); +``` + +
+
🔗 Audit for servers usage in Jest environment diff --git a/docs/interfaces/handler.HandlerOptions.md b/docs/interfaces/handler.HandlerOptions.md index eaa1b738..7c3c1b53 100644 --- a/docs/interfaces/handler.HandlerOptions.md +++ b/docs/interfaces/handler.HandlerOptions.md @@ -23,6 +23,7 @@ - [onOperation](handler.HandlerOptions.md#onoperation) - [onSubscribe](handler.HandlerOptions.md#onsubscribe) - [parse](handler.HandlerOptions.md#parse) +- [parseRequestParams](handler.HandlerOptions.md#parserequestparams) - [rootValue](handler.HandlerOptions.md#rootvalue) - [schema](handler.HandlerOptions.md#schema) - [validate](handler.HandlerOptions.md#validate) @@ -203,6 +204,16 @@ GraphQL parse function allowing you to apply a custom parser. ___ +### parseRequestParams + +• `Optional` **parseRequestParams**: [`ParseRequestParams`](../modules/handler.md#parserequestparams)<`RequestRaw`, `RequestContext`\> + +The request parser for an incoming GraphQL request. + +Read more about it in [ParseRequestParams](../modules/handler.md#parserequestparams). + +___ + ### rootValue • `Optional` **rootValue**: `unknown` diff --git a/docs/modules/handler.md b/docs/modules/handler.md index 216b84a7..6ee6c03c 100644 --- a/docs/modules/handler.md +++ b/docs/modules/handler.md @@ -16,6 +16,7 @@ - [Handler](handler.md#handler) - [OperationArgs](handler.md#operationargs) - [OperationContext](handler.md#operationcontext) +- [ParseRequestParams](handler.md#parserequestparams) - [RequestHeaders](handler.md#requestheaders) - [Response](handler.md#response) - [ResponseBody](handler.md#responsebody) @@ -108,6 +109,48 @@ the `context` server option. ___ +### ParseRequestParams + +Ƭ **ParseRequestParams**<`RequestRaw`, `RequestContext`\>: (`req`: [`Request`](../interfaces/handler.Request.md)<`RequestRaw`, `RequestContext`\>) => `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void`\> \| [`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void` + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `RequestRaw` | `unknown` | +| `RequestContext` | `unknown` | + +#### Type declaration + +▸ (`req`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void`\> \| [`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void` + +The request parser for an incoming GraphQL request. It parses and validates the +request itself, including the request method and the content-type of the body. + +In case you are extending the server to handle more request types, this is the +perfect place to do so. + +If an error is thrown, it will be formatted using the provided [FormatError](handler.md#formaterror) +and handled following the spec to be gracefully reported to the client. + +Throwing an instance of `Error` will _always_ have the client respond with a `400: Bad Request` +and the error's message in the response body; however, if an instance of `GraphQLError` is thrown, +it will be reported depending on the accepted content-type. + +If you return nothing, the default parser will be used instead. + +##### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | [`Request`](../interfaces/handler.Request.md)<`RequestRaw`, `RequestContext`\> | + +##### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void`\> \| [`RequestParams`](../interfaces/common.RequestParams.md) \| [`Response`](handler.md#response) \| `void` + +___ + ### RequestHeaders Ƭ **RequestHeaders**: { `[key: string]`: `string` \| `string`[] \| `undefined`; `set-cookie?`: `string` \| `string`[] } \| { `get`: (`key`: `string`) => `string` \| ``null`` } diff --git a/src/__tests__/handler.ts b/src/__tests__/handler.ts index 5c276650..18691866 100644 --- a/src/__tests__/handler.ts +++ b/src/__tests__/handler.ts @@ -242,3 +242,116 @@ it('should respect plain errors toJSON implementation', async () => { } `); }); + +it('should use the custom request params parser', async () => { + const server = startTServer({ + parseRequestParams() { + return { + query: '{ hello }', + }; + }, + }); + + const url = new URL(server.url); + url.searchParams.set('query', '{ __typename }'); + const res = await fetch(url.toString(), { + // different methods and content-types are not disallowed by the spec + method: 'PUT', + headers: { 'content-type': 'application/lol' }, + }); + + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "data": { + "hello": "world", + }, + } + `); +}); + +it('should use the response returned from the custom request params parser', async () => { + const server = startTServer({ + parseRequestParams() { + return [ + 'Hello', + { status: 200, statusText: 'OK', headers: { 'x-hi': 'there' } }, + ]; + }, + }); + + const url = new URL(server.url); + url.searchParams.set('query', '{ __typename }'); + const res = await fetch(url.toString()); + + expect(res.ok).toBeTruthy(); + expect(res.headers.get('x-hi')).toBe('there'); + await expect(res.text()).resolves.toBe('Hello'); +}); + +it('should report thrown Error from custom request params parser', async () => { + const server = startTServer({ + parseRequestParams() { + throw new Error('Wrong.'); + }, + }); + + const url = new URL(server.url); + url.searchParams.set('query', '{ __typename }'); + const res = await fetch(url.toString()); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "errors": [ + { + "message": "Wrong.", + }, + ], + } + `); +}); + +it('should report thrown GraphQLError from custom request params parser', async () => { + const server = startTServer({ + parseRequestParams() { + throw new GraphQLError('Wronger.'); + }, + }); + + const url = new URL(server.url); + url.searchParams.set('query', '{ __typename }'); + const res = await fetch(url.toString(), { + headers: { accept: 'application/json' }, + }); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "errors": [ + { + "message": "Wronger.", + }, + ], + } + `); +}); + +it('should use the default if nothing is returned from the custom request params parser', async () => { + const server = startTServer({ + parseRequestParams() { + return; + }, + }); + + const url = new URL(server.url); + url.searchParams.set('query', '{ hello }'); + const res = await fetch(url.toString()); + + await expect(res.json()).resolves.toMatchInlineSnapshot(` + { + "data": { + "hello": "world", + }, + } + `); +}); diff --git a/src/handler.ts b/src/handler.ts index edfb7ba9..3f0f107d 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -161,6 +161,31 @@ export type FormatError = ( err: Readonly, ) => GraphQLError | Error; +/** + * The request parser for an incoming GraphQL request. It parses and validates the + * request itself, including the request method and the content-type of the body. + * + * In case you are extending the server to handle more request types, this is the + * perfect place to do so. + * + * If an error is thrown, it will be formatted using the provided {@link FormatError} + * and handled following the spec to be gracefully reported to the client. + * + * Throwing an instance of `Error` will _always_ have the client respond with a `400: Bad Request` + * and the error's message in the response body; however, if an instance of `GraphQLError` is thrown, + * it will be reported depending on the accepted content-type. + * + * If you return nothing, the default parser will be used instead. + * + * @category Server + */ +export type ParseRequestParams< + RequestRaw = unknown, + RequestContext = unknown, +> = ( + req: Request, +) => Promise | RequestParams | Response | void; + /** @category Server */ export type OperationArgs = ExecutionArgs & { contextValue?: Context }; @@ -326,6 +351,12 @@ export interface HandlerOptions< * this formatter. */ formatError?: FormatError; + /** + * The request parser for an incoming GraphQL request. + * + * Read more about it in {@link ParseRequestParams}. + */ + parseRequestParams?: ParseRequestParams; } /** @@ -416,23 +447,10 @@ export function createHandler< onSubscribe, onOperation, formatError = (err) => err, + parseRequestParams = defaultParseRequestParams, } = options; return async function handler(req) { - const method = req.method; - if (method !== 'GET' && method !== 'POST') { - return [ - null, - { - status: 405, - statusText: 'Method Not Allowed', - headers: { - allow: 'GET, POST', - }, - }, - ]; - } - let acceptedMediaType: AcceptableMediaType | null = null; const accepts = (getHeader(req, 'accept') || '*/*') .replace(/\s/g, '') @@ -478,96 +496,12 @@ export function createHandler< ]; } - // TODO: should graphql-http care about content-encoding? I'd say unzipping should happen before handler is reached - - const [ - mediaType, - charset = 'charset=utf-8', // utf-8 is assumed when not specified. this parameter is either "charset" or "boundary" (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) - ] = (getHeader(req, 'content-type') || '') - .replace(/\s/g, '') - .toLowerCase() - .split(';'); - - let params; + let params: RequestParams; try { - const partParams: Partial = {}; - switch (true) { - case method === 'GET': { - // TODO: what if content-type is specified and is not application/x-www-form-urlencoded? - try { - const [, search] = req.url.split('?'); - const searchParams = new URLSearchParams(search); - partParams.operationName = - searchParams.get('operationName') ?? undefined; - partParams.query = searchParams.get('query') ?? undefined; - const variables = searchParams.get('variables'); - if (variables) partParams.variables = JSON.parse(variables); - const extensions = searchParams.get('extensions'); - if (extensions) partParams.extensions = JSON.parse(extensions); - } catch { - throw new Error('Unparsable URL'); - } - break; - } - case method === 'POST' && - mediaType === 'application/json' && - charset === 'charset=utf-8': { - if (!req.body) { - throw new Error('Missing body'); - } - let data; - try { - const body = - typeof req.body === 'function' ? await req.body() : req.body; - data = typeof body === 'string' ? JSON.parse(body) : body; - } catch (err) { - throw new Error('Unparsable JSON body'); - } - if (!isObject(data)) { - throw new Error('JSON body must be an object'); - } - partParams.operationName = data.operationName; - partParams.query = data.query; - partParams.variables = data.variables; - partParams.extensions = data.extensions; - break; - } - default: // graphql-http doesnt support any other content type - return [ - null, - { - status: 415, - statusText: 'Unsupported Media Type', - }, - ]; - } - - if (partParams.query == null) throw new Error('Missing query'); - if (typeof partParams.query !== 'string') - throw new Error('Invalid query'); - if ( - partParams.variables != null && - (typeof partParams.variables !== 'object' || - Array.isArray(partParams.variables)) - ) { - throw new Error('Invalid variables'); - } - if ( - partParams.operationName != null && - typeof partParams.operationName !== 'string' - ) { - throw new Error('Invalid operationName'); - } - if ( - partParams.extensions != null && - (typeof partParams.extensions !== 'object' || - Array.isArray(partParams.extensions)) - ) { - throw new Error('Invalid extensions'); - } - - // request parameters are checked and now complete - params = partParams as RequestParams; + let paramsOrRes = await parseRequestParams(req); + if (!paramsOrRes) paramsOrRes = await defaultParseRequestParams(req); + if (isResponse(paramsOrRes)) return paramsOrRes; + params = paramsOrRes; } catch (err) { return makeResponse(err, acceptedMediaType, formatError); } @@ -653,7 +587,7 @@ export function createHandler< // mutations cannot happen over GETs // https://graphql.github.io/graphql-over-http/draft/#sel-CALFJRPAAELBAAxwP - if (operation === 'mutation' && method === 'GET') { + if (operation === 'mutation' && req.method === 'GET') { return [ JSON.stringify({ errors: [new GraphQLError('Cannot perform mutations over GET')], @@ -701,6 +635,118 @@ type AcceptableMediaType = | 'application/graphql-response+json' | 'application/json'; +/** + * The default request params parser. Used when no custom one is provided or if it + * returns nothing. + * + * Read more about it in {@link ParseRequestParams}. + * + * TODO: should graphql-http itself care about content-encoding? I'd say unzipping should happen before handler is reached + */ +async function defaultParseRequestParams( + req: Request, +): Promise { + const method = req.method; + if (method !== 'GET' && method !== 'POST') { + return [ + null, + { + status: 405, + statusText: 'Method Not Allowed', + headers: { + allow: 'GET, POST', + }, + }, + ]; + } + + const [ + mediaType, + charset = 'charset=utf-8', // utf-8 is assumed when not specified. this parameter is either "charset" or "boundary" (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) + ] = (getHeader(req, 'content-type') || '') + .replace(/\s/g, '') + .toLowerCase() + .split(';'); + + const partParams: Partial = {}; + switch (true) { + case method === 'GET': { + // TODO: what if content-type is specified and is not application/x-www-form-urlencoded? + try { + const [, search] = req.url.split('?'); + const searchParams = new URLSearchParams(search); + partParams.operationName = + searchParams.get('operationName') ?? undefined; + partParams.query = searchParams.get('query') ?? undefined; + const variables = searchParams.get('variables'); + if (variables) partParams.variables = JSON.parse(variables); + const extensions = searchParams.get('extensions'); + if (extensions) partParams.extensions = JSON.parse(extensions); + } catch { + throw new Error('Unparsable URL'); + } + break; + } + case method === 'POST' && + mediaType === 'application/json' && + charset === 'charset=utf-8': { + if (!req.body) { + throw new Error('Missing body'); + } + let data; + try { + const body = + typeof req.body === 'function' ? await req.body() : req.body; + data = typeof body === 'string' ? JSON.parse(body) : body; + } catch (err) { + throw new Error('Unparsable JSON body'); + } + if (!isObject(data)) { + throw new Error('JSON body must be an object'); + } + partParams.operationName = data.operationName; + partParams.query = data.query; + partParams.variables = data.variables; + partParams.extensions = data.extensions; + break; + } + default: // graphql-http doesnt support any other content type + return [ + null, + { + status: 415, + statusText: 'Unsupported Media Type', + }, + ]; + } + + if (partParams.query == null) throw new Error('Missing query'); + if (typeof partParams.query !== 'string') throw new Error('Invalid query'); + if ( + partParams.variables != null && + (typeof partParams.variables !== 'object' || + Array.isArray(partParams.variables)) + ) { + throw new Error('Invalid variables'); + } + if ( + partParams.operationName != null && + typeof partParams.operationName !== 'string' + ) { + throw new Error('Invalid operationName'); + } + if ( + partParams.extensions != null && + (typeof partParams.extensions !== 'object' || + Array.isArray(partParams.extensions)) + ) { + throw new Error('Invalid extensions'); + } + + // request parameters are checked and now complete + return partParams as RequestParams; +} + /** * Creates an appropriate GraphQL over HTTP response following the provided arguments. *