UNPKG

@ardatan/openapi-to-graphql

Version:

Generates a GraphQL schema for a given OpenAPI Specification (OAS)

721 lines 31.3 kB
"use strict"; // Copyright IBM Corp. 2018. All Rights Reserved. // Node module: openapi-to-graphql // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); // Imports: const Oas3Tools = require("./oas_3_tools"); const JSONPath = require("jsonpath-plus"); const debug_1 = require("debug"); const graphql_1 = require("graphql"); const form_urlencoded_1 = require("form-urlencoded"); const urlJoin = require("url-join"); const translationLog = debug_1.debug('translation'); const httpLog = debug_1.debug('http'); function headersToObject(headers) { const headersObj = {}; headers.forEach((value, key) => { headersObj[key] = value; }); return headersObj; } /** * Creates and returns a resolver function that performs API requests for the * given GraphQL query */ function getResolver({ operation, argsFromLink = {}, payloadName, data, baseUrl, requestOptions }) { // Determine the appropriate URL: if (typeof baseUrl === 'undefined') { baseUrl = Oas3Tools.getBaseUrl(operation); } // Return custom resolver if it is defined const customResolvers = data.options.customResolvers; const title = operation.oas.info.title; const path = operation.path; const method = operation.method; if (typeof customResolvers === 'object' && typeof customResolvers[title] === 'object' && typeof customResolvers[title][path] === 'object' && typeof customResolvers[title][path][method] === 'function') { translationLog(`Use custom resolver for ${operation.operationString}`); return customResolvers[title][path][method]; } // Return resolve function: return (root, args, ctx, info = {}) => __awaiter(this, void 0, void 0, function* () { /** * Retch resolveData from possibly existing _openAPIToGraphQL * * NOTE: _openAPIToGraphQL is an object used to pass security info and data * from previous resolvers */ let resolveData = {}; if (root && typeof root === 'object' && typeof root['_openAPIToGraphQL'] === 'object' && typeof root['_openAPIToGraphQL'].data === 'object') { const parentIdentifier = getParentIdentifier(info); if (!(parentIdentifier.length === 0) && parentIdentifier in root['_openAPIToGraphQL'].data) { /** * Resolving link params may change the usedParams, but these changes * should not be present in the parent _openAPIToGraphQL, therefore copy * the object */ resolveData = JSON.parse(JSON.stringify(root['_openAPIToGraphQL'].data[parentIdentifier])); } } if (typeof resolveData.usedParams === 'undefined') { resolveData.usedParams = {}; } /** * Handle default values of parameters, if they have not yet been defined by * the user. */ operation.parameters.forEach(param => { const paramName = Oas3Tools.sanitize(param.name, !data.options.simpleNames ? Oas3Tools.CaseStyle.camelCase : Oas3Tools.CaseStyle.simple); if (typeof args[paramName] === 'undefined' && param.schema && typeof param.schema === 'object') { let schema = param.schema; if (schema && schema.$ref && typeof schema.$ref === 'string') { schema = Oas3Tools.resolveRef(schema.$ref, operation.oas); } if (schema && schema.default && typeof schema.default !== 'undefined') { args[paramName] = schema.default; } } }); // Handle arguments provided by links for (const paramName in argsFromLink) { const saneParamName = Oas3Tools.sanitize(paramName, !data.options.simpleNames ? Oas3Tools.CaseStyle.camelCase : Oas3Tools.CaseStyle.simple); let value = argsFromLink[paramName]; /** * see if the link parameter contains constants that are appended to the link parameter * * e.g. instead of: * $response.body#/employerId * * it could be: * abc_{$response.body#/employerId} */ if (value.search(/{|}/) === -1) { args[saneParamName] = isRuntimeExpression(value) ? resolveLinkParameter(paramName, value, resolveData, root, args) : value; } else { // Replace link parameters with appropriate values const linkParams = value.match(/{([^}]*)}/g); linkParams.forEach(linkParam => { value = value.replace(linkParam, resolveLinkParameter(paramName, linkParam.substring(1, linkParam.length - 1), resolveData, root, args)); }); args[saneParamName] = value; } } // Stored used parameters to future requests: resolveData.usedParams = Object.assign(resolveData.usedParams, args); // Build URL (i.e., fill in path parameters): const { path, query, headers } = Oas3Tools.instantiatePathAndGetQuery(operation.path, operation.parameters, args, data); const urlObject = new URL(urlJoin(baseUrl, path)); /** * The Content-type and accept property should not be changed because the * object type has already been created and unlike these properties, it * cannot be easily changed * * NOTE: This may cause the user to encounter unexpected changes */ headers['content-type'] = typeof operation.payloadContentType !== 'undefined' ? operation.payloadContentType : 'application/json'; headers['accept'] = typeof operation.responseContentType !== 'undefined' ? operation.responseContentType : 'application/json'; let options; if (requestOptions) { options = Object.assign({}, requestOptions); options.method = operation.method; if (options.headers) { Object.assign(options.headers, headers); } else { options['headers'] = headers; } } else { options = { method: operation.method, headers: headers }; } for (const paramName in query) { const val = query[paramName]; urlObject.searchParams.set(paramName, val); } /** * Determine possible payload * * GraphQL produces sanitized payload names, so we have to sanitize before * lookup here */ resolveData.usedPayload = undefined; if (typeof payloadName === 'string') { // The option genericPayloadArgName will change the payload name to "requestBody" const sanePayloadName = data.options.genericPayloadArgName ? 'requestBody' : Oas3Tools.sanitize(payloadName, Oas3Tools.CaseStyle.camelCase); let rawPayload; if (operation.payloadContentType === 'application/json') { rawPayload = JSON.stringify(Oas3Tools.desanitizeObjectKeys(args[sanePayloadName], data.saneMap)); } else if (operation.payloadContentType === 'application/x-www-form-urlencoded') { rawPayload = form_urlencoded_1.default(Oas3Tools.desanitizeObjectKeys(args[sanePayloadName], data.saneMap)); } else { // Payload is not an object rawPayload = args[sanePayloadName]; } options.body = rawPayload; resolveData.usedPayload = rawPayload; } /** * Pass on OpenAPI-to-GraphQL options */ if (typeof data.options === 'object') { // Headers: if (typeof data.options.headers === 'object') { for (let header in data.options.headers) { const val = data.options.headers[header]; options.headers[header] = val; } } // Query string: if (typeof data.options.qs === 'object') { for (const query in data.options.qs) { const val = data.options.qs[query]; urlObject.searchParams.set(query, val); } } } // Get authentication headers and query parameters if (root && typeof root === 'object' && typeof root['_openAPIToGraphQL'] === 'object') { const { authHeaders, authQs, authCookie } = getAuthOptions(operation, root['_openAPIToGraphQL'], data); // ...and pass them to the options Object.assign(options.headers, authHeaders); for (const query in authQs) { const val = authQs[query]; urlObject.searchParams.set(query, val); } // Add authentication cookie if created if (authCookie !== null) { options.headers['cookie'] = authCookie; } } // Extract OAuth token from context (if available) if (data.options.sendOAuthTokenInQuery) { const oauthQueryObj = createOAuthQS(data, ctx); for (const query in oauthQueryObj) { const val = oauthQueryObj[query]; urlObject.searchParams.set(query, val); } } else { const oauthHeader = createOAuthHeader(data, ctx); Object.assign(options.headers, oauthHeader); } const urlWithoutQuery = urlObject.href.replace(urlObject.search, ''); resolveData.url = urlWithoutQuery; resolveData.usedRequestOptions = options; resolveData.usedStatusCode = operation.statusCode; // Make the call httpLog(`Call ${options.method.toUpperCase()} ${urlWithoutQuery}?${urlObject.search}\n` + `headers: ${JSON.stringify(options.headers)}\n` + `request body: ${options.body}`); let response; try { response = yield data.options.fetch(urlObject.href, options); } catch (err) { httpLog(err); throw err; } const body = yield response.text(); if (response.status < 200 || response.status > 299) { httpLog(`${response.status} - ${Oas3Tools.trim(body, 100)}`); const errorString = `Could not invoke operation ${operation.operationString}`; if (data.options.provideErrorExtensions) { let responseBody; try { responseBody = JSON.parse(body); } catch (e) { responseBody = body; } const extensions = { method: operation.method, path: operation.path, statusCode: response.status, responseHeaders: headersToObject(response.headers), responseBody }; throw graphQLErrorWithExtensions(errorString, extensions); } else { throw new Error(errorString); } // Successful response 200-299 } else { httpLog(`${response.status} - ${Oas3Tools.trim(body, 100)}`); if (response.headers.get('content-type')) { /** * Throw warning if the non-application/json content does not * match the OAS. * * Use an inclusion test in case of charset * * i.e. text/plain; charset=utf-8 */ if (!(response.headers .get('content-type') .includes(operation.responseContentType) || operation.responseContentType.includes(response.headers.get('content-type')))) { const errorString = `Operation ` + `${operation.operationString} ` + `should have a content-type '${operation.responseContentType}' ` + `but has '${response.headers.get('content-type')}' instead`; httpLog(errorString); throw errorString; } else { /** * If the response body is type JSON, then parse it * * content-type may not be necessarily 'application/json' it can be * 'application/json; charset=utf-8' for example */ if (response.headers.get('content-type').includes('application/json')) { let responseBody; try { responseBody = JSON.parse(body); } catch (e) { const errorString = `Cannot JSON parse response body of ` + `operation ${operation.operationString} ` + `even though it has content-type 'application/json'`; httpLog(errorString); throw errorString; } resolveData.responseHeaders = headersToObject(response.headers); // Deal with the fact that the server might send unsanitized data let saneData = Oas3Tools.sanitizeObjectKeys(responseBody, !data.options.simpleNames ? Oas3Tools.CaseStyle.camelCase : Oas3Tools.CaseStyle.simple); // Pass on _openAPIToGraphQL to subsequent resolvers if (saneData && typeof saneData === 'object') { if (Array.isArray(saneData)) { saneData.forEach(element => { if (typeof element['_openAPIToGraphQL'] === 'undefined') { element['_openAPIToGraphQL'] = { data: {} }; } if (root && typeof root === 'object' && typeof root['_openAPIToGraphQL'] === 'object') { Object.assign(element['_openAPIToGraphQL'], root['_openAPIToGraphQL']); } element['_openAPIToGraphQL'].data[getIdentifier(info)] = resolveData; }); } else { if (typeof saneData['_openAPIToGraphQL'] === 'undefined') { saneData['_openAPIToGraphQL'] = { data: {} }; } if (root && typeof root === 'object' && typeof root['_openAPIToGraphQL'] === 'object') { Object.assign(saneData['_openAPIToGraphQL'], root['_openAPIToGraphQL']); } saneData['_openAPIToGraphQL'].data[getIdentifier(info)] = resolveData; } } // Apply limit argument if (data.options.addLimitArgument && /** * NOTE: Does not differentiate between autogenerated args and * preexisting args * * Ensure that there is not preexisting 'limit' argument */ !operation.parameters.find(parameter => { return parameter.name === 'limit'; }) && // Only array data Array.isArray(saneData) && // Only array of objects/arrays saneData.some(data => { return typeof data === 'object'; })) { let arraySaneData = saneData; if ('limit' in args) { const limit = args['limit']; if (limit >= 0) { arraySaneData = arraySaneData.slice(0, limit); } else { throw new Error(`Auto-generated 'limit' argument must be greater than or equal to 0`); } } else { throw new Error(`Cannot get value for auto-generated 'limit' argument`); } saneData = arraySaneData; } return saneData; } else { // TODO: Handle YAML return body; } } } else { /** * Check to see if there is not supposed to be a response body, * if that is the case, that would explain why there is not * a content-type */ const { responseContentType } = Oas3Tools.getResponseObject(operation, operation.statusCode, operation.oas); if (responseContentType === null) { return null; } else { const errorString = 'Response does not have a Content-Type property'; httpLog(errorString); throw errorString; } } } }); } exports.getResolver = getResolver; /** * Attempts to create an object to become an OAuth query string by extracting an * OAuth token from the ctx based on the JSON path provided in the options. */ function createOAuthQS(data, ctx) { return typeof data.options.tokenJSONpath !== 'string' ? {} : extractToken(data, ctx); } function extractToken(data, ctx) { const tokenJSONpath = data.options.tokenJSONpath; const tokens = JSONPath.JSONPath({ path: tokenJSONpath, json: ctx }); if (Array.isArray(tokens) && tokens.length > 0) { const token = tokens[0]; return { access_token: token }; } else { httpLog(`Warning: could not extract OAuth token from context at '${tokenJSONpath}'`); return {}; } } /** * Attempts to create an OAuth authorization header by extracting an OAuth token * from the ctx based on the JSON path provided in the options. */ function createOAuthHeader(data, ctx) { if (typeof data.options.tokenJSONpath !== 'string') { return {}; } // Extract token const tokenJSONpath = data.options.tokenJSONpath; const tokens = JSONPath.JSONPath({ path: tokenJSONpath, json: ctx }); if (Array.isArray(tokens) && tokens.length > 0) { const token = tokens[0]; return { Authorization: `Bearer ${token}`, 'User-Agent': 'openapi-to-graphql' }; } else { httpLog(`Warning: could not extract OAuth token from context at ` + `'${tokenJSONpath}'`); return {}; } } /** * Returns the headers and query strings to authenticate a request (if any). * Object containing authHeader and authQs object, * which hold headers and query parameters respectively to authentication a * request. */ function getAuthOptions(operation, _openAPIToGraphQL, data) { const authHeaders = {}; const authQs = {}; let authCookie = null; /** * Determine if authentication is required, and which protocol (if any) we can * use */ const { authRequired, sanitizedSecurityRequirement } = getAuthReqAndProtcolName(operation, _openAPIToGraphQL); const securityRequirement = data.saneMap[sanitizedSecurityRequirement]; // Possibly, we don't need to do anything: if (!authRequired) { return { authHeaders, authQs, authCookie }; } // If authentication is required, but we can't fulfill the protocol, throw: if (authRequired && typeof securityRequirement !== 'string') { throw new Error(`Missing information to authenticate API request.`); } if (typeof securityRequirement === 'string') { const security = data.security[securityRequirement]; switch (security.def.type) { case 'apiKey': const apiKey = _openAPIToGraphQL.security[sanitizedSecurityRequirement].apiKey; if ('in' in security.def) { if (typeof security.def.name === 'string') { if (security.def.in === 'header') { authHeaders[security.def.name] = apiKey; } else if (security.def.in === 'query') { authQs[security.def.name] = apiKey; } else if (security.def.in === 'cookie') { authCookie = `${security.def.name}=${apiKey}`; } } else { throw new Error(`Cannot send API key in '${JSON.stringify(security.def.in)}'`); } } break; case 'http': switch (security.def.scheme) { case 'basic': const username = _openAPIToGraphQL.security[sanitizedSecurityRequirement].username; const password = _openAPIToGraphQL.security[sanitizedSecurityRequirement].password; const credentials = `${username}:${password}`; authHeaders['Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`; break; default: throw new Error(`Cannot recognize http security scheme ` + `'${JSON.stringify(security.def.scheme)}'`); } break; case 'oauth2': break; case 'openIdConnect': break; default: throw new Error(`Cannot recognize security type '${security.def.type}'`); } } return { authHeaders, authQs, authCookie }; } /** * Determines whether given operation requires authentication, and which of the * (possibly multiple) authentication protocols can be used based on the data * present in the given context. */ function getAuthReqAndProtcolName(operation, _openAPIToGraphQL) { let authRequired = false; if (Array.isArray(operation.securityRequirements) && operation.securityRequirements.length > 0) { authRequired = true; for (let securityRequirement of operation.securityRequirements) { const sanitizedSecurityRequirement = Oas3Tools.sanitize(securityRequirement, Oas3Tools.CaseStyle.camelCase); if (typeof _openAPIToGraphQL.security[sanitizedSecurityRequirement] === 'object') { return { authRequired, sanitizedSecurityRequirement }; } } } return { authRequired }; } /** * Given a link parameter, determine the value * * The link parameter is a reference to data contained in the * url/method/statuscode or response/request body/query/path/header */ function resolveLinkParameter(paramName, value, resolveData, root, args) { if (value === '$url') { return resolveData.url; } else if (value === '$method') { return resolveData.usedRequestOptions.method; } else if (value === '$statusCode') { return resolveData.usedStatusCode; } else if (value.startsWith('$request.')) { // CASE: parameter is previous body if (value === '$request.body') { return resolveData.usedPayload; // CASE: parameter in previous body } else if (value.startsWith('$request.body#')) { const tokens = JSONPath.JSONPath({ path: value.split('body#/')[1], json: resolveData.usedPayload }); if (Array.isArray(tokens) && tokens.length > 0) { return tokens[0]; } else { httpLog(`Warning: could not extract parameter '${paramName}' from link`); } // CASE: parameter in previous query parameter } else if (value.startsWith('$request.query')) { return resolveData.usedParams[Oas3Tools.sanitize(value.split('query.')[1], Oas3Tools.CaseStyle.camelCase)]; // CASE: parameter in previous path parameter } else if (value.startsWith('$request.path')) { return resolveData.usedParams[Oas3Tools.sanitize(value.split('path.')[1], Oas3Tools.CaseStyle.camelCase)]; // CASE: parameter in previous header parameter } else if (value.startsWith('$request.header')) { return resolveData.usedRequestOptions.headers[value.split('header.')[1]]; } } else if (value.startsWith('$response.')) { /** * CASE: parameter is body * * NOTE: may not be used because it implies that the operation does not * return a JSON object and OpenAPI-to-GraphQL does not create GraphQL * objects for non-JSON data and links can only exists between objects. */ if (value === '$response.body') { const result = JSON.parse(JSON.stringify(root)); /** * _openAPIToGraphQL contains data used by OpenAPI-to-GraphQL to create the GraphQL interface * and should not be exposed */ result._openAPIToGraphQL = undefined; return result; // CASE: parameter in body } else if (value.startsWith('$response.body#')) { const tokens = JSONPath.JSONPath({ path: value.split('body#/')[1], json: root }); if (Array.isArray(tokens) && tokens.length > 0) { return tokens[0]; } else { httpLog(`Warning: could not extract parameter '${paramName}' from link`); } // CASE: parameter in query parameter } else if (value.startsWith('$response.query')) { // NOTE: handled the same way $request.query is handled return resolveData.usedParams[Oas3Tools.sanitize(value.split('query.')[1], Oas3Tools.CaseStyle.camelCase)]; // CASE: parameter in path parameter } else if (value.startsWith('$response.path')) { // NOTE: handled the same way $request.path is handled return resolveData.usedParams[Oas3Tools.sanitize(value.split('path.')[1], Oas3Tools.CaseStyle.camelCase)]; // CASE: parameter in header parameter } else if (value.startsWith('$response.header')) { return resolveData.responseHeaders[value.split('header.')[1]]; } } throw new Error(`Cannot create link because '${value}' is an invalid runtime expression`); } /** * Check if a string is a runtime expression in the context of link parameters */ function isRuntimeExpression(str) { const references = ['header.', 'query.', 'path.', 'body']; if (str === '$url' || str === '$method' || str === '$statusCode') { return true; } else if (str.startsWith('$request.')) { for (let i = 0; i < references.length; i++) { if (str.startsWith(`$request.${references[i]}`)) { return true; } } } else if (str.startsWith('$response.')) { for (let i = 0; i < references.length; i++) { if (str.startsWith(`$response.${references[i]}`)) { return true; } } } return false; } /** * From the info object provided by the resolver, get a unique identifier, which * is the path formed from the nested field names (or aliases if provided) * * Used to store and retrieve the _openAPIToGraphQL of parent field */ function getIdentifier(info) { return getIdentifierRecursive(info.path); } /** * From the info object provided by the resolver, get the unique identifier of * the parent object */ function getParentIdentifier(info) { return getIdentifierRecursive(info.path.prev); } /** * Get the path of nested field names (or aliases if provided) */ function getIdentifierRecursive(path) { return typeof path.prev === 'undefined' ? path.key : /** * Check if the identifier contains array indexing, if so remove. * * i.e. instead of 0/friends/1/friends/2/friends/user, create * friends/friends/friends/user */ isNaN(parseInt(path.key)) ? `${path.key}/${getIdentifierRecursive(path.prev)}` : getIdentifierRecursive(path.prev); } /** * Create a new GraphQLError with an extensions field */ function graphQLErrorWithExtensions(message, extensions) { return new graphql_1.GraphQLError(message, null, null, null, null, null, extensions); } //# sourceMappingURL=resolver_builder.js.map