runHttpQuery.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import type {
  2. BaseContext,
  3. GraphQLExperimentalFormattedIncrementalResult,
  4. GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
  5. GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
  6. GraphQLRequest,
  7. HTTPGraphQLHead,
  8. HTTPGraphQLRequest,
  9. HTTPGraphQLResponse,
  10. } from './externalTypes/index.js';
  11. import {
  12. type ApolloServer,
  13. type ApolloServerInternals,
  14. chooseContentTypeForSingleResultResponse,
  15. internalExecuteOperation,
  16. MEDIA_TYPES,
  17. type SchemaDerivedData,
  18. } from './ApolloServer.js';
  19. import { type FormattedExecutionResult, Kind } from 'graphql';
  20. import { BadRequestError } from './internalErrorClasses.js';
  21. import Negotiator from 'negotiator';
  22. import { HeaderMap } from './utils/HeaderMap.js';
  23. function fieldIfString(
  24. o: Record<string, unknown>,
  25. fieldName: string,
  26. ): string | undefined {
  27. const value = o[fieldName];
  28. if (typeof value === 'string') {
  29. return value;
  30. }
  31. return undefined;
  32. }
  33. function searchParamIfSpecifiedOnce(
  34. searchParams: URLSearchParams,
  35. paramName: string,
  36. ) {
  37. const values = searchParams.getAll(paramName);
  38. switch (values.length) {
  39. case 0:
  40. return undefined;
  41. case 1:
  42. return values[0];
  43. default:
  44. throw new BadRequestError(
  45. `The '${paramName}' search parameter may only be specified once.`,
  46. );
  47. }
  48. }
  49. function jsonParsedSearchParamIfSpecifiedOnce(
  50. searchParams: URLSearchParams,
  51. fieldName: string,
  52. ): Record<string, unknown> | undefined {
  53. const value = searchParamIfSpecifiedOnce(searchParams, fieldName);
  54. if (value === undefined) {
  55. return undefined;
  56. }
  57. let hopefullyRecord;
  58. try {
  59. hopefullyRecord = JSON.parse(value);
  60. } catch {
  61. throw new BadRequestError(
  62. `The ${fieldName} search parameter contains invalid JSON.`,
  63. );
  64. }
  65. if (!isStringRecord(hopefullyRecord)) {
  66. throw new BadRequestError(
  67. `The ${fieldName} search parameter should contain a JSON-encoded object.`,
  68. );
  69. }
  70. return hopefullyRecord;
  71. }
  72. function fieldIfRecord(
  73. o: Record<string, unknown>,
  74. fieldName: string,
  75. ): Record<string, unknown> | undefined {
  76. const value = o[fieldName];
  77. if (isStringRecord(value)) {
  78. return value;
  79. }
  80. return undefined;
  81. }
  82. function isStringRecord(o: unknown): o is Record<string, unknown> {
  83. return (
  84. !!o && typeof o === 'object' && !Buffer.isBuffer(o) && !Array.isArray(o)
  85. );
  86. }
  87. function isNonEmptyStringRecord(o: unknown): o is Record<string, unknown> {
  88. return isStringRecord(o) && Object.keys(o).length > 0;
  89. }
  90. function ensureQueryIsStringOrMissing(query: unknown) {
  91. if (!query || typeof query === 'string') {
  92. return;
  93. }
  94. // Check for a common error first.
  95. if ((query as any).kind === Kind.DOCUMENT) {
  96. throw new BadRequestError(
  97. "GraphQL queries must be strings. It looks like you're sending the " +
  98. 'internal graphql-js representation of a parsed query in your ' +
  99. 'request instead of a request in the GraphQL query language. You ' +
  100. 'can convert an AST to a string using the `print` function from ' +
  101. '`graphql`, or use a client like `apollo-client` which converts ' +
  102. 'the internal representation to a string for you.',
  103. );
  104. } else {
  105. throw new BadRequestError('GraphQL queries must be strings.');
  106. }
  107. }
  108. export async function runHttpQuery<TContext extends BaseContext>({
  109. server,
  110. httpRequest,
  111. contextValue,
  112. schemaDerivedData,
  113. internals,
  114. sharedResponseHTTPGraphQLHead,
  115. }: {
  116. server: ApolloServer<TContext>;
  117. httpRequest: HTTPGraphQLRequest;
  118. contextValue: TContext;
  119. schemaDerivedData: SchemaDerivedData;
  120. internals: ApolloServerInternals<TContext>;
  121. sharedResponseHTTPGraphQLHead: HTTPGraphQLHead | null;
  122. }): Promise<HTTPGraphQLResponse> {
  123. let graphQLRequest: GraphQLRequest;
  124. switch (httpRequest.method) {
  125. case 'POST': {
  126. if (!isNonEmptyStringRecord(httpRequest.body)) {
  127. throw new BadRequestError(
  128. 'POST body missing, invalid Content-Type, or JSON object has no keys.',
  129. );
  130. }
  131. ensureQueryIsStringOrMissing(httpRequest.body.query);
  132. if (typeof httpRequest.body.variables === 'string') {
  133. throw new BadRequestError(
  134. '`variables` in a POST body should be provided as an object, not a recursively JSON-encoded string.',
  135. );
  136. }
  137. if (typeof httpRequest.body.extensions === 'string') {
  138. throw new BadRequestError(
  139. '`extensions` in a POST body should be provided as an object, not a recursively JSON-encoded string.',
  140. );
  141. }
  142. if (
  143. 'extensions' in httpRequest.body &&
  144. httpRequest.body.extensions !== null &&
  145. !isStringRecord(httpRequest.body.extensions)
  146. ) {
  147. throw new BadRequestError(
  148. '`extensions` in a POST body must be an object if provided.',
  149. );
  150. }
  151. if (
  152. 'variables' in httpRequest.body &&
  153. httpRequest.body.variables !== null &&
  154. !isStringRecord(httpRequest.body.variables)
  155. ) {
  156. throw new BadRequestError(
  157. '`variables` in a POST body must be an object if provided.',
  158. );
  159. }
  160. if (
  161. 'operationName' in httpRequest.body &&
  162. httpRequest.body.operationName !== null &&
  163. typeof httpRequest.body.operationName !== 'string'
  164. ) {
  165. throw new BadRequestError(
  166. '`operationName` in a POST body must be a string if provided.',
  167. );
  168. }
  169. graphQLRequest = {
  170. query: fieldIfString(httpRequest.body, 'query'),
  171. operationName: fieldIfString(httpRequest.body, 'operationName'),
  172. variables: fieldIfRecord(httpRequest.body, 'variables'),
  173. extensions: fieldIfRecord(httpRequest.body, 'extensions'),
  174. http: httpRequest,
  175. };
  176. break;
  177. }
  178. case 'GET': {
  179. const searchParams = new URLSearchParams(httpRequest.search);
  180. graphQLRequest = {
  181. query: searchParamIfSpecifiedOnce(searchParams, 'query'),
  182. operationName: searchParamIfSpecifiedOnce(
  183. searchParams,
  184. 'operationName',
  185. ),
  186. variables: jsonParsedSearchParamIfSpecifiedOnce(
  187. searchParams,
  188. 'variables',
  189. ),
  190. extensions: jsonParsedSearchParamIfSpecifiedOnce(
  191. searchParams,
  192. 'extensions',
  193. ),
  194. http: httpRequest,
  195. };
  196. break;
  197. }
  198. default:
  199. throw new BadRequestError(
  200. 'Apollo Server supports only GET/POST requests.',
  201. {
  202. extensions: {
  203. http: {
  204. status: 405,
  205. headers: new HeaderMap([['allow', 'GET, POST']]),
  206. },
  207. },
  208. },
  209. );
  210. }
  211. const graphQLResponse = await internalExecuteOperation(
  212. {
  213. server,
  214. graphQLRequest,
  215. internals,
  216. schemaDerivedData,
  217. sharedResponseHTTPGraphQLHead,
  218. },
  219. { contextValue },
  220. );
  221. if (graphQLResponse.body.kind === 'single') {
  222. if (!graphQLResponse.http.headers.get('content-type')) {
  223. // If we haven't already set the content-type (via a plugin or something),
  224. // decide which content-type to use based on the accept header.
  225. const contentType = chooseContentTypeForSingleResultResponse(httpRequest);
  226. if (contentType === null) {
  227. throw new BadRequestError(
  228. `An 'accept' header was provided for this request which does not accept ` +
  229. `${MEDIA_TYPES.APPLICATION_JSON} or ${MEDIA_TYPES.APPLICATION_GRAPHQL_RESPONSE_JSON}`,
  230. // Use 406 Not Accepted
  231. { extensions: { http: { status: 406 } } },
  232. );
  233. }
  234. graphQLResponse.http.headers.set('content-type', contentType);
  235. }
  236. return {
  237. ...graphQLResponse.http,
  238. body: {
  239. kind: 'complete',
  240. string: await internals.stringifyResult(
  241. orderExecutionResultFields(graphQLResponse.body.singleResult),
  242. ),
  243. },
  244. };
  245. }
  246. // Note that incremental delivery is not yet part of the official GraphQL
  247. // spec. We are implementing a proposed version of the spec, and require
  248. // clients to explicitly state `deferSpec=20220824`. Once incremental delivery
  249. // has been added to the GraphQL spec, we will support `accept` headers
  250. // without `deferSpec` as well (perhaps with slightly different behavior if
  251. // anything has changed).
  252. const acceptHeader = httpRequest.headers.get('accept');
  253. if (
  254. !(
  255. acceptHeader &&
  256. new Negotiator({
  257. headers: { accept: httpRequest.headers.get('accept') },
  258. }).mediaType([
  259. // mediaType() will return the first one that matches, so if the client
  260. // doesn't include the deferSpec parameter it will match this one here,
  261. // which isn't good enough.
  262. MEDIA_TYPES.MULTIPART_MIXED_NO_DEFER_SPEC,
  263. MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL,
  264. ]) === MEDIA_TYPES.MULTIPART_MIXED_EXPERIMENTAL
  265. )
  266. ) {
  267. // The client ran an operation that would yield multiple parts, but didn't
  268. // specify `accept: multipart/mixed`. We return an error.
  269. throw new BadRequestError(
  270. 'Apollo server received an operation that uses incremental delivery ' +
  271. '(@defer or @stream), but the client does not accept multipart/mixed ' +
  272. 'HTTP responses. To enable incremental delivery support, add the HTTP ' +
  273. "header 'Accept: multipart/mixed; deferSpec=20220824'.",
  274. // Use 406 Not Accepted
  275. { extensions: { http: { status: 406 } } },
  276. );
  277. }
  278. graphQLResponse.http.headers.set(
  279. 'content-type',
  280. 'multipart/mixed; boundary="-"; deferSpec=20220824',
  281. );
  282. return {
  283. ...graphQLResponse.http,
  284. body: {
  285. kind: 'chunked',
  286. asyncIterator: writeMultipartBody(
  287. graphQLResponse.body.initialResult,
  288. graphQLResponse.body.subsequentResults,
  289. ),
  290. },
  291. };
  292. }
  293. async function* writeMultipartBody(
  294. initialResult: GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
  295. subsequentResults: AsyncIterable<GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult>,
  296. ): AsyncGenerator<string> {
  297. // Note: we assume in this function that every result other than the last has
  298. // hasNext=true and the last has hasNext=false. That is, we choose which kind
  299. // of delimiter to place at the end of each block based on the contents of the
  300. // message, not the structure of the async iterator. This makes sense because
  301. // we want to write the delimiter as soon as each block is done (so the client
  302. // can parse it immediately) but we may not know whether a general async
  303. // iterator is finished until we do async work.
  304. yield `\r\n---\r\ncontent-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
  305. orderInitialIncrementalExecutionResultFields(initialResult),
  306. )}\r\n---${initialResult.hasNext ? '' : '--'}\r\n`;
  307. for await (const result of subsequentResults) {
  308. yield `content-type: application/json; charset=utf-8\r\n\r\n${JSON.stringify(
  309. orderSubsequentIncrementalExecutionResultFields(result),
  310. )}\r\n---${result.hasNext ? '' : '--'}\r\n`;
  311. }
  312. }
  313. // See https://github.com/facebook/graphql/pull/384 for why
  314. // errors comes first.
  315. function orderExecutionResultFields(
  316. result: FormattedExecutionResult,
  317. ): FormattedExecutionResult {
  318. return {
  319. errors: result.errors,
  320. data: result.data,
  321. extensions: result.extensions,
  322. };
  323. }
  324. function orderInitialIncrementalExecutionResultFields(
  325. result: GraphQLExperimentalFormattedInitialIncrementalExecutionResult,
  326. ): GraphQLExperimentalFormattedInitialIncrementalExecutionResult {
  327. return {
  328. hasNext: result.hasNext,
  329. errors: result.errors,
  330. data: result.data,
  331. incremental: orderIncrementalResultFields(result.incremental),
  332. extensions: result.extensions,
  333. };
  334. }
  335. function orderSubsequentIncrementalExecutionResultFields(
  336. result: GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult,
  337. ): GraphQLExperimentalFormattedSubsequentIncrementalExecutionResult {
  338. return {
  339. hasNext: result.hasNext,
  340. incremental: orderIncrementalResultFields(result.incremental),
  341. extensions: result.extensions,
  342. };
  343. }
  344. function orderIncrementalResultFields(
  345. incremental?: readonly GraphQLExperimentalFormattedIncrementalResult[],
  346. ): undefined | GraphQLExperimentalFormattedIncrementalResult[] {
  347. return incremental?.map((i: any) => ({
  348. hasNext: i.hasNext,
  349. errors: i.errors,
  350. path: i.path,
  351. label: i.label,
  352. data: i.data,
  353. items: i.items,
  354. extensions: i.extensions,
  355. }));
  356. }
  357. // The result of a curl does not appear well in the terminal, so we add an extra new line
  358. export function prettyJSONStringify(value: FormattedExecutionResult) {
  359. return JSON.stringify(value) + '\n';
  360. }
  361. export function newHTTPGraphQLHead(status?: number): HTTPGraphQLHead {
  362. return {
  363. status,
  364. headers: new HeaderMap(),
  365. };
  366. }
  367. // Updates `target` with status code and headers from `source`. For now let's
  368. // consider it undefined what happens if both have a status code set or both set
  369. // the same header.
  370. export function mergeHTTPGraphQLHead(
  371. target: HTTPGraphQLHead,
  372. source: HTTPGraphQLHead,
  373. ) {
  374. if (source.status) {
  375. target.status = source.status;
  376. }
  377. if (source.headers) {
  378. for (const [name, value] of source.headers) {
  379. // If source.headers contains non-lowercase header names, this will
  380. // catch that case as long as target.headers is a HeaderMap.
  381. target.headers.set(name, value);
  382. }
  383. }
  384. }