openapi-to-graphql-lxwang2
Version:
Generates a GraphQL schema for a given OpenAPI Specification (OAS)
1,467 lines (1,314 loc) • 44.8 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
// 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
}
}
}