diff --git a/packages/openapi-v3/src/__tests__/unit/x-ts-type.unit.ts b/packages/openapi-v3/src/__tests__/unit/x-ts-type.unit.ts new file mode 100644 index 000000000000..7f6bd82a380d --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/x-ts-type.unit.ts @@ -0,0 +1,257 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/openapi-v3 +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT +import {Model, model, property} from '@loopback/repository'; +import {expect} from '@loopback/testlab'; +import {RequestBodyObject, ResponseObject} from 'openapi3-ts'; +import {getControllerSpec} from '../..'; +import {get, post, requestBody} from '../../decorators'; + +describe('x-ts-type is converted in the right places', () => { + // setup the models for use + @model() + class TestRequest extends Model { + @property({default: 1}) + value: number; + } + @model() + class SuccessModel extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'ok'}) + message: string; + } + + @model() + class FooError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'foo'}) + foo: string; + } + + @model() + class NotError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: true}) + fail: boolean; + } + + @model() + class BarError extends Model { + constructor(err: Partial) { + super(err); + } + @property({default: 'bar'}) + bar: string; + } + + const testRequestSchema: RequestBodyObject = { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestRequest', + }, + }, + }, + }; + const successSchema: ResponseObject = { + description: 'Success', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/SuccessModel', + }, + }, + }, + }; + + const notSchema: ResponseObject = { + description: 'Failure', + content: { + 'application/json': { + schema: { + not: {$ref: '#/components/schemas/BarError'}, + }, + }, + }, + }; + const fooBarSchema = (k: 'anyOf' | 'allOf' | 'oneOf'): ResponseObject => ({ + description: 'Failure', + content: { + 'application/json': { + schema: { + [k]: [ + {$ref: '#/components/schemas/FooError'}, + {$ref: '#/components/schemas/BarError'}, + ], + not: {$ref: '#/components/schemas/NotError'}, + }, + }, + }, + }); + + it('Allows a simple request schema', () => { + class MyController { + @post('/greet') + greet(@requestBody() body: TestRequest) { + return 'Hello world!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].post.requestBody).to.eql( + testRequestSchema, + ); + }); + + it('Does not process existing $ref responses', () => { + const successContent = {$ref: '#/components/schema/SomeReference'}; + class MyController { + @post('/greet', { + responses: { + 201: successContent, + }, + }) + greet(@requestBody() body: TestRequest) { + return 'Hello world!'; + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].post.responses[201]).to.eql( + successContent, + ); + }); + + it('Allows for a response schema using the spec', () => { + class MyController { + @get('/greet', { + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + 'x-ts-type': SuccessModel, + }, + }, + }, + }, + }, + }) + greet() { + return new SuccessModel({message: 'hello, world'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[200]).to.eql(successSchema); + expect(actualSpec.components?.schemas?.SuccessModel).to.not.be.undefined(); + }); + + it('Allows `anyOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + anyOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + not: {'x-ts-type': NotError}, + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('anyOf'), + ); + }); + it('Allows `allOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + allOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + not: {'x-ts-type': NotError}, + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('allOf'), + ); + }); + + it('Allows `oneOf` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + oneOf: [{'x-ts-type': FooError}, {'x-ts-type': BarError}], + not: {'x-ts-type': NotError}, + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql( + fooBarSchema('oneOf'), + ); + }); + it('Allows `not` responses', () => { + class MyController { + @get('/greet', { + responses: { + 404: { + description: 'Failure', + content: { + 'application/json': { + schema: { + not: {'x-ts-type': BarError}, + }, + }, + }, + }, + }, + }) + greet() { + throw new FooError({foo: 'foo'}); + } + } + const actualSpec = getControllerSpec(MyController); + expect(actualSpec.paths['/greet'].get.responses[404]).to.eql(notSchema); + }); +}); diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts index 41249c151291..a200e2a99da2 100644 --- a/packages/openapi-v3/src/controller-spec.ts +++ b/packages/openapi-v3/src/controller-spec.ts @@ -178,9 +178,11 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { requestBody = requestBodies[0]; debug(' requestBody for method %s: %j', op, requestBody); + /* istanbul ignore else */ if (requestBody) { operationSpec.requestBody = requestBody; + /* istanbul ignore else */ const content = requestBody.content || {}; for (const mediaType in content) { processSchemaExtensions(spec, content[mediaType].schema); @@ -233,6 +235,9 @@ function resolveControllerSpec(constructor: Function): ControllerSpec { return spec; } +declare type MixKey = 'allOf' | 'anyOf' | 'oneOf'; +const SCHEMA_ARR_KEYS: MixKey[] = ['allOf', 'anyOf', 'oneOf']; + /** * Resolve the x-ts-type in the schema object * @param spec - Controller spec @@ -248,24 +253,51 @@ function processSchemaExtensions( assignRelatedSchemas(spec, schema.definitions); delete schema.definitions; - if (isReferenceObject(schema)) return; + /** + * check if we have been provided a `not` + * `not` is valid in many cases- here we're checking for + * `not: { schema: {'x-ts-type': SomeModel }} + */ + if (schema.not) { + processSchemaExtensions(spec, schema.not); + } + + /** + * check for schema.allOf, schema.oneOf, schema.anyOf arrays first. + * You cannot provide BOTH a defnintion AND one of these keywords. + */ + /* istanbul ignore else */ + const hasOwn = (prop: string) => schema?.hasOwnProperty(prop); + + if (SCHEMA_ARR_KEYS.some(k => hasOwn(k))) { + SCHEMA_ARR_KEYS.forEach((k: MixKey) => { + /* istanbul ignore else */ + if (schema?.[k] && Array.isArray(schema[k])) { + schema[k].forEach((r: (SchemaObject | ReferenceObject)[]) => { + processSchemaExtensions(spec, r); + }); + } + }); + } else { + if (isReferenceObject(schema)) return; - const tsType = schema[TS_TYPE_KEY]; - debug(' %s => %o', TS_TYPE_KEY, tsType); - if (tsType) { - schema = resolveSchema(tsType, schema); - if (schema.$ref) generateOpenAPISchema(spec, tsType); + const tsType = schema[TS_TYPE_KEY]; + debug(' %s => %o', TS_TYPE_KEY, tsType); + if (tsType) { + schema = resolveSchema(tsType, schema); + if (schema.$ref) generateOpenAPISchema(spec, tsType); - // We don't want a Function type in the final spec. - delete schema[TS_TYPE_KEY]; - return; - } - if (schema.type === 'array') { - processSchemaExtensions(spec, schema.items); - } else if (schema.type === 'object') { - if (schema.properties) { - for (const p in schema.properties) { - processSchemaExtensions(spec, schema.properties[p]); + // We don't want a Function type in the final spec. + delete schema[TS_TYPE_KEY]; + return; + } + if (schema.type === 'array') { + processSchemaExtensions(spec, schema.items); + } else if (schema.type === 'object') { + if (schema.properties) { + for (const p in schema.properties) { + processSchemaExtensions(spec, schema.properties[p]); + } } } }