UNPKG

openapi-to-graphql-lxwang2

Version:

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

1,467 lines (1,314 loc) 44.8 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 // Type imports: import { Oas3, SchemaObject, LinkObject, ReferenceObject } from './types/oas3' import { InternalOptions } from './types/options' import { Operation, DataDefinition } from './types/operation' import { PreprocessingData, ProcessedSecurityScheme } from './types/preprocessing_data' // Imports: import * as Oas3Tools from './oas_3_tools' import * as deepEqual from 'deep-equal' import debug from 'debug' import { handleWarning, getCommonPropertyNames } from './utils' import { GraphQLOperationType } from './types/graphql' const preprocessingLog = debug('preprocessing') /** * Extract information from the OAS and put it inside a data structure that * is easier for OpenAPI-to-GraphQL to use */ export function preprocessOas( oass: Oas3[], options: InternalOptions ): PreprocessingData { const data: PreprocessingData = { usedTypeNames: [ 'Query', // Used by OpenAPI-to-GraphQL for root-level element 'Mutation' // Used by OpenAPI-to-GraphQL for root-level element ], defs: [], operations: {}, saneMap: {}, security: {}, options, oass } oass.forEach(oas => { // Store stats on OAS: data.options.report.numOps += Oas3Tools.countOperations(oas) data.options.report.numOpsMutation += Oas3Tools.countOperationsMutation(oas) data.options.report.numOpsQuery += Oas3Tools.countOperationsQuery(oas) // Get security schemes const currentSecurity = getProcessedSecuritySchemes(oas, data) const commonSecurityPropertyName = getCommonPropertyNames( data.security, currentSecurity ) commonSecurityPropertyName.forEach(propertyName => { handleWarning({ typeKey: 'DUPLICATE_SECURITY_SCHEME', message: `Multiple OASs share security schemes with the same name '${propertyName}'`, mitigationAddendum: `The security scheme from OAS ` + `'${currentSecurity[propertyName].oas.info.title}' will be ignored`, data, log: preprocessingLog }) }) // Do not overwrite preexisting security schemes data.security = { ...currentSecurity, ...data.security } // Process all operations for (let path in oas.paths) { for (let method in oas.paths[path]) { // Only consider Operation Objects if (!Oas3Tools.isOperation(method)) { continue } const endpoint = oas.paths[path][method] const operationString = oass.length === 1 ? Oas3Tools.formatOperationString(method, path) : Oas3Tools.formatOperationString(method, path, oas.info.title) // Determine description let description = endpoint.description if ( (typeof description !== 'string' || description === '') && typeof endpoint.summary === 'string' ) { description = endpoint.summary } if (data.options.equivalentToMessages) { // Description may not exist if (typeof description !== 'string') { description = '' } description += `\n\nEquivalent to ${operationString}` } // Hold on to the operationId const operationId = typeof endpoint.operationId !== 'undefined' ? endpoint.operationId : Oas3Tools.generateOperationId(method, path) // Request schema const { payloadContentType, payloadSchema, payloadSchemaNames, payloadRequired } = Oas3Tools.getRequestSchemaAndNames(path, method, oas) const payloadDefinition = payloadSchema && typeof payloadSchema !== 'undefined' ? createDataDef( payloadSchemaNames, payloadSchema as SchemaObject, true, data, undefined, oas ) : undefined // Response schema const { responseContentType, responseSchema, responseSchemaNames, statusCode } = Oas3Tools.getResponseSchemaAndNames( path, method, oas, data, options ) if (!responseSchema || typeof responseSchema !== 'object') { handleWarning({ typeKey: 'MISSING_RESPONSE_SCHEMA', message: `Operation ${operationString} has no (valid) response schema. ` + `You can use the fillEmptyResponses option to create a ` + `placeholder schema`, data, log: preprocessingLog }) continue } // Links const links = Oas3Tools.getEndpointLinks(path, method, oas, data) const responseDefinition = createDataDef( responseSchemaNames, responseSchema as SchemaObject, false, data, links, oas ) // Parameters const parameters = Oas3Tools.getParameters(path, method, oas) // Security protocols const securityRequirements = options.viewer ? Oas3Tools.getSecurityRequirements(path, method, data.security, oas) : [] // Servers const servers = Oas3Tools.getServers(path, method, oas) // Whether to place this operation into an authentication viewer const inViewer = securityRequirements.length > 0 && data.options.viewer !== false /** * Whether the operation should be added as a Query or Mutation field. * By default, all GET operations are Query fields and all other * operations are Mutation fields. */ let isMutation = method.toLowerCase() !== 'get' // Option selectQueryOrMutationField can override isMutation if ( typeof options.selectQueryOrMutationField === 'object' && typeof options.selectQueryOrMutationField[oas.info.title] === 'object' && typeof options.selectQueryOrMutationField[oas.info.title][path] === 'object' && typeof options.selectQueryOrMutationField[oas.info.title][path][ method ] === 'number' // This is an TS enum, which is translated to have a integer value ) { isMutation = options.selectQueryOrMutationField[oas.info.title][path][method] === GraphQLOperationType.Mutation } // Store determined information for operation const operation: Operation = { operationId, operationString, description, path, method: method.toLowerCase(), payloadContentType, payloadDefinition, payloadRequired, responseContentType, responseDefinition, parameters, securityRequirements, servers, inViewer, isMutation, statusCode, oas } /** * Handle operationId property name collision * May occur if multiple OAS are provided */ if (operationId in data.operations) { handleWarning({ typeKey: 'DUPLICATE_OPERATIONID', message: `Multiple OASs share operations with the same operationId '${operationId}'`, mitigationAddendum: `The operation from the OAS '${operation.oas.info.title}' will be ignored`, data, log: preprocessingLog }) } else { data.operations[operationId] = operation } } } }) return data } /** * Extracts the security schemes from given OAS and organizes the information in * a data structure that is easier for OpenAPI-to-GraphQL to use * * Here is the structure of the data: * { * {string} [sanitized name] { Contains information about the security protocol * {string} rawName Stores the raw security protocol name * {object} def Definition provided by OAS * {object} parameters Stores the names of the authentication credentials * NOTE: Structure will depend on the type of the protocol * (e.g. basic authentication, API key, etc.) * NOTE: Mainly used for the AnyAuth viewers * {object} schema Stores the GraphQL schema to create the viewers * } * } * * Here is an example: * { * MyApiKey: { * rawName: "My_api_key", * def: { ... }, * parameters: { * apiKey: MyKeyApiKey * }, * schema: { ... } * } * MyBasicAuth: { * rawName: "My_basic_auth", * def: { ... }, * parameters: { * username: MyBasicAuthUsername, * password: MyBasicAuthPassword, * }, * schema: { ... } * } * } */ function getProcessedSecuritySchemes( oas: Oas3, data: PreprocessingData ): { [key: string]: ProcessedSecurityScheme } { const result = {} const security = Oas3Tools.getSecuritySchemes(oas) // Loop through all the security protocols for (let key in security) { const protocol = security[key] // Determine the schema and the parameters for the security protocol let schema let parameters = {} let description switch (protocol.type) { case 'apiKey': description = `API key credentials for the security protocol '${key}'` if (data.oass.length > 1) { description += ` in ${oas.info.title}` } parameters = { apiKey: Oas3Tools.sanitize( `${key}_apiKey`, Oas3Tools.CaseStyle.camelCase ) } schema = { type: 'object', description, properties: { apiKey: { type: 'string' } } } break case 'http': switch (protocol.scheme) { /** * TODO: HTTP has a number of authentication types * * See http://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml */ case 'basic': description = `Basic auth credentials for security protocol '${key}'` parameters = { username: Oas3Tools.sanitize( `${key}_username`, Oas3Tools.CaseStyle.camelCase ), password: Oas3Tools.sanitize( `${key}_password`, Oas3Tools.CaseStyle.camelCase ) } schema = { type: 'object', description, properties: { username: { type: 'string' }, password: { type: 'string' } } } break default: handleWarning({ typeKey: 'UNSUPPORTED_HTTP_SECURITY_SCHEME', message: `Currently unsupported HTTP authentication protocol ` + `type 'http' and scheme '${protocol.scheme}' in OAS ` + `'${oas.info.title}'`, data, log: preprocessingLog }) } break // TODO: Implement case 'openIdConnect': handleWarning({ typeKey: 'UNSUPPORTED_HTTP_SECURITY_SCHEME', message: `Currently unsupported HTTP authentication protocol ` + `type 'openIdConnect' in OAS '${oas.info.title}'`, data, log: preprocessingLog }) break case 'oauth2': handleWarning({ typeKey: 'OAUTH_SECURITY_SCHEME', message: `OAuth security scheme found in OAS '${oas.info.title}'. ` + `OAuth support is provided using the 'tokenJSONpath' option`, data, log: preprocessingLog }) // Continue because we do not want to create an OAuth viewer continue default: handleWarning({ typeKey: 'UNSUPPORTED_HTTP_SECURITY_SCHEME', message: `Unsupported HTTP authentication protocol` + `type '${protocol.type}' in OAS '${oas.info.title}'`, data, log: preprocessingLog }) } // Add protocol data to the output result[key] = { rawName: key, def: protocol, parameters, schema, oas } } return result } /** * Method to either create a new or reuse an existing, centrally stored data * definition. Data definitions are objects that hold a schema (= JSON schema), * an otName (= String to use as the name for object types), and an iotName * (= String to use as the name for input object types). Eventually, data * definitions also hold an ot (= the object type for the schema) and an iot * (= the input object type for the schema). * * Either names or preferredName should exist. */ export function createDataDef( names: Oas3Tools.SchemaNames, schema: SchemaObject, isInputObjectType: boolean, data: PreprocessingData, links?: { [key: string]: LinkObject }, oas?: Oas3 ): DataDefinition { const preferredName = getPreferredName(names) // Basic validation test if (typeof schema !== 'object') { handleWarning({ typeKey: 'MISSING_SCHEMA', message: `Could not create data definition for schema with ` + `preferred name '${preferredName}' and schema '${JSON.stringify( schema )}'`, data, log: preprocessingLog }) // TODO: Does this change make the option fillEmptyResponses obsolete? return { preferredName, schema: null, required: [], links: null, subDefinitions: null, graphQLTypeName: null, graphQLInputObjectTypeName: null, targetGraphQLType: 'json' } } else { if ('$ref' in schema) { schema = Oas3Tools.resolveRef(schema['$ref'], oas) } const saneLinks = {} if (typeof links === 'object') { Object.keys(links).forEach(linkKey => { saneLinks[ Oas3Tools.sanitize( linkKey, !data.options.simpleNames ? Oas3Tools.CaseStyle.camelCase : Oas3Tools.CaseStyle.simple ) ] = links[linkKey] }) } // Determine the index of possible existing data definition const index = getSchemaIndex(preferredName, schema, data.defs) if (index !== -1) { // Found existing data definition and fetch it const existingDataDef = data.defs[index] /** * Collapse links if possible, i.e. if the current operation has links, * combine them with the prexisting ones */ if (typeof saneLinks !== 'undefined') { if (typeof existingDataDef.links !== 'undefined') { // Check if there are any overlapping links Object.keys(existingDataDef.links).forEach(saneLinkKey => { if ( typeof saneLinks[saneLinkKey] !== 'undefined' && !deepEqual( existingDataDef.links[saneLinkKey], saneLinks[saneLinkKey] ) ) { handleWarning({ typeKey: 'DUPLICATE_LINK_KEY', message: `Multiple operations with the same response body share the same sanitized ` + `link key '${saneLinkKey}' but have different link definitions ` + `'${JSON.stringify( existingDataDef.links[saneLinkKey] )}' and ` + `'${JSON.stringify(saneLinks[saneLinkKey])}'.`, data, log: preprocessingLog }) } }) /** * Collapse the links * * Avoid overwriting preexisting links */ existingDataDef.links = { ...saneLinks, ...existingDataDef.links } } else { // No preexisting links, so simply assign the links existingDataDef.links = saneLinks } } return existingDataDef } else { // Else, define a new name, store the def, and return it const name = getSchemaName(names, data.usedTypeNames) // Store and sanitize the name const saneName = !data.options.simpleNames ? Oas3Tools.sanitize(name, Oas3Tools.CaseStyle.PascalCase) : Oas3Tools.capitalize( Oas3Tools.sanitize(name, Oas3Tools.CaseStyle.simple) ) const saneInputName = Oas3Tools.capitalize(saneName + 'Input') Oas3Tools.storeSaneName(saneName, name, data.saneMap) /** * TODO: is there a better way of copying the schema object? * * Perhaps, just copy it at the root level (operation schema) */ const collapsedSchema = resolveAllOf(schema, {}, data, oas) const targetGraphQLType = Oas3Tools.getSchemaTargetGraphQLType( collapsedSchema as SchemaObject, data ) const def: DataDefinition = { preferredName, /** * Note that schema may contain $ref or schema composition (e.g. allOf) * * TODO: the schema is used in getSchemaIndex, which allows us to check * whether a dataDef has already been created for that particular * schema and name pair. The look up should resolve references but * currently, it does not. */ schema, required: [], targetGraphQLType, subDefinitions: undefined, links: saneLinks, graphQLTypeName: saneName, graphQLInputObjectTypeName: saneInputName } // Used type names and defs of union and object types are pushed during creation if ( targetGraphQLType === 'object' || targetGraphQLType === 'list' || targetGraphQLType === 'enum' ) { data.usedTypeNames.push(saneName) data.usedTypeNames.push(saneInputName) // Add the def to the master list data.defs.push(def) } // We currently only support simple cases of anyOf and oneOf if ( // TODO: Should also consider if the member schema contains type data (Array.isArray(collapsedSchema.anyOf) && Array.isArray(collapsedSchema.oneOf)) || // anyOf and oneOf used concurrently hasNestedAnyOfUsage(collapsedSchema, oas) || hasNestedOneOfUsage(collapsedSchema, oas) ) { handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify(schema)}' contains either both ` + `'anyOf' and 'oneOf' or nested 'anyOf' and 'oneOf' which ` + `is currently not supported.`, mitigationAddendum: `Use arbitrary JSON type instead.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' return def } // oneOf will ideally be turned into a union type if (Array.isArray(collapsedSchema.oneOf)) { const oneOfDataDef = createDataDefFromOneOf( saneName, saneInputName, collapsedSchema, isInputObjectType, def, data, oas ) if (typeof oneOfDataDef === 'object') { return oneOfDataDef } } /** * anyOf will ideally be turned into an object type * * Fields common to all member schemas will be made non-null */ if (Array.isArray(collapsedSchema.anyOf)) { const anyOfDataDef = createDataDefFromAnyOf( saneName, saneInputName, collapsedSchema, isInputObjectType, def, data, oas ) if (typeof anyOfDataDef === 'object') { return anyOfDataDef } } if (targetGraphQLType) { switch (targetGraphQLType) { case 'list': if (typeof collapsedSchema.items === 'object') { // Break schema down into component parts // I.e. if it is an list type, create a reference to the list item type // Or if it is an object type, create references to all of the field types let itemsSchema = collapsedSchema.items let itemsName = `${name}ListItem` if ('$ref' in itemsSchema) { itemsName = collapsedSchema.items['$ref'].split('/').pop() } const subDefinition = createDataDef( // Is this the correct classification for this name? It does not matter in the long run. { fromRef: itemsName }, itemsSchema as SchemaObject, isInputObjectType, data, undefined, oas ) // Add list item reference def.subDefinitions = subDefinition } break case 'object': def.subDefinitions = {} if ( typeof collapsedSchema.properties === 'object' && Object.keys(collapsedSchema.properties).length > 0 ) { addObjectPropertiesToDataDef( def, collapsedSchema, def.required, isInputObjectType, data, oas ) } else { handleWarning({ typeKey: 'OBJECT_MISSING_PROPERTIES', message: `Schema ${JSON.stringify(schema)} does not have ` + `any properties`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' } break } } else { // No target GraphQL type handleWarning({ typeKey: 'UNKNOWN_TARGET_TYPE', message: `No GraphQL target type could be identified for schema '${JSON.stringify( schema )}'.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' } return def } } } /** * Returns the index of the data definition object in the given list that * contains the same schema and preferred name as the given one. Returns -1 if * that schema could not be found. */ function getSchemaIndex( preferredName: string, schema: SchemaObject, dataDefs: DataDefinition[] ): number { /** * TODO: instead of iterating through the whole list every time, create a * hashing function and store all of the DataDefinitions in a hashmap. */ for (let index = 0; index < dataDefs.length; index++) { const def = dataDefs[index] /** * TODO: deepEquals is not sufficient. We also need to resolve references. * However, deepEquals should work for vast majority of cases. */ if (preferredName === def.preferredName && deepEqual(schema, def.schema)) { return index } } // The schema could not be found in the master list return -1 } /** * Determines the preferred name to use for schema regardless of name collisions. * * In other words, determines the ideal name for a schema. * * Similar to getSchemaName() except it does not check if the name has already * been taken. */ function getPreferredName(names: Oas3Tools.SchemaNames): string { if (typeof names.preferred === 'string') { return Oas3Tools.sanitize(names.preferred, Oas3Tools.CaseStyle.PascalCase) // CASE: preferred name already known } else if (typeof names.fromRef === 'string') { return Oas3Tools.sanitize(names.fromRef, Oas3Tools.CaseStyle.PascalCase) // CASE: name from reference } else if (typeof names.fromSchema === 'string') { return Oas3Tools.sanitize(names.fromSchema, Oas3Tools.CaseStyle.PascalCase) // CASE: name from schema (i.e., "title" property in schema) } else if (typeof names.fromPath === 'string') { return Oas3Tools.sanitize(names.fromPath, Oas3Tools.CaseStyle.PascalCase) // CASE: name from path } else { return 'PlaceholderName' // CASE: placeholder name } } /** * Determines name to use for schema from previously determined schemaNames and * considering not reusing existing names. */ function getSchemaName( names: Oas3Tools.SchemaNames, usedNames: string[] ): string { if (Object.keys(names).length === 1 && typeof names.preferred === 'string') { throw new Error( `Cannot create data definition without name(s), excluding the preferred name.` ) } let schemaName // CASE: name from reference if (typeof names.fromRef === 'string') { const saneName = Oas3Tools.sanitize( names.fromRef, Oas3Tools.CaseStyle.PascalCase ) if (!usedNames.includes(saneName)) { schemaName = names.fromRef } } // CASE: name from schema (i.e., "title" property in schema) if (!schemaName && typeof names.fromSchema === 'string') { const saneName = Oas3Tools.sanitize( names.fromSchema, Oas3Tools.CaseStyle.PascalCase ) if (!usedNames.includes(saneName)) { schemaName = names.fromSchema } } // CASE: name from path if (!schemaName && typeof names.fromPath === 'string') { const saneName = Oas3Tools.sanitize( names.fromPath, Oas3Tools.CaseStyle.PascalCase ) if (!usedNames.includes(saneName)) { schemaName = names.fromPath } } // CASE: all names are already used - create approximate name if (!schemaName) { schemaName = Oas3Tools.sanitize( typeof names.fromRef === 'string' ? names.fromRef : typeof names.fromSchema === 'string' ? names.fromSchema : typeof names.fromPath === 'string' ? names.fromPath : 'PlaceholderName', Oas3Tools.CaseStyle.PascalCase ) } if (usedNames.includes(schemaName)) { let appendix = 2 /** * GraphQL Objects cannot share the name so if the name already exists in * the master list append an incremental number until the name does not * exist anymore. */ while (usedNames.includes(`${schemaName}${appendix}`)) { appendix++ } schemaName = `${schemaName}${appendix}` } return schemaName } /** * Add the properties to the data definition */ function addObjectPropertiesToDataDef( def: DataDefinition, schema: SchemaObject, required: string[], isInputObjectType: boolean, data: PreprocessingData, oas?: Oas3 ) { /** * Resolve all required properties * * TODO: required may contain duplicates, which is not necessarily a problem */ if (Array.isArray(schema.required)) { schema.required.forEach(requiredProperty => { required.push(requiredProperty) }) } for (let propertyKey in schema.properties) { let propSchemaName = propertyKey let propSchema = schema.properties[propertyKey] if ('$ref' in propSchema) { propSchemaName = propSchema['$ref'].split('/').pop() propSchema = Oas3Tools.resolveRef(propSchema['$ref'], oas) as SchemaObject } if (!(propertyKey in def.subDefinitions)) { const subDefinition = createDataDef( { fromRef: propSchemaName, fromSchema: propSchema.title // TODO: Currently not utilized because of fromRef but arguably, propertyKey is a better field name and title is a better type name }, propSchema, isInputObjectType, data, undefined, oas ) // Add field type references def.subDefinitions[propertyKey] = subDefinition } else { handleWarning({ typeKey: 'DUPLICATE_FIELD_NAME', message: `By way of resolving 'allOf', multiple schemas contain ` + `properties with the same name, preventing consolidation. Cannot ` + `add property '${propertyKey}' from schema '${JSON.stringify( schema )}' ` + `to dataDefinition '${JSON.stringify(def)}'`, data, log: preprocessingLog }) } } } /** * Recursively traverse a schema and resolve allOf by appending the data to the * parent schema */ function resolveAllOf( schema: SchemaObject | ReferenceObject, references: { [reference: string]: SchemaObject }, data: PreprocessingData, oas: Oas3 ): SchemaObject { // Dereference schema if ('$ref' in schema) { const referenceLocation = schema['$ref'] schema = Oas3Tools.resolveRef(schema['$ref'], oas) as SchemaObject if (referenceLocation in references) { return references[referenceLocation] } else { // Store references in case of circular allOf references[referenceLocation] = schema } } const collapsedSchema: SchemaObject = JSON.parse(JSON.stringify(schema)) // Resolve allOf if (Array.isArray(collapsedSchema.allOf)) { collapsedSchema.allOf.forEach(memberSchema => { // Collapse type if applicable const resolvedSchema = resolveAllOf(memberSchema, references, data, oas) if (resolvedSchema.type) { if (!collapsedSchema.type) { collapsedSchema.type = resolvedSchema.type // Add type if applicable } else if (collapsedSchema.type !== resolvedSchema.type) { // Incompatible schema type handleWarning({ typeKey: 'UNRESOLVABLE_SCHEMA', message: `Resolving 'allOf' field in schema '${collapsedSchema}' ` + `results in incompatible schema type from partial schema '${resolvedSchema}'.`, data, log: preprocessingLog }) } } // Collapse properties if applicable if ('properties' in resolvedSchema) { if (!('properties' in collapsedSchema)) { collapsedSchema.properties = {} } Object.entries(resolvedSchema.properties).forEach( ([propertyName, property]) => { if (propertyName in collapsedSchema) { // Conflicting property handleWarning({ typeKey: 'UNRESOLVABLE_SCHEMA', message: `Resolving 'allOf' field in schema '${collapsedSchema}' ` + `results in incompatible property field from partial schema '${resolvedSchema}'.`, data, log: preprocessingLog }) } else { collapsedSchema.properties[propertyName] = property } } ) } // Collapse oneOf if applicable if ('oneOf' in resolvedSchema) { if (!('oneOf' in collapsedSchema)) { collapsedSchema.oneOf = [] } resolvedSchema.oneOf.forEach(oneOfProperty => { collapsedSchema.oneOf.push(oneOfProperty) }) } // Collapse anyOf if applicable if ('anyOf' in resolvedSchema) { if (!('anyOf' in collapsedSchema)) { collapsedSchema.anyOf = [] } resolvedSchema.anyOf.forEach(anyOfProperty => { collapsedSchema.anyOf.push(anyOfProperty) }) } // Collapse required if applicable if ('required' in resolvedSchema) { if (!('required' in collapsedSchema)) { collapsedSchema.required = [] } resolvedSchema.required.forEach(requiredProperty => { if (!collapsedSchema.required.includes(requiredProperty)) { collapsedSchema.required.push(requiredProperty) } }) } }) } return collapsedSchema } type MemberSchemaData = { allTargetGraphQLTypes: string[] allProperties: ({ [key: string]: SchemaObject | ReferenceObject })[] allRequired: string[] } /** * In the context of schemas that use keywords that combine member schemas, * collect data on certain aspects so it is all in one place for processing. */ function getMemberSchemaData( schemas: (SchemaObject | ReferenceObject)[], data: PreprocessingData, oas: Oas3 ): MemberSchemaData { const result: MemberSchemaData = { allTargetGraphQLTypes: [], allProperties: [], allRequired: [] } schemas.forEach(schema => { // Dereference schemas if ('$ref' in schema) { schema = Oas3Tools.resolveRef(schema['$ref'], oas) as SchemaObject } // Consolidate target GraphQL type const memberTargetGraphQLType = Oas3Tools.getSchemaTargetGraphQLType( schema, data ) if (memberTargetGraphQLType) { result.allTargetGraphQLTypes.push(memberTargetGraphQLType) } // Consolidate properties if (schema.properties) { result.allProperties.push(schema.properties) } // Consolidate required if (schema.required) { result.allRequired = result.allRequired.concat(schema.required) } }) return result } /** * Check to see if there are cases of nested oneOf fields in the member schemas * * We currently cannot handle complex cases of oneOf and anyOf */ function hasNestedOneOfUsage( collapsedSchema: SchemaObject, oas: Oas3 ): boolean { // TODO: Should also consider if the member schema contains type data return ( Array.isArray(collapsedSchema.oneOf) && collapsedSchema.oneOf.some(memberSchema => { // anyOf and oneOf are nested if ('$ref' in memberSchema) { memberSchema = Oas3Tools.resolveRef( memberSchema['$ref'], oas ) as SchemaObject } return ( Array.isArray(memberSchema.anyOf) || Array.isArray(memberSchema.oneOf) // Nested oneOf would result in nested unions which are not allowed by GraphQL ) }) ) } /** * Check to see if there are cases of nested anyOf fields in the member schemas * * We currently cannot handle complex cases of oneOf and anyOf */ function hasNestedAnyOfUsage( collapsedSchema: SchemaObject, oas: Oas3 ): boolean { // TODO: Should also consider if the member schema contains type data return ( Array.isArray(collapsedSchema.anyOf) && collapsedSchema.anyOf.some(memberSchema => { // anyOf and oneOf are nested if ('$ref' in memberSchema) { memberSchema = Oas3Tools.resolveRef( memberSchema['$ref'], oas ) as SchemaObject } return ( Array.isArray(memberSchema.anyOf) || Array.isArray(memberSchema.oneOf) ) }) ) } /** * Create a data definition for anyOf is applicable * * anyOf should resolve into an object that contains the superset of all * properties from the member schemas */ function createDataDefFromAnyOf( saneName: string, saneInputName: string, collapsedSchema: SchemaObject, isInputObjectType: boolean, def: DataDefinition, data: PreprocessingData, oas: Oas3 ) { const anyOfData = getMemberSchemaData(collapsedSchema.anyOf, data, oas) if ( anyOfData.allTargetGraphQLTypes.some(memberTargetGraphQLType => { return memberTargetGraphQLType === 'object' }) ) { // Every member type should be an object if ( anyOfData.allTargetGraphQLTypes.every(memberTargetGraphQLType => { return memberTargetGraphQLType === 'object' }) && anyOfData.allProperties.length > 0 // Redundant check ) { // Ensure that parent schema is compatiable with oneOf if ( def.targetGraphQLType === null || def.targetGraphQLType === 'object' ) { const allProperties: { [propertyName: string]: (SchemaObject | ReferenceObject)[] } = {} const incompatibleProperties = new Set<string>() /** * TODO: Check for consistent properties across all member schemas and * make them into non-nullable properties by manipulating the * required field */ if (typeof collapsedSchema.properties === 'object') { Object.keys(collapsedSchema.properties).forEach(propertyName => { allProperties[propertyName] = [ collapsedSchema.properties[propertyName] ] }) } // Check if any member schema has conflicting properties anyOfData.allProperties.forEach(properties => { Object.keys(properties).forEach(propertyName => { if ( !incompatibleProperties.has(propertyName) && // Has not been already identified as a problematic property (typeof allProperties[propertyName] === 'object' && allProperties[propertyName].some(property => { // Property does not match a recorded one return !deepEqual(property, properties[propertyName]) })) ) { incompatibleProperties.add(propertyName) } // Add property in the store if (!(propertyName in allProperties)) { allProperties[propertyName] = [] } allProperties[propertyName].push(properties[propertyName]) }) }) def.subDefinitions = {} if ( typeof collapsedSchema.properties === 'object' && Object.keys(collapsedSchema.properties).length > 0 ) { addObjectPropertiesToDataDef( def, collapsedSchema, def.required, isInputObjectType, data, oas ) } anyOfData.allProperties.forEach(properties => { Object.keys(properties).forEach(propertyName => { if (!incompatibleProperties.has(propertyName)) { // Dereferenced by processing anyOfData const propertySchema = properties[propertyName] as SchemaObject const subDefinition = createDataDef( { fromRef: propertyName, fromSchema: propertySchema.title // TODO: Currently not utilized because of fromRef but arguably, propertyKey is a better field name and title is a better type name }, propertySchema, isInputObjectType, data, undefined, oas ) /** * Add field type references * There should not be any collisions */ def.subDefinitions[propertyName] = subDefinition } }) }) // Add in incompatible properties incompatibleProperties.forEach(propertyName => { // TODO: add description def.subDefinitions[propertyName] = { targetGraphQLType: 'json' } }) data.usedTypeNames.push(saneName) data.usedTypeNames.push(saneInputName) data.defs.push(def) def.targetGraphQLType = 'object' return def } else { // The parent schema is incompatible with the member schemas handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify(def.schema)}' contains 'anyOf' and ` + `some member schemas are object types so create a GraphQL ` + `object type but the parent schema is a non-object type ` + `so they are not compatible.`, mitigationAddendum: `Use arbitrary JSON type instead.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' return def } } else { // The member schemas are not all object types handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${def.schema}' contains 'anyOf' and ` + `some member schemas are object types so create a GraphQL ` + `object type but some member schemas are non-object types ` + `so they are not compatible.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' return def } } } function createDataDefFromOneOf( saneName: string, saneInputName: string, collapsedSchema: SchemaObject, isInputObjectType: boolean, def: DataDefinition, data: PreprocessingData, oas: Oas3 ) { const oneOfData = getMemberSchemaData(collapsedSchema.oneOf, data, oas) if ( oneOfData.allTargetGraphQLTypes.some(memberTargetGraphQLType => { return memberTargetGraphQLType === 'object' }) ) { // unions must be created from object types if ( oneOfData.allTargetGraphQLTypes.every(memberTargetGraphQLType => { return memberTargetGraphQLType === 'object' }) && oneOfData.allProperties.length > 0 // Redundant check ) { // Ensure that parent schema is compatiable with oneOf if ( def.targetGraphQLType === null || def.targetGraphQLType === 'object' ) { def.subDefinitions = [] collapsedSchema.oneOf.forEach(memberSchema => { // Dereference member schema let fromRef: string if ('$ref' in memberSchema) { fromRef = memberSchema['$ref'].split('/').pop() memberSchema = Oas3Tools.resolveRef( memberSchema['$ref'], oas ) as SchemaObject } // Member types of GraphQL unions must be object types if ( Oas3Tools.getSchemaTargetGraphQLType(memberSchema, data) === 'object' ) { const subDefinition = createDataDef( { fromRef, fromSchema: memberSchema.title, fromPath: `${saneName}Member` }, memberSchema, isInputObjectType, data, undefined, oas ) ;(def.subDefinitions as DataDefinition[]).push(subDefinition) } else { handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify(def.schema)}' contains 'oneOf' so ` + `create a GraphQL union type but member schema '${JSON.stringify( memberSchema )}' ` + `is not an object type and union member types must be ` + `object base types.`, data, log: preprocessingLog }) } }) // Not all member schemas may have been turned into GraphQL member types if ( def.subDefinitions.length > 0 && def.subDefinitions.every(subDefinition => { return subDefinition.targetGraphQLType === 'object' }) ) { // Ensure all member schemas have been verified as object types data.usedTypeNames.push(saneName) data.usedTypeNames.push(saneInputName) data.defs.push(def) def.targetGraphQLType = 'union' return def } else { handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify(def.schema)}' contains 'oneOf' so ` + `create a GraphQL union type but all member schemas are not` + `object types and union member types must be object types.`, mitigationAddendum: `Use arbitrary JSON type instead.`, data, log: preprocessingLog }) // Default arbitrary JSON type def.targetGraphQLType = 'json' return def } } else { // The parent schema is incompatible with the member schemas handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify( def.schema )}' contains 'oneOf' so create ` + `a GraphQL union type but the parent schema is a non-object ` + `type and member types must be object types.`, mitigationAddendum: `Use arbitrary JSON type instead.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' return def } } else { // The member schemas are not all object types handleWarning({ typeKey: 'COMBINE_SCHEMAS', message: `Schema '${JSON.stringify(def.schema)}' contains 'oneOf' so create ` + `a GraphQL union type but some member schemas are non-object ` + `types and union member types must be object types.`, mitigationAddendum: `Use arbitrary JSON type instead.`, data, log: preprocessingLog }) def.targetGraphQLType = 'json' return def } } }