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.
*