import type { BaseContext, GraphQLExperimentalFormattedIncrementalResult, GraphQLExperimentalFormattedInitialIncrementalExecutionResult, GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult, GraphQLRequest, HTTPGraphQLHead, HTTPGraphQLRequest, HTTPGraphQLResponse, } from './externalTypes/index.js'; import { type ApolloServer, type ApolloServerInternals, chooseContentTypeForSingleResultResponse, internalExecuteOperation, MEDIA_TYPES, type SchemaDerivedData, } from './ApolloServer.js'; import { type FormattedExecutionResult, Kind } from 'graphql'; import { BadRequestError } from './internalErrorClasses.js'; import Negotiator from 'negotiator'; import { HeaderMap } from './utils/HeaderMap.js'; function fieldIfString( o: Record, fieldName: string, ): string | undefined { const value = o[fieldName]; if (typeof value === 'string') { return value; } return undefined; } function searchParamIfSpecifiedOnce( searchParams: URLSearchParams, paramName: string, ) { const values = searchParams.getAll(paramName); switch (values.length) { case 0: return undefined; case 1: return values[0]; default: throw new BadRequestError( `The '${paramName}' search parameter may only be specified once.`, ); } } function jsonParsedSearchParamIfSpecifiedOnce( searchParams: URLSearchParams, fieldName: string, ): Record | undefined { const value = searchParamIfSpecifiedOnce(searchParams, fieldName); if (value === undefined) { return undefined; } let hopefullyRecord; try { hopefullyRecord = JSON.parse(value); } catch { throw new BadRequestError( `The ${fieldName} search parameter contains invalid JSON.`, ); } if (!isStringRecord(hopefullyRecord)) { throw new BadRequestError( `The ${fieldName} search parameter should contain a JSON-encoded object.`, ); } return hopefullyRecord; } function fieldIfRecord( o: Record, fieldName: string, ): Record | undefined { const value = o[fieldName]; if (isStringRecord(value)) { return value; } return undefined; } function isStringRecord(o: unknown): o is Record { return ( !!o && typeof o === 'object' && !Buffer.isBuffer(o) && !Array.isArray(o) ); } function isNonEmptyStringRecord(o: unknown): o is Record { return isStringRecord(o) && Object.keys(o).length > 0; } function ensureQueryIsStringOrMissing(query: unknown) { if (!query || typeof query === 'string') { return; } // Check for a common error first. if ((query as any).kind === Kind.DOCUMENT) { throw new BadRequestError( "GraphQL queries must be strings. It looks like you're sending the " + 'internal graphql-js representation of a parsed query in your ' + 'request instead of a request in the GraphQL query language. You ' + 'can convert an AST to a string using the `print` function from ' + '`graphql`, or use a client like `apollo-client` which converts ' + 'the internal representation to a string for you.', ); } else { throw new BadRequestError('GraphQL queries must be strings.'); } } export async function runHttpQuery({ server, httpRequest, contextValue, schemaDerivedData, internals, sharedResponseHTTPGraphQLHead, }: { server: ApolloServer; httpRequest: HTTPGraphQLRequest; contextValue: TContext; schemaDerivedData: SchemaDerivedData; internals: ApolloServerInternals; sharedResponseHTTPGraphQLHead: HTTPGraphQLHead | null; }): Promise { let graphQLRequest: GraphQLRequest; switch (httpRequest.method) { case 'POST': { if (!isNonEmptyStringRecord(httpRequest.body)) { throw new BadRequestError( 'POST body missing, invalid Content-Type, or JSON object has no keys.', ); } ensureQueryIsStringOrMissing(httpRequest.body.query); if (typeof httpRequest.body.variables === 'string') { throw new BadRequestError( '`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.', ); } if (typeof httpRequest.body.extensions === 'string') { throw new BadRequestError( '`extensions` in a POST body should be provided as an object, not a recursively JSON-encoded string.', ); } if ( 'extensions' in httpRequest.body && httpRequest.body.extensions !== null && !isStringRecord(httpRequest.body.extensions) ) { throw new BadRequestError( '`extensions` in a POST body must be an object if provided.', ); } if ( 'variables' in httpRequest.body && httpRequest.body.variables !== null && !isStringRecord(httpRequest.body.variables) ) { throw new BadRequestError( '`variables` in a POST body must be an object if provided.', ); } if ( 'operationName' in httpRequest.body && httpRequest.body.operationName !== null && typeof httpRequest.body.operationName !== 'string' ) { throw new BadRequestError( '`operationName` in a POST body must be a string if provided.', ); } graphQLRequest = { query: fieldIfString(httpRequest.body, 'query'), operationName: fieldIfString(httpRequest.body, 'operationName'), variables: fieldIfRecord(httpRequest.body, 'variables'), extensions: fieldIfRecord(httpRequest.body, 'extensions'), http: httpRequest, }; break; } case 'GET': { const searchParams = new URLSearchParams(httpRequest.search); graphQLRequest = { query: searchParamIfSpecifiedOnce(searchParams, 'query'), operationName: searchParamIfSpecifiedOnce( searchParams, 'operationName', ), variables: jsonParsedSearchParamIfSpecifiedOnce( searchParams, 'variables', ), extensions: jsonParsedSearchParamIfSpecifiedOnce( searchParams, 'extensions', ), http: httpRequest, }; break; } default: throw new BadRequestError( 'Apollo Server supports only GET/POST requests.', { extensions: { http: { status: 405, headers: new HeaderMap([['allow', 'GET, POST']]), }, }, }, ); } const graphQLResponse = await internalExecuteOperation( { server, graphQLRequest, internals, schemaDerivedData, sharedResponseHTTPGraphQLHead, }, { contextValue }, ); if (graphQLResponse.body.kind === 'single') { if (!graphQLResponse.http.headers.get('content-type')) { // If we haven't already set the content-type (via a plugin or something), // decide which content-type to use based on the accept header. const contentType = chooseContentTypeForSingleResultResponse(httpRequest); if (contentType === null) { throw new BadRequestError( `An 'accept' header was provided for this request which does not accept ` + `${MEDIA_TYPES.APPLICATION_JSON} or ${MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON}`, // Use 406 Not Accepted { extensions: { http: { status: 406 } } }, ); } graphQLResponse.http.headers.set('content-type', contentType); } return { ...graphQLResponse.http, body: { kind: 'complete', string: await internals.stringifyResult( orderExecutionResultFields(graphQLResponse.body.singleResult), ), }, }; } // Note that incremental delivery is not yet part of the official GraphQL // spec. We are implementing a proposed version of the spec, and require // clients to explicitly state `deferSpec=20220824`. Once incremental delivery // has been added to the GraphQL spec, we will support `accept` headers // without `deferSpec` as well (perhaps with slightly different behavior if // anything has changed). const acceptHeader = httpRequest.headers.get('accept'); if ( !( acceptHeader && new Negotiator({ headers: { accept: httpRequest.headers.get('accept') }, }).mediaType([ // mediaType() will return the first one that matches, so if the client // doesn't include the deferSpec parameter it will match this one here, // which isn't good enough. MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC, MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL, ]) === MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL ) ) { // The client ran an operation that would yield multiple parts, but didn't // specify `accept: multipart/mixed`. We return an error. throw new BadRequestError( 'Apollo server received an operation that uses incremental delivery ' + '(@defer or @stream), but the client does not accept multipart/mixed ' + 'HTTP responses. To enable incremental delivery support, add the HTTP ' + "header 'Accept: multipart/mixed; deferSpec=20220824'.", // Use 406 Not Accepted { extensions: { http: { status: 406 } } }, ); } graphQLResponse.http.headers.set( 'content-type', 'multipart/mixed; boundary="-"; deferSpec=20220824', ); return { ...graphQLResponse.http, body: { kind: 'chunked', asyncIterator: writeMultipartBody( graphQLResponse.body.initialResult, graphQLResponse.body.subsequentResults, ), }, }; } async function* writeMultipartBody( initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResult, subsequentResults: AsyncIterable, ): AsyncGenerator { // Note: we assume in this function that every result other than the last has // hasNext=true and the last has hasNext=false. That is, we choose which kind // of delimiter to place at the end of each block based on the contents of the // message, not the structure of the async iterator. This makes sense because // we want to write the delimiter as soon as each block is done (so the client // can parse it immediately) but we may not know whether a general async // iterator is finished until we do async work. yield `\r\n---\r\ncontent-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify( orderInitialIncrementalExecutionResultFields(initialResult), )}\r\n---${initialResult.hasNext ? '' : '--'}\r\n`; for await (const result of subsequentResults) { yield `content-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify( orderSubsequentIncrementalExecutionResultFields(result), )}\r\n---${result.hasNext ? '' : '--'}\r\n`; } } // See https://github.com/facebook/graphql/pull/384 for why // errors comes first. function orderExecutionResultFields( result: FormattedExecutionResult, ): FormattedExecutionResult { return { errors: result.errors, data: result.data, extensions: result.extensions, }; } function orderInitialIncrementalExecutionResultFields( result: GraphQLExperimentalFormattedInitialIncrementalExecutionResult, ): GraphQLExperimentalFormattedInitialIncrementalExecutionResult { return { hasNext: result.hasNext, errors: result.errors, data: result.data, incremental: orderIncrementalResultFields(result.incremental), extensions: result.extensions, }; } function orderSubsequentIncrementalExecutionResultFields( result: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult, ): GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult { return { hasNext: result.hasNext, incremental: orderIncrementalResultFields(result.incremental), extensions: result.extensions, }; } function orderIncrementalResultFields( incremental?: readonly GraphQLExperimentalFormattedIncrementalResult[], ): undefined | GraphQLExperimentalFormattedIncrementalResult[] { return incremental?.map((i: any) => ({ hasNext: i.hasNext, errors: i.errors, path: i.path, label: i.label, data: i.data, items: i.items, extensions: i.extensions, })); } // The result of a curl does not appear well in the terminal, so we add an extra new line export function prettyJSONStringify(value: FormattedExecutionResult) { return JSON.stringify(value) + '\n'; } export function newHTTPGraphQLHead(status?: number): HTTPGraphQLHead { return { status, headers: new HeaderMap(), }; } // Updates `target` with status code and headers from `source`. For now let's // consider it undefined what happens if both have a status code set or both set // the same header. export function mergeHTTPGraphQLHead( target: HTTPGraphQLHead, source: HTTPGraphQLHead, ) { if (source.status) { target.status = source.status; } if (source.headers) { for (const [name, value] of source.headers) { // If source.headers contains non-lowercase header names, this will // catch that case as long as target.headers is a HeaderMap. target.headers.set(name, value); } } }