Skip to content
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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

New 'multipart' option #1149

Merged
merged 7 commits into from May 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/brave-elephants-mix.md
@@ -0,0 +1,23 @@
---
'@graphql-yoga/common': patch
'@graphql-yoga/node': minor
---

Now you can configure multipart request parsing limits for file uploads with `multipart` option in `createServer` of @graphql-yoga/node
You can also disable `multipart` processing by passing `false`.

```ts
createServer({
multipart: {
maxFileSize: 2000, // Default: Infinity
},
})
```

In `@graphql-yoga/common`'s `createServer`, we can only enable or disable multipart which is enabled by default.

```ts
createServer({
multipart: false, // enabled by default
})
```
4 changes: 2 additions & 2 deletions .vscode/settings.json
Expand Up @@ -13,8 +13,8 @@
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/dist": true,
"**/node_modules": false,
"**/dist": false,
"test-lib": true,
"lib": true,
"coverage": true,
Expand Down
2 changes: 1 addition & 1 deletion benchmark/hello-world/start-server.js
Expand Up @@ -3,7 +3,7 @@ const { createServer } = require('@graphql-yoga/node')
const server = createServer({
logging: false,
hostname: '127.0.0.1',
healthCheckPath: false,
multipart: false,
})

server.start()
2 changes: 1 addition & 1 deletion examples/error-handling/package.json
Expand Up @@ -8,7 +8,7 @@
},
"dependencies": {
"@graphql-yoga/node": "2.4.1",
"cross-undici-fetch": "^0.2.5",
"cross-undici-fetch": "^0.4.2",
"graphql": "^16.1.0",
"ts-node": "10.4.0",
"typescript": "^4.4.4"
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Expand Up @@ -52,7 +52,7 @@
"@graphql-tools/schema": "^8.3.1",
"@graphql-tools/utils": "^8.6.0",
"@graphql-yoga/subscription": "2.0.0",
"cross-undici-fetch": "^0.4.0",
"cross-undici-fetch": "^0.4.2",
"dset": "^3.1.1",
"tslib": "^2.3.1"
},
Expand Down
18 changes: 3 additions & 15 deletions packages/common/src/getGraphQLParameters.ts
@@ -1,15 +1,9 @@
import { dset } from 'dset'

type GraphQLRequestPayload = {
operationName?: string
query?: string
variables?: Record<string, unknown>
extensions?: Record<string, unknown>
}
import { GraphQLParams } from './types'

type RequestParser = {
is: (request: Request) => boolean
parse: (request: Request) => Promise<GraphQLRequestPayload>
parse: (request: Request) => Promise<GraphQLParams>
}

export const GETRequestParser: RequestParser = {
Expand Down Expand Up @@ -72,7 +66,7 @@ export const POSTMultipartFormDataRequestParser: RequestParser = {
export function buildGetGraphQLParameters(parsers: Array<RequestParser>) {
return async function getGraphQLParameters(
request: Request,
): Promise<GraphQLRequestPayload> {
): Promise<GraphQLParams> {
for (const parser of parsers) {
if (parser.is(request)) {
return parser.parse(request)
Expand All @@ -86,9 +80,3 @@ export function buildGetGraphQLParameters(parsers: Array<RequestParser>) {
}
}
}

export const getGraphQLParameters = buildGetGraphQLParameters([
GETRequestParser,
POSTMultipartFormDataRequestParser,
POSTRequestParser,
])
45 changes: 37 additions & 8 deletions packages/common/src/server.ts
@@ -1,4 +1,4 @@
import { GraphQLSchema, isSchema, print } from 'graphql'
import { GraphQLError, GraphQLSchema, isSchema, print } from 'graphql'
import {
Plugin,
GetEnvelopedFn,
Expand All @@ -25,14 +25,20 @@ import {
YogaInitialContext,
FetchEvent,
FetchAPI,
GraphQLParams,
} from './types'
import {
GraphiQLOptions,
renderGraphiQL,
shouldRenderGraphiQL,
} from './graphiql'
import * as crossUndiciFetch from 'cross-undici-fetch'
import { getGraphQLParameters } from './getGraphQLParameters'
import {
buildGetGraphQLParameters,
GETRequestParser,
POSTMultipartFormDataRequestParser,
POSTRequestParser,
} from './getGraphQLParameters'
import { processRequest } from './processRequest'
import { defaultYogaLogger, titleBold, YogaLogger } from './logger'

Expand Down Expand Up @@ -128,6 +134,7 @@ export type YogaServerOptions<
parserCache?: boolean | ParserCacheOptions
validationCache?: boolean | ValidationCache
fetchAPI?: FetchAPI
multipart?: boolean
} & Partial<
OptionsWithPlugins<TUserContext & TServerContext & YogaInitialContext>
>
Expand Down Expand Up @@ -203,6 +210,10 @@ export class YogaServer<
ReadableStream: typeof ReadableStream
}

private getGraphQLParameters: (
request: Request,
) => PromiseOrValue<GraphQLParams>

renderGraphiQL: (options?: GraphiQLOptions) => PromiseOrValue<BodyInit>

constructor(
Expand Down Expand Up @@ -346,6 +357,13 @@ export class YogaServer<
this.renderGraphiQL = options?.renderGraphiQL || renderGraphiQL

this.endpoint = options?.endpoint

const requestParsers = [GETRequestParser]
if (options?.multipart !== false) {
requestParsers.push(POSTMultipartFormDataRequestParser)
}
requestParsers.push(POSTRequestParser)
this.getGraphQLParameters = buildGetGraphQLParameters(requestParsers)
}

getCORSResponseHeaders(
Expand Down Expand Up @@ -523,8 +541,9 @@ export class YogaServer<
}

this.logger.debug(`Extracting GraphQL Parameters`)

const { query, variables, operationName, extensions } =
await getGraphQLParameters(request)
await this.getGraphQLParameters(request)

const initialContext = {
request,
Expand Down Expand Up @@ -557,11 +576,21 @@ export class YogaServer<
})
return response
} catch (error: any) {
this.logger.error(error.stack || error.message || error)
const response = new this.fetchAPI.Response(error.message, {
status: 500,
statusText: 'Internal Server Error',
})
const response = new this.fetchAPI.Response(
JSON.stringify({
errors: [
error instanceof GraphQLError
? error
: {
message: error.message,
},
],
}),
{
status: 500,
statusText: 'Internal Server Error',
},
)
return response
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/common/src/types.ts
Expand Up @@ -20,10 +20,14 @@ export interface ExecutionPatchResult<
extensions?: TExtensions
}

export interface GraphQLParams<TVariables = Record<string, any>> {
export interface GraphQLParams<
TVariables = Record<string, any>,
TExtensions = Record<string, unknown>,
> {
operationName?: string
query?: string
variables?: string | TVariables
extensions?: TExtensions
}

export interface FormatPayloadParams<TContext, TRootValue> {
Expand Down
93 changes: 92 additions & 1 deletion packages/node/__tests__/integration.spec.ts
Expand Up @@ -12,6 +12,7 @@ import http from 'http'
import { useLiveQuery } from '@envelop/live-query'
import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'
import { fetch, File, FormData } from 'cross-undici-fetch'
import { Readable } from 'stream'

describe('Disable Introspection with plugin', () => {
it('succeeds introspection query', async () => {
Expand Down Expand Up @@ -354,7 +355,13 @@ describe('Requests', () => {
})

describe('Incremental Delivery', () => {
const yoga = createServer({ schema, logging: false })
const yoga = createServer({
schema,
logging: false,
multipart: {
fileSize: 12,
},
})
beforeAll(() => {
return yoga.start()
})
Expand Down Expand Up @@ -394,6 +401,90 @@ describe('Incremental Delivery', () => {
expect(body.data.singleUpload.text).toBe(fileContent)
})

it('should provide a correct readable stream', async () => {
const UPLOAD_MUTATION = /* GraphQL */ `
mutation upload($file: File!) {
parseFileStream(file: $file)
}
`

const fileName = 'test.txt'
const fileType = 'text/plain'
const fileContent = 'Hello World'

const formData = new FormData()
formData.set('operations', JSON.stringify({ query: UPLOAD_MUTATION }))
formData.set('map', JSON.stringify({ 0: ['variables.file'] }))
formData.set('0', new File([fileContent], fileName, { type: fileType }))

const response = await fetch(yoga.getServerUrl(), {
method: 'POST',
body: formData,
})

const body = await response.json()

expect(body.errors).toBeUndefined()
expect(body.data.parseFileStream).toBe(fileContent)
})

it('should provide a correct readable stream', async () => {
const UPLOAD_MUTATION = /* GraphQL */ `
mutation upload($file: File!) {
parseArrayBuffer(file: $file)
}
`

const fileName = 'test.txt'
const fileType = 'text/plain'
const fileContent = 'Hello World'

const formData = new FormData()
formData.set('operations', JSON.stringify({ query: UPLOAD_MUTATION }))
formData.set('map', JSON.stringify({ 0: ['variables.file'] }))
formData.set('0', new File([fileContent], fileName, { type: fileType }))

const response = await fetch(yoga.getServerUrl(), {
method: 'POST',
body: formData,
})

const body = await response.json()

expect(body.errors).toBeUndefined()
expect(body.data.parseArrayBuffer).toBe(fileContent)
})

it('should not allow the files that exceed the limit', async () => {
const UPLOAD_MUTATION = /* GraphQL */ `
mutation upload($file: File!) {
singleUpload(file: $file) {
name
type
text
}
}
`

const fileName = 'test.txt'
const fileType = 'text/plain'
const fileContent = 'I am a very long string that exceeds the limit'

const formData = new FormData()
formData.set('operations', JSON.stringify({ query: UPLOAD_MUTATION }))
formData.set('map', JSON.stringify({ 0: ['variables.file'] }))
formData.set('0', new File([fileContent], fileName, { type: fileType }))
const response = await fetch(yoga.getServerUrl(), {
method: 'POST',
body: formData,
})

const body = await response.json()

expect(body.errors).toBeDefined()
expect(body.errors[0].message).toBe('File size limit exceeded: 12 bytes')
})

it('should get subscription', async () => {
const serverUrl = yoga.getServerUrl()

Expand Down
2 changes: 1 addition & 1 deletion packages/node/package.json
Expand Up @@ -47,7 +47,7 @@
"@graphql-tools/utils": "^8.6.0",
"@graphql-yoga/common": "2.4.1",
"@graphql-yoga/subscription": "2.0.0",
"cross-undici-fetch": "^0.4.0",
"cross-undici-fetch": "^0.4.2",
"tslib": "^2.3.1"
},
"devDependencies": {
Expand Down
5 changes: 5 additions & 0 deletions packages/node/src/index.ts
Expand Up @@ -32,10 +32,15 @@ class YogaNodeServer<
) {
super({
...options,
multipart: options?.multipart !== false,
fetchAPI:
options?.fetchAPI ??
create({
useNodeFetch: true,
formDataLimits:
typeof options?.multipart === 'object'
? options.multipart
: undefined,
}),
})
this.addressInfo = {
Expand Down
12 changes: 10 additions & 2 deletions packages/node/src/types.ts
@@ -1,11 +1,15 @@
import type { YogaServerOptions } from '@graphql-yoga/common'
import { ServerOptions as HttpsServerOptions } from 'https'
import type { FormDataLimits } from 'cross-undici-fetch'
import type { ServerOptions as HttpsServerOptions } from 'https'

/**
* Configuration options for the server
*/
export type YogaNodeServerOptions<TServerContext, TUserContext, TRootValue> =
YogaServerOptions<TServerContext, TUserContext, TRootValue> & {
Omit<
YogaServerOptions<TServerContext, TUserContext, TRootValue>,
'multipart'
> & {
/**
* Port to run server
*/
Expand All @@ -19,6 +23,10 @@ export type YogaNodeServerOptions<TServerContext, TUserContext, TRootValue> =
* Enable HTTPS
*/
https?: HttpsServerOptions | boolean
/**
* Limits for multipart request parsing
*/
multipart?: FormDataLimits | boolean
}

export interface AddressInfo {
Expand Down