UNPKG

openapi-to-graphql-lxwang2

Version:

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

937 lines (850 loc) 28.9 kB
// 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 /** * Functions to create resolve functions. */ // Type imports: import { SchemaObject } from './types/oas3' import { Operation } from './types/operation' import { ResolveFunction } from './types/graphql' import { PreprocessingData } from './types/preprocessing_data' import * as NodeRequest from 'request' // Imports: import * as Oas3Tools from './oas_3_tools' import * as querystring from 'querystring' import * as JSONPath from 'jsonpath-plus' import { debug } from 'debug' import { GraphQLError } from 'graphql' import formurlencoded from 'form-urlencoded' const translationLog = debug('translation') const httpLog = debug('http') // Type definitions & exports: type AuthReqAndProtcolName = { authRequired: boolean sanitizedSecurityRequirement?: string } type AuthOptions = { authHeaders: { [key: string]: string } authQs: { [key: string]: string } authCookie: NodeRequest.Cookie } type GetResolverParams = { operation: Operation argsFromLink?: { [key: string]: string } payloadName?: string data: PreprocessingData baseUrl?: string requestOptions?: NodeRequest.OptionsWithUrl } /** * Creates and returns a resolver function that performs API requests for the * given GraphQL query */ export function getResolver({ operation, argsFromLink = {}, payloadName, data, baseUrl, requestOptions }: GetResolverParams): ResolveFunction { // 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: any, args, ctx, info = {}) => { /** * Retch resolveData from possibly existing _openAPIToGraphQL * * NOTE: _openAPIToGraphQL is an object used to pass security info and data * from previous resolvers */ let resolveData: any = {} 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 as SchemaObject).default && typeof (schema as SchemaObject).default !== 'undefined' ) { args[paramName] = (schema as SchemaObject).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.operationId.split(':')[1], operation.parameters, args, data ) const url = 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: NodeRequest.OptionsWithUrl if (requestOptions) { options = { ...requestOptions } options['method'] = operation.method options['url'] = url if (options.headers) { Object.assign(options.headers, headers) } else { options['headers'] = headers } if (options.qs) { Object.assign(options.qs, query) } else { options['qs'] = query } } else { options = { method: operation.method, url: url, headers: headers, qs: query } } /** * 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 = formurlencoded( 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 (let query in data.options.qs) { const val = data.options.qs[query] options.qs[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) Object.assign(options.qs, authQs) // Add authentication cookie if created if (authCookie !== null) { const j = NodeRequest.jar() j.setCookie(authCookie, options.url) options.jar = j } } // Extract OAuth token from context (if available) if (data.options.sendOAuthTokenInQuery) { const oauthQueryObj = createOAuthQS(data, ctx) Object.assign(options.qs, oauthQueryObj) } else { const oauthHeader = createOAuthHeader(data, ctx) Object.assign(options.headers, oauthHeader) } resolveData.usedRequestOptions = options resolveData.usedStatusCode = operation.statusCode // Make the call httpLog( `Call ${options.method.toUpperCase()} ${ options.url }?${querystring.stringify(options.qs)}\n` + `headers: ${JSON.stringify(options.headers)}\n` + `request body: ${options.body}` ) return new Promise((resolve, reject) => { NodeRequest(options, (err, response, body) => { if (err) { httpLog(err) reject(err) } else if (response.statusCode < 200 || response.statusCode > 299) { httpLog(`${response.statusCode} - ${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.statusCode, responseHeaders: response.headers, responseBody } reject(graphQLErrorWithExtensions(errorString, extensions)) } else { reject(new Error(errorString)) } // Successful response 200-299 } else { httpLog(`${response.statusCode} - ${Oas3Tools.trim(body, 100)}`) if (response.headers['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['content-type'].includes( operation.responseContentType ) || operation.responseContentType.includes( response.headers['content-type'] ) ) ) { const errorString = `Operation ` + `${operation.operationString} ` + `should have a content-type '${operation.responseContentType}' ` + `but has '${response.headers['content-type']}' instead` httpLog(errorString) reject(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['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) reject(errorString) } resolveData.responseHeaders = 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 { reject( new Error( `Auto-generated 'limit' argument must be greater than or equal to 0` ) ) } } else { reject( new Error( `Cannot get value for auto-generated 'limit' argument` ) ) } saneData = arraySaneData } resolve(saneData) } else { // TODO: Handle YAML resolve(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) { resolve(null) } else { const errorString = 'Response does not have a Content-Type property' httpLog(errorString) reject(errorString) } } } }) }) } } /** * 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: PreprocessingData, ctx: object ): { [key: string]: string } { return typeof data.options.tokenJSONpath !== 'string' ? {} : extractToken(data, ctx) } function extractToken(data: PreprocessingData, ctx: object) { 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: PreprocessingData, ctx: object ): { [key: string]: string } { 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: Operation, _openAPIToGraphQL: any, data: PreprocessingData ): AuthOptions { 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 = NodeRequest.cookie(`${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: Operation, _openAPIToGraphQL ): AuthReqAndProtcolName { 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: string, value: string, resolveData: any, root: any, args: any ): any { if (value === '$url') { return resolveData.usedRequestOptions.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: string): boolean { 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): string { return getIdentifierRecursive(info.path) } /** * From the info object provided by the resolver, get the unique identifier of * the parent object */ function getParentIdentifier(info): string { return getIdentifierRecursive(info.path.prev) } /** * Get the path of nested field names (or aliases if provided) */ function getIdentifierRecursive(path): string { 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: string, extensions: { [key: string]: any } ): GraphQLError { return new GraphQLError(message, null, null, null, null, null, extensions) }