UNPKG

grafserv

Version:

A highly optimized server for GraphQL, powered by Grafast

467 lines 19.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.makeGraphQLHandler = exports.TEXT_HTML = exports.APPLICATION_GRAPHQL_RESPONSE_JSON = exports.APPLICATION_JSON = exports.DEFAULT_ALLOWED_REQUEST_CONTENT_TYPES = void 0; exports.makeParseAndValidateFunction = makeParseAndValidateFunction; exports.validateGraphQLBody = validateGraphQLBody; const tslib_1 = require("tslib"); const node_querystring_1 = require("node:querystring"); const lru_1 = require("@graphile/lru"); const crypto_1 = require("crypto"); const grafast_1 = require("grafast"); const graphql = tslib_1.__importStar(require("grafast/graphql")); const accept_js_1 = require("../accept.js"); const interfaces_js_1 = require("../interfaces.js"); const utils_js_1 = require("../utils.js"); const { getOperationAST, GraphQLError, parse, Source, validate } = graphql; let lastString; let lastHash; const calculateQueryHash = (queryString) => { if (queryString !== lastString) { lastString = queryString; lastHash = (0, crypto_1.createHash)("sha1").update(queryString).digest("base64"); } return lastHash; }; function makeParseAndValidateFunction(schema, resolvedPreset, dynamicOptions) { const maxLength = resolvedPreset.grafserv?.parseAndValidateCacheSize ?? 500; const parseAndValidationCache = maxLength >= 1 ? new lru_1.LRU({ maxLength, }) : null; let lastParseAndValidateQuery; let lastParseAndValidateResult; function parseAndValidate(query) { if (lastParseAndValidateQuery === query) { return lastParseAndValidateResult; } const hash = query.length > 500 ? calculateQueryHash(query) : query; const cached = parseAndValidationCache?.get(hash); if (cached !== undefined) { lastParseAndValidateQuery = query; lastParseAndValidateResult = cached; return cached; } const source = new Source(query, "GraphQL HTTP Request"); let document; try { document = parse(source); } catch (e) { const result = { errors: [e] }; parseAndValidationCache?.set(hash, result); lastParseAndValidateQuery = query; lastParseAndValidateResult = result; return result; } const errors = validate(schema, document, dynamicOptions.validationRules); const result = errors.length ? { errors } : { document }; parseAndValidationCache?.set(hash, result); lastParseAndValidateQuery = query; lastParseAndValidateResult = result; return result; } return parseAndValidate; } function parseGraphQLQueryParams(params) { const id = params.id; const documentId = params.documentId; const query = params.query; const operationName = params.operationName ?? undefined; const variablesString = params.variables ?? undefined; const variableValues = typeof variablesString === "string" ? JSON.parse(variablesString) : undefined; const onError = params.onError ?? undefined; const extensionsString = params.extensions ?? undefined; const extensions = typeof extensionsString === "string" ? JSON.parse(extensionsString) : undefined; return { id, documentId, query, operationName, variableValues, onError, extensions, }; } /** * The default allowed request content types do not include * `application/x-www-form-urlencoded` because that is treated specially by * browsers (e.g. it can be submitted cross origins without CORS). * * If you're using CORS then no media type is CSRF safe - it's up to you to * manage your CSRF protection. */ exports.DEFAULT_ALLOWED_REQUEST_CONTENT_TYPES = Object.freeze([ "application/json", "application/graphql", // CSRF risk: // "application/x-www-form-urlencoded", // Not supported, AND CSRF risk: // 'multipart/form-data' ]); function parseGraphQLBody(resolvedPreset, request, body) { const supportedContentTypes = resolvedPreset.grafserv?.allowedRequestContentTypes ?? exports.DEFAULT_ALLOWED_REQUEST_CONTENT_TYPES; const contentType = request[interfaces_js_1.$$normalizedHeaders]["content-type"]; if (!contentType) { throw (0, utils_js_1.httpError)(400, "Could not determine the Content-Type of the request"); } const semi = contentType.indexOf(";"); const rawContentType = semi >= 0 ? contentType.slice(0, semi).trim() : contentType.trim(); if (!supportedContentTypes.includes(rawContentType)) { throw (0, utils_js_1.httpError)(415, `Media type '${rawContentType}' is not allowed`); } const ct = rawContentType; // FIXME: we should probably at least look at the parameters... e.g. throw if encoding !== utf-8 switch (ct) { case "application/json": { switch (body.type) { case "buffer": { return (0, utils_js_1.parseGraphQLJSONBody)(JSON.parse(body.buffer.toString("utf8"))); } case "text": { return (0, utils_js_1.parseGraphQLJSONBody)(JSON.parse(body.text)); } case "json": { return (0, utils_js_1.parseGraphQLJSONBody)(body.json); } default: { const never = body; throw (0, utils_js_1.httpError)(400, `Do not understand type ${never.type}`); } } } case "application/x-www-form-urlencoded": { switch (body.type) { case "buffer": { return parseGraphQLQueryParams((0, node_querystring_1.parse)(body.buffer.toString("utf8"))); } case "text": { return parseGraphQLQueryParams((0, node_querystring_1.parse)(body.text)); } case "json": { if (body.json == null || typeof body.json !== "object" || Array.isArray(body.json)) { throw (0, utils_js_1.httpError)(400, `Invalid body`); } return parseGraphQLQueryParams(body.json); } default: { const never = body; throw (0, utils_js_1.httpError)(400, `Do not understand type ${never.type}`); } } } case "application/graphql": { // ENHANCE: I have a vague feeling that people that do this pass variables via the query string? switch (body.type) { case "text": { return { id: undefined, documentId: undefined, query: body.text, operationName: undefined, variableValues: undefined, onError: undefined, extensions: undefined, }; } case "buffer": { return { id: undefined, documentId: undefined, query: body.buffer.toString("utf8"), operationName: undefined, variableValues: undefined, onError: undefined, extensions: undefined, }; } case "json": { // ERRORS: non-standard; perhaps raise a warning? return (0, utils_js_1.parseGraphQLJSONBody)(body.json); } default: { const never = body; throw (0, utils_js_1.httpError)(400, `Do not understand type ${never.type}`); } } } default: { const never = ct; throw (0, utils_js_1.httpError)(415, `Media type '${never}' is not understood`); } } } exports.APPLICATION_JSON = "application/json;charset=utf-8"; exports.APPLICATION_GRAPHQL_RESPONSE_JSON = "application/graphql-response+json;charset=utf-8"; exports.TEXT_HTML = "text/html;charset=utf-8"; /** https://graphql.github.io/graphql-over-http/draft/#sec-Legacy-Watershed */ const isAfterWatershed = Date.now() >= +new Date(2025, 0, 1); const GRAPHQL_TYPES = isAfterWatershed ? [exports.APPLICATION_GRAPHQL_RESPONSE_JSON, exports.APPLICATION_JSON] : [exports.APPLICATION_JSON, exports.APPLICATION_GRAPHQL_RESPONSE_JSON]; const graphqlAcceptMatcher = (0, accept_js_1.makeAcceptMatcher)([...GRAPHQL_TYPES]); const graphqlOrHTMLAcceptMatcher = (0, accept_js_1.makeAcceptMatcher)([ ...GRAPHQL_TYPES, // Must be lowest priority, otherwise GraphiQL may override GraphQL in some // situations exports.TEXT_HTML, ]); function validateGraphQLBody(parsed) { const { query, operationName, variableValues, onError, extensions } = parsed; if (typeof query !== "string") { throw (0, utils_js_1.httpError)(400, "query must be a string"); } if (operationName != null && typeof operationName !== "string") { throw (0, utils_js_1.httpError)(400, "operationName, if given, must be a string"); } if (variableValues != null && (typeof variableValues !== "object" || Array.isArray(variableValues))) { throw (0, utils_js_1.httpError)(400, "Invalid variables; expected JSON-encoded object"); } if (onError != null && !grafast_1.GraphQLSpecifiedErrorBehaviors.includes(onError)) { throw (0, utils_js_1.httpError)(400, `Invalid onError; supported error behaviors are: ${grafast_1.GraphQLSpecifiedErrorBehaviors.join(", ")}`); } if (extensions != null && (typeof extensions !== "object" || Array.isArray(extensions))) { throw (0, utils_js_1.httpError)(400, "Invalid extensions; expected JSON-encoded object"); } return parsed; } const _makeGraphQLHandlerInternal = (instance) => { const { dynamicOptions, resolvedPreset, middleware, grafastMiddleware } = instance; return async (request, graphiqlHandler) => { const accept = request[interfaces_js_1.$$normalizedHeaders].accept; // Do they want HTML, or do they want GraphQL? const chosenContentType = request.method === "GET" && dynamicOptions.graphiqlOnGraphQLGET && graphiqlHandler ? graphqlOrHTMLAcceptMatcher(accept) : graphqlAcceptMatcher(accept); if (chosenContentType === exports.TEXT_HTML) { // They want HTML -> Ruru return graphiqlHandler(request); } else if (chosenContentType === exports.APPLICATION_JSON || chosenContentType === exports.APPLICATION_GRAPHQL_RESPONSE_JSON) { // They want GraphQL if (request.method === "POST" || (dynamicOptions.graphqlOverGET && request.method === "GET")) { /* continue */ } else { return { type: "graphql", request, dynamicOptions, statusCode: 405, contentType: "application/json", payload: { errors: [new GraphQLError("Method not supported, please use POST")], }, }; } } else { // > Respond with a 406 Not Acceptable status code and stop processing the request. // https://graphql.github.io/graphql-over-http/draft/#sel-DANHELDAACNA4rR return { type: "graphql", request, dynamicOptions, statusCode: 406, contentType: "application/json", payload: { errors: [ new GraphQLError("Could not find a supported media type; consider adding 'application/json' or 'application/graphql-response+json' to your Accept header."), ], }, }; } // If we get here, we're handling a GraphQL request const isLegacy = chosenContentType === exports.APPLICATION_JSON; let body; try { // Read the body const parsedBody = request.method === "POST" ? parseGraphQLBody(resolvedPreset, request, await request.getBody()) : parseGraphQLQueryParams(await request.getQueryParams()); // Apply our middleware (if any) to the body (they will mutate the body in place) if (middleware != null && middleware.middleware.processGraphQLRequestBody != null) { const hookResult = middleware.run("processGraphQLRequestBody", { resolvedPreset, body: parsedBody, request, }, utils_js_1.noop); if (hookResult != null) { await hookResult; } } // Validate that the body is of the right shape body = validateGraphQLBody(parsedBody); } catch (e) { if (e instanceof grafast_1.SafeError) { throw e; } else if (typeof e.statusCode === "number" && e.statusCode >= 400 && e.statusCode < 600) { throw e; } else { // ENHANCE: should maybe handle more specific issues here. See examples: // https://graphql.github.io/graphql-over-http/draft/#sec-application-json.Examples throw (0, utils_js_1.httpError)(400, `Parsing failed, please check that the data you're sending to the server is correct`); } } const grafastCtx = { ...request.requestContext, http: request, }; const { schema, parseAndValidate, execute, // subscribe, contextValue, // dynamicOptions? } = await instance.getExecutionConfig(grafastCtx); const outputDataAsString = dynamicOptions.outputDataAsString; const { maskIterator, maskPayload, maskError } = dynamicOptions; const { query, operationName, variableValues, onError } = body; const { errors, document } = parseAndValidate(query); if (errors !== undefined) { return { type: "graphql", request, dynamicOptions, statusCode: isLegacy ? 200 : 400, contentType: chosenContentType, payload: { errors }, }; } if (request.method !== "POST") { // Forbid mutation const operation = getOperationAST(document, operationName); if (!operation || operation.operation !== "query") { const error = new GraphQLError("Only queries may take place over non-POST requests.", operation); return { type: "graphql", request, dynamicOptions, // Note: the GraphQL-over-HTTP spec currently mandates 405, even for legacy clients: // https://graphql.github.io/graphql-over-http/draft/#sel-FALJRPCE2BCGoBitR statusCode: 405, contentType: chosenContentType, payload: { errors: [error], }, }; } } const args = { schema, document, rootValue: null, contextValue, variableValues, operationName, onError, resolvedPreset, requestContext: grafastCtx, middleware: grafastMiddleware, }; try { await (0, grafast_1.hookArgs)(args); const result = await execute(args); if ((0, grafast_1.isAsyncIterable)(result)) { return { type: "graphqlIncremental", request, dynamicOptions, statusCode: 200, iterator: maskIterator(result), outputDataAsString, }; } return { type: "graphql", request, dynamicOptions, statusCode: isLegacy || !result.errors ? 200 : result.data === undefined ? 400 : 200, contentType: chosenContentType, payload: maskPayload(result), outputDataAsString, }; } catch (e) { console.error(e); return { type: "graphql", request, dynamicOptions, // e.g. We should always return 400 on no Content-Type header: // https://graphql.github.io/graphql-over-http/draft/#sel-DALLDJAADLCA8tb statusCode: e.statusCode ?? (isLegacy ? 200 : 500), contentType: chosenContentType, payload: { errors: [maskError(new GraphQLError(e.message))], extensions: args.rootValue?.[grafast_1.$$extensions], }, }; } }; }; const makeGraphQLHandler = (instance) => { const handler = _makeGraphQLHandlerInternal(instance); const { dynamicOptions } = instance; return (request, graphiqlHandler) => handler(request, graphiqlHandler).catch((e) => handleGraphQLHandlerError(request, dynamicOptions, e)); }; exports.makeGraphQLHandler = makeGraphQLHandler; function handleGraphQLHandlerError(request, dynamicOptions, e) { if (e instanceof grafast_1.SafeError) { return { type: "graphql", request, dynamicOptions, payload: { errors: [ new GraphQLError(e.message, null, null, null, null, e, e.extensions), ], }, statusCode: e.extensions?.statusCode ?? 500, // FIXME: we should respect the `accept` header here if we can. contentType: exports.APPLICATION_JSON, }; } // TODO: if a GraphQLError is thrown... WTF? const graphqlError = e instanceof GraphQLError ? e : new GraphQLError("Unknown error occurred", null, null, null, null, e); // Special error handling for GraphQL route console.error("An error occurred whilst attempting to handle the GraphQL request:"); console.dir(e); return { type: "graphql", request, dynamicOptions, payload: { errors: [graphqlError] }, statusCode: graphqlError.extensions?.statusCode ?? 500, // Fall back to application/json; this is when an unexpected error happens // so it shouldn't be hit. contentType: exports.APPLICATION_JSON, }; } //# sourceMappingURL=graphql.js.map