Skip to content

feat(handler): Custom request params parser #100

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 8, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -725,6 +725,78 @@ console.log('Listening to port 4000');

</details>

<details id="graphql-upload-http">
<summary><a href="#graphql-upload-http">🔗</a> Server handler usage with <a href="https://github.com/jaydenseric/graphql-upload">graphql-upload</a> and <a href="https://nodejs.org/api/http.html">http</a></summary>

```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');
```

</details>

<details id="graphql-upload-express">
<summary><a href="#graphql-upload-express">🔗</a> Server handler usage with <a href="https://github.com/jaydenseric/graphql-upload">graphql-upload</a> and <a href="https://expressjs.com/">express</a></summary>

```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');
```

</details>

<details id="audit-jest">
<summary><a href="#audit-jest">🔗</a> Audit for servers usage in <a href="https://jestjs.io">Jest</a> environment</summary>

11 changes: 11 additions & 0 deletions docs/interfaces/handler.HandlerOptions.md
Original file line number Diff line number Diff line change
@@ -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`
43 changes: 43 additions & 0 deletions docs/modules/handler.md
Original file line number Diff line number Diff line change
@@ -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`` }
113 changes: 113 additions & 0 deletions src/__tests__/handler.ts
Original file line number Diff line number Diff line change
@@ -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",
},
}
`);
});
254 changes: 150 additions & 104 deletions src/handler.ts
Original file line number Diff line number Diff line change
@@ -161,6 +161,31 @@ export type FormatError = (
err: Readonly<GraphQLError | Error>,
) => 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<RequestRaw, RequestContext>,
) => Promise<RequestParams | Response | void> | RequestParams | Response | void;

/** @category Server */
export type OperationArgs<Context extends OperationContext = undefined> =
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<RequestRaw, RequestContext>;
}

/**
@@ -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<RequestParams> = {};
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<unknown, unknown>,
): Promise<Response | RequestParams> {
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<RequestParams> = {};
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.
*