From 2caae009ee88bbe83769ec00885d2c75583e3cfd Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 28 Aug 2023 16:29:53 +0200 Subject: [PATCH] feat(handler): Expose `parseRequestParams` from the core and each of the adapters (#111) --- docs/modules/handler.md | 41 +++++- docs/modules/use_express.md | 54 +++++++ docs/modules/use_fastify.md | 54 +++++++ docs/modules/use_fetch.md | 63 +++++++++ docs/modules/use_http.md | 57 ++++++++ docs/modules/use_http2.md | 69 +++++++++ docs/modules/use_koa.md | 58 ++++++++ docs/modules/use_uWebSockets.md | 68 +++++++++ src/handler.ts | 243 +++++++++++++++++--------------- src/use/express.ts | 99 ++++++++++--- src/use/fastify.ts | 84 +++++++++-- src/use/fetch.ts | 91 ++++++++++-- src/use/http.ts | 98 +++++++++++-- src/use/http2.ts | 115 +++++++++++++-- src/use/koa.ts | 93 +++++++++++- src/use/uWebSockets.ts | 149 ++++++++++++++++---- 16 files changed, 1225 insertions(+), 211 deletions(-) diff --git a/docs/modules/handler.md b/docs/modules/handler.md index 6ee6c03c..dd7ada25 100644 --- a/docs/modules/handler.md +++ b/docs/modules/handler.md @@ -25,6 +25,7 @@ ### Functions - [createHandler](handler.md#createhandler) +- [parseRequestParams](handler.md#parserequestparams-1) ## Server @@ -124,8 +125,10 @@ ___ ▸ (`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. +The request parser for an incoming GraphQL request in the handler. + +It should parse and validate 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. @@ -258,3 +261,37 @@ console.log('Listening to port 4000'); #### Returns [`Handler`](handler.md#handler)<`RequestRaw`, `RequestContext`\> + +___ + +### parseRequestParams + +▸ **parseRequestParams**<`RequestRaw`, `RequestContext`\>(`req`): `Promise`<[`Response`](handler.md#response) \| [`RequestParams`](../interfaces/common.RequestParams.md)\> + +The GraphQL over HTTP spec compliant 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. + +If the HTTP request itself is invalid or malformed, the function will return an +appropriate [Response](handler.md#response). + +If the HTTP request is valid, but is not a well-formatted GraphQL request, the +function will throw an error and it is up to the user to handle and respond as +they see fit. + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `RequestRaw` | `unknown` | +| `RequestContext` | `unknown` | + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | [`Request`](../interfaces/handler.Request.md)<`RequestRaw`, `RequestContext`\> | + +#### Returns + +`Promise`<[`Response`](handler.md#response) \| [`RequestParams`](../interfaces/common.RequestParams.md)\> diff --git a/docs/modules/use_express.md b/docs/modules/use_express.md index fba026ad..de0ec4bf 100644 --- a/docs/modules/use_express.md +++ b/docs/modules/use_express.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_express.md#createhandler) +- [parseRequestParams](use_express.md#parserequestparams) ## Server/express @@ -66,3 +67,56 @@ console.log('Listening to port 4000'); #### Returns `Handler` + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `res`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on the `Response` argument and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import express from 'express'; // yarn add express +import { parseRequestParams } from 'graphql-http/lib/use/express'; + +const app = express(); +app.all('/graphql', async (req, res) => { + try { + const maybeParams = await parseRequestParams(req, res); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + res.writeHead(400).end(err.message); + } +}); + +app.listen({ port: 4000 }); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `Request`<`ParamsDictionary`, `any`, `any`, `ParsedQs`, `Record`<`string`, `any`\>\> | +| `res` | `Response`<`any`, `Record`<`string`, `any`\>\> | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/docs/modules/use_fastify.md b/docs/modules/use_fastify.md index 1a459fd1..70fa76d5 100644 --- a/docs/modules/use_fastify.md +++ b/docs/modules/use_fastify.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_fastify.md#createhandler) +- [parseRequestParams](use_fastify.md#parserequestparams) ## Server/fastify @@ -66,3 +67,56 @@ console.log('Listening to port 4000'); #### Returns `RouteHandler` + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `reply`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on the `FastifyReply` argument and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import Fastify from 'fastify'; // yarn add fastify +import { parseRequestParams } from 'graphql-http/lib/use/fastify'; + +const fastify = Fastify(); +fastify.all('/graphql', async (req, reply) => { + try { + const maybeParams = await parseRequestParams(req, reply); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + reply.status(200).send(JSON.stringify(maybeParams, null, ' ')); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + reply.status(400).send(err.message); + } +}); + +fastify.listen({ port: 4000 }); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `FastifyRequest`<`RouteGenericInterface`, `RawServerDefault`, `IncomingMessage`, `FastifySchema`, `FastifyTypeProviderDefault`, `unknown`, `FastifyBaseLogger`, `ResolveFastifyRequestType`<`FastifyTypeProviderDefault`, `FastifySchema`, `RouteGenericInterface`\>\> | +| `reply` | `FastifyReply`<`RawServerDefault`, `IncomingMessage`, `ServerResponse`<`IncomingMessage`\>, `RouteGenericInterface`, `unknown`, `FastifySchema`, `FastifyTypeProviderDefault`, `unknown`\> | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/docs/modules/use_fetch.md b/docs/modules/use_fetch.md index 6f801825..23f15f27 100644 --- a/docs/modules/use_fetch.md +++ b/docs/modules/use_fetch.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_fetch.md#createhandler) +- [parseRequestParams](use_fetch.md#parserequestparams) ## Server/fetch @@ -87,3 +88,65 @@ console.log('Listening to port 4000'); ##### Returns `Promise`<`Response`\> + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `api?`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| `Response`\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +It is important to pass in the `abortedRef` so that the parser does not perform any +operations on a disposed request (see example). + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will return a `Response`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import http from 'http'; +import { createServerAdapter } from '@whatwg-node/server'; // yarn add @whatwg-node/server +import { parseRequestParams } from 'graphql-http/lib/use/fetch'; + +// Use this adapter in _any_ environment. +const adapter = createServerAdapter({ + handleRequest: async (req) => { + try { + const paramsOrResponse = await parseRequestParams(req); + if (paramsOrResponse instanceof Response) { + // not a well-formatted GraphQL over HTTP request, + // parser created a response object to use + return paramsOrResponse; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + return new Response(JSON.stringify(paramsOrResponse, null, ' '), { + status: 200, + }); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + return new Response(err.message, { status: 400 }); + } + }, +}); + +const server = http.createServer(adapter); + +server.listen(4000); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `Request` | +| `api` | `Partial`<[`FetchAPI`](../interfaces/use_fetch.FetchAPI.md)\> | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| `Response`\> diff --git a/docs/modules/use_http.md b/docs/modules/use_http.md index 3656a5c4..5588c66d 100644 --- a/docs/modules/use_http.md +++ b/docs/modules/use_http.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_http.md#createhandler) +- [parseRequestParams](use_http.md#parserequestparams) ## Server/http @@ -78,3 +79,59 @@ console.log('Listening to port 4000'); ##### Returns `Promise`<`void`\> + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `res`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on the `ServerResponse` argument and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import http from 'http'; +import { parseRequestParams } from 'graphql-http/lib/use/http'; + +const server = http.createServer(async (req, res) => { + if (req.url.startsWith('/graphql')) { + try { + const maybeParams = await parseRequestParams(req, res); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + res.writeHead(400).end(err.message); + } + } else { + res.writeHead(404).end(); + } +}); + +server.listen(4000); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `IncomingMessage` | +| `res` | `ServerResponse`<`IncomingMessage`\> | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/docs/modules/use_http2.md b/docs/modules/use_http2.md index 1f145871..bf3e01d4 100644 --- a/docs/modules/use_http2.md +++ b/docs/modules/use_http2.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_http2.md#createhandler) +- [parseRequestParams](use_http2.md#parserequestparams) ## Server/http2 @@ -90,3 +91,71 @@ console.log('Listening to port 4000'); ##### Returns `Promise`<`void`\> + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `res`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on the `Http2ServerResponse` argument and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + + ```shell +$ openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + -keyout localhost-privkey.pem -out localhost-cert.pem +``` + +```js +import fs from 'fs'; +import http2 from 'http2'; +import { parseRequestParams } from 'graphql-http/lib/use/http2'; + +const server = http2.createSecureServer( + { + key: fs.readFileSync('localhost-privkey.pem'), + cert: fs.readFileSync('localhost-cert.pem'), + }, + async (req, res) => { + if (req.url.startsWith('/graphql')) { + try { + const maybeParams = await parseRequestParams(req, res); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + res.writeHead(400).end(err.message); + } + } else { + res.writeHead(404).end(); + } + }, +); + +server.listen(4000); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `Http2ServerRequest` | +| `res` | `Http2ServerResponse` | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/docs/modules/use_koa.md b/docs/modules/use_koa.md index bbb556db..115eb911 100644 --- a/docs/modules/use_koa.md +++ b/docs/modules/use_koa.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_koa.md#createhandler) +- [parseRequestParams](use_koa.md#parserequestparams) ## Server/koa @@ -67,3 +68,60 @@ console.log('Listening to port 4000'); #### Returns `Middleware` + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`ctx`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on Koa's `ParameterizedContext` response and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import Koa from 'koa'; // yarn add koa +import mount from 'koa-mount'; // yarn add koa-mount +import { parseRequestParams } from 'graphql-http/lib/use/koa'; + +const app = new Koa(); +app.use( + mount('/', async (ctx) => { + try { + const maybeParams = await parseRequestParams(ctx); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + ctx.response.status = 200; + ctx.body = JSON.stringify(maybeParams, null, ' '); + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + ctx.response.status = 400; + ctx.body = err.message; + } + }), +); + +app.listen({ port: 4000 }); +console.log('Listening to port 4000'); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `ctx` | `ParameterizedContext` | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/docs/modules/use_uWebSockets.md b/docs/modules/use_uWebSockets.md index fb312080..bf760e4c 100644 --- a/docs/modules/use_uWebSockets.md +++ b/docs/modules/use_uWebSockets.md @@ -15,6 +15,7 @@ ### Functions - [createHandler](use_uWebSockets.md#createhandler) +- [parseRequestParams](use_uWebSockets.md#parserequestparams) ## Server/uWebSockets @@ -80,3 +81,70 @@ uWS ##### Returns `Promise`<`void`\> + +___ + +### parseRequestParams + +▸ **parseRequestParams**(`req`, `res`, `abortedRef`): `Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> + +The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + +It is important to pass in the `abortedRef` so that the parser does not perform any +operations on a disposed request (see example). + +If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond +on the `HttpResponse` argument and return `null`. + +If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, +the function will throw an error and it is up to the user to handle and respond as they see fit. + +```js +import uWS from 'uWebSockets.js'; // yarn add uWebSockets.js@uNetworking/uWebSockets.js# +import { parseRequestParams } from 'graphql-http/lib/use/uWebSockets'; + +uWS + .App() + .any('/graphql', async (res, req) => { + const abortedRef = { current: false }; + res.onAborted(() => (abortedRef.current = true)); + try { + const maybeParams = await parseRequestParams(req, res, abortedRef); + if (!maybeParams) { + // not a well-formatted GraphQL over HTTP request, + // parser responded and there's nothing else to do + return; + } + + // well-formatted GraphQL over HTTP request, + // with valid parameters + if (!abortedRef.current) { + res.writeStatus('200 OK'); + res.end(JSON.stringify(maybeParams, null, ' ')); + } + } catch (err) { + // well-formatted GraphQL over HTTP request, + // but with invalid parameters + if (!abortedRef.current) { + res.writeStatus('400 Bad Request'); + res.end(err.message); + } + } + }) + .listen(4000, () => { + console.log('Listening to port 4000'); + }); +``` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `req` | `HttpRequest` | +| `res` | `HttpResponse` | +| `abortedRef` | `Object` | +| `abortedRef.current` | `boolean` | + +#### Returns + +`Promise`<[`RequestParams`](../interfaces/common.RequestParams.md) \| ``null``\> diff --git a/src/handler.ts b/src/handler.ts index 67b59f28..8e98e42a 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -155,8 +155,10 @@ export type FormatError = ( ) => 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. + * The request parser for an incoming GraphQL request in the handler. + * + * It should parse and validate 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. @@ -179,6 +181,125 @@ export type ParseRequestParams< req: Request, ) => Promise | RequestParams | Response | void; +/** + * The GraphQL over HTTP spec compliant 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. + * + * If the HTTP request itself is invalid or malformed, the function will return an + * appropriate {@link Response}. + * + * If the HTTP request is valid, but is not a well-formatted GraphQL request, the + * function will throw an error and it is up to the user to handle and respond as + * they see fit. + * + * @category Server + */ +export async function parseRequestParams< + RequestRaw = unknown, + RequestContext = unknown, +>(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; +} + /** @category Server */ export type OperationArgs = ExecutionArgs & { contextValue?: Context }; @@ -440,7 +561,7 @@ export function createHandler< onSubscribe, onOperation, formatError = (err) => err, - parseRequestParams = defaultParseRequestParams, + parseRequestParams: optionsParseRequestParams = parseRequestParams, } = options; return async function handler(req) { @@ -491,8 +612,8 @@ export function createHandler< let params: RequestParams; try { - let paramsOrRes = await parseRequestParams(req); - if (!paramsOrRes) paramsOrRes = await defaultParseRequestParams(req); + let paramsOrRes = await optionsParseRequestParams(req); + if (!paramsOrRes) paramsOrRes = await parseRequestParams(req); if (isResponse(paramsOrRes)) return paramsOrRes; params = paramsOrRes; } catch (err) { @@ -628,118 +749,6 @@ 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. * diff --git a/src/use/express.ts b/src/use/express.ts index 0d472026..ab2722d9 100644 --- a/src/use/express.ts +++ b/src/use/express.ts @@ -2,8 +2,11 @@ import type { Request, Response, Handler } from 'express'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -14,6 +17,59 @@ export interface RequestContext { res: Response; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on the `Response` argument and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import express from 'express'; // yarn add express + * import { parseRequestParams } from 'graphql-http/lib/use/express'; + * + * const app = express(); + * app.all('/graphql', async (req, res) => { + * try { + * const maybeParams = await parseRequestParams(req, res); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * res.writeHead(400).end(err.message); + * } + * }); + * + * app.listen({ port: 4000 }); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/express + */ +export async function parseRequestParams( + req: Request, + res: Response, +): Promise { + const rawReq = toRequest(req, res); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + res.writeHead(init.status, init.statusText, init.headers).end(body); + return null; + } + return paramsOrRes; +} + /** * Handler options when using the express adapter. * @@ -46,24 +102,7 @@ export function createHandler( const handle = createRawHandler(options); return async function requestListener(req, res) { try { - const [body, init] = await handle({ - url: req.url, - method: req.method, - headers: req.headers, - body: () => { - if (req.body) { - // in case express has a body parser - return req.body; - } - return new Promise((resolve) => { - let body = ''; - req.on('data', (chunk) => (body += chunk)); - req.on('end', () => resolve(body)); - }); - }, - raw: req, - context: { res }, - }); + const [body, init] = await handle(toRequest(req, res)); res.writeHead(init.status, init.statusText, init.headers).end(body); } catch (err) { // The handler shouldnt throw errors. @@ -77,3 +116,27 @@ export function createHandler( } }; } + +function toRequest( + req: Request, + res: Response, +): RawRequest { + return { + url: req.url, + method: req.method, + headers: req.headers, + body: () => { + if (req.body) { + // in case express has a body parser + return req.body; + } + return new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => resolve(body)); + }); + }, + raw: req, + context: { res }, + }; +} diff --git a/src/use/fastify.ts b/src/use/fastify.ts index 63b981a5..5d7e24d6 100644 --- a/src/use/fastify.ts +++ b/src/use/fastify.ts @@ -2,8 +2,11 @@ import type { FastifyRequest, FastifyReply, RouteHandler } from 'fastify'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -13,6 +16,62 @@ import { export interface RequestContext { reply: FastifyReply; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on the `FastifyReply` argument and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import Fastify from 'fastify'; // yarn add fastify + * import { parseRequestParams } from 'graphql-http/lib/use/fastify'; + * + * const fastify = Fastify(); + * fastify.all('/graphql', async (req, reply) => { + * try { + * const maybeParams = await parseRequestParams(req, reply); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * reply.status(200).send(JSON.stringify(maybeParams, null, ' ')); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * reply.status(400).send(err.message); + * } + * }); + * + * fastify.listen({ port: 4000 }); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/fastify + */ +export async function parseRequestParams( + req: FastifyRequest, + reply: FastifyReply, +): Promise { + const rawReq = toRequest(req, reply); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + reply + .status(init.status) + .headers(init.headers || {}) + // "or undefined" because `null` will be JSON stringified + .send(body || undefined); + return null; + } + return paramsOrRes; +} /** * Handler options when using the fastify adapter. @@ -46,15 +105,7 @@ export function createHandler( const handle = createRawHandler(options); return async function requestListener(req, reply) { try { - const [body, init] = await handle({ - url: req.url, - method: req.method, - headers: req.headers, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - body: req.body as any, - raw: req, - context: { reply }, - }); + const [body, init] = await handle(toRequest(req, reply)); reply .status(init.status) .headers(init.headers || {}) @@ -72,3 +123,18 @@ export function createHandler( } }; } + +function toRequest( + req: FastifyRequest, + reply: FastifyReply, +): RawRequest { + return { + url: req.url, + method: req.method, + headers: req.headers, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: req.body as any, + raw: req, + context: { reply }, + }; +} diff --git a/src/use/fetch.ts b/src/use/fetch.ts index 9c8be673..8f7604ac 100644 --- a/src/use/fetch.ts +++ b/src/use/fetch.ts @@ -1,8 +1,11 @@ import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The necessary API from the fetch environment for the handler. @@ -15,6 +18,67 @@ export interface FetchAPI { TextEncoder: typeof TextEncoder; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * It is important to pass in the `abortedRef` so that the parser does not perform any + * operations on a disposed request (see example). + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will return a `Response`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import http from 'http'; + * import { createServerAdapter } from '@whatwg-node/server'; // yarn add @whatwg-node/server + * import { parseRequestParams } from 'graphql-http/lib/use/fetch'; + * + * // Use this adapter in _any_ environment. + * const adapter = createServerAdapter({ + * handleRequest: async (req) => { + * try { + * const paramsOrResponse = await parseRequestParams(req); + * if (paramsOrResponse instanceof Response) { + * // not a well-formatted GraphQL over HTTP request, + * // parser created a response object to use + * return paramsOrResponse; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * return new Response(JSON.stringify(paramsOrResponse, null, ' '), { + * status: 200, + * }); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * return new Response(err.message, { status: 400 }); + * } + * }, + * }); + * + * const server = http.createServer(adapter); + * + * server.listen(4000); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/fetch + */ +export async function parseRequestParams( + req: Request, + api: Partial = {}, +): Promise { + const rawReq = toRequest(req, api); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + return new (api.Response || Response)(body, init); + } + return paramsOrRes; +} + /** * Handler options when using the fetch adapter. * @@ -63,14 +127,7 @@ export function createHandler( const handler = createRawHandler(options); return async function handleRequest(req) { try { - const [body, init] = await handler({ - method: req.method, - url: req.url, - headers: req.headers, - body: () => req.text(), - raw: req, - context: api, - }); + const [body, init] = await handler(toRequest(req, api)); return new api.Response(body, init); } catch (err) { // The handler shouldnt throw errors. @@ -84,3 +141,21 @@ export function createHandler( } }; } + +function toRequest( + req: Request, + api: Partial = {}, +): RawRequest { + return { + method: req.method, + url: req.url, + headers: req.headers, + body: () => req.text(), + raw: req, + context: { + Response: api.Response || Response, + TextEncoder: api.TextEncoder || TextEncoder, + ReadableStream: api.ReadableStream || ReadableStream, + }, + }; +} diff --git a/src/use/http.ts b/src/use/http.ts index 9da1297c..8a51c935 100644 --- a/src/use/http.ts +++ b/src/use/http.ts @@ -2,8 +2,11 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -14,6 +17,62 @@ export interface RequestContext { res: ServerResponse; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on the `ServerResponse` argument and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import http from 'http'; + * import { parseRequestParams } from 'graphql-http/lib/use/http'; + * + * const server = http.createServer(async (req, res) => { + * if (req.url.startsWith('/graphql')) { + * try { + * const maybeParams = await parseRequestParams(req, res); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * res.writeHead(400).end(err.message); + * } + * } else { + * res.writeHead(404).end(); + * } + * }); + * + * server.listen(4000); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/http + */ +export async function parseRequestParams( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const rawReq = toRequest(req, res); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + res.writeHead(init.status, init.statusText, init.headers).end(body); + return null; + } + return paramsOrRes; +} + /** * Handler options when using the http adapter. * @@ -51,19 +110,7 @@ export function createHandler( if (!req.method) { throw new Error('Missing request method'); } - const [body, init] = await handle({ - url: req.url, - method: req.method, - headers: req.headers, - body: () => - new Promise((resolve) => { - let body = ''; - req.on('data', (chunk) => (body += chunk)); - req.on('end', () => resolve(body)); - }), - raw: req, - context: { res }, - }); + const [body, init] = await handle(toRequest(req, res)); res.writeHead(init.status, init.statusText, init.headers).end(body); } catch (err) { // The handler shouldnt throw errors. @@ -77,3 +124,28 @@ export function createHandler( } }; } + +function toRequest( + req: IncomingMessage, + res: ServerResponse, +): RawRequest { + if (!req.url) { + throw new Error('Missing request URL'); + } + if (!req.method) { + throw new Error('Missing request method'); + } + return { + url: req.url, + method: req.method, + headers: req.headers, + body: () => + new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => resolve(body)); + }), + raw: req, + context: { res }, + }; +} diff --git a/src/use/http2.ts b/src/use/http2.ts index 15d4daf2..900d7ec2 100644 --- a/src/use/http2.ts +++ b/src/use/http2.ts @@ -2,8 +2,11 @@ import type { Http2ServerRequest, Http2ServerResponse } from 'http2'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -14,6 +17,79 @@ export interface RequestContext { res: Http2ServerResponse; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on the `Http2ServerResponse` argument and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```shell + * $ openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \ + * -keyout localhost-privkey.pem -out localhost-cert.pem + * ``` + * + * ```js + * import fs from 'fs'; + * import http2 from 'http2'; + * import { parseRequestParams } from 'graphql-http/lib/use/http2'; + * + * const server = http2.createSecureServer( + * { + * key: fs.readFileSync('localhost-privkey.pem'), + * cert: fs.readFileSync('localhost-cert.pem'), + * }, + * async (req, res) => { + * if (req.url.startsWith('/graphql')) { + * try { + * const maybeParams = await parseRequestParams(req, res); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * res.writeHead(200).end(JSON.stringify(maybeParams, null, ' ')); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * res.writeHead(400).end(err.message); + * } + * } else { + * res.writeHead(404).end(); + * } + * }, + * ); + * + * server.listen(4000); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/http2 + */ +export async function parseRequestParams( + req: Http2ServerRequest, + res: Http2ServerResponse, +): Promise { + const rawReq = toRequest(req, res); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + res.writeHead(init.status, init.statusText, init.headers); + if (body) { + res.end(body); + } else { + res.end(); + } + return null; + } + return paramsOrRes; +} + /** * Handler options when using the http adapter. * @@ -63,19 +139,7 @@ export function createHandler( if (!req.method) { throw new Error('Missing request method'); } - const [body, init] = await handle({ - url: req.url, - method: req.method, - headers: req.headers, - body: () => - new Promise((resolve) => { - let body = ''; - req.on('data', (chunk) => (body += chunk)); - req.on('end', () => resolve(body)); - }), - raw: req, - context: { res }, - }); + const [body, init] = await handle(toRequest(req, res)); res.writeHead(init.status, init.statusText, init.headers); if (body) { res.end(body); @@ -94,3 +158,28 @@ export function createHandler( } }; } + +function toRequest( + req: Http2ServerRequest, + res: Http2ServerResponse, +): RawRequest { + if (!req.url) { + throw new Error('Missing request URL'); + } + if (!req.method) { + throw new Error('Missing request method'); + } + return { + url: req.url, + method: req.method, + headers: req.headers, + body: () => + new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => resolve(body)); + }), + raw: req, + context: { res }, + }; +} diff --git a/src/use/koa.ts b/src/use/koa.ts index bef8ce1c..ddde1511 100644 --- a/src/use/koa.ts +++ b/src/use/koa.ts @@ -1,10 +1,13 @@ -import type { Middleware, Response } from 'koa'; +import type { Middleware, ParameterizedContext, Response } from 'koa'; import type { IncomingMessage } from 'http'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -15,6 +18,70 @@ export interface RequestContext { res: Response; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on Koa's `ParameterizedContext` response and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import Koa from 'koa'; // yarn add koa + * import mount from 'koa-mount'; // yarn add koa-mount + * import { parseRequestParams } from 'graphql-http/lib/use/koa'; + * + * const app = new Koa(); + * app.use( + * mount('/', async (ctx) => { + * try { + * const maybeParams = await parseRequestParams(ctx); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * ctx.response.status = 200; + * ctx.body = JSON.stringify(maybeParams, null, ' '); + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * ctx.response.status = 400; + * ctx.body = err.message; + * } + * }), + * ); + * + * app.listen({ port: 4000 }); + * console.log('Listening to port 4000'); + * ``` + * + * @category Server/koa + */ +export async function parseRequestParams( + ctx: ParameterizedContext, +): Promise { + const rawReq = toRequest(ctx); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + const [body, init] = paramsOrRes; + ctx.body = body; + ctx.response.status = init.status; + ctx.response.message = init.statusText; + if (init.headers) { + for (const [name, value] of Object.entries(init.headers)) { + ctx.response.set(name, value); + } + } + return null; + } + return paramsOrRes; +} + /** * Handler options when using the koa adapter. * @@ -86,3 +153,27 @@ export function createHandler( } }; } + +function toRequest( + ctx: ParameterizedContext, +): RawRequest { + return { + url: ctx.url, + method: ctx.method, + headers: ctx.headers, + body: () => { + if (ctx.body) { + // in case koa has a body parser + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ctx.body as any; + } + return new Promise((resolve) => { + let body = ''; + ctx.req.on('data', (chunk) => (body += chunk)); + ctx.req.on('end', () => resolve(body)); + }); + }, + raw: ctx.req, + context: { res: ctx.response }, + }; +} diff --git a/src/use/uWebSockets.ts b/src/use/uWebSockets.ts index 0263beea..825eadc3 100644 --- a/src/use/uWebSockets.ts +++ b/src/use/uWebSockets.ts @@ -2,8 +2,11 @@ import type { HttpRequest, HttpResponse } from 'uWebSockets.js'; import { createHandler as createRawHandler, HandlerOptions as RawHandlerOptions, + Request as RawRequest, + parseRequestParams as rawParseRequestParams, OperationContext, } from '../handler'; +import { RequestParams } from '../common'; /** * The context in the request for the handler. @@ -14,6 +17,84 @@ export interface RequestContext { res: HttpResponse; } +/** + * The GraphQL over HTTP spec compliant request parser for an incoming GraphQL request. + * + * It is important to pass in the `abortedRef` so that the parser does not perform any + * operations on a disposed request (see example). + * + * If the HTTP request _is not_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), the function will respond + * on the `HttpResponse` argument and return `null`. + * + * If the HTTP request _is_ a [well-formatted GraphQL over HTTP request](https://graphql.github.io/graphql-over-http/draft/#sec-Request), but is invalid or malformed, + * the function will throw an error and it is up to the user to handle and respond as they see fit. + * + * ```js + * import uWS from 'uWebSockets.js'; // yarn add uWebSockets.js@uNetworking/uWebSockets.js# + * import { parseRequestParams } from 'graphql-http/lib/use/uWebSockets'; + * + * uWS + * .App() + * .any('/graphql', async (res, req) => { + * const abortedRef = { current: false }; + * res.onAborted(() => (abortedRef.current = true)); + * try { + * const maybeParams = await parseRequestParams(req, res, abortedRef); + * if (!maybeParams) { + * // not a well-formatted GraphQL over HTTP request, + * // parser responded and there's nothing else to do + * return; + * } + * + * // well-formatted GraphQL over HTTP request, + * // with valid parameters + * if (!abortedRef.current) { + * res.writeStatus('200 OK'); + * res.end(JSON.stringify(maybeParams, null, ' ')); + * } + * } catch (err) { + * // well-formatted GraphQL over HTTP request, + * // but with invalid parameters + * if (!abortedRef.current) { + * res.writeStatus('400 Bad Request'); + * res.end(err.message); + * } + * } + * }) + * .listen(4000, () => { + * console.log('Listening to port 4000'); + * }); + * ``` + * + * @category Server/uWebSockets + */ +export async function parseRequestParams( + req: HttpRequest, + res: HttpResponse, + abortedRef: { current: boolean }, +): Promise { + const rawReq = toRequest(req, res, abortedRef); + const paramsOrRes = await rawParseRequestParams(rawReq); + if (!('query' in paramsOrRes)) { + if (!abortedRef.current) { + const [body, init] = paramsOrRes; + res.cork(() => { + res.writeStatus(`${init.status} ${init.statusText}`); + for (const [key, val] of Object.entries(init.headers || {})) { + res.writeHeader(key, val); + } + if (body) { + res.end(body); + } else { + res.endWithoutBody(); + } + }); + } + return null; + } + return paramsOrRes; +} + /** * Handler options when using the http adapter. * @@ -46,36 +127,11 @@ export function createHandler( ): (res: HttpResponse, req: HttpRequest) => Promise { const handle = createRawHandler(options); return async function requestListener(res, req) { - let aborted = false; - res.onAborted(() => (aborted = true)); + const abortedRef = { current: false }; + res.onAborted(() => (abortedRef.current = true)); try { - let url = req.getUrl(); - const query = req.getQuery(); - if (query) { - url += '?' + query; - } - const [body, init] = await handle({ - url, - method: req.getMethod().toUpperCase(), - headers: { get: (key) => req.getHeader(key) }, - body: () => - new Promise((resolve) => { - let body = ''; - if (aborted) { - resolve(body); - } else { - res.onData((chunk, isLast) => { - body += Buffer.from(chunk, 0, chunk.byteLength).toString(); - if (isLast) { - resolve(body); - } - }); - } - }), - raw: req, - context: { res }, - }); - if (!aborted) { + const [body, init] = await handle(toRequest(req, res, abortedRef)); + if (!abortedRef.current) { res.cork(() => { res.writeStatus(`${init.status} ${init.statusText}`); for (const [key, val] of Object.entries(init.headers || {})) { @@ -96,7 +152,7 @@ export function createHandler( 'Please check your implementation.', err, ); - if (!aborted) { + if (!abortedRef.current) { res.cork(() => { res.writeStatus('500 Internal Server Error').endWithoutBody(); }); @@ -104,3 +160,36 @@ export function createHandler( } }; } + +function toRequest( + req: HttpRequest, + res: HttpResponse, + abortedRef: { current: boolean }, +): RawRequest { + let url = req.getUrl(); + const query = req.getQuery(); + if (query) { + url += '?' + query; + } + return { + url, + method: req.getMethod().toUpperCase(), + headers: { get: (key) => req.getHeader(key) }, + body: () => + new Promise((resolve) => { + let body = ''; + if (abortedRef.current) { + resolve(body); + } else { + res.onData((chunk, isLast) => { + body += Buffer.from(chunk, 0, chunk.byteLength).toString(); + if (isLast) { + resolve(body); + } + }); + } + }), + raw: req, + context: { res }, + }; +}