diff --git a/docs/.vuepress/public/vite-plugin-ssr-tsed.png b/docs/.vuepress/public/vite-tsed.png similarity index 100% rename from docs/.vuepress/public/vite-plugin-ssr-tsed.png rename to docs/.vuepress/public/vite-tsed.png diff --git a/docs/docs/model.md b/docs/docs/model.md index 81aac30f5b3..ce52f56dfd9 100644 --- a/docs/docs/model.md +++ b/docs/docs/model.md @@ -112,6 +112,131 @@ json type or when you use a mixed TypeScript types. +You can also use @@Any@@ decorator to allow all types: + + + + +```typescript +import {Any} from "@tsed/schema"; + +export class Model { + @Any() + prop: any; +} +``` + + + + +```json +{ + "properties": { + "prop": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "integer", + "multipleOf": 1 + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + } + }, + "type": "object" +} +``` + + + + +```json +{ + "properties": { + "prop": { + "nullable": true, + "anyOf": [ + { + "type": "integer", + "multipleOf": 1 + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + } + }, + "type": "object" +} +``` + + + + +Since v7.75.0, when you use @@Any@@ decorator combined with other decorators like @@MinLength@@, @@Minimum@@, etc. metadata will be automatically assigned to the right +type. For example, if you add a @@Minimum@@ decorator, it will be assigned to the number type. + +```ts +import {Any} from "@tsed/schema"; + +class Model { + @Any(String, Number) + @Minimum(0) + @MaxLength(100) + prop: string | number; +} +``` + +Produce a json-schema as follows: + +```json +{ + "properties": { + "prop": { + "allOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "number", + "minimum": 0 + } + ] + } + }, + "type": "object" +} +``` + ## Nullable The @@Nullable@@ decorator is used allow a null value on a field while preserving the original Typescript type. @@ -189,62 +314,50 @@ class NullableModel { `returnsCoercedValue` will become true by default in the next major version of Ts.ED. ::: -## Any - -The @@Any@@ decorator is used to allow any types: +## Nullable and mixed types - - +The @@Nullable@@ decorator can be used with Tuple types: -```typescript -import {Any} from "@tsed/schema"; +```ts +import {Nullable} from "@tsed/schema"; -export class Model { - @Any() - prop: any; +class Model { + @Nullable(String, Number) + prop: string | number | null; } ``` - - +Since v7.75.0, when you use @@Nullable@@ decorator combined with other decorators like @@MinLength@@, @@Minimum@@, etc. metadata will be automatically assigned to the right +type. For example, if you add a @@Minimum@@ decorator, it will be assigned to the number type. -```json -{ - "properties": { - "prop": { - "type": ["integer", "number", "string", "boolean", "array", "object", "null"] - } - }, - "type": "object" +```ts +import {Nullable} from "@tsed/schema"; + +class Model { + @Nullable(String, Number) + @Minimum(0) + @MaxLength(100) + prop: string | number | null; } ``` - - +Produce a json-schema as follows: ```json { "properties": { "prop": { - "nullable": true, - "oneOf": [ + "anyOf": [ { - "type": "integer" + "type": "null" }, { - "type": "number" + "type": "string", + "maxLength": 100 }, { - "type": "string" - }, - { - "type": "boolean" - }, - { - "type": "array" - }, - { - "type": "object" + "type": "number", + "minimum": 0 } ] } @@ -253,9 +366,6 @@ export class Model { } ``` - - - ## Regular expressions The @@Pattern@@ decorator is used to restrict a string to a particular regular expression. The regular expression syntax diff --git a/docs/docs/snippets/model/any-types.json b/docs/docs/snippets/model/any-types.json index cc0cd5deca6..31c463b5878 100644 --- a/docs/docs/snippets/model/any-types.json +++ b/docs/docs/snippets/model/any-types.json @@ -1,14 +1,53 @@ { - "definitions": {}, "properties": { "prop1": { - "type": ["integer", "number", "string", "boolean", "array", "object", "null"] + "allOf": [ + { + "type": "integer", + "minLength": 1 + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object" + }, + { + "type": "null" + } + ] }, "prop2": { - "type": ["string", "number", "boolean"] + "allOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "boolean" + } + ] }, "prop3": { - "type": ["string", "null"] + "allOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "type": "object" diff --git a/packages/orm/mongoose/src/decorators/virtualRef.spec.ts b/packages/orm/mongoose/src/decorators/virtualRef.spec.ts index 8e20073a277..ebe167ac8fa 100644 --- a/packages/orm/mongoose/src/decorators/virtualRef.spec.ts +++ b/packages/orm/mongoose/src/decorators/virtualRef.spec.ts @@ -201,7 +201,7 @@ describe("@VirtualRef()", () => { type: "number" }, members: { - oneOf: [ + anyOf: [ { $ref: "#/components/schemas/TestPerson" } diff --git a/packages/orm/mongoose/test/enums.integration.spec.ts b/packages/orm/mongoose/test/enums.integration.spec.ts index 4b68915e9ad..1856a7a3206 100644 --- a/packages/orm/mongoose/test/enums.integration.spec.ts +++ b/packages/orm/mongoose/test/enums.integration.spec.ts @@ -47,8 +47,7 @@ describe("Enums integration", () => { ResponseTimeThreshold: { properties: { status: { - $ref: "#/definitions/ComponentStatuses", - minLength: 1 + $ref: "#/definitions/ComponentStatuses" } }, required: ["status"], diff --git a/packages/platform/platform-express/test/__snapshots__/array-body.spec.ts.snap b/packages/platform/platform-express/test/__snapshots__/array-body.spec.ts.snap index 201be2cd67f..68a68f192dc 100644 --- a/packages/platform/platform-express/test/__snapshots__/array-body.spec.ts.snap +++ b/packages/platform/platform-express/test/__snapshots__/array-body.spec.ts.snap @@ -29,9 +29,9 @@ Object { "application/json": Object { "schema": Object { "items": Object { - "nullable": true, - "oneOf": Array [ + "anyOf": Array [ Object { + "multipleOf": 1, "type": "integer", }, Object { @@ -50,6 +50,7 @@ Object { "type": "object", }, ], + "nullable": true, }, "type": "array", }, @@ -76,9 +77,9 @@ Object { "application/json": Object { "schema": Object { "items": Object { - "nullable": true, - "oneOf": Array [ + "anyOf": Array [ Object { + "multipleOf": 1, "type": "integer", }, Object { @@ -97,6 +98,7 @@ Object { "type": "object", }, ], + "nullable": true, }, "type": "array", }, @@ -200,9 +202,9 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "nullable": true, - "oneOf": Array [ + "anyOf": Array [ Object { + "multipleOf": 1, "type": "integer", }, Object { @@ -221,6 +223,7 @@ Object { "type": "object", }, ], + "nullable": true, }, }, }, diff --git a/packages/platform/platform-express/test/__snapshots__/discriminator.spec.ts.snap b/packages/platform/platform-express/test/__snapshots__/discriminator.spec.ts.snap index f8c3ef4eae1..e95b0b89011 100644 --- a/packages/platform/platform-express/test/__snapshots__/discriminator.spec.ts.snap +++ b/packages/platform/platform-express/test/__snapshots__/discriminator.spec.ts.snap @@ -30,6 +30,7 @@ Object { "ActionPartial": Object { "properties": Object { "event": Object { + "minLength": 1, "type": "string", }, "type": Object { @@ -71,6 +72,7 @@ Object { "CustomActionPartial": Object { "properties": Object { "event": Object { + "minLength": 1, "type": "string", }, "meta": Object { @@ -112,6 +114,7 @@ Object { "type": "string", }, "url": Object { + "minLength": 1, "type": "string", }, "value": Object { @@ -203,7 +206,6 @@ Object { "discriminator": Object { "propertyName": "type", }, - "nullable": true, "oneOf": Array [ Object { "$ref": "#/components/schemas/PageView", diff --git a/packages/platform/platform-params/src/decorators/useType.ts b/packages/platform/platform-params/src/decorators/useType.ts index 6067ab9dcde..7b2b8c6fe55 100644 --- a/packages/platform/platform-params/src/decorators/useType.ts +++ b/packages/platform/platform-params/src/decorators/useType.ts @@ -1,7 +1,15 @@ import {Type} from "@tsed/core"; -import {Any, CollectionOf} from "@tsed/schema"; +import {Any, CollectionOf, type JsonParameterStore} from "@tsed/schema"; import {ParamFn} from "./paramFn.js"; +function shouldFallBackToAny(entity: JsonParameterStore) { + if (entity.itemSchema.has("allOf") || entity.itemSchema.has("anyOf") || entity.itemSchema.has("oneOf")) { + return false; + } + + return entity.isCollection && entity.type === Object && [undefined, "object"].includes(entity.itemSchema.get("type")); +} + /** * Set the type of the item collection. * @@ -19,7 +27,7 @@ export function UseType(useType: undefined | any | Type) { return CollectionOf(useType); } - if (entity.isCollection && entity.type === Object && [undefined, "object"].includes(entity.itemSchema.get("type"))) { + if (shouldFallBackToAny(entity)) { Any()(...parameters); } }); diff --git a/packages/platform/platform-params/src/pipes/ValidationPipe.spec.ts b/packages/platform/platform-params/src/pipes/ValidationPipe.spec.ts index 9efb8452e0a..fc8c8f795ba 100644 --- a/packages/platform/platform-params/src/pipes/ValidationPipe.spec.ts +++ b/packages/platform/platform-params/src/pipes/ValidationPipe.spec.ts @@ -1,6 +1,6 @@ import {PlatformTest, Post} from "@tsed/common"; import {catchAsyncError} from "@tsed/core"; -import {CollectionOf, getSpec, JsonParameterStore, Required, SpecTypes} from "@tsed/schema"; +import {AllOf, AnyOf, CollectionOf, getSpec, JsonParameterStore, OneOf, Property, Required, SpecTypes} from "@tsed/schema"; import {BodyParams} from "../decorators/bodyParams.js"; import {PathParams} from "../decorators/pathParams.js"; import {QueryParams} from "../decorators/queryParams.js"; @@ -61,6 +61,342 @@ describe("ValidationPipe", () => { }); expect(result).toEqual("value"); }); + it("should return value (Body with array param)", async () => { + const validator = await PlatformTest.invoke(ValidationPipe); + // @ts-ignore + validator.validator = undefined; + + class Test { + @Post("/") + test(@BodyParams() type: any[]) {} + } + + // WHEN + const param = JsonParameterStore.get(Test, "test", 0); + const result = await validator.transform("value", param); + + // THEN + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + items: { + anyOf: [ + { + multipleOf: 1, + type: "integer" + }, + { + type: "number" + }, + { + type: "string" + }, + { + type: "boolean" + }, + { + type: "array" + }, + { + type: "object" + } + ], + nullable: true + }, + type: "array" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); + expect(result).toEqual("value"); + }); + it("should return value (Body with array oneOf)", async () => { + const validator = await PlatformTest.invoke(ValidationPipe); + // @ts-ignore + validator.validator = undefined; + + class Model { + @Property() + id: string; + } + + class Model2 { + @Property() + id: string; + } + + class Test { + @Post("/") + test(@BodyParams() @OneOf(Model, Model2) type: any[]) {} + } + + // WHEN + const param = JsonParameterStore.get(Test, "test", 0); + const result = await validator.transform("value", param); + + // THEN + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + components: { + schemas: { + Model: { + properties: { + id: { + type: "string" + } + }, + type: "object" + }, + Model2: { + properties: { + id: { + type: "string" + } + }, + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + items: { + oneOf: [ + { + $ref: "#/components/schemas/Model" + }, + { + $ref: "#/components/schemas/Model2" + } + ] + }, + type: "array" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); + expect(result).toEqual("value"); + }); + it("should return value (Body with array anyOf)", async () => { + const validator = await PlatformTest.invoke(ValidationPipe); + // @ts-ignore + validator.validator = undefined; + + class Model { + @Property() + id: string; + } + + class Model2 { + @Property() + id: string; + } + + class Test { + @Post("/") + test(@BodyParams() @AnyOf(Model, Model2) type: any[]) {} + } + + // WHEN + const param = JsonParameterStore.get(Test, "test", 0); + const result = await validator.transform("value", param); + + // THEN + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + components: { + schemas: { + Model: { + properties: { + id: { + type: "string" + } + }, + type: "object" + }, + Model2: { + properties: { + id: { + type: "string" + } + }, + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + items: { + anyOf: [ + { + $ref: "#/components/schemas/Model" + }, + { + $ref: "#/components/schemas/Model2" + } + ] + }, + type: "array" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); + expect(result).toEqual("value"); + }); + it("should return value (Body with array allOf)", async () => { + const validator = await PlatformTest.invoke(ValidationPipe); + // @ts-ignore + validator.validator = undefined; + + class Model { + @Property() + id: string; + } + + class Model2 { + @Property() + id: string; + } + + class Test { + @Post("/") + test(@BodyParams() @AllOf(Model, Model2) type: any[]) {} + } + + // WHEN + const param = JsonParameterStore.get(Test, "test", 0); + const result = await validator.transform("value", param); + + // THEN + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + components: { + schemas: { + Model: { + properties: { + id: { + type: "string" + } + }, + type: "object" + }, + Model2: { + properties: { + id: { + type: "string" + } + }, + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + items: { + allOf: [ + { + $ref: "#/components/schemas/Model" + }, + { + $ref: "#/components/schemas/Model2" + } + ] + }, + type: "array" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); + expect(result).toEqual("value"); + }); it("should skip validation for array path params", async () => { const validator = await PlatformTest.invoke(ValidationPipe); // @ts-ignore @@ -206,7 +542,11 @@ describe("ValidationPipe", () => { // THEN expect(result).toEqual("1"); // @ts-ignore - expect(validator.validator.validate).toHaveBeenCalledWith("1", {collectionType: undefined, schema: {type: "string"}, type: undefined}); + expect(validator.validator.validate).toHaveBeenCalledWith("1", { + collectionType: undefined, + schema: {type: "string", minLength: 1}, + type: undefined + }); }); it("should cast string to array", async () => { const validator = await PlatformTest.invoke(ValidationPipe); diff --git a/packages/specs/schema/jest.config.js b/packages/specs/schema/jest.config.js index 178c8afed95..35253a72613 100644 --- a/packages/specs/schema/jest.config.js +++ b/packages/specs/schema/jest.config.js @@ -8,7 +8,7 @@ module.exports = { coverageThreshold: { global: { statements: 99.41, - branches: 96.21, + branches: 96.07, functions: 100, lines: 99.41 } diff --git a/packages/specs/schema/src/constants/jsonSchemaProperties.ts b/packages/specs/schema/src/constants/jsonSchemaProperties.ts new file mode 100644 index 00000000000..004d845a6ca --- /dev/null +++ b/packages/specs/schema/src/constants/jsonSchemaProperties.ts @@ -0,0 +1,17 @@ +export const MANY_OF_PROPERTIES = ["oneOf", "allOf", "anyOf"]; +export const STRING_PROPERTIES = ["minLength", "maxLength", "pattern", "format"]; +export const BOOLEAN_PROPERTIES = []; +export const NUMBER_PROPERTIES = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]; +export const ARRAY_PROPERTIES = ["maxItems", "minItems", "uniqueItems", "items", "contains", "maxContains", "minContains"]; +export const OBJECT_PROPERTIES = [ + "maxItems", + "minItems", + "uniqueItems", + "items", + "contains", + "maxContains", + "minContains", + "patternProperties", + "dependencies" +]; +export const COMMON_PROPERTIES = ["const", "enum"]; diff --git a/packages/specs/schema/src/decorators/common/allow.spec.ts b/packages/specs/schema/src/decorators/common/allow.spec.ts index fb0be426a84..7ae328976a9 100644 --- a/packages/specs/schema/src/decorators/common/allow.spec.ts +++ b/packages/specs/schema/src/decorators/common/allow.spec.ts @@ -68,8 +68,15 @@ describe("@Allow", () => { expect(classSchema).toEqual({ properties: { allow: { - minLength: 1, - type: ["null", "string"] + anyOf: [ + { + type: "null" + }, + { + minLength: 1, + type: "string" + } + ] } }, required: ["allow"], @@ -108,7 +115,7 @@ describe("@Allow", () => { }, properties: { allow: { - oneOf: [ + anyOf: [ { type: "null" }, diff --git a/packages/specs/schema/src/decorators/common/allow.ts b/packages/specs/schema/src/decorators/common/allow.ts index 600444eeddb..8d4ef95319e 100644 --- a/packages/specs/schema/src/decorators/common/allow.ts +++ b/packages/specs/schema/src/decorators/common/allow.ts @@ -35,8 +35,6 @@ export function Allow(...values: any[]) { return useDecorators( model && Property(model), JsonEntityFn((store, args) => { - store.schema.allow(...values); - if (store.decoratorType === DecoratorTypes.PARAM) { (store as JsonParameterStore).required = true; } @@ -44,6 +42,8 @@ export function Allow(...values: any[]) { if (store.decoratorType === DecoratorTypes.PROP) { store.parentSchema.addRequired(store.propertyName); } + + store.schema.allow(...values); }) ); } diff --git a/packages/specs/schema/src/decorators/common/any.spec.ts b/packages/specs/schema/src/decorators/common/any.spec.ts index d2efb87f73d..1d86c333fc6 100644 --- a/packages/specs/schema/src/decorators/common/any.spec.ts +++ b/packages/specs/schema/src/decorators/common/any.spec.ts @@ -13,7 +13,30 @@ describe("@Any", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop: { - type: ["null", "integer", "number", "string", "boolean", "array", "object"] + anyOf: [ + { + type: "null" + }, + { + multipleOf: 1, + type: "integer" + }, + { + type: "number" + }, + { + type: "string" + }, + { + type: "boolean" + }, + { + type: "array" + }, + { + type: "object" + } + ] } }, type: "object" @@ -47,7 +70,20 @@ describe("@Any", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop: { - type: ["null", "string", "number", "boolean"] + anyOf: [ + { + type: "null" + }, + { + type: "string" + }, + { + type: "number" + }, + { + type: "boolean" + } + ] } }, type: "object" @@ -64,7 +100,14 @@ describe("@Any", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop: { - type: ["null", "string"] + anyOf: [ + { + type: "null" + }, + { + type: "string" + } + ] } }, type: "object" diff --git a/packages/specs/schema/src/decorators/common/enum.spec.ts b/packages/specs/schema/src/decorators/common/enum.spec.ts index 85b15fce3fd..1527c047400 100644 --- a/packages/specs/schema/src/decorators/common/enum.spec.ts +++ b/packages/specs/schema/src/decorators/common/enum.spec.ts @@ -1,4 +1,3 @@ -import Ajv from "ajv"; import {enums} from "../../utils/from.js"; import {getJsonSchema} from "../../utils/getJsonSchema.js"; import {Enum} from "./enum.js"; @@ -32,8 +31,16 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ properties: { num: { - enum: ["0", "1", 10], - type: ["string", "number"] + anyOf: [ + { + enum: ["0", "1"], + type: "string" + }, + { + enum: [10], + type: "number" + } + ] } }, type: "object" @@ -49,8 +56,47 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ properties: { num: { - enum: ["0", "1", 10, null], - type: ["null", "string", "number"] + anyOf: [ + { + type: "null" + }, + { + enum: ["0", "1"], + type: "string" + }, + { + enum: [10], + type: "number" + } + ] + } + }, + type: "object" + }); + }); + it("should declare prop (mixed type, object, and null)", () => { + // WHEN + class Model { + @Enum("0", "1", 10, {test: "test"}, null) + num: string | number; + } + + expect(getJsonSchema(Model)).toEqual({ + properties: { + num: { + anyOf: [ + { + type: "null" + }, + { + enum: ["0", "1", "test"], + type: "string" + }, + { + enum: [10], + type: "number" + } + ] } }, type: "object" @@ -125,8 +171,16 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ properties: { num: { - enum: [0, "test", "test2"], - type: ["number", "string"] + anyOf: [ + { + enum: [0], + type: "number" + }, + { + enum: ["test", "test2"], + type: "string" + } + ] } }, type: "object" @@ -152,8 +206,16 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ definitions: { SomeEnum: { - enum: [0, "test", "test2"], - type: ["number", "string"] + anyOf: [ + { + enum: [0], + type: "number" + }, + { + enum: ["test", "test2"], + type: "string" + } + ] } }, properties: { @@ -184,8 +246,16 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ definitions: { SomeEnum: { - enum: [0, "test", "test2"], - type: ["number", "string"] + anyOf: [ + { + enum: [0], + type: "number" + }, + { + enum: ["test", "test2"], + type: "string" + } + ] } }, properties: { @@ -197,7 +267,6 @@ describe("@Enum", () => { }); }); }); - describe("when is a typescript enum schema without label (set enum)", () => { it("should inline enum", () => { enum SomeEnum { @@ -217,8 +286,16 @@ describe("@Enum", () => { expect(getJsonSchema(Model)).toEqual({ properties: { num: { - enum: [0, "test", "test2"], - type: ["number", "string"] + anyOf: [ + { + enum: [0], + type: "number" + }, + { + enum: ["test", "test2"], + type: "string" + } + ] } }, type: "object" diff --git a/packages/specs/schema/src/decorators/common/groups.spec.ts b/packages/specs/schema/src/decorators/common/groups.spec.ts index 56a5f827926..ad02e311416 100644 --- a/packages/specs/schema/src/decorators/common/groups.spec.ts +++ b/packages/specs/schema/src/decorators/common/groups.spec.ts @@ -405,6 +405,7 @@ describe("@Groups", () => { type: "string" }, password: { + minLength: 1, type: "string" } }, @@ -419,9 +420,11 @@ describe("@Groups", () => { expect(spec2).toEqual({ properties: { email: { + minLength: 1, type: "string" }, firstName: { + minLength: 1, type: "string" }, id: { @@ -429,6 +432,7 @@ describe("@Groups", () => { type: "string" }, lastName: { + minLength: 1, type: "string" } }, diff --git a/packages/specs/schema/src/decorators/common/nullable.spec.ts b/packages/specs/schema/src/decorators/common/nullable.spec.ts index 1e8c4778f72..ad7b9efd0d9 100644 --- a/packages/specs/schema/src/decorators/common/nullable.spec.ts +++ b/packages/specs/schema/src/decorators/common/nullable.spec.ts @@ -6,6 +6,9 @@ import {getSpec} from "../../utils/getSpec.js"; import {In} from "../operations/in.js"; import {Path} from "../operations/path.js"; import {Post} from "../operations/route.js"; +import {Format} from "./format.js"; +import {MaxLength} from "./maxLength.js"; +import {Minimum} from "./minimum.js"; import {Nullable} from "./nullable.js"; import {Property} from "./property.js"; import {Required} from "./required.js"; @@ -20,16 +23,85 @@ describe("@Nullable", () => { } // THEN - expect(getJsonSchema(Model)).toEqual({ + const schema = getJsonSchema(Model); + + expect(schema).toEqual({ properties: { prop2: { - type: ["null", "string"], - minLength: 1 + anyOf: [ + { + type: "null" + }, + { + type: "string", + minLength: 1 + } + ] } }, required: ["prop2"], type: "object" }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {prop2: null})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: "test"})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: 1})).toBeFalsy(); + expect(ajv.validate(schema, {prop2: ""})).toBeFalsy(); + + @Path("/") + class Test { + @Post("/") + test(@BodyParams() model: Model) {} + } + + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + components: { + schemas: { + Model: { + properties: { + prop2: { + minLength: 1, + type: "string", + nullable: true + } + }, + required: ["prop2"], + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Model" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); }); it("should declare any prop (String + Required + Nullable)", () => { // WHEN @@ -48,12 +120,57 @@ describe("@Nullable", () => { expect(schema).toEqual({ properties: { prop2: { - type: ["null", "string"] + anyOf: [ + { + type: "null" + }, + { + type: "string" + } + ] } }, required: ["prop2"], type: "object" }); + + @Path("/") + class Test { + @Post("/") + test(@BodyParams() model: Model) {} + } + + expect(getSpec(Test, {specType: SpecTypes.OPENAPI})).toEqual({ + components: { + schemas: { + Model: { + properties: { + prop2: { + type: "string", + nullable: true + } + }, + required: ["prop2"], + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: {"application/json": {schema: {$ref: "#/components/schemas/Model"}}}, + required: false + }, + responses: {"200": {description: "Success"}}, + tags: ["Test"] + } + } + }, + tags: [{name: "Test"}] + }); }); it("should declare any prop (String + Nullable)", () => { // WHEN @@ -66,7 +183,14 @@ describe("@Nullable", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop2: { - type: ["null", "string"] + anyOf: [ + { + type: "null" + }, + { + type: "string" + } + ] } }, type: "object" @@ -80,20 +204,40 @@ describe("@Nullable", () => { } // THEN - expect(getJsonSchema(Model)).toEqual({ + const schema = getJsonSchema(Model); + expect(schema).toEqual({ properties: { prop2: { - type: ["null", "string", "number"] + anyOf: [ + { + type: "null" + }, + { + type: "string" + }, + { + type: "number" + } + ] } }, type: "object" }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {prop2: null})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: "test"})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: 1})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: false})).toBeFalsy(); }); it("should declare any prop (String & Number + Required + Nullable)", () => { // WHEN class Model { @Required(true, null, "") @Nullable(String, Number) + @MaxLength(10) + @Minimum(0) prop2: number | string | null; } @@ -101,7 +245,19 @@ describe("@Nullable", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop2: { - type: ["null", "string", "number"] + anyOf: [ + { + type: "null" + }, + { + maxLength: 10, + type: "string" + }, + { + minimum: 0, + type: "number" + } + ] } }, required: ["prop2"], @@ -112,6 +268,7 @@ describe("@Nullable", () => { // WHEN class Model { @Nullable(Date) + @Format("date-time") prop2: Date | null; } @@ -119,7 +276,15 @@ describe("@Nullable", () => { expect(getJsonSchema(Model)).toEqual({ properties: { prop2: { - type: ["null", "string"] + anyOf: [ + { + type: "null" + }, + { + format: "date-time", + type: "string" + } + ] } }, type: "object" @@ -137,8 +302,71 @@ describe("@Nullable", () => { prop2: Nested | null; } + @Path("/") + class Test { + @Post("/") + test(@BodyParams() model: Model) {} + } + // THEN - expect(getJsonSchema(Model)).toEqual({ + expect(getSpec(Test)).toEqual({ + components: { + schemas: { + Model: { + properties: { + prop2: { + anyOf: [ + { + $ref: "#/components/schemas/Nested" + } + ], + nullable: true + } + }, + type: "object" + }, + Nested: { + properties: { + id: { + type: "string" + } + }, + type: "object" + } + } + }, + paths: { + "/": { + post: { + operationId: "testTest", + parameters: [], + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Model" + } + } + }, + required: false + }, + responses: { + "200": { + description: "Success" + } + }, + tags: ["Test"] + } + } + }, + tags: [ + { + name: "Test" + } + ] + }); + const schema = getJsonSchema(Model); + expect(schema).toEqual({ definitions: { Nested: { properties: { @@ -151,7 +379,7 @@ describe("@Nullable", () => { }, properties: { prop2: { - oneOf: [ + anyOf: [ { type: "null" }, @@ -163,6 +391,11 @@ describe("@Nullable", () => { }, type: "object" }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {prop2: null})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: {id: "id"}})).toBeTruthy(); }); it("should declare any prop (many Models + Nullable + JsonSchema)", () => { // WHEN @@ -188,12 +421,7 @@ describe("@Nullable", () => { } const schema = getJsonSchema(Model); - const ajv = new Ajv({strict: true}); - ajv.validate(schema, {prop2: null}); - - expect(ajv.errors).toBe(null); - // THEN expect(schema).toEqual({ definitions: { Nested1: { @@ -221,7 +449,7 @@ describe("@Nullable", () => { }, properties: { prop2: { - oneOf: [ + anyOf: [ { type: "null" }, @@ -236,6 +464,12 @@ describe("@Nullable", () => { }, type: "object" }); + + const ajv = new Ajv({strict: true}); + + ajv.validate(schema, {prop2: null}); + + expect(ajv.errors).toBe(null); }); it("should declare any prop (many Models + Nullable + OS3)", () => { // WHEN @@ -273,7 +507,7 @@ describe("@Nullable", () => { Model: { properties: { prop2: { - oneOf: [ + anyOf: [ { $ref: "#/components/schemas/Nested1" }, @@ -340,6 +574,56 @@ describe("@Nullable", () => { } ] }); + + const schema = getJsonSchema(Model); + + expect(schema).toEqual({ + definitions: { + Nested1: { + properties: { + id: { + type: "string" + }, + top: { + type: "string" + } + }, + type: "object" + }, + Nested2: { + properties: { + id: { + type: "string" + }, + other: { + type: "string" + } + }, + type: "object" + } + }, + properties: { + prop2: { + anyOf: [ + { + type: "null" + }, + { + $ref: "#/definitions/Nested1" + }, + { + $ref: "#/definitions/Nested2" + } + ] + } + }, + type: "object" + }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {prop2: null})).toBeTruthy(); + expect(ajv.validate(schema, {prop2: {id: "id", other: "other"}})).toBeTruthy(); }); it("should declare any prop (many Models + Nullable + OS2)", () => { // WHEN @@ -377,7 +661,7 @@ describe("@Nullable", () => { Model: { properties: { prop2: { - oneOf: [ + anyOf: [ { $ref: "#/components/schemas/Nested1" }, diff --git a/packages/specs/schema/src/decorators/common/readOnly.spec.ts b/packages/specs/schema/src/decorators/common/readOnly.spec.ts index 2eedbb07bfa..8c5aec08a2a 100644 --- a/packages/specs/schema/src/decorators/common/readOnly.spec.ts +++ b/packages/specs/schema/src/decorators/common/readOnly.spec.ts @@ -66,7 +66,7 @@ describe("@ReadOnly", () => { type: "number" }, members: { - oneOf: [ + anyOf: [ { $ref: "#/components/schemas/TestPerson" } diff --git a/packages/specs/schema/src/decorators/common/required.spec.ts b/packages/specs/schema/src/decorators/common/required.spec.ts index 9f97ec38dea..000bef52465 100644 --- a/packages/specs/schema/src/decorators/common/required.spec.ts +++ b/packages/specs/schema/src/decorators/common/required.spec.ts @@ -1,5 +1,5 @@ import {validateModel} from "../../../test/helpers/validateModel.js"; -import {getJsonSchema} from "../../index.js"; +import {getJsonSchema, MinLength} from "../../index.js"; import Ajv from "ajv"; import {JsonEntityStore} from "../../domain/JsonEntityStore.js"; import {Required} from "./required.js"; @@ -45,6 +45,49 @@ describe("@Required", () => { type: "object" }); }); + it("should declare required field on string property", () => { + // WHEN + class Model { + @Required() + prop: string; + } + + // THEN + const classSchema = JsonEntityStore.from(Model); + + expect(classSchema.schema.toJSON()).toEqual({ + properties: { + prop: { + minLength: 1, + type: "string" + } + }, + required: ["prop"], + type: "object" + }); + }); + it("should declare required field on string property (minLength 3)", () => { + // WHEN + class Model { + @Required() + @MinLength(3) + prop: string; + } + + // THEN + const classSchema = JsonEntityStore.from(Model); + + expect(classSchema.schema.toJSON()).toEqual({ + properties: { + prop: { + minLength: 3, + type: "string" + } + }, + required: ["prop"], + type: "object" + }); + }); it("should declare required field with a model an null", () => { // WHEN class NestedModel { @@ -73,7 +116,7 @@ describe("@Required", () => { }, properties: { allow: { - oneOf: [ + anyOf: [ { type: "null" }, diff --git a/packages/specs/schema/src/decorators/common/requiredGroups.spec.ts b/packages/specs/schema/src/decorators/common/requiredGroups.spec.ts index 81af79f5cca..bd16eddb279 100644 --- a/packages/specs/schema/src/decorators/common/requiredGroups.spec.ts +++ b/packages/specs/schema/src/decorators/common/requiredGroups.spec.ts @@ -65,6 +65,7 @@ describe("@RequiredGroups", () => { type: "string" }, prop3: { + minLength: 1, type: "string" } }, @@ -87,6 +88,7 @@ describe("@RequiredGroups", () => { type: "string" }, prop2: { + minLength: 1, type: "string" }, prop3: { @@ -114,6 +116,7 @@ describe("@RequiredGroups", () => { type: "string" }, prop3: { + minLength: 1, type: "string" } }, diff --git a/packages/specs/schema/src/decorators/operations/partial.spec.ts b/packages/specs/schema/src/decorators/operations/partial.spec.ts index a71c44e5cd2..b92cc0eff3e 100644 --- a/packages/specs/schema/src/decorators/operations/partial.spec.ts +++ b/packages/specs/schema/src/decorators/operations/partial.spec.ts @@ -1,15 +1,14 @@ +import Ajv from "ajv"; import { CollectionOf, getJsonSchema, getSpec, Groups, In, - Name, OperationPath, Path, Property, Required, - RequiredGroups, Returns, SpecTypes } from "../../index.js"; @@ -79,6 +78,7 @@ describe("@Partial", () => { type: "string" }, prop3: { + minLength: 1, type: "string" }, prop4: { @@ -157,5 +157,92 @@ describe("@Partial", () => { ] }); }); + it("should return a valid json-schema", () => { + const schema = getJsonSchema(MyModel, {}); + + expect(schema).toEqual({ + definitions: { + ChildModel: { + properties: { + id: { + type: "string" + }, + prop1: { + minLength: 1, + type: "string" + } + }, + required: ["prop1"], + type: "object" + } + }, + properties: { + id: { + type: "string" + }, + prop3: { + minLength: 1, + type: "string" + }, + prop4: { + items: { + $ref: "#/definitions/ChildModel" + }, + type: "array" + } + }, + required: ["prop3"], + type: "object" + }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {})).toBe(false); + expect(ajv.validate(schema, {prop3: "test"})).toBe(true); + }); + it("should return a valid json-schema (partial)", () => { + const schema = getJsonSchema(MyModel, { + groups: ["partial"] + }); + + expect(schema).toEqual({ + definitions: { + ChildModel: { + properties: { + id: { + type: "string" + }, + prop1: { + minLength: 1, + type: "string" + } + }, + required: ["prop1"], + type: "object" + } + }, + properties: { + id: { + type: "string" + }, + prop3: { + minLength: 1, + type: "string" + }, + prop4: { + items: { + $ref: "#/definitions/ChildModel" + }, + type: "array" + } + }, + type: "object" + }); + + const ajv = new Ajv({strict: true}); + + expect(ajv.validate(schema, {})).toBe(true); + expect(ajv.validate(schema, {prop3: "test"})).toBe(true); + }); }); }); diff --git a/packages/specs/schema/src/domain/JsonSchema.spec.ts b/packages/specs/schema/src/domain/JsonSchema.spec.ts index f0d17457a74..283bc620666 100644 --- a/packages/specs/schema/src/domain/JsonSchema.spec.ts +++ b/packages/specs/schema/src/domain/JsonSchema.spec.ts @@ -358,8 +358,8 @@ describe("JsonSchema", () => { expect(schema).toEqual({ type: "object", properties: { - name: {type: "string", minLength: 1}, - email: {type: "string", minLength: 1}, + name: {type: "string"}, + email: {type: "string"}, address: {type: "string"}, telephone: {type: "string"} }, @@ -392,7 +392,7 @@ describe("JsonSchema", () => { const schema = JsonSchema.from({ type: "object", properties: { - name: {type: "string"} + name: {type: "string", minLength: 1} }, required: ["name"] }).toObject(); @@ -540,15 +540,12 @@ describe("JsonSchema", () => { it("should create a valid jsonchema", () => { const schema = JsonSchema.from({ type: "object", - properties: { name: {type: "string"}, credit_card: {type: "number"}, billing_address: {type: "string"} }, - required: ["name"], - dependencies: { credit_card: ["billing_address"] } @@ -558,7 +555,7 @@ describe("JsonSchema", () => { type: "object", properties: { - name: {type: "string", minLength: 1}, + name: {type: "string"}, credit_card: {type: "number"}, billing_address: {type: "string"} }, @@ -1068,8 +1065,19 @@ describe("JsonSchema", () => { }).toObject(); expect(schema).toEqual({ - enum: ["red", "amber", "green", null, 42], - type: ["null", "string", "number"] + anyOf: [ + { + type: "null" + }, + { + enum: ["red", "amber", "green"], + type: "string" + }, + { + enum: [42], + type: "number" + } + ] }); const validate = new Ajv({allowUnionTypes: true}).compile(schema); @@ -1370,13 +1378,12 @@ describe("JsonSchema", () => { const jsonSchema = schema.toObject(); expect(schema.getAliasOf("prop")).toBe("aliasProp"); - expect(schema.getTarget()).toBeUndefined(); + expect(schema.getTarget()).toBe("object"); expect(jsonSchema).toEqual({ type: "object", properties: { aliasProp: { - type: "string", - minLength: 1 + type: "string" } }, required: ["aliasProp"] @@ -1401,8 +1408,7 @@ describe("JsonSchema", () => { type: "object", properties: { prop: { - type: "string", - minLength: 1 + type: "string" } }, required: ["prop"] @@ -1431,7 +1437,30 @@ describe("JsonSchema", () => { const result = JsonSchema.from({type: Object}).any().toObject(); expect(result).toEqual({ - type: ["null", "integer", "number", "string", "boolean", "array", "object"] + anyOf: [ + { + type: "null" + }, + { + multipleOf: 1, + type: "integer" + }, + { + type: "number" + }, + { + type: "string" + }, + { + type: "boolean" + }, + { + type: "array" + }, + { + type: "object" + } + ] }); }); }); diff --git a/packages/specs/schema/src/domain/JsonSchema.ts b/packages/specs/schema/src/domain/JsonSchema.ts index a10b7decd07..bdda6b3f148 100644 --- a/packages/specs/schema/src/domain/JsonSchema.ts +++ b/packages/specs/schema/src/domain/JsonSchema.ts @@ -17,6 +17,7 @@ import {IgnoreCallback} from "../interfaces/IgnoreCallback.js"; import {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions.js"; import {enumsRegistry} from "../registries/enumRegistries.js"; import {execMapper} from "../registries/JsonSchemaMapperContainer.js"; +import {string} from "../utils/from.js"; import {NestedGenerics} from "../utils/generics.js"; import {getComputedType} from "../utils/getComputedType.js"; import {getJsonType} from "../utils/getJsonType.js"; @@ -170,7 +171,7 @@ export class JsonSchema extends Map implements NestedGenerics { } get isNullable(): boolean { - return this.#nullable || this.$allow.includes(null); + return this.#nullable; } get isReadOnly() { @@ -455,7 +456,28 @@ export class JsonSchema extends Map implements NestedGenerics { } allow(...allow: any[]) { + if (([] as string[]).concat(this.getJsonType()).includes("string") && !this.has("minLength")) { + this.minLength(1); + } + + allow.forEach((value) => { + switch (value) { + case "": + this.set("minLength", undefined); + break; + case null: + this.any( + ...["null"].concat( + this.getJsonType(), + this.$allow.map((v) => typeof v) + ) + ); + break; + } + }); + this.$allow.push(...allow); + return this; } @@ -474,6 +496,11 @@ export class JsonSchema extends Map implements NestedGenerics { } else { const schema = this.clone(); schema.$selfRequired = required; + + if (([] as string[]).concat(schema.getJsonType()).includes("string")) { + schema.minLength(1); + } + return schema; } @@ -604,7 +631,7 @@ export class JsonSchema extends Map implements NestedGenerics { if (enumValue.getName()) { super.set("enum", enumValue); } else { - super.set("enum", enumValue.get("enum")).any(...enumValue.getJsonType()); + super.set("enum", enumValue.get("enum")).any(...enumValue.get("enum").map((value: any) => typeof value)); } } else { const {values, types} = serializeEnumValues([enumValue, enumValues].flat()); @@ -628,7 +655,7 @@ export class JsonSchema extends Map implements NestedGenerics { * @see https://tools.ietf.org/html/draft-wright-json-schema-validation-01#section-6.26 */ allOf(allOf: AnyJsonSchema[]) { - super.set("allOf", allOf.map(mapToJsonSchema)); + this.setManyOf("allOf", allOf); return this; } @@ -637,7 +664,7 @@ export class JsonSchema extends Map implements NestedGenerics { * @see https://tools.ietf.org/html/draft-wright-json-schema-validation-01#section-6.27 */ anyOf(anyOf: AnyJsonSchema[]) { - super.set("anyOf", anyOf.map(mapToJsonSchema)); + this.setManyOf("anyOf", anyOf); return this; } @@ -646,33 +673,7 @@ export class JsonSchema extends Map implements NestedGenerics { * @see https://tools.ietf.org/html/draft-wright-json-schema-validation-01#section-6.28 */ oneOf(oneOf: AnyJsonSchema[]) { - let resolvedOneOf = oneOf.map(mapToJsonSchema); - - if (resolvedOneOf.length === 1 && !(oneOf[0] instanceof JsonSchema)) { - if (!resolvedOneOf[0].hasDiscriminator) { - return this.type(oneOf[0]); - } - - const children = resolvedOneOf[0].discriminator().children(); - - if (!children.length) { - return this.type(oneOf[0]); - } - - resolvedOneOf = children.map(mapToJsonSchema); - } - - super.set("oneOf", resolvedOneOf); - - const jsonSchema: JsonSchema = resolvedOneOf[0]; - - if (jsonSchema.isDiscriminator) { - const discriminator = jsonSchema.discriminatorAncestor.discriminator(); - const {propertyName} = discriminator; - super.set("discriminator", {propertyName}); - this.isDiscriminator = true; - this.#discriminator = discriminator; - } + this.setManyOf("oneOf", oneOf); return this; } @@ -789,6 +790,7 @@ export class JsonSchema extends Map implements NestedGenerics { */ type(type: any | JSONSchema6TypeName | JSONSchema6TypeName[]): this { switch (type) { + case "map": case Map: super.set("type", getJsonType(type)); this.#target = type; @@ -798,6 +800,7 @@ export class JsonSchema extends Map implements NestedGenerics { } break; + case "array": case Array: super.set("type", getJsonType(type)); this.#target = type; @@ -808,6 +811,7 @@ export class JsonSchema extends Map implements NestedGenerics { } break; + case "set": case Set: super.set("type", getJsonType(type)); this.#target = type; @@ -824,6 +828,10 @@ export class JsonSchema extends Map implements NestedGenerics { this.integer(); break; + case "number": + case "string": + case "boolean": + case "object": case Object: case Date: case Boolean: @@ -859,32 +867,16 @@ export class JsonSchema extends Map implements NestedGenerics { } any(...types: any[]) { - const hasClasses = types.filter((type) => isClass(type)); - - if (hasClasses.length >= 2) { - this.oneOf( - types.filter((value) => { - if (value !== null) { - this.nullable(true); - return true; - } - return false; - }) - ); - } else { - if (types.length) { - types = uniq(types).map(getJsonType); + types = types.length ? types : ["integer", "number", "string", "boolean", "array", "object", "null"]; - if (types.includes("null")) { - this.nullable(true); - types = types.filter((o) => o !== "null"); - } - } else { - types = ["integer", "number", "string", "boolean", "array", "object"]; - this.nullable(true); - } + types = uniq(types).map((o) => { + return isClass(o) ? o : {type: getJsonType(o)}; + }); - this.type(types.length === 1 ? types[0] : types); + if (types.length > 1) { + this.anyOf(types); + } else { + this.type(types[0]?.type || types[0]); } return this; @@ -951,8 +943,8 @@ export class JsonSchema extends Map implements NestedGenerics { this.#discriminator = this.#discriminator ? new Discriminator(this.#discriminator) : null; this.isDiscriminator = obj.isDiscriminator; this.isDiscriminatorKey = obj.isDiscriminatorKey; - this.#ref = obj.#ref; + this.#alias = new Map(this.#alias.entries()); obj.#genericLabels && (this.#genericLabels = [...obj.#genericLabels]); this.#nestedGenerics = obj.#nestedGenerics.map((item) => [...item]); @@ -960,6 +952,7 @@ export class JsonSchema extends Map implements NestedGenerics { this.#isGeneric = obj.#isGeneric; this.#isCollection = obj.#isCollection; this.#ref = obj.#ref; + this.#nullable = obj.#nullable; super.set("type", obj.get("type")); } @@ -994,6 +987,12 @@ export class JsonSchema extends Map implements NestedGenerics { * Return the Json type as string */ getJsonType(): string | string[] { + if (this.get("anyOf")) { + return this.get("anyOf").map((o: JsonSchema) => { + return o.getJsonType(); + }); + } + return this.get("type") || getJsonType(this.getComputedType()); } @@ -1011,4 +1010,42 @@ export class JsonSchema extends Map implements NestedGenerics { clone() { return new JsonSchema(this); } + + protected setManyOf(keyword: "oneOf" | "anyOf" | "allOf", value: AnyJsonSchema[]) { + let resolved = value + .filter((o) => { + if (o?.type === "null") { + this.nullable(true); + return false; + } + return true; + }) + .map(mapToJsonSchema); + + if (resolved.length === 1 && !(value[0] instanceof JsonSchema) && !this.isNullable) { + if (!resolved[0].hasDiscriminator) { + return this.type(value[0]); + } + + const children = resolved[0].discriminator().children(); + + if (!children.length) { + return this.type(value[0]); + } + + resolved = children.map(mapToJsonSchema); + } + + super.set(keyword, resolved); + + const jsonSchema: JsonSchema = resolved[0]; + + if (jsonSchema.isDiscriminator) { + const discriminator = jsonSchema.discriminatorAncestor.discriminator(); + const {propertyName} = discriminator; + super.set("discriminator", {propertyName}); + this.isDiscriminator = true; + this.#discriminator = discriminator; + } + } } diff --git a/packages/specs/schema/src/hooks/alterOneOf.ts b/packages/specs/schema/src/hooks/alterOneOf.ts index f36e6d83886..91c8293de80 100644 --- a/packages/specs/schema/src/hooks/alterOneOf.ts +++ b/packages/specs/schema/src/hooks/alterOneOf.ts @@ -1,13 +1,65 @@ +import {isArray, isBoolean, isNumber, isObject, isString} from "@tsed/core"; +import type {JSONSchema6} from "json-schema"; +import {filter} from "rxjs"; +import { + ARRAY_PROPERTIES, + BOOLEAN_PROPERTIES, + COMMON_PROPERTIES, + MANY_OF_PROPERTIES, + NUMBER_PROPERTIES, + OBJECT_PROPERTIES, + STRING_PROPERTIES +} from "../constants/jsonSchemaProperties.js"; import type {JsonSchema} from "../domain/JsonSchema.js"; import type {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions.js"; +function findManyOf(obj: any) { + return MANY_OF_PROPERTIES.find((keyword) => obj[keyword]); +} + +const RULES: {[index: string]: {properties: string[]; is: (value: any) => boolean}} = { + string: {properties: STRING_PROPERTIES, is: isString}, + number: {properties: NUMBER_PROPERTIES, is: isNumber}, + boolean: {properties: BOOLEAN_PROPERTIES, is: isBoolean}, + array: {properties: ARRAY_PROPERTIES, is: () => false}, + object: {properties: OBJECT_PROPERTIES, is: () => false} +}; + +function pickProperties(type: string, obj: JSONSchema6 & Record, item: Record) { + const rule = RULES[type]; + + rule?.properties.concat(COMMON_PROPERTIES).forEach((keyword) => { + if (obj[keyword] !== undefined) { + if (COMMON_PROPERTIES.includes(keyword)) { + item[keyword] = (obj[keyword] as never[]).filter(rule.is) as never; + } else { + item[keyword] = obj[keyword] as never; + delete obj[keyword]; + } + } + }); +} + export function alterOneOf(obj: any, schema: JsonSchema, options: JsonSchemaOptions) { - if (obj.oneOf && options.groups !== false) { - obj = {...obj, oneOf: schema.$hooks.alter("oneOf", obj.oneOf, [options.groups])}; - } + const kind = findManyOf(obj); + + if (kind) { + obj[kind].forEach((item: {type: string} & Record) => { + pickProperties(item.type as string, obj, item); + }); + + MANY_OF_PROPERTIES.forEach((keyword) => { + if (obj[keyword] && options.groups !== false && schema.$hooks.has(keyword)) { + obj = {...obj, [keyword]: schema.$hooks.alter(keyword, obj[keyword], [options.groups])}; + } + }); + + delete obj.const; + delete obj.enum; - if ((obj.oneOf || obj.allOf || obj.anyOf) && !(obj.items || obj.properties)) { - delete obj.type; + if (!(obj.items || obj.properties)) { + delete obj.type; + } } return obj; diff --git a/packages/specs/schema/src/index.ts b/packages/specs/schema/src/index.ts index 38f7f545b70..e04affaa637 100644 --- a/packages/specs/schema/src/index.ts +++ b/packages/specs/schema/src/index.ts @@ -29,6 +29,7 @@ export * from "./components/open-spec/operationRequestBodyMapper.js"; export * from "./components/open-spec/operationResponseMapper.js"; export * from "./components/open-spec/pathsMapper.js"; export * from "./constants/httpStatusMessages.js"; +export * from "./constants/jsonSchemaProperties.js"; export * from "./constants/OperationVerbs.js"; export * from "./decorators/class/children.js"; export * from "./decorators/class/discriminatorValue.js"; diff --git a/packages/specs/schema/src/utils/from.spec.ts b/packages/specs/schema/src/utils/from.spec.ts index eef5c6cf6e1..4bebc922730 100644 --- a/packages/specs/schema/src/utils/from.spec.ts +++ b/packages/specs/schema/src/utils/from.spec.ts @@ -1,3 +1,4 @@ +import {type} from "node:os"; import {CollectionOf} from "../decorators/collections/collectionOf.js"; import {Property} from "../decorators/common/property.js"; import { @@ -70,7 +71,30 @@ describe("from", () => { }); expect(array().toJSON()).toEqual({type: "array"}); expect(any().toJSON()).toEqual({ - type: ["null", "integer", "number", "string", "boolean", "array", "object"] + anyOf: [ + { + type: "null" + }, + { + multipleOf: 1, + type: "integer" + }, + { + type: "number" + }, + { + type: "string" + }, + { + type: "boolean" + }, + { + type: "array" + }, + { + type: "object" + } + ] }); expect(anyOf(string(), number()).toJSON()).toEqual({ diff --git a/packages/specs/schema/src/utils/getRequiredProperties.ts b/packages/specs/schema/src/utils/getRequiredProperties.ts index 3403a7d80a0..aa4edd8dd7c 100644 --- a/packages/specs/schema/src/utils/getRequiredProperties.ts +++ b/packages/specs/schema/src/utils/getRequiredProperties.ts @@ -3,23 +3,6 @@ import type {JsonSchema} from "../domain/JsonSchema.js"; import {alterRequiredGroups} from "../hooks/alterRequiredGroups.js"; import type {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions.js"; -function applyStringRule(obj: any, propSchema: JsonSchema) { - if (!propSchema?.$allow.includes("")) { - if (([] as string[]).concat(propSchema?.get("type")).includes("string")) { - const minLength = obj?.minLength; - // Disallow empty string - if (minLength === undefined) { - return { - ...obj, - minLength: 1 - }; - } - } - } - - return obj; -} - function mapRequiredProps(obj: any, schema: JsonSchema, options: JsonSchemaOptions = {}) { const {useAlias} = options; const props = Object.keys(obj.properties || {}); @@ -28,11 +11,6 @@ function mapRequiredProps(obj: any, schema: JsonSchema, options: JsonSchemaOptio const aliasedKey = useAlias ? (schema.alias.get(key) as string) || key : key; if (props.includes(aliasedKey)) { - const propSchema = schema.get("properties")[key]; - const serializeSchema = obj.properties[aliasedKey]; - - obj.properties[aliasedKey] = applyStringRule(serializeSchema, propSchema); - return keys.concat(aliasedKey); } diff --git a/packages/specs/schema/src/utils/getSpec.spec.ts b/packages/specs/schema/src/utils/getSpec.spec.ts index b23fa32e744..b73dc772d7c 100644 --- a/packages/specs/schema/src/utils/getSpec.spec.ts +++ b/packages/specs/schema/src/utils/getSpec.spec.ts @@ -503,6 +503,7 @@ describe("getSpec()", () => { name: "hello", required: true, schema: { + minLength: 1, type: "string" } } diff --git a/packages/specs/schema/src/utils/mapNullableType.ts b/packages/specs/schema/src/utils/mapNullableType.ts index 86842fa0679..a3c1154de97 100644 --- a/packages/specs/schema/src/utils/mapNullableType.ts +++ b/packages/specs/schema/src/utils/mapNullableType.ts @@ -1,46 +1,68 @@ -import {uniq} from "@tsed/core"; +import {cleanObject} from "@tsed/core"; +import {MANY_OF_PROPERTIES} from "../constants/jsonSchemaProperties.js"; import type {JsonSchema} from "../domain/JsonSchema.js"; import {SpecTypes} from "../domain/SpecTypes.js"; import type {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions.js"; -function hasNullable(obj: any) { - return obj.oneOf.find((o: any) => o.type === "null"); -} - export function mapNullableType(obj: any, schema: JsonSchema | null, options: JsonSchemaOptions) { if (!schema?.isNullable) { return obj; } - let types: string[] = [].concat(obj.type).filter(Boolean); switch (options.specType) { default: case SpecTypes.JSON: if (!obj.discriminator) { - if (obj.oneOf) { - if (!hasNullable(obj)) { - obj.oneOf.unshift({ - type: "null" - }); - } + if (obj.$ref) { + obj = cleanObject({ + ...obj, + $ref: undefined, + anyOf: [ + {type: "null"}, + { + $ref: obj.$ref + } + ] + }); } else { - obj.type = uniq(["null", ...types]); + MANY_OF_PROPERTIES.some((keyword) => { + if (obj[keyword]) { + obj[keyword] = [{type: "null"}].concat(obj[keyword].filter((item: any) => item.type !== "null")); + } + }); + delete obj.type; } } break; case SpecTypes.OPENAPI: - obj.nullable = true; - - if (!obj.oneOf) { - if (types.length > 1) { - obj.oneOf = types.map((type) => ({type})); - delete obj.type; - } else { - obj.type = types[0]; - } + if (obj.$ref) { + return cleanObject({ + ...obj, + ...(obj.$ref && { + anyOf: [ + { + $ref: obj.$ref + } + ], + type: undefined + }), + nullable: true, + $ref: undefined + }); } - break; + return cleanObject({ + ...obj, + ...(obj.anyOf?.length === 1 + ? { + ...obj.anyOf[0], + anyOf: undefined + } + : { + type: obj.anyOf?.length > 1 ? undefined : obj.type + }), + nullable: true + }); } return obj; diff --git a/packages/specs/schema/src/utils/ref.ts b/packages/specs/schema/src/utils/ref.ts index cecfd872f70..b994e444fbe 100644 --- a/packages/specs/schema/src/utils/ref.ts +++ b/packages/specs/schema/src/utils/ref.ts @@ -3,6 +3,7 @@ import {pascalCase} from "change-case"; import type {JsonSchema} from "../domain/JsonSchema.js"; import {SpecTypes} from "../domain/SpecTypes.js"; import {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions.js"; +import {anyOf} from "./from.js"; /** * ignore @@ -35,29 +36,18 @@ export function createRef(name: string, schema: JsonSchema, options: JsonSchemaO $ref: `${host}/${name}` }; - const nullable = schema.isNullable; const readOnly = schema.isReadOnly; const writeOnly = schema.isWriteOnly; - if (nullable || readOnly || writeOnly) { - switch (options.specType) { - case SpecTypes.OPENAPI: - return cleanObject({ - nullable: nullable ? true : undefined, - readOnly: readOnly ? true : undefined, - writeOnly: writeOnly ? true : undefined, - oneOf: [ref] - }); - case SpecTypes.JSON: - return cleanObject({ - readOnly, - writeOnly, - oneOf: [ref] - }); - } - } - - return ref; + return cleanObject({ + readOnly: readOnly ? true : undefined, + writeOnly: writeOnly ? true : undefined, + ...(readOnly || writeOnly + ? { + anyOf: [ref] + } + : ref) + }); } /** diff --git a/packages/specs/schema/test/integrations/__snapshots__/body-params-any.integration.spec.ts.snap b/packages/specs/schema/test/integrations/__snapshots__/body-params-any.integration.spec.ts.snap index 823d803b9e7..4f0984e4142 100644 --- a/packages/specs/schema/test/integrations/__snapshots__/body-params-any.integration.spec.ts.snap +++ b/packages/specs/schema/test/integrations/__snapshots__/body-params-any.integration.spec.ts.snap @@ -178,9 +178,9 @@ Object { "application/json": Object { "schema": Object { "items": Object { - "nullable": true, - "oneOf": Array [ + "anyOf": Array [ Object { + "multipleOf": 1, "type": "integer", }, Object { @@ -199,6 +199,7 @@ Object { "type": "object", }, ], + "nullable": true, }, "type": "array", }, @@ -248,9 +249,9 @@ Object { "content": Object { "application/json": Object { "schema": Object { - "nullable": true, - "oneOf": Array [ + "anyOf": Array [ Object { + "multipleOf": 1, "type": "integer", }, Object { @@ -269,6 +270,7 @@ Object { "type": "object", }, ], + "nullable": true, }, }, }, diff --git a/packages/specs/schema/test/integrations/__snapshots__/partial.integration.spec.ts.snap b/packages/specs/schema/test/integrations/__snapshots__/partial.integration.spec.ts.snap index 7d5e03a6aac..ba30a547f7b 100644 --- a/packages/specs/schema/test/integrations/__snapshots__/partial.integration.spec.ts.snap +++ b/packages/specs/schema/test/integrations/__snapshots__/partial.integration.spec.ts.snap @@ -36,6 +36,7 @@ Object { "type": "array", }, "title": Object { + "minLength": 1, "type": "string", }, }, diff --git a/packages/specs/schema/test/integrations/__snapshots__/petstore.integration.spec.ts.snap b/packages/specs/schema/test/integrations/__snapshots__/petstore.integration.spec.ts.snap index 663df245717..2e2a9426424 100644 --- a/packages/specs/schema/test/integrations/__snapshots__/petstore.integration.spec.ts.snap +++ b/packages/specs/schema/test/integrations/__snapshots__/petstore.integration.spec.ts.snap @@ -370,6 +370,7 @@ Object { }, "name": Object { "example": "doggie", + "minLength": 1, "type": "string", }, "status": Object { @@ -557,6 +558,7 @@ Object { }, "name": Object { "example": "doggie", + "minLength": 1, "type": "string", }, "status": Object { diff --git a/packages/specs/schema/test/integrations/discriminator.integration.spec.ts b/packages/specs/schema/test/integrations/discriminator.integration.spec.ts index c667723a481..90702a01a8d 100644 --- a/packages/specs/schema/test/integrations/discriminator.integration.spec.ts +++ b/packages/specs/schema/test/integrations/discriminator.integration.spec.ts @@ -1,6 +1,5 @@ import {Controller} from "@tsed/di"; import {BodyParams, PathParams} from "@tsed/platform-params"; -import * as Path from "path"; import { DiscriminatorKey, DiscriminatorValue, @@ -448,6 +447,7 @@ describe("Discriminator", () => { ActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -467,6 +467,7 @@ describe("Discriminator", () => { CustomActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -494,6 +495,7 @@ describe("Discriminator", () => { type: "string" }, url: { + minLength: 1, type: "string" }, value: { @@ -506,7 +508,6 @@ describe("Discriminator", () => { discriminator: { propertyName: "type" }, - required: ["type"], oneOf: [ { $ref: "#/definitions/PageViewPartial" @@ -517,7 +518,8 @@ describe("Discriminator", () => { { $ref: "#/definitions/CustomActionPartial" } - ] + ], + required: ["type"] }); }); }); @@ -559,6 +561,7 @@ describe("Discriminator", () => { ActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -598,6 +601,7 @@ describe("Discriminator", () => { CustomActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -643,6 +647,7 @@ describe("Discriminator", () => { type: "string" }, url: { + minLength: 1, type: "string" }, value: { @@ -666,7 +671,6 @@ describe("Discriminator", () => { discriminator: { propertyName: "type" }, - nullable: true, oneOf: [ { $ref: "#/components/schemas/PageViewPartial" @@ -914,6 +918,7 @@ describe("Discriminator", () => { ActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -953,6 +958,7 @@ describe("Discriminator", () => { CustomActionPartial: { properties: { event: { + minLength: 1, type: "string" }, meta: { @@ -998,6 +1004,7 @@ describe("Discriminator", () => { type: "string" }, url: { + minLength: 1, type: "string" }, value: { diff --git a/packages/specs/schema/test/integrations/nullable.integration.spec.ts b/packages/specs/schema/test/integrations/nullable.integration.spec.ts index 92673347a73..5c725a58c7c 100644 --- a/packages/specs/schema/test/integrations/nullable.integration.spec.ts +++ b/packages/specs/schema/test/integrations/nullable.integration.spec.ts @@ -47,26 +47,50 @@ describe("Spec: Nullable", () => { } }, properties: { + description: { + anyOf: [ + { + type: "null" + }, + { + minLength: 1, + type: "string" + } + ] + }, id: { type: "string" }, + nested: { + anyOf: [ + { + type: "null" + }, + { + $ref: "#/definitions/Nested" + } + ] + }, price: { - type: ["null", "number"] + anyOf: [ + { + type: "null" + }, + { + type: "number" + } + ] }, priceDetails: { - type: ["null", "string", "number"] - }, - description: { - minLength: 1, - type: ["null", "string"] - }, - nested: { - oneOf: [ + anyOf: [ { type: "null" }, { - $ref: "#/definitions/Nested" + type: "string" + }, + { + type: "number" } ] } @@ -91,34 +115,34 @@ describe("Spec: Nullable", () => { }, Product: { properties: { + description: { + minLength: 1, + nullable: true, + type: "string" + }, id: { type: "string" }, - price: { - type: "number", + nested: { + anyOf: [ + { + $ref: "#/components/schemas/Nested" + } + ], nullable: true }, - priceDetails: { + price: { nullable: true, - oneOf: [ + type: "number" + }, + priceDetails: { + anyOf: [ { type: "string" }, { type: "number" } - ] - }, - description: { - type: "string", - minLength: 1, - nullable: true - }, - nested: { - oneOf: [ - { - $ref: "#/components/schemas/Nested" - } ], nullable: true }