diff --git a/package-lock.json b/package-lock.json index 58b2b02cdf77..05b873d8f2c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6461,6 +6461,7 @@ "integrity": "sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -26841,7 +26842,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "optional": true + "optional": true, + "peer": true }, "node_modules/meow": { "version": "12.1.1", @@ -30893,7 +30895,8 @@ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/pg-connection-string": { "version": "2.10.1", @@ -33928,6 +33931,7 @@ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "optional": true, + "peer": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -34170,6 +34174,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, "optional": true, "dependencies": { "@gar/promisify": "^1.0.1", @@ -34180,6 +34185,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, "optional": true, "engines": { "node": ">= 6" @@ -34189,6 +34195,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -34199,6 +34206,7 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, "optional": true, "dependencies": { "@npmcli/fs": "^1.0.0", @@ -34228,6 +34236,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -34240,6 +34249,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -34260,6 +34270,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, "optional": true, "dependencies": { "@tootallnate/once": "1", @@ -34274,12 +34285,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "optional": true }, "node_modules/sqlite3/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -34292,6 +34305,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, "optional": true, "dependencies": { "agentkeepalive": "^4.1.3", @@ -34319,6 +34333,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -34331,6 +34346,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -34343,6 +34359,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.0", @@ -34360,6 +34377,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, "optional": true, "bin": { "mkdirp": "bin/cmd.js" @@ -34372,6 +34390,7 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, "optional": true, "dependencies": { "env-paths": "^2.2.0", @@ -34396,6 +34415,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, "optional": true, "dependencies": { "abbrev": "1" @@ -34411,6 +34431,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -34426,6 +34447,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, "optional": true, "dependencies": { "agent-base": "^6.0.2", @@ -34440,6 +34462,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.1" @@ -34452,6 +34475,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, "optional": true, "dependencies": { "unique-slug": "^2.0.0" @@ -34461,6 +34485,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, "optional": true, "dependencies": { "imurmurhash": "^0.1.4" @@ -34470,6 +34495,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "optional": true, "dependencies": { "isexe": "^2.0.0" diff --git a/packages/openapi-v3/src/__tests__/unit/build-responses-advanced.unit.ts b/packages/openapi-v3/src/__tests__/unit/build-responses-advanced.unit.ts new file mode 100644 index 000000000000..baba17700f91 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/build-responses-advanced.unit.ts @@ -0,0 +1,410 @@ +// Copyright IBM Corp. and LoopBack contributors 2020. 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 {buildResponsesFromMetadata} from '../../build-responses-from-metadata'; +import { + OperationObject, + ResponseDecoratorMetadata, + SchemaObject, +} from '../../types'; + +describe('build-responses-from-metadata advanced tests', () => { + @model() + class Product extends Model { + @property() + id: number; + @property() + name: string; + @property() + price: number; + } + + @model() + class Category extends Model { + @property() + id: number; + @property() + title: string; + } + + @model() + class ErrorResponse extends Model { + @property() + code: string; + @property() + message: string; + } + + describe('buildResponsesFromMetadata - multiple models', () => { + it('builds response with multiple models using anyOf', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Category, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.have.property('200'); + expect(result.responses['200'].description).to.equal('Success'); + const schema = result.responses['200'].content['application/json'].schema; + expect(schema).to.have.property('anyOf'); + expect(schema.anyOf).to.be.Array(); + expect(schema.anyOf).to.have.length(2); + expect(schema.anyOf[0]).to.eql({'x-ts-type': Product}); + expect(schema.anyOf[1]).to.eql({'x-ts-type': Category}); + }); + + it('builds response with three or more models', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Multiple types', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Category, + description: 'Multiple types', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: ErrorResponse, + description: 'Multiple types', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + const schema = result.responses['200'].content['application/json'].schema; + expect(schema.anyOf).to.have.length(3); + }); + }); + + describe('buildResponsesFromMetadata - multiple content types', () => { + it('handles multiple content types for same response code', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/xml', + responseModelOrSpec: {type: 'string'}, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content).to.have.property( + 'application/json', + ); + expect(result.responses['200'].content).to.have.property( + 'application/xml', + ); + expect(result.responses['200'].content['application/json'].schema).to.eql( + {'x-ts-type': Product}, + ); + expect(result.responses['200'].content['application/xml'].schema).to.eql({ + type: 'string', + }); + }); + + it('handles text/plain and application/json', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'text/plain', + responseModelOrSpec: {type: 'string'}, + description: 'Plain text response', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'JSON response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + expect(result.responses['200'].content).to.have.property('text/plain'); + expect(result.responses['200'].content).to.have.property( + 'application/json', + ); + }); + }); + + describe('buildResponsesFromMetadata - multiple response codes', () => { + it('handles multiple response codes', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Success', + }, + { + responseCode: 404, + contentType: 'application/json', + responseModelOrSpec: ErrorResponse, + description: 'Not Found', + }, + { + responseCode: 500, + contentType: 'application/json', + responseModelOrSpec: ErrorResponse, + description: 'Internal Server Error', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.have.property('200'); + expect(result.responses).to.have.property('404'); + expect(result.responses).to.have.property('500'); + expect(result.responses['200'].description).to.equal('Success'); + expect(result.responses['404'].description).to.equal('Not Found'); + expect(result.responses['500'].description).to.equal( + 'Internal Server Error', + ); + }); + }); + + describe('buildResponsesFromMetadata - schema objects', () => { + it('handles plain schema objects instead of models', () => { + const schema: SchemaObject = { + type: 'object', + properties: { + id: {type: 'number'}, + name: {type: 'string'}, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema, + description: 'Success with schema', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + expect(result.responses['200'].content['application/json'].schema).to.eql( + schema, + ); + }); + + it('handles mixed models and schemas', () => { + const schema: SchemaObject = { + type: 'array', + items: {type: 'string'}, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + const responseSchema = + result.responses['200'].content['application/json'].schema; + expect(responseSchema).to.have.property('anyOf'); + expect(responseSchema.anyOf).to.have.length(2); + }); + }); + + describe('buildResponsesFromMetadata - with existing operation', () => { + it('merges with existing operation responses', () => { + const existingOperation: OperationObject = { + responses: { + '200': { + description: 'Existing response', + content: { + 'application/json': { + schema: {type: 'string'}, + }, + }, + }, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/xml', + responseModelOrSpec: {type: 'string'}, + description: 'Updated response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata, existingOperation); + + expect(result.responses['200'].description).to.equal('Updated response'); + expect(result.responses['200'].content).to.have.property( + 'application/json', + ); + expect(result.responses['200'].content).to.have.property( + 'application/xml', + ); + }); + + it('adds new response codes to existing operation', () => { + const existingOperation: OperationObject = { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: {type: 'object'}, + }, + }, + }, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 404, + contentType: 'application/json', + responseModelOrSpec: ErrorResponse, + description: 'Not Found', + }, + ]; + + const result = buildResponsesFromMetadata(metadata, existingOperation); + + expect(result.responses).to.have.property('200'); + expect(result.responses).to.have.property('404'); + expect(result.responses['200'].description).to.equal('Success'); + expect(result.responses['404'].description).to.equal('Not Found'); + }); + }); + + describe('buildResponsesFromMetadata - edge cases', () => { + it('handles empty metadata array', () => { + const metadata: ResponseDecoratorMetadata = []; + const result = buildResponsesFromMetadata(metadata); + expect(result.responses).to.eql({}); + }); + + it('handles single response with single model', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Single response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + expect(result.responses['200'].content['application/json'].schema).to.eql( + {'x-ts-type': Product}, + ); + }); + + it('handles response with reference object', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: {$ref: '#/components/schemas/Product'}, + description: 'Reference response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + expect(result.responses['200'].content['application/json'].schema).to.eql( + {$ref: '#/components/schemas/Product'}, + ); + }); + }); + + describe('buildResponsesFromMetadata - complex scenarios', () => { + it('handles multiple models for multiple content types', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Product, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: Category, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/xml', + responseModelOrSpec: {type: 'string'}, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + const jsonSchema = + result.responses['200'].content['application/json'].schema; + const xmlSchema = + result.responses['200'].content['application/xml'].schema; + + expect(jsonSchema).to.have.property('anyOf'); + expect(jsonSchema.anyOf).to.have.length(2); + expect(xmlSchema).to.eql({type: 'string'}); + }); + + it('handles array response with model items', () => { + const arraySchema: SchemaObject = { + type: 'array', + items: {'x-ts-type': Product}, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: arraySchema, + description: 'Array of products', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + const schema = result.responses['200'].content['application/json'].schema; + expect(schema.type).to.equal('array'); + expect(schema.items).to.eql({'x-ts-type': Product}); + }); + }); +}); + +// Made with Bob diff --git a/packages/openapi-v3/src/__tests__/unit/build-responses-from-metadata.unit.ts b/packages/openapi-v3/src/__tests__/unit/build-responses-from-metadata.unit.ts new file mode 100644 index 000000000000..43a2c3a02a20 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/build-responses-from-metadata.unit.ts @@ -0,0 +1,371 @@ +// Copyright IBM Corp. and LoopBack contributors 2020. 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 {buildResponsesFromMetadata} from '../../build-responses-from-metadata'; +import { + OperationObject, + ResponseDecoratorMetadata, + SchemaObject, +} from '../../types'; + +describe('build-responses-from-metadata', () => { + @model() + class TestModel extends Model { + @property() + id: number; + @property() + name: string; + } + + @model() + class AnotherModel extends Model { + @property() + value: string; + } + + describe('buildResponsesFromMetadata', () => { + it('builds a simple response with a model', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.eql({ + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: {'x-ts-type': TestModel}, + }, + }, + }, + }); + }); + + it('builds a response with a schema object', () => { + const schema: SchemaObject = { + type: 'object', + properties: { + message: {type: 'string'}, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema, + description: 'Success response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.eql({ + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: schema, + }, + }, + }, + }); + }); + + it('builds multiple responses with different status codes', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success response', + }, + { + responseCode: 404, + contentType: 'application/json', + responseModelOrSpec: {type: 'object'}, + description: 'Not found', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.have.keys('200', '404'); + expect(result.responses['200'].description).to.equal('Success response'); + expect(result.responses['404'].description).to.equal('Not found'); + }); + + it('builds response with multiple content types', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success response', + }, + { + responseCode: 200, + contentType: 'application/xml', + responseModelOrSpec: TestModel, + description: 'Success response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content).to.have.keys( + 'application/json', + 'application/xml', + ); + }); + + it('builds response with multiple models for same content type using anyOf', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success response', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: AnotherModel, + description: 'Success response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content['application/json'].schema).to.eql( + { + anyOf: [{'x-ts-type': TestModel}, {'x-ts-type': AnotherModel}], + }, + ); + }); + + it('builds response with multiple schema objects using anyOf', () => { + const schema1: SchemaObject = {type: 'string'}; + const schema2: SchemaObject = {type: 'number'}; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema1, + description: 'Success response', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema2, + description: 'Success response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content['application/json'].schema).to.eql( + { + anyOf: [schema1, schema2], + }, + ); + }); + + it('merges with existing operation responses', () => { + const existingOperation: OperationObject = { + responses: { + '200': { + description: 'Existing response', + content: { + 'text/plain': { + schema: {type: 'string'}, + }, + }, + }, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'New response', + }, + ]; + + const result = buildResponsesFromMetadata(metadata, existingOperation); + + expect(result.responses['200'].description).to.equal('New response'); + expect(result.responses['200'].content).to.have.keys( + 'text/plain', + 'application/json', + ); + }); + + it('handles empty metadata array', () => { + const metadata: ResponseDecoratorMetadata = []; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.eql({}); + }); + + it('preserves existing responses for different status codes', () => { + const existingOperation: OperationObject = { + responses: { + '404': { + description: 'Not found', + content: { + 'application/json': { + schema: {type: 'object'}, + }, + }, + }, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata, existingOperation); + + expect(result.responses).to.have.keys('200', '404'); + expect(result.responses['404'].description).to.equal('Not found'); + }); + + it('handles class decorated with @model but not extending Model', () => { + @model() + class CustomClass { + @property() + value: string; + } + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: CustomClass, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content['application/json'].schema).to.eql( + { + 'x-ts-type': CustomClass, + }, + ); + }); + + it('handles mixed models and schemas in anyOf', () => { + const schema: SchemaObject = {type: 'string'}; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: schema, + description: 'Success', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses['200'].content['application/json'].schema).to.eql( + { + anyOf: [{'x-ts-type': TestModel}, schema], + }, + ); + }); + + it('handles multiple status codes with multiple content types', () => { + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Success', + }, + { + responseCode: 200, + contentType: 'application/xml', + responseModelOrSpec: TestModel, + description: 'Success', + }, + { + responseCode: 201, + contentType: 'application/json', + responseModelOrSpec: AnotherModel, + description: 'Created', + }, + ]; + + const result = buildResponsesFromMetadata(metadata); + + expect(result.responses).to.have.keys('200', '201'); + expect(result.responses['200'].content).to.have.keys( + 'application/json', + 'application/xml', + ); + expect(result.responses['201'].content).to.have.keys('application/json'); + }); + + it('updates description when merging same status code', () => { + const existingOperation: OperationObject = { + responses: { + '200': { + description: 'Old description', + content: { + 'text/plain': { + schema: {type: 'string'}, + }, + }, + }, + }, + }; + + const metadata: ResponseDecoratorMetadata = [ + { + responseCode: 200, + contentType: 'application/json', + responseModelOrSpec: TestModel, + description: 'Updated description', + }, + ]; + + const result = buildResponsesFromMetadata(metadata, existingOperation); + + expect(result.responses['200'].description).to.equal( + 'Updated description', + ); + }); + }); +}); + +// Made with Bob diff --git a/packages/openapi-v3/src/__tests__/unit/controller-spec-advanced.unit.ts b/packages/openapi-v3/src/__tests__/unit/controller-spec-advanced.unit.ts new file mode 100644 index 000000000000..ae79097999b8 --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/controller-spec-advanced.unit.ts @@ -0,0 +1,361 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. 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 { + api, + deprecated, + get, + getControllerSpec, + getModelSchemaRef, + OperationVisibility, + param, + post, + requestBody, + response, + tags, + visibility, +} from '../..'; + +describe('controller-spec advanced tests', () => { + @model() + class Product extends Model { + @property() + id: number; + @property() + name: string; + @property() + price: number; + } + + @model() + class Category extends Model { + @property() + id: number; + @property() + title: string; + } + + describe('getModelSchemaRef', () => { + it('generates schema reference for a model', () => { + const schema = getModelSchemaRef(Product); + expect(schema).to.have.property('$ref'); + expect(schema.$ref).to.equal('#/components/schemas/Product'); + expect(schema).to.have.property('definitions'); + expect(schema.definitions).to.have.property('Product'); + }); + + it('generates schema reference with partial option', () => { + const schema = getModelSchemaRef(Product, {partial: true}); + expect(schema).to.have.property('$ref'); + expect(schema.$ref).to.match(/ProductPartial/); + }); + + it('generates schema reference with exclude option', () => { + const schema = getModelSchemaRef(Product, {exclude: ['id']}); + expect(schema).to.have.property('$ref'); + expect(schema.definitions).to.be.ok(); + }); + }); + + describe('controller with multiple decorators', () => { + it('combines class-level and method-level tags', () => { + @tags('ClassTag1', 'ClassTag2') + class TestController { + @get('/test') + @tags('MethodTag1') + testMethod() {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/test'].get; + expect(operation.tags).to.eql(['ClassTag1', 'ClassTag2', 'MethodTag1']); + }); + + it('applies class-level deprecated to all methods', () => { + @deprecated() + class TestController { + @get('/test1') + method1() {} + + @post('/test2') + method2() {} + } + + const spec = getControllerSpec(TestController); + expect(spec.paths['/test1'].get.deprecated).to.be.true(); + expect(spec.paths['/test2'].post.deprecated).to.be.true(); + }); + + it('applies class-level visibility to all methods', () => { + @visibility(OperationVisibility.UNDOCUMENTED) + class TestController { + @get('/test1') + method1() {} + + @get('/test2') + method2() {} + } + + const spec = getControllerSpec(TestController); + expect(spec.paths['/test1'].get['x-visibility']).to.equal('undocumented'); + expect(spec.paths['/test2'].get['x-visibility']).to.equal('undocumented'); + }); + + it('method-level visibility overrides class-level', () => { + @visibility(OperationVisibility.UNDOCUMENTED) + class TestController { + @get('/test') + @visibility(OperationVisibility.DOCUMENTED) + testMethod() {} + } + + const spec = getControllerSpec(TestController); + expect(spec.paths['/test'].get['x-visibility']).to.equal('documented'); + }); + }); + + describe('controller with complex responses', () => { + it('handles multiple response decorators', () => { + class TestController { + @get('/products') + @response(200, { + description: 'Success', + content: { + 'application/json': { + schema: {type: 'array', items: getModelSchemaRef(Product)}, + }, + }, + }) + @response(404, { + description: 'Not Found', + content: { + 'application/json': { + schema: {type: 'object', properties: {error: {type: 'string'}}}, + }, + }, + }) + getProducts() {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/products'].get; + expect(operation.responses).to.have.property('200'); + expect(operation.responses).to.have.property('404'); + expect(operation.responses['200'].description).to.equal('Success'); + expect(operation.responses['404'].description).to.equal('Not Found'); + }); + + it('handles response with anyOf schema', () => { + class TestController { + @get('/items') + @response(200, { + description: 'Product or Category', + content: { + 'application/json': { + schema: { + anyOf: [ + getModelSchemaRef(Product), + getModelSchemaRef(Category), + ], + }, + }, + }, + }) + getItems() {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/items'].get; + const schema = + operation.responses['200'].content['application/json'].schema; + expect(schema).to.have.property('anyOf'); + expect(schema.anyOf).to.be.Array(); + expect(schema.anyOf).to.have.length(2); + }); + }); + + describe('controller with parameters', () => { + it('handles path parameters correctly', () => { + class TestController { + @get('/products/{id}') + getProduct(@param.path.number('id') id: number) {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/products/{id}'].get; + expect(operation.parameters).to.be.Array(); + expect(operation.parameters).to.have.length(1); + expect(operation.parameters[0].name).to.equal('id'); + expect(operation.parameters[0].in).to.equal('path'); + expect(operation.parameters[0].required).to.be.true(); + }); + + it('handles query parameters with schema', () => { + class TestController { + @get('/products') + getProducts( + @param.query.string('filter') filter?: string, + @param.query.number('limit') limit?: number, + ) {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/products'].get; + expect(operation.parameters).to.have.length(2); + expect(operation.parameters[0].name).to.equal('filter'); + expect(operation.parameters[0].in).to.equal('query'); + expect(operation.parameters[1].name).to.equal('limit'); + expect(operation.parameters[1].schema?.type).to.equal('number'); + }); + + it('handles sparse parameters with dependency injection', () => { + class TestController { + @get('/products') + getProducts( + // First param is injected, not a REST parameter + injectedDep: string, + @param.query.string('name') name: string, + ) {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/products'].get; + // Should only have the REST parameter, not the injected one + expect(operation.parameters).to.have.length(1); + expect(operation.parameters[0].name).to.equal('name'); + }); + }); + + describe('controller with operation metadata', () => { + it('generates operationId from controller and method names', () => { + class ProductController { + @get('/products') + findAll() {} + } + + const spec = getControllerSpec(ProductController); + const operation = spec.paths['/products'].get; + expect(operation.operationId).to.equal('ProductController.findAll'); + expect(operation['x-operation-name']).to.equal('findAll'); + expect(operation['x-controller-name']).to.equal('ProductController'); + }); + }); + + describe('controller with @api decorator', () => { + it('uses spec from @api decorator', () => { + @api({ + basePath: '/api', + paths: { + '/custom': { + get: { + responses: { + '200': {description: 'Custom response'}, + }, + }, + }, + }, + }) + class TestController {} + + const spec = getControllerSpec(TestController); + expect(spec.basePath).to.equal('/api'); + expect(spec.paths).to.have.property('/custom'); + }); + + it('merges @api spec with method decorators', () => { + @api({ + paths: { + '/existing': { + get: { + responses: {'200': {description: 'Existing'}}, + }, + }, + }, + }) + class TestController { + @get('/new') + newMethod() {} + } + + const spec = getControllerSpec(TestController); + expect(spec.paths).to.have.property('/existing'); + expect(spec.paths).to.have.property('/new'); + }); + }); + + describe('controller with nested schemas', () => { + it('handles nested model references', () => { + @model() + class Address extends Model { + @property() + street: string; + @property() + city: string; + } + + @model() + class User extends Model { + @property() + id: number; + @property.array(Address) + addresses: Address[]; + } + + class TestController { + @post('/users') + createUser( + @requestBody({ + content: { + 'application/json': { + schema: {'x-ts-type': User}, + }, + }, + }) + user: User, + ) {} + } + + const spec = getControllerSpec(TestController); + expect(spec.components?.schemas).to.have.property('User'); + expect(spec.components?.schemas).to.have.property('Address'); + }); + }); + + describe('edge cases', () => { + it('handles controller with no methods', () => { + class EmptyController {} + + const spec = getControllerSpec(EmptyController); + expect(spec.paths).to.eql({}); + }); + + it('handles method with no parameters', () => { + class TestController { + @get('/test') + testMethod() {} + } + + const spec = getControllerSpec(TestController); + const operation = spec.paths['/test'].get; + expect(operation.parameters).to.be.undefined(); + }); + + it('caches controller spec', () => { + class TestController { + @get('/test') + testMethod() {} + } + + const spec1 = getControllerSpec(TestController); + const spec2 = getControllerSpec(TestController); + // Should return the same cached instance + expect(spec1).to.equal(spec2); + }); + }); +}); + +// Made with Bob diff --git a/packages/openapi-v3/src/__tests__/unit/generate-schema.unit.ts b/packages/openapi-v3/src/__tests__/unit/generate-schema.unit.ts index 2a0f552ff577..420759437f03 100644 --- a/packages/openapi-v3/src/__tests__/unit/generate-schema.unit.ts +++ b/packages/openapi-v3/src/__tests__/unit/generate-schema.unit.ts @@ -46,4 +46,114 @@ describe('generate-schema unit tests', () => { const schema = {foo: 'bar'}; expect(resolveSchema(String, schema)).to.eql({type: 'string', foo: 'bar'}); }); + + it('does not override existing format in schema', () => { + const schema = {type: 'string' as const, format: 'email'}; + expect(resolveSchema(String, schema)).to.eql({ + type: 'string', + format: 'email', + }); + }); + + it('merges resolved schema with existing properties', () => { + const schema = {description: 'A test string', minLength: 5}; + expect(resolveSchema(String, schema)).to.eql({ + type: 'string', + description: 'A test string', + minLength: 5, + }); + }); + + it('handles custom class with specific name', () => { + class UserModel {} + expect(resolveSchema(UserModel)).to.eql({ + $ref: '#/components/schemas/UserModel', + }); + }); + + it('handles arrow function', () => { + const fn = () => {}; + expect(resolveSchema(fn)).to.eql({ + $ref: '#/components/schemas/fn', + }); + }); + + it('preserves $ref when schema has existing properties', () => { + class Product {} + const schema = {description: 'A product'}; + expect(resolveSchema(Product, schema)).to.eql({ + $ref: '#/components/schemas/Product', + description: 'A product', + }); + }); + + it('handles undefined function with existing schema', () => { + const schema = {type: 'string' as const, description: 'Test'}; + expect(resolveSchema(undefined, schema)).to.eql({ + type: 'string', + description: 'Test', + }); + }); + + it('handles null function with existing schema', () => { + const schema = {type: 'number' as const}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(resolveSchema(null as any, schema)).to.eql({type: 'number'}); + }); + + it('returns empty object for non-function types', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(resolveSchema('string' as any)).to.eql({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(resolveSchema(123 as any)).to.eql({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(resolveSchema(true as any)).to.eql({}); + }); + + it('handles schema with multiple properties for Date', () => { + const schema = {description: 'Creation date', example: '2023-01-01'}; + expect(resolveSchema(Date, schema)).to.eql({ + type: 'string', + format: 'date-time', + description: 'Creation date', + example: '2023-01-01', + }); + }); + + it('handles schema with multiple properties for Array', () => { + const schema = {description: 'List of items', minItems: 1}; + expect(resolveSchema(Array, schema)).to.eql({ + type: 'array', + description: 'List of items', + minItems: 1, + }); + }); + + it('handles schema with multiple properties for Object', () => { + const schema = { + description: 'Configuration object', + additionalProperties: false, + }; + expect(resolveSchema(Object, schema)).to.eql({ + type: 'object', + description: 'Configuration object', + additionalProperties: false, + }); + }); + + it('handles schema with nullable property', () => { + const schema = {nullable: true}; + expect(resolveSchema(String, schema)).to.eql({ + type: 'string', + nullable: true, + }); + }); + + it('handles schema with enum values', () => { + const schema = {enum: ['active', 'inactive']}; + expect(resolveSchema(String, schema)).to.eql({ + type: 'string', + enum: ['active', 'inactive'], + }); + }); }); diff --git a/packages/openapi-v3/src/__tests__/unit/json-to-schema-advanced.unit.ts b/packages/openapi-v3/src/__tests__/unit/json-to-schema-advanced.unit.ts new file mode 100644 index 000000000000..7b662dca06fd --- /dev/null +++ b/packages/openapi-v3/src/__tests__/unit/json-to-schema-advanced.unit.ts @@ -0,0 +1,457 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. 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 {JsonSchema} from '@loopback/repository-json-schema'; +import {expect} from '@loopback/testlab'; +import { + isSchemaObject, + jsonOrBooleanToJSON, + jsonToSchemaObject, + SchemaObject, +} from '../..'; + +describe('json-to-schema advanced tests', () => { + describe('jsonToSchemaObject - complex schemas', () => { + it('handles deeply nested allOf structures', () => { + const jsonSchema: JsonSchema = { + allOf: [ + { + type: 'object', + properties: { + id: {type: 'number'}, + }, + }, + { + allOf: [ + { + type: 'object', + properties: { + name: {type: 'string'}, + }, + }, + { + type: 'object', + properties: { + email: {type: 'string'}, + }, + }, + ], + }, + ], + }; + + const result = jsonToSchemaObject(jsonSchema); + expect(result).to.have.property('allOf'); + if (isSchemaObject(result)) { + expect(result.allOf).to.be.Array(); + expect(result.allOf).to.have.length(2); + } + }); + + it('handles oneOf with multiple schemas', () => { + const jsonSchema: JsonSchema = { + oneOf: [{type: 'string'}, {type: 'number'}, {type: 'boolean'}], + }; + + const result = jsonToSchemaObject(jsonSchema); + expect(result).to.have.property('oneOf'); + if (isSchemaObject(result)) { + expect(result.oneOf).to.have.length(3); + } + }); + + it('converts $ref from definitions to components/schemas', () => { + const jsonSchema: JsonSchema = { + $ref: '#/definitions/MyModel', + }; + + const result = jsonToSchemaObject(jsonSchema); + expect(result.$ref).to.equal('#/components/schemas/MyModel'); + }); + + it('handles nested $refs in properties', () => { + const jsonSchema: JsonSchema = { + type: 'object', + properties: { + user: {$ref: '#/definitions/User'}, + address: {$ref: '#/definitions/Address'}, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result) && result.properties) { + expect(result.properties.user).to.have.property('$ref'); + expect(result.properties.user.$ref).to.equal( + '#/components/schemas/User', + ); + expect(result.properties.address.$ref).to.equal( + '#/components/schemas/Address', + ); + } + }); + }); + + describe('jsonToSchemaObject - array handling', () => { + it('handles array type with items', () => { + const jsonSchema: JsonSchema = { + type: 'array', + items: {type: 'string'}, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.type).to.equal('array'); + expect(result.items).to.eql({type: 'string'}); + } + }); + + it('handles array with multiple item types (takes first)', () => { + const jsonSchema: JsonSchema = { + type: 'array', + items: [{type: 'string'}, {type: 'number'}], + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.type).to.equal('array'); + expect(result.items).to.eql({type: 'string'}); + } + }); + + it('throws error for array without items', () => { + const jsonSchema: JsonSchema = { + type: 'array', + }; + + expect(() => jsonToSchemaObject(jsonSchema)).to.throw( + '"items" property must be present if "type" is an array', + ); + }); + + it('handles array with complex item schema', () => { + const jsonSchema: JsonSchema = { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'number'}, + name: {type: 'string'}, + }, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if ( + isSchemaObject(result) && + result.items && + isSchemaObject(result.items) + ) { + expect(result.items.type).to.equal('object'); + expect(result.items).to.have.property('properties'); + } + }); + }); + + describe('jsonToSchemaObject - additionalProperties', () => { + it('handles boolean additionalProperties', () => { + const jsonSchema: JsonSchema = { + type: 'object', + additionalProperties: true, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.additionalProperties).to.be.true(); + } + }); + + it('handles schema additionalProperties', () => { + const jsonSchema: JsonSchema = { + type: 'object', + additionalProperties: {type: 'string'}, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.additionalProperties).to.eql({type: 'string'}); + } + }); + + it('handles complex additionalProperties schema', () => { + const jsonSchema: JsonSchema = { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + value: {type: 'number'}, + }, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if ( + isSchemaObject(result) && + result.additionalProperties && + typeof result.additionalProperties !== 'boolean' && + isSchemaObject(result.additionalProperties) + ) { + expect(result.additionalProperties.type).to.equal('object'); + expect(result.additionalProperties).to.have.property('properties'); + } + }); + }); + + describe('jsonToSchemaObject - definitions', () => { + it('converts definitions to components/schemas format', () => { + const jsonSchema: JsonSchema = { + type: 'object', + definitions: { + User: { + type: 'object', + properties: { + id: {type: 'number'}, + name: {type: 'string'}, + }, + }, + Address: { + type: 'object', + properties: { + street: {type: 'string'}, + }, + }, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result).to.have.property('definitions'); + expect(result.definitions).to.have.property('User'); + expect(result.definitions).to.have.property('Address'); + } + }); + + it('handles nested definitions with references', () => { + const jsonSchema: JsonSchema = { + definitions: { + Parent: { + type: 'object', + properties: { + child: {$ref: '#/definitions/Child'}, + }, + }, + Child: { + type: 'object', + properties: { + name: {type: 'string'}, + }, + }, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if ( + isSchemaObject(result) && + result.definitions && + isSchemaObject(result.definitions.Parent) && + result.definitions.Parent.properties + ) { + expect(result.definitions.Parent.properties.child).to.have.property( + '$ref', + ); + expect(result.definitions.Parent.properties.child.$ref).to.equal( + '#/components/schemas/Child', + ); + } + }); + }); + + describe('jsonToSchemaObject - type handling', () => { + it('handles array of types (takes first)', () => { + const jsonSchema: JsonSchema = { + type: ['string', 'null'], + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.type).to.equal('string'); + } + }); + + it('handles single type', () => { + const jsonSchema: JsonSchema = { + type: 'number', + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.type).to.equal('number'); + } + }); + }); + + describe('jsonToSchemaObject - examples', () => { + it('converts examples array to single example', () => { + const jsonSchema: JsonSchema = { + type: 'string', + examples: ['example1', 'example2', 'example3'], + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result).to.have.property('example'); + expect(result.example).to.equal('example1'); + expect(result).to.not.have.property('examples'); + } + }); + + it('handles empty examples array', () => { + const jsonSchema: JsonSchema = { + type: 'string', + examples: [], + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.example).to.be.undefined(); + } + }); + }); + + describe('jsonToSchemaObject - TypeScript type extraction', () => { + it('extracts x-typescript-type from description', () => { + const jsonSchema: JsonSchema = { + type: 'object', + description: '(tsType: MyCustomType, schemaOptions: {...})', + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result).to.have.property('x-typescript-type', 'MyCustomType'); + } + }); + + it('preserves description without tsType', () => { + const jsonSchema: JsonSchema = { + type: 'object', + description: 'This is a regular description', + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.description).to.equal('This is a regular description'); + expect(result).to.not.have.property('x-typescript-type'); + } + }); + }); + + describe('jsonToSchemaObject - circular references', () => { + it('handles circular references with visited map', () => { + const jsonSchema: JsonSchema = { + title: 'Node', + type: 'object', + properties: { + value: {type: 'string'}, + children: { + type: 'array', + items: {$ref: '#/definitions/Node'}, + }, + }, + definitions: { + Node: { + title: 'Node', + type: 'object', + properties: { + value: {type: 'string'}, + }, + }, + }, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result).to.have.property('properties'); + expect(result.properties).to.have.property('children'); + } + }); + }); + + describe('jsonToSchemaObject - ignored properties', () => { + it('ignores additionalItems property', () => { + const jsonSchema: JsonSchema = { + type: 'array', + items: {type: 'string'}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + additionalItems: {type: 'number'} as any, + }; + + const result = jsonToSchemaObject(jsonSchema); + expect(result).to.not.have.property('additionalItems'); + }); + }); + + describe('jsonOrBooleanToJSON', () => { + it('returns object as-is', () => { + const schema: JsonSchema = {type: 'string'}; + const result = jsonOrBooleanToJSON(schema); + expect(result).to.equal(schema); + }); + + it('converts true to empty object', () => { + const result = jsonOrBooleanToJSON(true); + expect(result).to.eql({}); + }); + + it('converts false to not schema', () => { + const result = jsonOrBooleanToJSON(false); + expect(result).to.eql({not: {}}); + }); + }); + + describe('jsonToSchemaObject - property preservation', () => { + it('preserves standard OpenAPI properties', () => { + const jsonSchema: JsonSchema = { + type: 'string', + title: 'Test Title', + description: 'Test Description', + default: 'default value', + enum: ['value1', 'value2'], + format: 'email', + pattern: '^[a-z]+$', + minLength: 5, + maxLength: 50, + }; + + const result = jsonToSchemaObject(jsonSchema) as SchemaObject; + if (isSchemaObject(result)) { + expect(result.title).to.equal('Test Title'); + expect(result.description).to.equal('Test Description'); + expect(result.default).to.equal('default value'); + expect(result.enum).to.eql(['value1', 'value2']); + expect(result.format).to.equal('email'); + expect(result.pattern).to.equal('^[a-z]+$'); + expect(result.minLength).to.equal(5); + expect(result.maxLength).to.equal(50); + } + }); + + it('preserves numeric constraints', () => { + const jsonSchema: JsonSchema = { + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 5, + }; + + const result = jsonToSchemaObject(jsonSchema); + if (isSchemaObject(result)) { + expect(result.minimum).to.equal(0); + expect(result.maximum).to.equal(100); + expect(result.multipleOf).to.equal(5); + } + }); + }); +}); + +// Made with Bob