openapi-to-graphql-nullable
Version:
Fork of openapi-to-graphql that fixes nullable handling. Published to NPM since there's no other easy way to consume a Lerna package. Unmaintained.
805 lines (734 loc) • 24.7 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
/**
* Defines the functions exposed by OpenAPI-to-GraphQL.
*
* Some general notes:
*
* - GraphQL interfaces rely on sanitized strings for (input) object type names
* and fields. We perform sanitization only when assigning (field-) names, but
* keep keys in the OAS otherwise as-is, to ensure that inner-OAS references
* work as expected.
*
* - GraphQL (input) object types must have a unique name. Thus, sometimes Input
* object types and object types need separate names, despite them having the
* same structure. We thus append 'Input' to every input object type's name
* as a convention.
*
* - To pass data between resolve functions, OpenAPI-to-GraphQL uses a _openAPIToGraphQL object
* returned by every resolver in addition to its original data (OpenAPI-to-GraphQL does
* not use the context to do so, which is an anti-pattern according to
* https://github.com/graphql/graphql-js/issues/953).
*
* - OpenAPI-to-GraphQL can handle basic authentication and API key-based authentication
* through GraphQL. To do this, OpenAPI-to-GraphQL creates two new intermediate Object
* Types called QueryViewer and MutationViewer that take as input security
* credentials and pass them on using the _openAPIToGraphQL object to other resolve
* functions.
*/
// Type imports:
import {
Options,
InternalOptions,
Report,
ConnectOptions
} from './types/options'
import { Oas3 } from './types/oas3'
import { Oas2 } from './types/oas2'
import {
Args,
GraphQLOperationType,
SubscriptionContext
} from './types/graphql'
import { Operation } from './types/operation'
import { PreprocessingData } from './types/preprocessing_data'
import {
GraphQLSchema,
GraphQLObjectType,
GraphQLOutputType,
GraphQLFieldConfig
} from 'graphql'
import * as NodeRequest from 'request'
// Imports:
import { getGraphQLType, getArgs } from './schema_builder'
import {
getResolver,
getSubscribe,
getPublishResolver
} from './resolver_builder'
import * as GraphQLTools from './graphql_tools'
import { preprocessOas } from './preprocessor'
import * as Oas3Tools from './oas_3_tools'
import { createAndLoadViewer } from './auth_builder'
import debug from 'debug'
import { GraphQLSchemaConfig } from 'graphql/type/schema'
import { sortObject, handleWarning, MitigationTypes } from './utils'
type Result = {
schema: GraphQLSchema
report: Report
}
const translationLog = debug('translation')
/**
* Creates a GraphQL interface from the given OpenAPI Specification (2 or 3).
*/
export function createGraphQLSchema<TSource, TContext, TArgs>(
spec: Oas3 | Oas2 | (Oas3 | Oas2)[],
options?: Options<TSource, TContext, TArgs>
): Promise<Result> {
return new Promise((resolve, reject) => {
if (typeof options === 'undefined') {
options = {}
}
// Setting default options
options.strict =
typeof options.strict === 'boolean' ? options.strict : false
// Schema options
options.operationIdFieldNames =
typeof options.operationIdFieldNames === 'boolean'
? options.operationIdFieldNames
: false
options.fillEmptyResponses =
typeof options.fillEmptyResponses === 'boolean'
? options.fillEmptyResponses
: false
options.addLimitArgument =
typeof options.addLimitArgument === 'boolean'
? options.addLimitArgument
: false
options.genericPayloadArgName =
typeof options.genericPayloadArgName === 'boolean'
? options.genericPayloadArgName
: false
options.simpleNames =
typeof options.simpleNames === 'boolean' ? options.simpleNames : false
options.singularNames =
typeof options.singularNames === 'boolean' ? options.singularNames : false
options.createSubscriptionsFromCallbacks =
typeof options.createSubscriptionsFromCallbacks === 'boolean'
? options.createSubscriptionsFromCallbacks
: false
// Authentication options
options.viewer = typeof options.viewer === 'boolean' ? options.viewer : true
options.sendOAuthTokenInQuery =
typeof options.sendOAuthTokenInQuery === 'boolean'
? options.sendOAuthTokenInQuery
: false
// Logging options
options.provideErrorExtensions =
typeof options.provideErrorExtensions === 'boolean'
? options.provideErrorExtensions
: true
options.equivalentToMessages =
typeof options.equivalentToMessages === 'boolean'
? options.equivalentToMessages
: true
options['report'] = {
warnings: [],
numOps: 0,
numOpsQuery: 0,
numOpsMutation: 0,
numOpsSubscription: 0,
numQueriesCreated: 0,
numMutationsCreated: 0,
numSubscriptionsCreated: 0
}
if (Array.isArray(spec)) {
// Convert all non-OAS 3 into OAS 3
Promise.all(
spec.map(ele => {
return Oas3Tools.getValidOAS3(ele)
})
).then(oass => {
resolve(
translateOpenAPIToGraphQL(
oass,
options as InternalOptions<TSource, TContext, TArgs>
)
)
})
} else {
/**
* Check if the spec is a valid OAS 3
* If the spec is OAS 2.0, attempt to translate it into 3, then try to
* translate the spec into a GraphQL schema
*/
Oas3Tools.getValidOAS3(spec).then(oas => {
resolve(
translateOpenAPIToGraphQL(
[oas],
options as InternalOptions<TSource, TContext, TArgs>
)
)
})
}
})
}
/**
* Creates a GraphQL interface from the given OpenAPI Specification 3
*/
function translateOpenAPIToGraphQL<TSource, TContext, TArgs>(
oass: Oas3[],
{
strict,
report,
// Schema options
operationIdFieldNames,
fillEmptyResponses,
addLimitArgument,
idFormats,
selectQueryOrMutationField,
genericPayloadArgName,
simpleNames,
singularNames,
createSubscriptionsFromCallbacks,
// Resolver options
headers,
qs,
requestOptions,
connectOptions,
baseUrl,
customResolvers,
customSubscriptionResolvers,
// Authentication options
viewer,
tokenJSONpath,
sendOAuthTokenInQuery,
// Logging options
provideErrorExtensions,
equivalentToMessages
}: InternalOptions<TSource, TContext, TArgs>
): { schema: GraphQLSchema; report: Report } {
const options = {
strict,
report,
// Schema options
operationIdFieldNames,
fillEmptyResponses,
addLimitArgument,
idFormats,
selectQueryOrMutationField,
genericPayloadArgName,
simpleNames,
singularNames,
createSubscriptionsFromCallbacks,
// Resolver options
headers,
qs,
requestOptions,
connectOptions,
baseUrl,
customResolvers,
customSubscriptionResolvers,
// Authentication options
viewer,
tokenJSONpath,
sendOAuthTokenInQuery,
// Logging options
provideErrorExtensions,
equivalentToMessages
}
translationLog(`Options: ${JSON.stringify(options)}`)
/**
* Extract information from the OASs and put it inside a data structure that
* is easier for OpenAPI-to-GraphQL to use
*/
const data: PreprocessingData<TSource, TContext, TArgs> = preprocessOas(
oass,
options
)
preliminaryChecks(options, data)
// Query, Mutation, and Subscription fields
let queryFields: { [fieldName: string]: GraphQLFieldConfig<any, any> } = {}
let mutationFields: { [fieldName: string]: GraphQLFieldConfig<any, any> } = {}
let subscriptionFields: {
[fieldName: string]: GraphQLFieldConfig<any, any>
} = {}
// Authenticated Query, Mutation, and Subscription fields
let authQueryFields: {
[fieldName: string]: {
[securityRequirement: string]: GraphQLFieldConfig<any, any>
}
} = {}
let authMutationFields: {
[fieldName: string]: {
[securityRequirement: string]: GraphQLFieldConfig<any, any>
}
} = {}
let authSubscriptionFields: {
[fieldName: string]: {
[securityRequirement: string]: GraphQLFieldConfig<any, any>
}
} = {}
// Add Query and Mutation fields
Object.entries(data.operations).forEach(([operationId, operation]) => {
translationLog(`Process operation '${operation.operationString}'...`)
const field = getFieldForOperation(
operation,
options.baseUrl,
data,
requestOptions,
connectOptions
)
const saneOperationId = Oas3Tools.sanitize(
operationId,
Oas3Tools.CaseStyle.camelCase
)
// Check if the operation should be added as a Query or Mutation
if (operation.operationType === GraphQLOperationType.Query) {
let fieldName = !singularNames
? Oas3Tools.uncapitalize(operation.responseDefinition.graphQLTypeName)
: Oas3Tools.sanitize(
Oas3Tools.inferResourceNameFromPath(operation.path),
Oas3Tools.CaseStyle.camelCase
)
if (operation.inViewer) {
for (let securityRequirement of operation.securityRequirements) {
if (typeof authQueryFields[securityRequirement] !== 'object') {
authQueryFields[securityRequirement] = {}
}
// Avoid overwriting fields that return the same data:
if (
fieldName in authQueryFields[securityRequirement] ||
/**
* If the option is set operationIdFieldNames, the fieldName is
* forced to be the operationId
*/
operationIdFieldNames
) {
fieldName = Oas3Tools.storeSaneName(
saneOperationId,
operationId,
data.saneMap
)
}
if (fieldName in authQueryFields[securityRequirement]) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${fieldName}' and security requirement ` +
`'${securityRequirement}'. GraphQL field names must be ` +
`unique so only one can be added to the authentication ` +
`viewer. Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
authQueryFields[securityRequirement][fieldName] = field
}
}
} else {
// Avoid overwriting fields that return the same data:
if (
fieldName in queryFields ||
/**
* If the option is set operationIdFieldNames, the fieldName is
* forced to be the operationId
*/
operationIdFieldNames
) {
fieldName = Oas3Tools.storeSaneName(
saneOperationId,
operationId,
data.saneMap
)
}
if (fieldName in queryFields) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${fieldName}'. GraphQL field names must be ` +
`unique so only one can be added to the Query object. ` +
`Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
queryFields[fieldName] = field
}
}
} else {
let saneFieldName
if (!singularNames) {
/**
* Use operationId to avoid problems differentiating operations with the
* same path but differnet methods
*/
saneFieldName = Oas3Tools.storeSaneName(
saneOperationId,
operationId,
data.saneMap
)
} else {
const fieldName = `${
operation.method
}${Oas3Tools.inferResourceNameFromPath(operation.path)}`
saneFieldName = Oas3Tools.storeSaneName(
Oas3Tools.sanitize(fieldName, Oas3Tools.CaseStyle.camelCase),
fieldName,
data.saneMap
)
}
if (operation.inViewer) {
for (let securityRequirement of operation.securityRequirements) {
if (typeof authMutationFields[securityRequirement] !== 'object') {
authMutationFields[securityRequirement] = {}
}
if (saneFieldName in authMutationFields[securityRequirement]) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${saneFieldName}' and security requirement ` +
`'${securityRequirement}'. GraphQL field names must be ` +
`unique so only one can be added to the authentication ` +
`viewer. Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
authMutationFields[securityRequirement][saneFieldName] = field
}
}
} else {
if (saneFieldName in mutationFields) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${saneFieldName}'. GraphQL field names must be ` +
`unique so only one can be added to the Mutation object. ` +
`Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
mutationFields[saneFieldName] = field
}
}
}
})
// Add Subscription fields
Object.entries(data.callbackOperations).forEach(
([operationId, operation]) => {
translationLog(`Process operation '${operationId}'...`)
let field = getFieldForOperation(
operation,
options.baseUrl,
data,
requestOptions,
connectOptions
)
const saneOperationId = Oas3Tools.sanitize(
operationId,
Oas3Tools.CaseStyle.camelCase
)
let saneFieldName = Oas3Tools.storeSaneName(
saneOperationId,
operationId,
data.saneMap
)
if (operation.inViewer) {
for (let securityRequirement of operation.securityRequirements) {
if (typeof authSubscriptionFields[securityRequirement] !== 'object') {
authSubscriptionFields[securityRequirement] = {}
}
if (saneFieldName in authSubscriptionFields[securityRequirement]) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${saneFieldName}' and security requirement ` +
`'${securityRequirement}'. GraphQL field names must be ` +
`unique so only one can be added to the authentication ` +
`viewer. Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
authSubscriptionFields[securityRequirement][saneFieldName] = field
}
}
} else {
if (saneFieldName in subscriptionFields) {
handleWarning({
mitigationType: MitigationTypes.DUPLICATE_FIELD_NAME,
message:
`Multiple operations have the same name ` +
`'${saneFieldName}'. GraphQL field names must be ` +
`unique so only one can be added to the Mutation object. ` +
`Operation '${operation.operationString}' will be ignored.`,
data,
log: translationLog
})
} else {
subscriptionFields[saneFieldName] = field
}
}
}
)
// Sorting fields
queryFields = sortObject(queryFields)
mutationFields = sortObject(mutationFields)
subscriptionFields = sortObject(subscriptionFields)
authQueryFields = sortObject(authQueryFields)
Object.keys(authQueryFields).forEach(key => {
authQueryFields[key] = sortObject(authQueryFields[key])
})
authMutationFields = sortObject(authMutationFields)
Object.keys(authMutationFields).forEach(key => {
authMutationFields[key] = sortObject(authMutationFields[key])
})
authSubscriptionFields = sortObject(authSubscriptionFields)
Object.keys(authSubscriptionFields).forEach(key => {
authSubscriptionFields[key] = sortObject(authSubscriptionFields[key])
})
// Count created Query, Mutation, and Subscription fields
options.report.numQueriesCreated =
Object.keys(queryFields).length +
Object.keys(authQueryFields).reduce((sum, key) => {
return sum + Object.keys(authQueryFields[key]).length
}, 0)
options.report.numMutationsCreated =
Object.keys(mutationFields).length +
Object.keys(authMutationFields).reduce((sum, key) => {
return sum + Object.keys(authMutationFields[key]).length
}, 0)
options.report.numSubscriptionsCreated =
Object.keys(subscriptionFields).length +
Object.keys(authSubscriptionFields).reduce((sum, key) => {
return sum + Object.keys(authSubscriptionFields[key]).length
}, 0)
/**
* Organize authenticated Query, Mutation, and Subscriptions fields into
* viewer objects.
*/
if (Object.keys(authQueryFields).length > 0) {
Object.assign(
queryFields,
createAndLoadViewer(authQueryFields, GraphQLOperationType.Query, data)
)
}
if (Object.keys(authMutationFields).length > 0) {
Object.assign(
mutationFields,
createAndLoadViewer(
authMutationFields,
GraphQLOperationType.Mutation,
data
)
)
}
if (Object.keys(authSubscriptionFields).length > 0) {
Object.assign(
subscriptionFields,
createAndLoadViewer(
authSubscriptionFields,
GraphQLOperationType.Subscription,
data
)
)
}
// Build up the schema
const schemaConfig: GraphQLSchemaConfig = {
query:
Object.keys(queryFields).length > 0
? new GraphQLObjectType({
name: 'Query',
fields: queryFields
})
: GraphQLTools.getEmptyObjectType('Query'), // A GraphQL schema must contain a Query object type
mutation:
Object.keys(mutationFields).length > 0
? new GraphQLObjectType({
name: 'Mutation',
fields: mutationFields
})
: null,
subscription:
Object.keys(subscriptionFields).length > 0
? new GraphQLObjectType({
name: 'Subscription',
fields: subscriptionFields
})
: null
}
/**
* Fill in yet undefined object types to avoid GraphQLSchema from breaking.
*
* The reason: once creating the schema, the 'fields' thunks will resolve and
* if a field references an undefined object type, GraphQL will throw.
*/
Object.entries(data.operations).forEach(([opId, operation]) => {
if (typeof operation.responseDefinition.graphQLType === 'undefined') {
operation.responseDefinition.graphQLType = GraphQLTools.getEmptyObjectType(
operation.responseDefinition.graphQLTypeName
)
}
})
const schema = new GraphQLSchema(schemaConfig)
return { schema, report: options.report }
}
/**
* Creates the field object for the given operation.
*/
function getFieldForOperation<TSource, TContext, TArgs>(
operation: Operation,
baseUrl: string,
data: PreprocessingData<TSource, TContext, TArgs>,
requestOptions: NodeRequest.OptionsWithUrl,
connectOptions: ConnectOptions
): GraphQLFieldConfig<TSource, TContext | SubscriptionContext, TArgs> {
// Create GraphQL Type for response:
const type = getGraphQLType({
def: operation.responseDefinition,
data,
operation
}) as GraphQLOutputType
const payloadSchemaName = operation.payloadDefinition
? operation.payloadDefinition.graphQLInputObjectTypeName
: null
const args: Args = getArgs({
/**
* Even though these arguments seems redundent because of the operation
* argument, the function cannot be refactored because it is also used to
* create arguments for links. The operation argument is really used to pass
* data to other functions.
*/
requestPayloadDef: operation.payloadDefinition,
parameters: operation.parameters,
operation,
data
})
// Get resolver and subscribe function for Subscription fields
if (operation.operationType === GraphQLOperationType.Subscription) {
const responseSchemaName = operation.responseDefinition
? operation.responseDefinition.graphQLTypeName
: null
const resolve = getPublishResolver({
operation,
responseName: responseSchemaName,
data
})
const subscribe = getSubscribe({
operation,
payloadName: payloadSchemaName,
data,
baseUrl,
connectOptions
})
return {
type,
resolve,
subscribe,
args,
description: operation.description
}
// Get resolver for Query and Mutation fields
} else {
const resolve = getResolver({
operation,
payloadName: payloadSchemaName,
data,
baseUrl,
requestOptions
})
return {
type,
resolve,
args,
description: operation.description
}
}
}
/**
* Ensure that the customResolvers/customSubscriptionResolvers object is a
* triply nested object using the name of the OAS, the path, and the method
* as keys.
*/
function checkCustomResolversStructure<TSource, TContext, TArgs>(
customResolvers: any,
data: PreprocessingData<TSource, TContext, TArgs>
) {
if (typeof customResolvers === 'object') {
// Check that all OASs that are referenced in the customResolvers are provided
Object.keys(customResolvers)
.filter(title => {
// If no OAS contains this title
return !data.oass.some(oas => {
return title === oas.info.title
})
})
.forEach(title => {
handleWarning({
mitigationType: MitigationTypes.CUSTOM_RESOLVER_UNKNOWN_OAS,
message:
`Custom resolvers reference OAS '${title}' but no such ` +
`OAS was provided`,
data,
log: translationLog
})
})
// TODO: Only run the following test on OASs that exist. See previous check.
Object.keys(customResolvers).forEach(title => {
// Get all operations from a particular OAS
const operations = Object.values(data.operations).filter(operation => {
return title === operation.oas.info.title
})
Object.keys(customResolvers[title]).forEach(path => {
Object.keys(customResolvers[title][path]).forEach(method => {
if (
!operations.some(operation => {
return path === operation.path && method === operation.method
})
) {
handleWarning({
mitigationType:
MitigationTypes.CUSTOM_RESOLVER_UNKNOWN_PATH_METHOD,
message:
`A custom resolver references an operation with ` +
`path '${path}' and method '${method}' but no such operation ` +
`exists in OAS '${title}'`,
data,
log: translationLog
})
}
})
})
})
}
}
/**
* Ensures that the options are valid
*/
function preliminaryChecks<TSource, TContext, TArgs>(
options: InternalOptions<TSource, TContext, TArgs>,
data: PreprocessingData<TSource, TContext, TArgs>
): void {
// Check if OASs have unique titles
const titles = data.oass.map(oas => {
return oas.info.title
})
// Find duplicates among titles
new Set(
titles.filter((title, index) => {
return titles.indexOf(title) !== index
})
).forEach(title => {
handleWarning({
mitigationType: MitigationTypes.MULTIPLE_OAS_SAME_TITLE,
message: `Multiple OAS share the same title '${title}'`,
data,
log: translationLog
})
})
// Check customResolvers
checkCustomResolversStructure(options.customResolvers, data)
// Check customSubscriptionResolvers
checkCustomResolversStructure(options.customSubscriptionResolvers, data)
}
export { sanitize, CaseStyle } from './oas_3_tools'
export { GraphQLOperationType } from './types/graphql'