openapi-to-graphql-harshith
Version:
Generates a GraphQL schema for a given OpenAPI Specification (OAS)
1,491 lines (1,339 loc) • 45.5 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, ParameterObject } from './types/oas3'
import { ConnectOptions } from './types/options'
import { TargetGraphQLType, Operation } from './types/operation'
import { SubscriptionContext } from './types/graphql'
import { PreprocessingData } from './types/preprocessing_data'
import { RequestOptions, FileUploadOptions } from './types/options'
import crossFetch from 'cross-fetch'
import { FileUpload } from 'graphql-upload'
// Imports:
import stream from 'stream'
import * as Oas3Tools from './oas_3_tools'
import { JSONPath } from 'jsonpath-plus'
import * as JSONPointer from 'jsonpointer'
import { debug } from 'debug'
import { GraphQLError, GraphQLFieldResolver } from 'graphql'
import formurlencoded from 'form-urlencoded'
import { PubSub } from 'graphql-subscriptions'
import urljoin from 'url-join'
import FormData from 'form-data'
import { MitigationTypes, handleWarning } from './utils'
const pubsub = new PubSub()
const translationLog = debug('translation')
const httpLog = debug('http')
const pubsubLog = debug('pubsub')
const uploadLog = debug('fileUpload')
const generalLog = debug('generalLog')
// OAS runtime expression reference locations
const RUNTIME_REFERENCES = ['header.', 'query.', 'path.', 'body']
export const OPENAPI_TO_GRAPHQL = '_openAPIToGraphQL'
// Type definitions & exports:
type AuthReqAndProtcolName = {
authRequired: boolean
securityRequirement?: string
sanitizedSecurityRequirement?: string
}
type AuthOptions = {
authHeaders: { [key: string]: string }
authQs: { [key: string]: string }
authCookie: string
}
type GetResolverParams<TSource, TContext, TArgs> = {
operation: Operation
argsFromLink?: { [key: string]: any }
payloadName?: string
responseName?: string
data: PreprocessingData<TSource, TContext, TArgs>
baseUrl?: string | any
requestOptions?: Partial<RequestOptions<TSource, TContext, TArgs>>
fileUploadOptions?: FileUploadOptions
fetch: typeof crossFetch
}
type inferLinkArgumentsParam<TSource, TContext, TArgs> = {
paramName: string
value: any
resolveData: Partial<ResolveData<TSource, TContext, TArgs>>
source: TSource
args: TArgs
}
type GetSubscribeParams<TSource, TContext, TArgs> = {
operation: Operation
argsFromLink?: { [key: string]: string }
payloadName?: string
data: PreprocessingData<TSource, TContext, TArgs>
baseUrl?: string
connectOptions?: ConnectOptions
}
type ResolveData<TSource, TContext, TArgs> = {
url: string
usedParams: any
usedPayload: any
usedRequestOptions: RequestOptions<TSource, TContext, TArgs>
usedStatusCode: string
responseHeaders: HeadersInit
}
// TODO: Determine better name
type OpenAPIToGraphQLRoot<TSource, TContext, TArgs> = {
data?: {
[identifier: string]: ResolveData<TSource, TContext, TArgs>
}
/**
* TODO: We can define more specific types. See getProcessedSecuritySchemes().
*
* Is it related TArgs?
*/
security: { [saneProtocolName: string]: any }
}
// TODO: Determine better name
type OpenAPIToGraphQLSource<TSource, TContext, TArgs> = {
_openAPIToGraphQL: OpenAPIToGraphQLRoot<TSource, TContext, TArgs>
}
/*
* If the operation type is Subscription, create and return a resolver object
* that contains subscribe to perform subscription and resolve to execute
* payload transformation
*/
export function getSubscribe<TSource, TContext, TArgs>({
operation,
payloadName,
data,
baseUrl,
connectOptions
}: GetSubscribeParams<TSource, TContext, TArgs>): GraphQLFieldResolver<
TSource,
SubscriptionContext,
TArgs
> {
// Determine the appropriate URL:
if (typeof baseUrl === 'undefined') {
baseUrl = Oas3Tools.getBaseUrl(operation)
}
// Return custom resolver if it is defined
const customResolvers = data.options.customSubscriptionResolvers
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] === 'object' &&
typeof customResolvers[title][path][method].subscribe === 'function'
) {
translationLog(
`Use custom publish resolver for ${operation.operationString}`
)
return customResolvers[title][path][method].subscribe
}
return (root, args, context, info) => {
/**
* Determine possible topic(s) by resolving callback path
*
* GraphQL produces sanitized payload names, so we have to sanitize before
* lookup here
*/
const paramName = Oas3Tools.sanitize(
payloadName,
Oas3Tools.CaseStyle.camelCase
)
let resolveData: any = {}
if (payloadName && 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)
if (sanePayloadName in args) {
if (typeof args[sanePayloadName] === 'object') {
const rawPayload = Oas3Tools.desanitizeObjectKeys(
args[sanePayloadName],
data.saneMap
)
resolveData.usedPayload = rawPayload
} else {
const rawPayload = JSON.parse(args[sanePayloadName])
resolveData.usedPayload = rawPayload
}
}
}
if (connectOptions) {
resolveData.usedRequestOptions = connectOptions
} else {
resolveData.usedRequestOptions = {
method: resolveData.usedPayload.method
? resolveData.usedPayload.method
: method.toUpperCase()
}
}
pubsubLog(`Subscription schema: ${JSON.stringify(resolveData.usedPayload)}`)
let value = path
let paramNameWithoutLocation = paramName
if (paramName.indexOf('.') !== -1) {
paramNameWithoutLocation = paramName.split('.')[1]
}
// See if the callback path contains constants expression
if (value.search(/{|}/) === -1) {
args[paramNameWithoutLocation] = isRuntimeExpression(value)
? resolveRuntimeExpression(paramName, value, resolveData, root, args)
: value
} else {
// Replace callback expression with appropriate values
const cbParams = value.match(/{([^}]*)}/g)
pubsubLog(`Analyzing subscription path: ${cbParams.toString()}`)
cbParams.forEach((cbParam) => {
value = value.replace(
cbParam,
resolveRuntimeExpression(
paramName,
cbParam.substring(1, cbParam.length - 1),
resolveData,
root,
args
)
)
})
args[paramNameWithoutLocation] = value
}
const topic = args[paramNameWithoutLocation] || 'test'
pubsubLog(`Subscribing to: ${topic}`)
return context.pubsub
? context.pubsub.asyncIterator(topic)
: pubsub.asyncIterator(topic)
}
}
/*
* If the operation type is Subscription, create and return a resolver function
* triggered after a message has been published to the corresponding subscribe
* topic(s) to execute payload transformation
*/
export function getPublishResolver<TSource, TContext, TArgs>({
operation,
responseName,
data
}: GetResolverParams<TSource, TContext, TArgs>): GraphQLFieldResolver<
TSource,
TContext,
TArgs
> {
// Return custom resolver if it is defined
const customResolvers = data.options.customSubscriptionResolvers
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] === 'object' &&
typeof customResolvers[title][path][method].resolve === 'function'
) {
translationLog(
`Use custom publish resolver for ${operation.operationString}`
)
return customResolvers[title][path][method].resolve
}
return (payload, args, context, info) => {
// Validate and format based on operation.responseDefinition
const typeOfResponse = operation.responseDefinition.targetGraphQLType
pubsubLog(
`Message received: ${responseName}, ${typeOfResponse}, ${JSON.stringify(
payload
)}`
)
let responseBody
let saneData
if (typeof payload === 'object') {
if (typeOfResponse === TargetGraphQLType.object) {
if (Buffer.isBuffer(payload)) {
try {
responseBody = JSON.parse(payload.toString())
} catch (e) {
const errorString =
`Cannot JSON parse payload` +
`operation ${operation.operationString} ` +
`even though it has content-type 'application/json'`
pubsubLog(errorString)
return null
}
} else {
responseBody = payload
}
saneData = Oas3Tools.sanitizeObjectKeys(payload)
} else if (
(Buffer.isBuffer(payload) || Array.isArray(payload)) &&
typeOfResponse === TargetGraphQLType.string
) {
saneData = payload.toString()
}
} else if (typeof payload === 'string') {
if (typeOfResponse === TargetGraphQLType.object) {
try {
responseBody = JSON.parse(payload)
saneData = Oas3Tools.sanitizeObjectKeys(responseBody)
} catch (e) {
const errorString =
`Cannot JSON parse payload` +
`operation ${operation.operationString} ` +
`even though it has content-type 'application/json'`
pubsubLog(errorString)
return null
}
} else if (typeOfResponse === TargetGraphQLType.string) {
saneData = payload
}
}
pubsubLog(
`Message forwarded: ${JSON.stringify(saneData ? saneData : payload)}`
)
return saneData ? saneData : payload
}
}
/**
* Returns values for link arguments, also covers the cases for
* 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}
*/
function inferLinkArguments<TSource, TContext, TArgs>({
paramName,
value,
resolveData,
source,
args
}: inferLinkArgumentsParam<TSource, TContext, TArgs>) {
if (typeof value === 'object') {
return Object.entries(value).reduce((acc, [key, value]) => {
acc[key] = inferLinkArguments({
paramName,
value,
resolveData,
source,
args
})
return acc
}, {})
}
if (typeof value !== 'string') {
return value
} else if (value.search(/{|}/) === -1) {
return isRuntimeExpression(value)
? resolveRuntimeExpression(paramName, value, resolveData, source, args)
: value
} else {
// Replace link parameters with appropriate values
const linkParams = value.match(/{([^}]*)}/g)
linkParams.forEach((linkParam) => {
value = value.replace(
linkParam,
resolveRuntimeExpression(
paramName,
linkParam.substring(1, linkParam.length - 1),
resolveData,
source,
args
)
)
})
return value
}
}
/**
* If the operation type is Query or Mutation, create and return a resolver
* function that performs API requests for the given GraphQL query
*/
export function getResolver<TSource, TContext, TArgs>({
operation,
argsFromLink = {},
payloadName,
data,
baseUrl,
requestOptions,
fileUploadOptions,
fetch
}: GetResolverParams<TSource, TContext, TArgs>): GraphQLFieldResolver<
TSource & OpenAPIToGraphQLSource<TSource, TContext, TArgs>,
TContext,
TArgs
> {
// 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 async (source, args, context, info) => {
/**
* Fetch resolveData from possibly existing _openAPIToGraphQL
*
* NOTE: _openAPIToGraphQL is an object used to pass security info and data
* from previous resolvers
*/
// Determine the appropriate URL:
if (typeof baseUrl !== 'undefined') {
if (typeof baseUrl === 'function') {
baseUrl = await baseUrl(context)
}
} else {
baseUrl = Oas3Tools.getBaseUrl(operation)
}
let resolveData: Partial<ResolveData<TSource, TContext, TArgs>> = {}
if (
source &&
typeof source === 'object' &&
typeof source[OPENAPI_TO_GRAPHQL] === 'object' &&
typeof source[OPENAPI_TO_GRAPHQL].data === 'object'
) {
const parentIdentifier = getParentIdentifier(info)
if (
!(parentIdentifier.length === 0) &&
parentIdentifier in source[OPENAPI_TO_GRAPHQL].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(source[OPENAPI_TO_GRAPHQL].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 saneParamName = Oas3Tools.sanitize(
param.name,
!data.options.simpleNames
? Oas3Tools.CaseStyle.camelCase
: Oas3Tools.CaseStyle.simple
)
if (
typeof args[saneParamName] === 'undefined' &&
param.schema &&
typeof param.schema === 'object'
) {
const schemaOrRef = param.schema
let schema: SchemaObject
if ('$ref' in schemaOrRef) {
schema = Oas3Tools.resolveRef<SchemaObject>(
schemaOrRef.$ref,
operation.oas
)
} else {
schema = schemaOrRef as SchemaObject
}
if (schema && schema.default && typeof schema.default !== 'undefined') {
args[saneParamName] = 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]
args[saneParamName] = inferLinkArguments({
paramName,
value,
resolveData,
source,
args
})
}
// Stored used parameters to future requests:
resolveData.usedParams = Object.assign(resolveData.usedParams, args)
// Build URL (i.e., fill in path parameters):
const { path, qs, headers } = extractRequestDataFromArgs(
operation.path,
operation.parameters,
args,
data
)
const url = 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
*/
if (operation.method !== Oas3Tools.HTTP_METHODS.get) {
headers['content-type'] =
typeof operation.payloadContentType !== 'undefined'
? operation.payloadContentType
: 'application/json'
}
headers['accept'] =
typeof operation.responseContentType !== 'undefined'
? operation.responseContentType
: 'application/json'
let options: RequestInit
if (requestOptions) {
options = {
...requestOptions,
method: operation.method,
headers: {}
}
options.headers = {} // Handle requestOptions.header later if applicable
if (requestOptions.headers) {
// requestOptions.headers may be either an object or a function
httpLog(`${typeof requestOptions.headers} is the type`)
if (typeof requestOptions.headers === 'object') {
Object.assign(options.headers, headers, requestOptions.headers)
} else if (typeof requestOptions.headers === 'function') {
const headers = await requestOptions.headers(method, path, title, {
source,
args,
context,
info
})
httpLog(`${headers} are the headers the type`)
Object.assign(options.headers, headers)
}
} else {
options.headers = headers
}
if (typeof requestOptions.qs === 'object') {
Object.assign(qs, requestOptions.qs)
}
} else {
options = {
method: operation.method,
headers
}
}
/**
* Determine possible payload
*
* GraphQL produces sanitized payload names, so we have to sanitize before
* lookup here
*/
let form: FormData
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 if (operation.payloadContentType === 'multipart/form-data') {
form = new FormData(fileUploadOptions)
const formFieldsPayloadEntries = Object.entries(args[sanePayloadName])
;(
await Promise.all(formFieldsPayloadEntries.map(([_, v]) => v))
).forEach((fieldValue, idx) => {
const fieldName = formFieldsPayloadEntries[idx][0]
if (
typeof fieldValue === 'object' &&
Boolean((fieldValue as Partial<FileUpload>).createReadStream)
) {
const uploadingFile = fieldValue as FileUpload
const originalFileStream = uploadingFile.createReadStream()
const filePassThrough = new stream.PassThrough()
originalFileStream.on('readable', function () {
let data
while ((data = this.read())) {
const canReadNext = filePassThrough.write(data)
if (!canReadNext) {
this.pause()
filePassThrough.once('drain', () => this.resume())
}
}
})
originalFileStream.on('error', () => {
uploadLog(
'Encountered an error while uploading the file %s',
uploadingFile.filename
)
})
originalFileStream.on('end', () => {
uploadLog(
'Upload for received file %s completed',
uploadingFile.filename
)
filePassThrough.end()
})
uploadLog(
'Queuing upload for received file %s',
uploadingFile.filename
)
form.append(fieldName, filePassThrough, {
filename: uploadingFile.filename,
contentType: uploadingFile.mimetype
})
} else if (typeof fieldValue !== 'string') {
// Handle all other primitives that aren't strings as strings the way the web server would expect it
form.append(fieldName, JSON.stringify(fieldValue))
} else {
form.append(fieldName, fieldValue)
}
})
rawPayload = form
} 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') {
Object.assign(options.headers, data.options.headers)
} else if (typeof data.options.headers === 'function') {
const headers = await data.options.headers(method, path, title, {
source,
args,
context,
info
})
httpLog(`${headers} are the headers the type`)
if (typeof headers === 'object') {
Object.assign(options.headers, headers)
}
if (form) {
/**
* When there is a form, remove default content type and leave
* computation of content-type header to fetch
*
* See https://github.com/github/fetch/issues/505#issuecomment-293064470
*/
Object.assign(options.headers, form.getHeaders())
delete options.headers['content-type']
}
}
// Query string:
if (typeof data.options.qs === 'object') {
Object.assign(qs, data.options.qs)
}
}
// Get authentication headers and query parameters
if (
source &&
typeof source === 'object' &&
typeof source[OPENAPI_TO_GRAPHQL] === 'object'
) {
const { authHeaders, authQs, authCookie } = getAuthOptions(
operation,
source[OPENAPI_TO_GRAPHQL],
data
)
// ...and pass them to the options
Object.assign(options.headers, authHeaders)
Object.assign(qs, authQs)
// Add authentication cookie if created
if (authCookie !== null) {
const cookieHeaderName = 'cookie'
options.headers[cookieHeaderName] = authCookie
}
}
// Extract OAuth token from context (if available)
if (data.options.sendOAuthTokenInQuery) {
const oauthQueryObj = createOAuthQS(data, context)
Object.assign(qs, oauthQueryObj)
} else {
const oauthHeader = createOAuthHeader(data, context)
Object.assign(options.headers, oauthHeader)
}
resolveData.usedRequestOptions = options
resolveData.usedStatusCode = operation.statusCode
setSearchParamsFromObj(url, qs, [])
resolveData.url = url.toString().replace(url.search, '')
// Make the call
httpLog(
`Call ${options.method.toUpperCase()} ${url.toString()}\n` +
`headers: ${JSON.stringify(options.headers)}\n` +
`request body: ${options.body}`
)
let response: Response
try {
response = await fetch(url.toString(), options)
} catch (err) {
httpLog(err)
throw err
}
const body = await 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,
url: url.toString(),
statusText: response.statusText,
statusCode: response.status,
responseHeaders: headersToObject(response.headers),
responseBody
}
throw graphQLErrorWithExtensions(errorString, extensions)
} else {
throw new Error(errorString)
}
// Successful response code 200-299
} else {
httpLog(`${response.status} - ${Oas3Tools.trim(body, 100)}`)
if (
response.headers.get('content-type') &&
operation.responseContentType
) {
/**
* 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 new Error(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 new Error(errorString)
}
resolveData.responseHeaders = {}
response.headers.forEach((val, key) => {
resolveData.responseHeaders[key] = val
})
// 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[OPENAPI_TO_GRAPHQL] === 'undefined') {
element[OPENAPI_TO_GRAPHQL] = {
data: {}
}
}
if (
source &&
typeof source === 'object' &&
typeof source[OPENAPI_TO_GRAPHQL] === 'object'
) {
Object.assign(
element[OPENAPI_TO_GRAPHQL],
source[OPENAPI_TO_GRAPHQL]
)
}
element[OPENAPI_TO_GRAPHQL].data[getIdentifier(info)] =
resolveData
})
} else {
if (typeof saneData[OPENAPI_TO_GRAPHQL] === 'undefined') {
saneData[OPENAPI_TO_GRAPHQL] = {
data: {}
}
}
if (
source &&
typeof source === 'object' &&
typeof source[OPENAPI_TO_GRAPHQL] === 'object'
) {
Object.assign(
saneData[OPENAPI_TO_GRAPHQL],
source[OPENAPI_TO_GRAPHQL]
)
}
saneData[OPENAPI_TO_GRAPHQL].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
*/
if (typeof operation.responseContentType !== 'string') {
return null
} else {
const errorString = 'Response does not have a Content-Type header'
httpLog(errorString)
throw new Error(errorString)
}
}
}
}
}
function headersToObject(headers: Headers) {
const headersObj: HeadersInit = {}
headers.forEach((value, key) => {
headersObj[key] = value
})
return headersObj
}
/**
* Attempts to create an object to become an OAuth query string by extracting an
* OAuth token from the context based on the JSON path provided in the options.
*/
function createOAuthQS<TSource, TContext, TArgs>(
data: PreprocessingData<TSource, TContext, TArgs>,
context: TContext
): { [key: string]: string } {
return typeof data.options.tokenJSONpath !== 'string'
? {}
: extractToken(data, context)
}
function extractToken<TSource, TContext, TArgs>(
data: PreprocessingData<TSource, TContext, TArgs>,
context: TContext
) {
const tokenJSONpath = data.options.tokenJSONpath
const tokens = JSONPath({
path: tokenJSONpath,
json: context as unknown as object
})
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 context based on the JSON path provided in the options.
*/
function createOAuthHeader<TSource, TContext, TArgs>(
data: PreprocessingData<TSource, TContext, TArgs>,
context: TContext
): { [key: string]: string } {
if (typeof data.options.tokenJSONpath !== 'string') {
return {}
}
// Extract token
const tokenJSONpath = data.options.tokenJSONpath
const tokens = JSONPath({
path: tokenJSONpath,
json: context as unknown as object
})
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 {}
}
}
/**
* Return the headers and query strings to authenticate a request (if any).
* Return authHeader and authQs, which hold headers and query parameters
* respectively to authentication a request.
*/
function getAuthOptions<TSource, TContext, TArgs>(
operation: Operation,
_openAPIToGraphQL: OpenAPIToGraphQLRoot<TSource, TContext, TArgs>,
data: PreprocessingData<TSource, TContext, TArgs>
): AuthOptions {
const authHeaders = {}
const authQs = {}
let authCookie = null
/**
* Determine if authentication is required, and which protocol (if any) we can
* use
*/
const { authRequired, securityRequirement, sanitizedSecurityRequirement } =
getAuthReqAndProtcolName(operation, _openAPIToGraphQL)
// 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
case 'bearer':
const token =
_openAPIToGraphQL.security[sanitizedSecurityRequirement].token
authHeaders['Authorization'] = `Bearer ${token}`
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 a 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<TSource, TContext, TArgs>(
operation: Operation,
_openAPIToGraphQL: OpenAPIToGraphQLRoot<TSource, TContext, TArgs>
): 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,
securityRequirement,
sanitizedSecurityRequirement
}
}
}
}
return {
authRequired
}
}
/**
* Given a link parameter or callback path, determine the value from the runtime
* expression
*
* The link parameter or callback path is a reference to data contained in the
* url/method/statuscode or response/request body/query/path/header
*/
function resolveRuntimeExpression(
paramName: string,
runtimeExpression: string,
resolveData: any,
root: any,
args: any
): any {
if (runtimeExpression === '$url') {
return resolveData.url
} else if (runtimeExpression === '$method') {
return resolveData.usedRequestOptions.method
} else if (runtimeExpression === '$statusCode') {
return resolveData.usedStatusCode
} else if (runtimeExpression.startsWith('$request.')) {
// CASE: parameter is previous body
if (runtimeExpression === '$request.body') {
return resolveData.usedPayload
// CASE: parameter in previous body
} else if (runtimeExpression.startsWith('$request.body#')) {
const tokens = JSONPath({
path: runtimeExpression.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 (runtimeExpression.startsWith('$request.query')) {
return resolveData.usedParams[
Oas3Tools.sanitize(
runtimeExpression.split('query.')[1],
Oas3Tools.CaseStyle.camelCase
)
]
// CASE: parameter in previous path parameter
} else if (runtimeExpression.startsWith('$request.path')) {
return resolveData.usedParams[
Oas3Tools.sanitize(
runtimeExpression.split('path.')[1],
Oas3Tools.CaseStyle.camelCase
)
]
// CASE: parameter in previous header parameter
} else if (runtimeExpression.startsWith('$request.header')) {
return resolveData.usedRequestOptions.headers[
runtimeExpression.split('header.')[1]
]
}
} else if (runtimeExpression.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 (runtimeExpression === '$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 (runtimeExpression.startsWith('$response.body#')) {
return JSONPointer.get(root, runtimeExpression.split('body#')[1])
// CASE: parameter in query parameter
} else if (runtimeExpression.startsWith('$response.query')) {
// NOTE: handled the same way $request.query is handled
return resolveData.usedParams[
Oas3Tools.sanitize(
runtimeExpression.split('query.')[1],
Oas3Tools.CaseStyle.camelCase
)
]
// CASE: parameter in path parameter
} else if (runtimeExpression.startsWith('$response.path')) {
// NOTE: handled the same way $request.path is handled
return resolveData.usedParams[
Oas3Tools.sanitize(
runtimeExpression.split('path.')[1],
Oas3Tools.CaseStyle.camelCase
)
]
// CASE: parameter in header parameter
} else if (runtimeExpression.startsWith('$response.header')) {
return resolveData.responseHeaders[runtimeExpression.split('header.')[1]]
}
}
throw new Error(
`Cannot resolve link because '${runtimeExpression}' is an invalid runtime expression.`
)
}
/**
* Check if a string is a runtime expression in the context of link parameters
*/
function isRuntimeExpression(str: string): boolean {
if (str === '$url' || str === '$method' || str === '$statusCode') {
return true
} else if (str.startsWith('$request.')) {
for (let i = 0; i < RUNTIME_REFERENCES.length; i++) {
if (str.startsWith(`$request.${RUNTIME_REFERENCES[i]}`)) {
return true
}
}
} else if (str.startsWith('$response.')) {
for (let i = 0; i < RUNTIME_REFERENCES.length; i++) {
if (str.startsWith(`$response.${RUNTIME_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)
}
/**
* Extracts data from the GraphQL arguments of a particular field
*
* Replaces the path parameter in the given path with values in the given args.
* Furthermore adds the query parameters for a request.
*/
export function extractRequestDataFromArgs<TSource, TContext, TArgs>(
path: string,
parameters: ParameterObject[],
args: TArgs, // NOTE: argument keys are sanitized!
data: PreprocessingData<TSource, TContext, TArgs>
): {
path: string
qs: { [key: string]: string }
headers: { [key: string]: string }
} {
const qs = {}
const headers = {}
// Iterate parameters:
for (const param of parameters) {
const saneParamName = Oas3Tools.sanitize(
param.name,
!data.options.simpleNames
? Oas3Tools.CaseStyle.camelCase
: Oas3Tools.CaseStyle.simple
)
if (saneParamName && saneParamName in args) {
switch (param.in) {
// Path parameters
case 'path':
path = path.replace(`{${param.name}}`, args[saneParamName])
break
// Query parameters
case 'query':
// setting param style as form assumes explode is true by default
if (
param.style === 'form' &&
typeof args[saneParamName] === 'object'
) {
if (param.explode === false) {
qs[param.name] = Object.entries(args[saneParamName]).reduce(
(acc, val) => {
acc += val.join(',')
return acc
},
''
)
} else {
Object.entries(args[saneParamName]).forEach(([key, value]) => {
qs[key] = value
})
}
} else if (
Array.isArray(args[saneParamName]) &&
param.style === 'form' &&
param.explode !== false
) {
qs[param.name] = args[saneParamName].join(',')
} else {
qs[param.name] = args[saneParamName]
}
break
// Header parameters
case 'header':
headers[param.name] = args[saneParamName]
break
// Cookie parameters
case 'cookie':
if (!('cookie' in headers)) {
headers['cookie'] = ''
}
headers['cookie'] += `${param.name}=${args[saneParamName]}; `
break
default:
httpLog(
`Warning: The parameter location '${param.in}' in the ` +
`parameter '${param.name}' of operation '${path}' is not ` +
`supported`
)
}
}
}
return { path, qs, headers }
}
const setSearchParamsFromObj = (url: URL, obj: any, path: string[]) => {
for (const key in obj) {
const val = obj[key]
const newPath = [...path, key]
if (typeof val === 'object') {
setSearchParamsFromObj(url, val, newPath)
} else {
const finalKey = newPath.reduce(
(acc, pathElem, i) => (i === 0 ? pathElem : `${acc}[${pathElem}]`),
''
)
url.searchParams.set(finalKey, val)
}
}
}