diff --git a/.changeset/more-accept-headers.md b/.changeset/more-accept-headers.md new file mode 100644 index 0000000000..51c55e47e8 --- /dev/null +++ b/.changeset/more-accept-headers.md @@ -0,0 +1,5 @@ +--- +'graphql-yoga': major +--- + +Now it is possible to decide the returned `Content-Type` by specifying the `Accept` header. So if `Accept` header has `text/event-stream` without `application/json`, Yoga respects that returns `text/event-stream` instead of `application/json`. diff --git a/e2e/utils.ts b/e2e/utils.ts index 293ce47b31..78e15f88b4 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -111,7 +111,7 @@ export async function assertQuery( const response = await fetch(endpoint, { method: 'POST', headers: { - accept: 'applications/json', + accept: 'application/json', 'content-type': 'application/json', }, body: JSON.stringify({ diff --git a/packages/graphql-yoga/__tests__/node.spec.ts b/packages/graphql-yoga/__tests__/node.spec.ts index 2526a12adb..6a811f43e5 100644 --- a/packages/graphql-yoga/__tests__/node.spec.ts +++ b/packages/graphql-yoga/__tests__/node.spec.ts @@ -621,6 +621,7 @@ describe('Incremental Delivery', () => { body: formData, }) + expect(response.status).toBe(200) const body = await response.json() expect(body.errors).toBeUndefined() @@ -1481,3 +1482,58 @@ describe('404 Handling', () => { expect(body).toEqual('Do you really like em?') }) }) + +describe('Respect Accept headers', () => { + const yoga = createYoga({ + schema, + }) + const server = createServer(yoga) + let port: number + let url: string + beforeAll((done) => { + port = Math.floor(Math.random() * 100) + 4000 + url = `http://localhost:${port}/graphql` + server.listen(port, done) + }) + afterAll(() => { + server.close() + }) + it('should force the server return event stream even if the result is not', async () => { + const response = await fetch(`${url}?query=query{ping}`, { + headers: { + Accept: 'text/event-stream', + }, + }) + expect(response.headers.get('content-type')).toEqual('text/event-stream') + const iterator = response.body![Symbol.asyncIterator]() + const { value } = await iterator.next() + const valueStr = Buffer.from(value).toString('utf-8') + expect(valueStr).toContain( + `data: ${JSON.stringify({ data: { ping: 'pong' } })}`, + ) + }) + it('should force the server return multipart even if the result is not', async () => { + const response = await fetch(`${url}?query=query{ping}`, { + headers: { + Accept: 'multipart/mixed', + }, + }) + expect(response.headers.get('content-type')).toEqual( + 'multipart/mixed; boundary="-"', + ) + const iterator = response.body![Symbol.asyncIterator]() + const { value } = await iterator.next() + const valueStr = Buffer.from(value).toString('utf-8') + expect(valueStr).toContain(`Content-Type: application/json; charset=utf-8`) + expect(valueStr).toContain(`Content-Length: 24`) + expect(valueStr).toContain(`${JSON.stringify({ data: { ping: 'pong' } })}`) + }) + it('should not allow to return if the result is an async iterable and accept is just json', async () => { + const response = await fetch(`${url}?query=subscription{counter}`, { + headers: { + Accept: 'application/json', + }, + }) + expect(response.status).toEqual(406) + }) +}) diff --git a/packages/graphql-yoga/src/plugins/resultProcessor/multipart.ts b/packages/graphql-yoga/src/plugins/resultProcessor/multipart.ts index c22f72d1ed..58675eaa9a 100644 --- a/packages/graphql-yoga/src/plugins/resultProcessor/multipart.ts +++ b/packages/graphql-yoga/src/plugins/resultProcessor/multipart.ts @@ -1,20 +1,15 @@ import { isAsyncIterable } from '@envelop/core' import { ExecutionResult } from 'graphql' -import { ExecutionPatchResult, FetchAPI } from '../../types.js' +import { FetchAPI } from '../../types.js' import { ResultProcessorInput } from '../types.js' -export function isMultipartResult( - request: Request, - result: ResultProcessorInput, -): result is AsyncIterable { - return ( - isAsyncIterable(result) && - !!request.headers.get('accept')?.includes('multipart/mixed') - ) +export function isMultipartResult(request: Request): boolean { + // There should be an explicit accept header for this result type + return !!request.headers.get('accept')?.includes('multipart/mixed') } export function processMultipartResult( - executionPatchResultIterable: AsyncIterable, + result: ResultProcessorInput, fetchAPI: FetchAPI, ): Response { const headersInit: HeadersInit = { @@ -33,7 +28,20 @@ export function processMultipartResult( const readableStream = new fetchAPI.ReadableStream({ start(controller) { - iterator = executionPatchResultIterable[Symbol.asyncIterator]() + if (isAsyncIterable(result)) { + iterator = result[Symbol.asyncIterator]() + } else { + let finished = false + iterator = { + next: () => { + if (finished) { + return Promise.resolve({ done: true, value: null }) + } + finished = true + return Promise.resolve({ done: false, value: result }) + }, + } + } controller.enqueue(textEncoder.encode(`---`)) }, async pull(controller) { diff --git a/packages/graphql-yoga/src/plugins/resultProcessor/push.ts b/packages/graphql-yoga/src/plugins/resultProcessor/push.ts index 27e75e53b1..9d46613d6e 100644 --- a/packages/graphql-yoga/src/plugins/resultProcessor/push.ts +++ b/packages/graphql-yoga/src/plugins/resultProcessor/push.ts @@ -3,18 +3,13 @@ import { ExecutionResult } from 'graphql' import { FetchAPI } from '../../types.js' import { ResultProcessorInput } from '../types.js' -export function isPushResult( - request: Request, - result: ResultProcessorInput, -): result is AsyncIterable { - return ( - isAsyncIterable(result) && - !!request.headers.get('accept')?.includes('text/event-stream') - ) +export function isPushResult(request: Request): boolean { + // There should be an explicit accept header for this result type + return !!request.headers.get('accept')?.includes('text/event-stream') } export function processPushResult( - result: AsyncIterable, + result: ResultProcessorInput, fetchAPI: FetchAPI, ): Response { const headersInit: HeadersInit = { @@ -33,7 +28,20 @@ export function processPushResult( const textEncoder = new fetchAPI.TextEncoder() const readableStream = new fetchAPI.ReadableStream({ start() { - iterator = result[Symbol.asyncIterator]() + if (isAsyncIterable(result)) { + iterator = result[Symbol.asyncIterator]() + } else { + let finished = false + iterator = { + next: () => { + if (finished) { + return Promise.resolve({ done: true, value: null }) + } + finished = true + return Promise.resolve({ done: false, value: result }) + }, + } + } }, async pull(controller) { const { done, value } = await iterator.next() diff --git a/packages/graphql-yoga/src/plugins/resultProcessor/regular.ts b/packages/graphql-yoga/src/plugins/resultProcessor/regular.ts index 132039090d..e48b69885c 100644 --- a/packages/graphql-yoga/src/plugins/resultProcessor/regular.ts +++ b/packages/graphql-yoga/src/plugins/resultProcessor/regular.ts @@ -1,24 +1,45 @@ import { isAsyncIterable } from '@graphql-tools/utils' -import { ExecutionResult } from 'graphql' import { FetchAPI } from '../../types.js' import { ResultProcessorInput } from '../types.js' +const acceptHeaderByResult = new WeakMap() + export function isRegularResult( request: Request, result: ResultProcessorInput, -): result is ExecutionResult { - return !isAsyncIterable(result) +): boolean { + if (!isAsyncIterable(result)) { + const acceptHeader = request.headers.get('accept') + if (acceptHeader && !acceptHeader.includes('*/*')) { + if (acceptHeader.includes('application/json')) { + acceptHeaderByResult.set(result, 'application/json') + return true + } + if (acceptHeader.includes('application/graphql+json')) { + acceptHeaderByResult.set(result, 'application/graphql+json') + return true + } + // If there is an accept header but this processer doesn't support, reject + return false + } + // If there is no header, assume it's a regular result per spec + acceptHeaderByResult.set(result, 'application/json') + return true + } + // If it is not an async iterable, it's not a regular result + return false } export function processRegularResult( - executionResult: ExecutionResult, + executionResult: ResultProcessorInput, fetchAPI: FetchAPI, ): Response { const textEncoder = new fetchAPI.TextEncoder() const responseBody = JSON.stringify(executionResult) const decodedString = textEncoder.encode(responseBody) + const contentType = acceptHeaderByResult.get(executionResult) const headersInit: HeadersInit = { - 'Content-Type': 'application/json', + 'Content-Type': contentType || 'application/json', 'Content-Length': decodedString.byteLength.toString(), } const responseInit: ResponseInit = { diff --git a/packages/graphql-yoga/src/plugins/types.ts b/packages/graphql-yoga/src/plugins/types.ts index 22e9c2206f..0c3be3ae1b 100644 --- a/packages/graphql-yoga/src/plugins/types.ts +++ b/packages/graphql-yoga/src/plugins/types.ts @@ -70,21 +70,21 @@ export type OnResultProcess = ( payload: OnResultProcessEventPayload, ) => PromiseOrValue -export type ResultProcessorInput = PromiseOrValue< - ExecutionResult | AsyncIterable -> +export type ResultProcessorInput = + | ExecutionResult + | AsyncIterable + | AsyncIterable -export type ResultProcessor< - TResult extends ResultProcessorInput = ResultProcessorInput, -> = (result: TResult, fetchAPI: FetchAPI) => PromiseOrValue +export type ResultProcessor = ( + result: ResultProcessorInput, + fetchAPI: FetchAPI, +) => PromiseOrValue export interface OnResultProcessEventPayload { request: Request result: ResultProcessorInput resultProcessor?: ResultProcessor - setResultProcessor( - resultProcessor: ResultProcessor, - ): void + setResultProcessor(resultProcessor: ResultProcessor): void } export type OnResponseHook = ( diff --git a/packages/graphql-yoga/src/plugins/useResultProcessor.ts b/packages/graphql-yoga/src/plugins/useResultProcessor.ts index b55ea20c6c..d1d3eda47c 100644 --- a/packages/graphql-yoga/src/plugins/useResultProcessor.ts +++ b/packages/graphql-yoga/src/plugins/useResultProcessor.ts @@ -1,15 +1,13 @@ import { Plugin, ResultProcessor, ResultProcessorInput } from './types.js' -export interface ResultProcessorPluginOptions< - TResult extends ResultProcessorInput, -> { - processResult: ResultProcessor - match?(request: Request, result: ResultProcessorInput): result is TResult +export interface ResultProcessorPluginOptions { + processResult: ResultProcessor + match?(request: Request, result: ResultProcessorInput): boolean } -export function useResultProcessor< - TResult extends ResultProcessorInput = ResultProcessorInput, ->(options: ResultProcessorPluginOptions): Plugin { +export function useResultProcessor( + options: ResultProcessorPluginOptions, +): Plugin { const matchFn = options.match || (() => true) return { onResultProcess({ request, result, setResultProcessor }) { diff --git a/packages/graphql-yoga/src/processRequest.ts b/packages/graphql-yoga/src/processRequest.ts index 2876ffe17e..d90a60f9eb 100644 --- a/packages/graphql-yoga/src/processRequest.ts +++ b/packages/graphql-yoga/src/processRequest.ts @@ -21,7 +21,7 @@ export async function processResult({ */ onResultProcessHooks: OnResultProcess[] }) { - let resultProcessor: ResultProcessor | undefined + let resultProcessor: ResultProcessor | undefined for (const onResultProcessHook of onResultProcessHooks) { await onResultProcessHook({ @@ -79,7 +79,5 @@ export async function processRequest({ : enveloped.execute // Get the result to be processed - const result = await executeFn(executionArgs) - - return result + return executeFn(executionArgs) } diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index 959bb18bd5..84bde881cb 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -387,16 +387,16 @@ export class YogaServer< }), // Middlewares after the GraphQL execution useResultProcessor({ - match: isRegularResult, - processResult: processRegularResult, + match: isMultipartResult, + processResult: processMultipartResult, }), useResultProcessor({ match: isPushResult, processResult: processPushResult, }), useResultProcessor({ - match: isMultipartResult, - processResult: processMultipartResult, + match: isRegularResult, + processResult: processRegularResult, }), ...(options?.plugins ?? []),