openapi-to-graphql-lxwang2
Version:
Generates a GraphQL schema for a given OpenAPI Specification (OAS)
937 lines (850 loc) • 28.9 kB
text/typescript
// 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)
}