UNPKG

openapi-to-graphql-harshith

Version:

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

1,603 lines (1,422 loc) 45.2 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 /** * Utility functions around the OpenAPI Specification 3. */ // Type imports: import { Oas2 } from './types/oas2' import { TargetGraphQLType, Operation } from './types/operation' import { Oas3, ServerObject, ParameterObject, SchemaObject, OperationObject, ResponsesObject, ResponseObject, PathItemObject, RequestBodyObject, ReferenceObject, LinksObject, LinkObject, MediaTypesObject, SecuritySchemeObject, SecurityRequirementObject } from './types/oas3' import { PreprocessingData, ProcessedSecurityScheme } from './types/preprocessing_data' import { InternalOptions } from './types/options' // Imports: import * as Swagger2OpenAPI from 'swagger2openapi' import * as OASValidator from 'oas-validator' import debug from 'debug' import { handleWarning, MitigationTypes } from './utils' import * as jsonptr from 'json-ptr' import * as pluralize from 'pluralize' // Type definitions & exports: export type SchemaNames = { // Sorted in the following priority order fromExtension?: string fromRef?: string fromSchema?: string fromPath?: string /** * Used when the preferred name is known, i.e. a new data def does not need to * be created */ preferred?: string } export type RequestSchemaAndNames = { payloadContentType?: string payloadSchema?: SchemaObject payloadSchemaNames?: SchemaNames payloadRequired: boolean } export type ResponseSchemaAndNames = { responseContentType?: string responseSchema?: SchemaObject responseSchemaNames?: SchemaNames statusCode?: string } const httpLog = debug('http') const preprocessingLog = debug('preprocessing') const translationLog = debug('translation') // OAS constants export enum HTTP_METHODS { 'get' = 'get', 'put' = 'put', 'post' = 'post', 'patch' = 'patch', 'delete' = 'delete', 'options' = 'options', 'head' = 'head' } export const SUCCESS_STATUS_RX = /2[0-9]{2}|2XX/ export enum OAS_GRAPHQL_EXTENSIONS { TypeName = 'x-graphql-type-name', FieldName = 'x-graphql-field-name', EnumMapping = 'x-graphql-enum-mapping' } /** * Given an HTTP method, convert it to the HTTP_METHODS enum */ export function methodToHttpMethod(method: string): HTTP_METHODS { switch (method.toLowerCase()) { case 'get': return HTTP_METHODS.get case 'put': return HTTP_METHODS.put case 'post': return HTTP_METHODS.post case 'patch': return HTTP_METHODS.patch case 'delete': return HTTP_METHODS.delete case 'options': return HTTP_METHODS.options case 'head': return HTTP_METHODS.head default: throw new Error(`Invalid HTTP method '${method}'`) } } export function isOas2(spec: any): spec is Oas2 { return typeof spec.swagger === 'string' && /^2/.test(spec.swagger) } export function isOas3(spec: any): spec is Oas3 { return typeof spec.openapi === 'string' && /^3/.test(spec.openapi) } /** * Resolves on a validated OAS 3 for the given spec (OAS 2 or OAS 3), or rejects * if errors occur. */ export async function getValidOAS3( spec: Oas2 | Oas3, oasValidatorOptions: object, swagger2OpenAPIOptions: object ): Promise<Oas3> { // CASE: translate if (isOas2(spec)) { preprocessingLog( `Received Swagger - going to translate to OpenAPI Specification...` ) try { const { openapi } = await Swagger2OpenAPI.convertObj( spec, swagger2OpenAPIOptions ) return openapi } catch (error) { throw new Error( `Could not convert Swagger '${spec.info.title}' to OpenAPI Specification. ${error.message}` ) } // CASE: validate } else if (isOas3(spec)) { preprocessingLog(`Received OpenAPI Specification - going to validate...`) await OASValidator.validate(spec, oasValidatorOptions) } else { throw new Error(`Invalid specification provided`) } return spec } /** * Counts the number of operations in an OAS. */ export function countOperations(oas: Oas3): number { let numOps = 0 for (let path in oas.paths) { for (let method in oas.paths[path]) { if (isHttpMethod(method)) { numOps++ if (oas.paths[path][method].callbacks) { for (let cbName in oas.paths[path][method].callbacks) { for (let cbPath in oas.paths[path][method].callbacks[cbName]) { numOps++ } } } } } } return numOps } /** * Counts the number of operations that translate to queries in an OAS. */ export function countOperationsQuery(oas: Oas3): number { let numOps = 0 for (let path in oas.paths) { for (let method in oas.paths[path]) { if (isHttpMethod(method) && method.toLowerCase() === HTTP_METHODS.get) { numOps++ } } } return numOps } /** * Counts the number of operations that translate to mutations in an OAS. */ export function countOperationsMutation(oas: Oas3): number { let numOps = 0 for (let path in oas.paths) { for (let method in oas.paths[path]) { if (isHttpMethod(method) && method.toLowerCase() !== HTTP_METHODS.get) { numOps++ } } } return numOps } /** * Counts the number of operations that translate to subscriptions in an OAS. */ export function countOperationsSubscription(oas: Oas3): number { let numOps = 0 for (let path in oas.paths) { for (let method in oas.paths[path]) { if ( isHttpMethod(method) && method.toLowerCase() !== HTTP_METHODS.get && oas.paths[path][method].callbacks ) { for (let cbName in oas.paths[path][method].callbacks) { for (let cbPath in oas.paths[path][method].callbacks[cbName]) { numOps++ } } } } } return numOps } /** * Counts the number of operations with a payload definition in an OAS. */ export function countOperationsWithPayload(oas: Oas3): number { let numOps = 0 for (let path in oas.paths) { for (let method in oas.paths[path]) { if ( isHttpMethod(method) && typeof oas.paths[path][method].requestBody === 'object' ) { numOps++ } } } return numOps } /** * Resolves the given reference in the given object. */ export function resolveRef<T = any>(ref: string, oas: Oas3): T { return jsonptr.JsonPointer.get(oas, ref) as T } /** * Recursively traverse a schema and resolve allOf by appending the data to the * parent schema */ export function resolveAllOf<TSource, TContext, TArgs>( schema: SchemaObject | ReferenceObject, references: { [reference: string]: SchemaObject }, data: PreprocessingData<TSource, TContext, TArgs>, oas: Oas3 ): SchemaObject { // Dereference schema if ('$ref' in schema && typeof schema.$ref === 'string') { if (schema.$ref in references) { return references[schema.$ref] } const reference = schema.$ref schema = resolveRef(schema.$ref, oas) as SchemaObject references[reference] = schema } /** * TODO: Is there a better method to copy the schema? * * Copy the schema */ const collapsedSchema: SchemaObject = JSON.parse(JSON.stringify(schema)) // Resolve allOf if (Array.isArray(collapsedSchema.allOf)) { collapsedSchema.allOf.forEach((memberSchema) => { const collapsedMemberSchema = resolveAllOf( memberSchema, references, data, oas ) // Collapse type if applicable if (collapsedMemberSchema.type) { if (!collapsedSchema.type) { collapsedSchema.type = collapsedMemberSchema.type // Check for incompatible schema type } else if (collapsedSchema.type !== collapsedMemberSchema.type) { handleWarning({ mitigationType: MitigationTypes.UNRESOLVABLE_SCHEMA, message: `Resolving 'allOf' field in schema '${JSON.stringify( collapsedSchema )}' ` + `results in incompatible schema type.`, data, log: preprocessingLog }) } } // Collapse properties if applicable if ('properties' in collapsedMemberSchema) { if (!('properties' in collapsedSchema)) { collapsedSchema.properties = {} } Object.entries(collapsedMemberSchema.properties).forEach( ([propertyName, property]) => { if (!(propertyName in collapsedSchema.properties)) { collapsedSchema.properties[propertyName] = property // Conflicting property } else { handleWarning({ mitigationType: MitigationTypes.UNRESOLVABLE_SCHEMA, message: `Resolving 'allOf' field in schema '${JSON.stringify( collapsedSchema )}' ` + `results in incompatible property field '${propertyName}'.`, data, log: preprocessingLog }) } } ) } // Collapse oneOf if applicable if ('oneOf' in collapsedMemberSchema) { if (!('oneOf' in collapsedSchema)) { collapsedSchema.oneOf = [] } collapsedMemberSchema.oneOf.forEach((oneOfProperty) => { collapsedSchema.oneOf.push(oneOfProperty) }) } // Collapse anyOf if applicable if ('anyOf' in collapsedMemberSchema) { if (!('anyOf' in collapsedSchema)) { collapsedSchema.anyOf = [] } collapsedMemberSchema.anyOf.forEach((anyOfProperty) => { collapsedSchema.anyOf.push(anyOfProperty) }) } // Collapse required if applicable if ('required' in collapsedMemberSchema) { if (!('required' in collapsedSchema)) { collapsedSchema.required = [] } collapsedMemberSchema.required.forEach((requiredProperty) => { if (!collapsedSchema.required.includes(requiredProperty)) { collapsedSchema.required.push(requiredProperty) } }) } }) } return collapsedSchema } /** * Returns the base URL to use for the given operation. */ export function getBaseUrl(operation: Operation): string { // Check for servers: if (!Array.isArray(operation.servers) || operation.servers.length === 0) { throw new Error( `No servers defined for operation '${operation.operationString}'` ) } // Check for local servers if (Array.isArray(operation.servers) && operation.servers.length > 0) { const url = buildUrl(operation.servers[0]) if (Array.isArray(operation.servers) && operation.servers.length > 1) { httpLog(`Warning: Randomly selected first server '${url}'`) } return url.replace(/\/$/, '') } const oas = operation.oas if (Array.isArray(oas.servers) && oas.servers.length > 0) { const url = buildUrl(oas.servers[0]) if (Array.isArray(oas.servers) && oas.servers.length > 1) { httpLog(`Warning: Randomly selected first server '${url}'`) } return url.replace(/\/$/, '') } throw new Error('Cannot find a server to call') } /** * Returns the default URL for a given OAS server object. */ function buildUrl(server: ServerObject): string { let url = server.url // Replace with variable defaults, if applicable if ( typeof server.variables === 'object' && Object.keys(server.variables).length > 0 ) { for (let variableKey in server.variables) { // TODO: check for default? Would be invalid OAS url = url.replace( `{${variableKey}}`, server.variables[variableKey].default.toString() ) } } return url } /** * Returns object/array/scalar where all object keys (if applicable) are * sanitized. */ export function sanitizeObjectKeys( obj: any, // obj does not necessarily need to be an object caseStyle: CaseStyle = CaseStyle.camelCase ): any { const cleanKeys = (obj: any): any => { // Case: no (response) data if (obj === null || typeof obj === 'undefined') { return null // Case: array } else if (Array.isArray(obj)) { return obj.map(cleanKeys) // Case: object } else if (typeof obj === 'object') { const res: object = {} for (const key in obj) { const saneKey = sanitize(key, caseStyle) if (Object.prototype.hasOwnProperty.call(obj, key)) { res[saneKey] = cleanKeys(obj[key]) } } return res // Case: scalar } else { return obj } } return cleanKeys(obj) } /** * Desanitizes keys in given object by replacing them with the keys stored in * the given mapping. */ export function desanitizeObjectKeys( obj: object | Array<any>, mapping: object = {} ): object | Array<any> { const replaceKeys = (obj) => { if (obj === null) { return null } else if (Array.isArray(obj)) { return obj.map(replaceKeys) } else if (typeof obj === 'object') { const res = {} for (let key in obj) { if (key in mapping) { const rawKey = mapping[key] if (Object.prototype.hasOwnProperty.call(obj, key)) { res[rawKey] = replaceKeys(obj[key]) } } else { res[key] = replaceKeys(obj[key]) } } return res } else { return obj } } return replaceKeys(obj) } /** * Returns the GraphQL type that the provided schema should be made into */ export function getSchemaTargetGraphQLType<TSource, TContext, TArgs>( schemaOrRef: SchemaObject | ReferenceObject, data: PreprocessingData<TSource, TContext, TArgs>, oas: Oas3 ): TargetGraphQLType | null { let schema: SchemaObject if ('$ref' in schemaOrRef && typeof schemaOrRef.$ref === 'string') { schema = resolveRef(schemaOrRef.$ref, oas) } else { schema = schemaOrRef as SchemaObject } // TODO: Need to resolve allOf here as well. // CASE: Check for nested or concurrent anyOf and oneOf if ( // TODO: Should also consider if the member schema contains type data (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) || // anyOf and oneOf used concurrently hasNestedAnyOfUsage(schema, oas) || hasNestedOneOfUsage(schema, oas) ) { handleWarning({ mitigationType: MitigationTypes.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 }) return TargetGraphQLType.json } if (Array.isArray(schema.anyOf)) { return GetAnyOfTargetGraphQLType(schema, data, oas) } if (Array.isArray(schema.oneOf)) { return GetOneOfTargetGraphQLType(schema, data, oas) } // CASE: enum if (Array.isArray(schema.enum)) { return TargetGraphQLType.enum } // CASE: object if (schema.type === 'object' || typeof schema.properties === 'object') { // TODO: additionalProperties is more like a flag than a type itself // CASE: arbitrary JSON if (typeof schema.additionalProperties === 'object') { return TargetGraphQLType.json } else { return TargetGraphQLType.object } } // CASE: array if (schema.type === 'array' || 'items' in schema) { return TargetGraphQLType.list } // Special edge cases involving the schema format if (typeof schema.format === 'string') { if (schema.type === 'integer' && schema.format === 'int64') { return TargetGraphQLType.bigint // CASE: file upload } else if (schema.type === 'string' && schema.format === 'binary') { return TargetGraphQLType.upload // CASE: id } else if ( schema.type === 'string' && (schema.format === 'uuid' || // Custom ID format (Array.isArray(data.options.idFormats) && data.options.idFormats.includes(schema.format))) ) { return TargetGraphQLType.id } } switch (schema.type) { case 'string': return TargetGraphQLType.string case 'number': return TargetGraphQLType.float case 'integer': return TargetGraphQLType.integer case 'boolean': return TargetGraphQLType.boolean default: // Error: unsupported schema type } return null } /** * 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(schema: SchemaObject, oas: Oas3): boolean { // TODO: Should also consider if the member schema contains type data return ( Array.isArray(schema.oneOf) && schema.oneOf.some((memberSchemaOrRef) => { let memberSchema: SchemaObject if ( '$ref' in memberSchemaOrRef && typeof memberSchemaOrRef.$ref === 'string' ) { memberSchema = resolveRef(memberSchemaOrRef.$ref, oas) } else { memberSchema = memberSchemaOrRef as SchemaObject } return ( /** * anyOf and oneOf are nested * * Nested oneOf would result in nested unions which are not allowed by * GraphQL */ Array.isArray(memberSchema.anyOf) || Array.isArray(memberSchema.oneOf) ) }) ) } /** * 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(schema: SchemaObject, oas: Oas3): boolean { // TODO: Should also consider if the member schema contains type data return ( Array.isArray(schema.anyOf) && schema.anyOf.some((memberSchemaOrRef) => { let memberSchema: SchemaObject if ( '$ref' in memberSchemaOrRef && typeof memberSchemaOrRef.$ref === 'string' ) { memberSchema = resolveRef(memberSchemaOrRef.$ref, oas) } else { memberSchema = memberSchemaOrRef as SchemaObject } return ( // anyOf and oneOf are nested Array.isArray(memberSchema.anyOf) || Array.isArray(memberSchema.oneOf) ) }) ) } function GetAnyOfTargetGraphQLType<TSource, TContext, TArgs>( schema: SchemaObject, data: PreprocessingData<TSource, TContext, TArgs>, oas: Oas3 ): TargetGraphQLType { // Identify the type of the base schema, meaning ignoring the anyOf const schemaWithNoAnyOf = { ...schema } delete schemaWithNoAnyOf.anyOf const baseTargetType = getSchemaTargetGraphQLType( schemaWithNoAnyOf, data, oas ) // Target GraphQL types of all the member schemas const memberTargetTypes: TargetGraphQLType[] = [] schema.anyOf.forEach((memberSchema) => { const memberTargetType = getSchemaTargetGraphQLType(memberSchema, data, oas) if (memberTargetType !== null) { memberTargetTypes.push(memberTargetType) } }) if (memberTargetTypes.length > 0) { const firstMemberTargetType = memberTargetTypes[0] const consistentMemberTargetTypes = memberTargetTypes.every( (targetType) => { return targetType === firstMemberTargetType } ) if (consistentMemberTargetTypes) { if (baseTargetType !== null) { if (baseTargetType === firstMemberTargetType) { if (baseTargetType === 'object') { // Base schema and member schema types are object types return TargetGraphQLType.anyOfObject } else { // Base schema and member schema types but no object types return baseTargetType } } else { // Base schema and member schema types are not consistent return TargetGraphQLType.json } } else { if (firstMemberTargetType === TargetGraphQLType.object) { return TargetGraphQLType.anyOfObject } else { return firstMemberTargetType } } } else { // Member schema types are not consistent return TargetGraphQLType.json } } else { // No member schema types, therefore use the base schema type return baseTargetType } } function GetOneOfTargetGraphQLType<TSource, TContext, TArgs>( schema: SchemaObject, data: PreprocessingData<TSource, TContext, TArgs>, oas: Oas3 ): TargetGraphQLType { // Identify the type of the base schema, meaning ignoring the oneOf const schemaWithNoOneOf = { ...schema } delete schemaWithNoOneOf.oneOf const baseTargetType = getSchemaTargetGraphQLType( schemaWithNoOneOf, data, oas ) // Target GraphQL types of all the member schemas const memberTargetTypes: TargetGraphQLType[] = [] schema.oneOf.forEach((memberSchema) => { const collapsedMemberSchema = resolveAllOf(memberSchema, {}, data, oas); const memberTargetType = getSchemaTargetGraphQLType(collapsedMemberSchema, data, oas) if (memberTargetType !== null) { memberTargetTypes.push(memberTargetType) } }) if (memberTargetTypes.length > 0) { const firstMemberTargetType = memberTargetTypes[0] const consistentMemberTargetTypes = memberTargetTypes.every( (targetType) => { return targetType === firstMemberTargetType } ) if (consistentMemberTargetTypes) { if (baseTargetType !== null) { if (baseTargetType === firstMemberTargetType) { if (baseTargetType === 'object') { // Base schema and member schema types are object types return TargetGraphQLType.oneOfUnion } else { // Base schema and member schema types but no object types return baseTargetType } } else { // Base schema and member schema types are not consistent return TargetGraphQLType.json } } else { if (firstMemberTargetType === TargetGraphQLType.object) { return TargetGraphQLType.oneOfUnion } else { return firstMemberTargetType } } } else { // Member schema types are not consistent return TargetGraphQLType.json } } else { // No member schema types, therefore use the base schema type return baseTargetType } } /** * Identifies common path components in the given list of paths. Returns these * components as well as an updated list of paths where the common prefix was * removed. */ function extractBasePath( paths: string[] ): { basePath: string updatedPaths: string[] } { if (paths.length <= 1) { return { basePath: '/', updatedPaths: paths } } let basePathComponents: string[] = paths[0].split('/') for (let path of paths) { if (basePathComponents.length === 0) { break } const pathComponents = path.split('/') for (let i = 0; i < pathComponents.length; i++) { if (i < basePathComponents.length) { if (pathComponents[i] !== basePathComponents[i]) { basePathComponents = basePathComponents.slice(0, i) } } else { break } } } const updatedPaths = paths.map((path) => path.split('/').slice(basePathComponents.length).join('/') ) let basePath = basePathComponents.length === 0 || (basePathComponents.length === 1 && basePathComponents[0] === '') ? '/' : basePathComponents.join('/') return { basePath, updatedPaths } } function isIdParam(part) { return /^{.*(id|name|key).*}$/gi.test(part) } function isSingularParam(part, nextPart) { return `\{${pluralize.singular(part)}\}` === nextPart } /** * Infers a resource name from the given URL path. * * For example, turns "/users/{userId}/car" into "userCar". */ export function inferResourceNameFromPath(path: string): string { const parts = path.split('/') let pathNoPathParams = parts.reduce((path, part, i) => { if (!/{/g.test(part)) { if ( parts[i + 1] && (isIdParam(parts[i + 1]) || isSingularParam(part, parts[i + 1])) ) { return path + capitalize(pluralize.singular(part)) } else { return path + capitalize(part) } } else { return path } }, '') return pathNoPathParams } /** * Returns the request schema (if any) for the given operation, * a dictionary of names from different sources (if available), and whether the * request schema is required for the operation. */ export function getRequestSchemaAndNames( path: string, method: HTTP_METHODS, operation: OperationObject, oas: Oas3 ): RequestSchemaAndNames { let payloadContentType: string // randomly selected content-type, prioritizing application/json let requestBodyObject: RequestBodyObject // request object let payloadSchema: SchemaObject // request schema with given content-type let payloadSchemaNames: SchemaNames // dictionary of names let payloadRequired = false // Get request body const requestBodyObjectOrRef = operation?.requestBody if ( typeof requestBodyObjectOrRef === 'object' && requestBodyObjectOrRef !== null ) { // Resolve reference if applicable. Make sure we have a RequestBodyObject: if ( '$ref' in requestBodyObjectOrRef && typeof requestBodyObjectOrRef.$ref === 'string' ) { requestBodyObject = resolveRef(requestBodyObjectOrRef.$ref, oas) } else { requestBodyObject = requestBodyObjectOrRef as RequestBodyObject } if (typeof requestBodyObject === 'object' && requestBodyObject !== null) { // Determine if request body is required: payloadRequired = typeof requestBodyObject?.required === 'boolean' ? requestBodyObject?.required : false // Determine content-type const content: MediaTypesObject = requestBodyObject?.content if ( typeof content === 'object' && content !== null && Object.keys(content).length > 0 ) { // Prioritize content-type JSON if ('application/json' in content) { payloadContentType = 'application/json' } else if ('application/x-www-form-urlencoded' in content) { payloadContentType = 'application/x-www-form-urlencoded' } else { // Pick first (random) content type const randomContentType = Object.keys(content)[0] payloadContentType = randomContentType } if ( payloadContentType === 'application/json' || payloadContentType === '*/*' || payloadContentType === 'application/x-www-form-urlencoded' || payloadContentType === 'multipart/form-data' ) { // Name extracted from a reference, if applicable let fromRef: string // Determine payload schema const payloadSchemaOrRef = content?.[payloadContentType]?.schema if ( typeof payloadSchemaOrRef === 'object' && payloadSchemaOrRef !== null ) { // Resolve payload schema reference if applicable if ( '$ref' in payloadSchemaOrRef && typeof payloadSchemaOrRef.$ref === 'string' ) { fromRef = payloadSchemaOrRef.$ref.split('/').pop() payloadSchema = resolveRef(payloadSchemaOrRef.$ref, oas) } else { payloadSchema = payloadSchemaOrRef as SchemaObject } } // Determine possible schema names payloadSchemaNames = { fromExtension: payloadSchema[OAS_GRAPHQL_EXTENSIONS.TypeName], fromRef, fromSchema: payloadSchema?.title, fromPath: inferResourceNameFromPath(path) } /** * Edge case: if request body content-type is not application/json or * application/x-www-form-urlencoded, do not parse it. * * Instead, treat the request body as a black box and send it as a string * with the proper content-type header */ } else { const saneContentTypeName = uncapitalize( payloadContentType.split('/').reduce((name, term) => { return name + capitalize(term) }) ) let description = `String represents payload of content type '${payloadContentType}'` if (typeof payloadSchema?.description === 'string') { description += `\n\nOriginal top level description: '${payloadSchema.description}'` } // Replacement schema to avoid parsing payloadSchema = { description, type: 'string' } // Determine possible schema names payloadSchemaNames = { fromPath: saneContentTypeName } } } } } return { payloadContentType, payloadSchema, payloadSchemaNames, payloadRequired } } /** * Returns the response schema for the given operation, * a successful status code, and a dictionary of names from different sources * (if available). */ export function getResponseSchemaAndNames<TSource, TContext, TArgs>( path: string, method: HTTP_METHODS, operation: OperationObject, oas: Oas3, data: PreprocessingData<TSource, TContext, TArgs>, options: InternalOptions<TSource, TContext, TArgs> ): ResponseSchemaAndNames { let responseContentType: string // randomly selected content-type, prioritizing application/json let responseObject: ResponseObject // response object let responseSchema: SchemaObject // response schema with given content-type let responseSchemaNames: SchemaNames // dictionary of names const statusCode = getResponseStatusCode(path, method, operation, oas, data) // Get response object const responseObjectOrRef = operation?.responses?.[statusCode] if (typeof responseObjectOrRef === 'object' && responseObjectOrRef !== null) { if ( '$ref' in responseObjectOrRef && typeof responseObjectOrRef.$ref === 'string' ) { responseObject = resolveRef(responseObjectOrRef.$ref, oas) } else { responseObject = responseObjectOrRef as ResponseObject } // Determine content-type if (typeof responseObject === 'object' && responseObject !== null) { const content: MediaTypesObject = responseObject?.content if ( typeof content === 'object' && content !== null && Object.keys(content).length > 0 ) { // Prioritize content-type JSON if ('application/json' in content) { responseContentType = 'application/json' } else { // Pick first (random) content type const randomContentType = Object.keys(content)[0] responseContentType = randomContentType } if ( responseContentType === 'application/json' || responseContentType === '*/*' ) { // Name from reference, if applicable let fromRef: string // Determine response schema const responseSchemaOrRef = responseObject?.content?.[responseContentType]?.schema // Resolve response schema reference if applicable if ( '$ref' in responseSchemaOrRef && typeof responseSchemaOrRef.$ref === 'string' ) { fromRef = responseSchemaOrRef.$ref.split('/').pop() responseSchema = resolveRef(responseSchemaOrRef.$ref, oas) } else { responseSchema = responseSchemaOrRef as SchemaObject } // Determine possible schema names responseSchemaNames = { fromExtension: responseSchema[OAS_GRAPHQL_EXTENSIONS.TypeName], fromRef, fromSchema: responseSchema?.title, fromPath: inferResourceNameFromPath(path) } /** * Edge case: if response body content-type is not application/json, * do not parse. */ } else { let description = 'Placeholder to access non-application/json response bodies' if (typeof responseSchema?.description === 'string') { description += `\n\nOriginal top level description: '${responseSchema.description}'` } // Replacement schema to avoid parsing responseSchema = { description, type: 'string' } // Determine possible schema names responseSchemaNames = { fromExtension: responseSchema?.[OAS_GRAPHQL_EXTENSIONS.TypeName], fromSchema: responseSchema?.title, fromPath: inferResourceNameFromPath(path) } } return { responseContentType, responseSchema, responseSchemaNames, statusCode } } } } // No response schema if (options.fillEmptyResponses) { return { responseSchemaNames: { fromPath: inferResourceNameFromPath(path) }, responseSchema: { description: 'Placeholder to support operations with no response schema', type: 'string' } } } else { return {} } } /** * Returns a success status code for the given operation */ export function getResponseStatusCode<TSource, TContext, TArgs>( path: string, method: string, operation: OperationObject, oas: Oas3, data: PreprocessingData<TSource, TContext, TArgs> ): string { if (typeof operation.responses === 'object' && operation.responses !== null) { const codes = Object.keys(operation.responses) const successCodes = codes.filter((code) => { return SUCCESS_STATUS_RX.test(code) }) if (successCodes.length === 1) { return successCodes[0] } else if (successCodes.length > 1) { // Select a random success code handleWarning({ mitigationType: MitigationTypes.MULTIPLE_RESPONSES, message: `Operation '${formatOperationString( method, path, oas.info.title )}' ` + `contains multiple possible successful response object ` + `(HTTP code 200-299 or 2XX). Only one can be chosen.`, mitigationAddendum: `The response object with the HTTP code ` + `${successCodes[0]} will be selected`, data, log: translationLog }) return successCodes[0] } } } /** * Returns a hash containing the links in the given operation. */ export function getLinks<TSource, TContext, TArgs>( path: string, method: HTTP_METHODS, operation: OperationObject, oas: Oas3, data: PreprocessingData<TSource, TContext, TArgs> ): { [key: string]: LinkObject } { const links = {} const statusCode = getResponseStatusCode(path, method, operation, oas, data) if (!statusCode) { return links } if (typeof operation.responses === 'object') { const responses: ResponsesObject = operation.responses if (typeof responses[statusCode] === 'object') { const responseObjectOrRef = responses[statusCode] let response: ResponseObject if ( '$ref' in responseObjectOrRef && typeof responseObjectOrRef.$ref === 'string' ) { response = resolveRef(responseObjectOrRef.$ref, oas) } else { response = responseObjectOrRef as ResponseObject } if (typeof response.links === 'object') { const epLinks: LinksObject = response.links for (let linkKey in epLinks) { const linkObjectOrRef = epLinks[linkKey] let link: LinkObject if ( '$ref' in linkObjectOrRef && typeof linkObjectOrRef.$ref === 'string' ) { link = resolveRef(linkObjectOrRef.$ref, oas) } else { link = linkObjectOrRef as LinkObject } links[linkKey] = link } } } } return links } /** * Returns the list of parameters in the given operation. */ export function getParameters( path: string, method: HTTP_METHODS, operation: OperationObject, pathItem: PathItemObject, oas: Oas3 ): ParameterObject[] { let parameters = [] if (!isHttpMethod(method)) { translationLog( `Warning: attempted to get parameters for ${method} ${path}, ` + `which is not an operation.` ) return parameters } // First, consider parameters in Path Item Object: const pathParams = pathItem.parameters if (Array.isArray(pathParams)) { const pathItemParameters: ParameterObject[] = pathParams.map((p) => { if ('$ref' in p && typeof p.$ref === 'string') { // Here we know we have a parameter object: return resolveRef(p.$ref, oas) as ParameterObject } else { // Here we know we have a parameter object: return p as ParameterObject } }) parameters = parameters.concat(pathItemParameters) } // Second, consider parameters in Operation Object: const opObjectParameters = operation.parameters if (Array.isArray(opObjectParameters)) { const operationParameters: ParameterObject[] = opObjectParameters.map( (p) => { if ('$ref' in p && typeof p.$ref === 'string') { // Here we know we have a parameter object: return resolveRef(p.$ref, oas) } else { // Here we know we have a parameter object: return p as ParameterObject } } ) parameters = parameters.concat(operationParameters) } return parameters } /** * Returns an array of server objects for the operation at the given path and * method. Considers in the following order: global server definitions, * definitions at the path item, definitions at the operation, or the OAS * default. */ export function getServers( operation: OperationObject, pathItem: PathItemObject, oas: Oas3 ): ServerObject[] { let servers = [] // Global server definitions: if (Array.isArray(oas.servers) && oas.servers.length > 0) { servers = oas.servers } // First, consider servers defined on the path if (Array.isArray(pathItem.servers) && pathItem.servers.length > 0) { servers = pathItem.servers } // Second, consider servers defined on the operation if (Array.isArray(operation.servers) && operation.servers.length > 0) { servers = operation.servers } // Default, in case there is no server: if (servers.length === 0) { let server: ServerObject = { url: '/' // TODO: avoid double-slashes } servers.push(server) } return servers } /** * Returns a map of security scheme definitions, identified by keys. Resolves * possible references. */ export function getSecuritySchemes( oas: Oas3 ): { [schemeKey: string]: SecuritySchemeObject } { // Collect all security schemes: const securitySchemes: { [schemeKey: string]: SecuritySchemeObject } = {} if ( typeof oas.components === 'object' && typeof oas.components.securitySchemes === 'object' ) { for (let schemeKey in oas.components.securitySchemes) { const securitySchemeOrRef = oas.components.securitySchemes[schemeKey] // Ensure we have actual SecuritySchemeObject: if ( '$ref' in securitySchemeOrRef && typeof securitySchemeOrRef.$ref === 'string' ) { // Result of resolution will be SecuritySchemeObject: securitySchemes[schemeKey] = resolveRef(securitySchemeOrRef.$ref, oas) } else { // We already have a SecuritySchemeObject: securitySchemes[schemeKey] = securitySchemeOrRef as SecuritySchemeObject } } } return securitySchemes } /** * Returns the list of sanitized keys of non-OAuth2 security schemes * required by the operation at the given path and method. */ export function getSecurityRequirements( operation: OperationObject, securitySchemes: { [key: string]: ProcessedSecurityScheme }, oas: Oas3 ): string[] { const results: string[] = [] // First, consider global requirements const globalSecurity: SecurityRequirementObject[] = oas.security if (globalSecurity && typeof globalSecurity !== 'undefined') { for (let secReq of globalSecurity) { for (let schemaKey in secReq) { if ( securitySchemes[schemaKey] && typeof securitySchemes[schemaKey] === 'object' && securitySchemes[schemaKey].def.type !== 'oauth2' ) { results.push(schemaKey) } } } } // Second, consider operation requirements const localSecurity: SecurityRequirementObject[] = operation.security if (localSecurity && typeof localSecurity !== 'undefined') { for (let secReq of localSecurity) { for (let schemaKey in secReq) { if ( securitySchemes[schemaKey] && typeof securitySchemes[schemaKey] === 'object' && securitySchemes[schemaKey].def.type !== 'oauth2' ) { if (!results.includes(schemaKey)) { results.push(schemaKey) } } } } } return results } export enum CaseStyle { simple, // No case style is applied. Only illegal characters are removed. PascalCase, // Used for type names camelCase, // Used for (input) object field names ALL_CAPS // Used for enum values } /** * Checks to see if the provided string is GraphQL-safe */ export function isSanitized(str: string): boolean { return /[a-zA-Z0-9_]/gi.test(str) } /** * First sanitizes given string and then also camelCases it. */ export function sanitize(str: string, caseStyle: CaseStyle): string { /** * Used in conjunction to simpleNames, which only removes illegal * characters and preserves casing */ if (caseStyle === CaseStyle.simple) { let sanitized = str.replace(/[^a-zA-Z0-9_]/gi, '') // Special case: we cannot start with number, and cannot be empty: if (/^[0-9]/.test(sanitized) || sanitized === '') { sanitized = '_' + sanitized } return sanitized } /** * Remove all GraphQL unsafe characters */ const regex = caseStyle === CaseStyle.ALL_CAPS ? /[^a-zA-Z0-9_]/g // ALL_CAPS has underscores : /[^a-zA-Z0-9]/g let sanitized = str.split(regex).reduce((path, part) => { if (caseStyle === CaseStyle.ALL_CAPS) { return path + '_' + part } else { return path + capitalize(part) } }) switch (caseStyle) { case CaseStyle.PascalCase: // The first character in PascalCase should be uppercase sanitized = capitalize(sanitized) break case CaseStyle.camelCase: // The first character in camelCase should be lowercase sanitized = uncapitalize(sanitized) break case CaseStyle.ALL_CAPS: sanitized = sanitized.toUpperCase() break } // Special case: we cannot start with number, and cannot be empty: if (/^[0-9]/.test(sanitized) || sanitized === '') { sanitized = '_' + sanitized } return sanitized } /** * Sanitizes the given string and stores the sanitized-to-original mapping in * the given mapping. */ export function storeSaneName( saneStr: string, str: string, mapping: { [key: string]: string } ): string { if (saneStr in mapping && str !== mapping[saneStr]) { // TODO: Follow warning model translationLog( `Warning: '${str}' and '${mapping[saneStr]}' both sanitize ` + `to '${saneStr}' - collision possible. Desanitize to '${str}'.` ) } mapping[saneStr] = str return saneStr } /** * Stringifies and possibly trims the given string to the provided length. */ export function trim(str: string, length: number): string { if (typeof str !== 'string') { str = JSON.stringify(str) } if (str && str.length > length) { str = `${str.substring(0, length)}...` } return str } /** * Determines if the given "method" is indeed an operation. Alternatively, the * method could point to other types of information (e.g., parameters, servers). */ export function isHttpMethod(method: string): boolean { return Object.keys(HTTP_METHODS).includes(method.toLowerCase()) } /** * Formats a string that describes an operation in the form: * {name of OAS} {HTTP method in ALL_CAPS} {operation path} * * Also used in preprocessing.ts where Operation objects are being constructed */ export function formatOperationString( method: string, path: string, title?: string ): string { if (title) { return `${title} ${method.toUpperCase()} ${path}` } else { return `${method.toUpperCase()} ${path}` } } /** * Capitalizes a given string */ export function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1) } /** * Uncapitalizes a given string */ export function uncapitalize(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1) } /** * For operations that do not have an operationId, generate one */ export function generateOperationId( method: HTTP_METHODS, path: string ): string { return sanitize(`${method} ${path}`, CaseStyle.camelCase) }