123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- 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<string, unknown>,
- 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<string, unknown> | 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<string, unknown>,
- fieldName: string,
- ): Record<string, unknown> | undefined {
- const value = o[fieldName];
- if (isStringRecord(value)) {
- return value;
- }
- return undefined;
- }
- function isStringRecord(o: unknown): o is Record<string, unknown> {
- return (
- !!o && typeof o === 'object' && !Buffer.isBuffer(o) && !Array.isArray(o)
- );
- }
- function isNonEmptyStringRecord(o: unknown): o is Record<string, unknown> {
- 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<TContext extends BaseContext>({
- server,
- httpRequest,
- contextValue,
- schemaDerivedData,
- internals,
- sharedResponseHTTPGraphQLHead,
- }: {
- server: ApolloServer<TContext>;
- httpRequest: HTTPGraphQLRequest;
- contextValue: TContext;
- schemaDerivedData: SchemaDerivedData;
- internals: ApolloServerInternals<TContext>;
- sharedResponseHTTPGraphQLHead: HTTPGraphQLHead | null;
- }): Promise<HTTPGraphQLResponse> {
- 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<GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult>,
- ): AsyncGenerator<string> {
- // 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);
- }
- }
- }
|