UNPKG

openapi-to-graphql-lxwang2

Version:

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

613 lines (554 loc) 18.4 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 /** * 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 } from './types/options' import { Oas3 } from './types/oas3' import { Oas2 } from './types/oas2' import { Args, Field } from './types/graphql' import { Operation } from './types/operation' import { PreprocessingData } from './types/preprocessing_data' import { GraphQLSchema, GraphQLObjectType } from 'graphql' import * as NodeRequest from 'request' // Imports: import { getGraphQLType, getArgs } from './schema_builder' import { getResolver } 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 } 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 async function createGraphQLSchema( spec: Oas3 | Oas2 | (Oas3 | Oas2)[], options?: Options ): Promise<Result> { 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 // 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, numQueriesCreated: 0, numMutationsCreated: 0 } let oass: Oas3[] if (Array.isArray(spec)) { /** * Convert all non-OAS 3.0.x into OAS 3.0.x */ oass = await Promise.all( spec.map(ele => { return Oas3Tools.getValidOAS3(ele) }) ) } else { /** * Check if the spec is a valid OAS 3.0.x * If the spec is OAS 2.0, attempt to translate it into 3.0.x, then try to * translate the spec into a GraphQL schema */ oass = [await Oas3Tools.getValidOAS3(spec)] } const { schema, report } = await translateOpenAPIToGraphQL( oass, options as InternalOptions ) return { schema, report } } /** * Creates a GraphQL interface from the given OpenAPI Specification 3.0.x */ async function translateOpenAPIToGraphQL( oass: Oas3[], { strict, report, // Schema options operationIdFieldNames, fillEmptyResponses, addLimitArgument, idFormats, selectQueryOrMutationField, genericPayloadArgName, simpleNames, singularNames, // Resolver options headers, qs, requestOptions, baseUrl, customResolvers, // Authentication options viewer, tokenJSONpath, sendOAuthTokenInQuery, // Logging options provideErrorExtensions, equivalentToMessages }: InternalOptions ): Promise<{ schema: GraphQLSchema; report: Report }> { const options = { strict, report, // Schema options operationIdFieldNames, fillEmptyResponses, addLimitArgument, idFormats, selectQueryOrMutationField, genericPayloadArgName, simpleNames, singularNames, // Resolver options headers, qs, requestOptions, baseUrl, customResolvers, // 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 = preprocessOas(oass, options) preliminaryChecks(options, data) /** * Create GraphQL fields for every operation and structure them based on their * characteristics (query vs. mutation, auth vs. non-auth). */ let queryFields = {} let mutationFields = {} let authQueryFields = {} let authMutationFields = {} Object.entries(data.operations).forEach(([operationId, operation]) => { translationLog(`Process operation '${operation.operationString}'...`) let field = getFieldForOperation( operation, options.baseUrl, data, requestOptions ) const saneOperationId = Oas3Tools.sanitize( operationId, Oas3Tools.CaseStyle.camelCase ) // Check if the operation should be added as a Query or Mutation field if (!operation.isMutation) { 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({ typeKey: '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({ typeKey: '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({ typeKey: '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({ typeKey: '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 } } } }) // Sorting fields queryFields = sortObject(queryFields) mutationFields = sortObject(mutationFields) 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]) }) /** * Count created queries / mutations */ 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) /** * Organize created queries / mutations into viewer objects. */ if (Object.keys(authQueryFields).length > 0) { Object.assign( queryFields, createAndLoadViewer(authQueryFields, data, false) ) } if (Object.keys(authMutationFields).length > 0) { Object.assign( mutationFields, createAndLoadViewer(authMutationFields, data, true) ) } /** * Build up the schema */ const schemaConfig: GraphQLSchemaConfig = { query: Object.keys(queryFields).length > 0 ? new GraphQLObjectType({ name: 'Query', description: 'The start of any 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', description: 'The start of any mutation', fields: mutationFields }) : 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 types, 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( operation: Operation, baseUrl: string, data: PreprocessingData, requestOptions: NodeRequest.OptionsWithUrl ): Field { // Create GraphQL Type for response: const type = getGraphQLType({ def: operation.responseDefinition, data, operation }) // Create resolve function: const payloadSchemaName = operation.payloadDefinition ? operation.payloadDefinition.graphQLInputObjectTypeName : null const resolve = getResolver({ operation, payloadName: payloadSchemaName, data, baseUrl, requestOptions }) // Create args: 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 }) return { type, resolve, args, description: operation.description } } /** * Ensures that the options are valid */ function preliminaryChecks( options: InternalOptions, data: PreprocessingData ): 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({ typeKey: 'MULTIPLE_OAS_SAME_TITLE', message: `Multiple OAS share the same title '${title}'`, data, log: translationLog }) }) // Check customResolvers if (typeof options.customResolvers === 'object') { // Check that all OASs that are referenced in the customResolvers are provided Object.keys(options.customResolvers) .filter(title => { // If no OAS contains this title return !data.oass.some(oas => { return title === oas.info.title }) }) .forEach(title => { handleWarning({ typeKey: '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(options.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(options.customResolvers[title]).forEach(path => { Object.keys(options.customResolvers[title][path]).forEach(method => { if ( !operations.some(operation => { return path === operation.path && method === operation.method }) ) { handleWarning({ typeKey: '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 }) } }) }) }) } } export { sanitize, CaseStyle } from './oas_3_tools' export { GraphQLOperationType } from './types/graphql'