From 2deccc0cf5908dabd4f1ea08b31d76029df996b6 Mon Sep 17 00:00:00 2001 From: Romain Lenzotti Date: Fri, 3 Mar 2023 09:50:57 +0100 Subject: [PATCH] feat(schema): add enums function to declare a shared enum in swagger Closes: #2237 --- docs/docs/model.md | 60 +++++++-- packages/di/src/services/InjectorService.ts | 2 +- .../specs/schema/src/components/anyMapper.ts | 7 + .../schema/src/components/objectMapper.ts | 1 + .../schema/src/decorators/common/enum.spec.ts | 125 +++++++++++++++--- .../specs/schema/src/domain/JsonSchema.ts | 31 +++-- packages/specs/schema/src/index.ts | 1 + .../schema/src/registries/enumRegistries.ts | 3 + packages/specs/schema/src/utils/from.ts | 7 + packages/specs/schema/src/utils/ref.ts | 2 +- 10 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 packages/specs/schema/src/registries/enumRegistries.ts diff --git a/docs/docs/model.md b/docs/docs/model.md index 892cb5e4b76..0deaac76429 100644 --- a/docs/docs/model.md +++ b/docs/docs/model.md @@ -112,7 +112,7 @@ json type or when you use a mixed TypeScript types. -## Nullable +## Nullable The @@Nullable@@ decorator is used allow a null value on a field while preserving the original Typescript type. @@ -326,6 +326,48 @@ class MyController { } ``` +### Set label to an enum + +With OpenSpec 3 it's now possible to create shared enum for many models in `components.schemas` instead of having its inlined values in +each model. + +Ts.ED introduce a new function `enums()` to declare the enum schema as follows: + +```ts +import {enums} from "@tsed/schema"; + +enum ProductTypes { + ALL = "ALL", + ASSETS = "ASSETS", + FOOD = "FOOD" +} + +enums(ProductTypes).label("ProductTypes"); + +// in models +class Product { + @Property() + title: string; + + @Enum(ProductTypes) + type: ProductTypes; +} + +// in controller + +import {Enum} from "@tsed/schema"; +import {QueryParams, Controller} from "@tsed/common"; + +@Controller("/products") +class ProductsController { + @Get("/:type") + @Returns(200, Array).Of(Product) + async get(@PathParams("type") @Enum(ProductTypes) type: ProductTypes): Promise { + return [new Product()]; + } +} +``` + ## Constant values The @@Const@@ decorator is used to restrict a value to a single value. For example, if you only support shipping to the @@ -504,7 +546,7 @@ Circular reference can be resolved by using arrow with a @@Property@@ and @@Coll <<< @/docs/snippets/model/circular-references.ts -## Custom Keys +## Custom Keys Ts.ED introduces the @@Keyword@@ decorator to declare a new custom validator for Ajv. Combined with the @@CustomKey@@ decorator to add keywords to a property of your class, you can use more complex scenarios than what basic JsonSchema @@ -860,7 +902,7 @@ class MyModel { Now `prop4` will have a `ChildModel` generated along to groups configuration. -## RequiredGroups +## RequiredGroups As @@Groups@@ decorator, @@RequiredGroups@@ allow you to define when a field is `required` depending on the given groups strategy. @@ -886,7 +928,7 @@ class MyModel { } ``` -## AllowedGroups +## AllowedGroups This feature let your API consumer to define which field he wants to consume. The server will filter automatically fields based on the @@Groups@@ strategy. @@ -1015,7 +1057,7 @@ Expected json: -## Partial +## Partial Partial allow you to create a Partial model on an endpoint: @@ -1036,7 +1078,7 @@ class MyController { ## Advanced validation -### BeforeDeserialize +### BeforeDeserialize If you want to validate or manipulate data before the model has been deserialized you can use the @@BeforeDeserialize@@ decorator. @@ -1070,7 +1112,7 @@ export class Animal { } ``` -### AfterDeserialize +### AfterDeserialize If you want to validate or manipulate data after the model has been deserialized you can use the @@AfterDeserialize@@ decorator. @@ -1412,7 +1454,7 @@ The used features are the following: -## Deep object on query +## Deep object on query With OpenAPI 3, it's possible to describe and use a [deepObject](https://swagger.io/docs/specification/serialization/#query) `style` as Query params. It means, a consumer can call your endpoint with the following url: @@ -1575,7 +1617,7 @@ You can declare schema by using the @@JsonSchemaObject@@ interface: <<< @/docs/snippets/model/raw-schema-controller.ts -### Using functions +### Using functions It's also possible to write a valid JsonSchema by using the functional approach (Joi like): diff --git a/packages/di/src/services/InjectorService.ts b/packages/di/src/services/InjectorService.ts index 4efd8f617dc..7a2084b27f3 100644 --- a/packages/di/src/services/InjectorService.ts +++ b/packages/di/src/services/InjectorService.ts @@ -141,7 +141,7 @@ export class InjectorService extends Container { * @param locals * @param options */ - getMany(type: string, locals?: LocalsContainer, options?: Partial>): Type[] { + getMany(type: any, locals?: LocalsContainer, options?: Partial>): Type[] { return this.getProviders(type).map((provider) => this.invoke(provider.token, locals, options)!); } diff --git a/packages/specs/schema/src/components/anyMapper.ts b/packages/specs/schema/src/components/anyMapper.ts index f9b05441c0e..7e87337a6d8 100644 --- a/packages/specs/schema/src/components/anyMapper.ts +++ b/packages/specs/schema/src/components/anyMapper.ts @@ -1,4 +1,5 @@ import {JsonLazyRef} from "../domain/JsonLazyRef"; +import {JsonSchema} from "../domain/JsonSchema"; import {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions"; import {execMapper, registerJsonSchemaMapper} from "../registries/JsonSchemaMapperContainer"; import {mapGenericsOptions} from "../utils/generics"; @@ -13,6 +14,12 @@ export function anyMapper(input: any, options: JsonSchemaOptions = {}): any { return execMapper("lazyRef", input, options); } + if (input instanceof JsonSchema && input.get("enum") instanceof JsonSchema) { + const enumSchema: JsonSchema = input.get("enum"); + + return toRef(enumSchema, enumSchema.toJSON(options), options); + } + if ("toJSON" in input) { const schema = input.toJSON(mapGenericsOptions(options)); diff --git a/packages/specs/schema/src/components/objectMapper.ts b/packages/specs/schema/src/components/objectMapper.ts index 86e888ce93d..2c51d5deddd 100644 --- a/packages/specs/schema/src/components/objectMapper.ts +++ b/packages/specs/schema/src/components/objectMapper.ts @@ -21,6 +21,7 @@ export function objectMapper(input: any, options: JsonSchemaOptions) { ...options, groups: input?.$forwardGroups || value?.$forwardGroups ? options.groups : undefined }; + // remove groups to avoid bad schema generation over children models obj[key] = execMapper("item", value, opts); obj[key] = mapNullableType(obj[key], value, opts); diff --git a/packages/specs/schema/src/decorators/common/enum.spec.ts b/packages/specs/schema/src/decorators/common/enum.spec.ts index 8d5a8529603..abd929cd6f0 100644 --- a/packages/specs/schema/src/decorators/common/enum.spec.ts +++ b/packages/specs/schema/src/decorators/common/enum.spec.ts @@ -1,3 +1,4 @@ +import {enums} from "../../utils/from"; import {getJsonSchema} from "../../utils/getJsonSchema"; import {Enum} from "./enum"; @@ -57,12 +58,12 @@ describe("@Enum", () => { }); describe("when is a typescript enum (string)", () => { - enum SomeEnum { - ENUM_1 = "enum1", - ENUM_2 = "enum2" - } - it("should declare prop", () => { + enum SomeEnum { + ENUM_1 = "enum1", + ENUM_2 = "enum2" + } + // WHEN class Model { @Enum(SomeEnum) @@ -82,12 +83,12 @@ describe("@Enum", () => { }); describe("when is a typescript enum (index)", () => { - enum SomeEnum { - ENUM_1, - ENUM_2 - } - it("should declare prop", () => { + enum SomeEnum { + ENUM_1, + ENUM_2 + } + // WHEN class Model { @Enum(SomeEnum) @@ -107,13 +108,105 @@ describe("@Enum", () => { }); describe("when is a typescript enum (mixed type)", () => { - enum SomeEnum { - ENUM_1, - ENUM_2 = "test", - ENUM_3 = "test2" - } - it("should declare prop", () => { + enum SomeEnum { + ENUM_1, + ENUM_2 = "test", + ENUM_3 = "test2" + } + + // WHEN + class Model { + @Enum(SomeEnum) + num: SomeEnum; + } + + expect(getJsonSchema(Model)).toEqual({ + properties: { + num: { + enum: [0, "test", "test2"], + type: ["number", "string"] + } + }, + type: "object" + }); + }); + }); + describe("when is a typescript enum with a label (set enum schema)", () => { + it("should declare prop with a shared enum in definitions", () => { + enum SomeEnum { + ENUM_1, + ENUM_2 = "test", + ENUM_3 = "test2" + } + + const enumValues = enums(SomeEnum).label("SomeEnum"); + + // WHEN + class Model { + @Enum(enumValues) + num: SomeEnum; + } + + expect(getJsonSchema(Model)).toEqual({ + definitions: { + SomeEnum: { + enum: [0, "test", "test2"], + type: ["number", "string"] + } + }, + properties: { + num: { + $ref: "#/definitions/SomeEnum" + } + }, + type: "object" + }); + }); + }); + describe("when is a typescript enum with a label (set enum)", () => { + it("should declare prop with a shared enum in definitions", () => { + enum SomeEnum { + ENUM_1, + ENUM_2 = "test", + ENUM_3 = "test2" + } + + enums(SomeEnum).label("SomeEnum"); + + // WHEN + class Model { + @Enum(SomeEnum) + num: SomeEnum; + } + + expect(getJsonSchema(Model)).toEqual({ + definitions: { + SomeEnum: { + enum: [0, "test", "test2"], + type: ["number", "string"] + } + }, + properties: { + num: { + $ref: "#/definitions/SomeEnum" + } + }, + type: "object" + }); + }); + }); + + describe("when is a typescript enum schema without label (set enum)", () => { + it("should inline enum", () => { + enum SomeEnum { + ENUM_1, + ENUM_2 = "test", + ENUM_3 = "test2" + } + + enums(SomeEnum); + // WHEN class Model { @Enum(SomeEnum) diff --git a/packages/specs/schema/src/domain/JsonSchema.ts b/packages/specs/schema/src/domain/JsonSchema.ts index ec13945c789..dc8dc6e951b 100644 --- a/packages/specs/schema/src/domain/JsonSchema.ts +++ b/packages/specs/schema/src/domain/JsonSchema.ts @@ -15,6 +15,7 @@ import { import type {JSONSchema6, JSONSchema6Definition, JSONSchema6Type, JSONSchema6TypeName, JSONSchema6Version} from "json-schema"; import {IgnoreCallback} from "../interfaces/IgnoreCallback"; import {JsonSchemaOptions} from "../interfaces/JsonSchemaOptions"; +import {enumsRegistry} from "../registries/enumRegistries"; import {execMapper} from "../registries/JsonSchemaMapperContainer"; import {NestedGenerics} from "../utils/generics"; import {getComputedType} from "../utils/getComputedType"; @@ -178,6 +179,10 @@ export class JsonSchema extends Map implements NestedGenerics { return this.get("writeOnly"); } + get hasDiscriminator() { + return !!this.#discriminator; + } + static from(obj: Partial = {}) { return new JsonSchema(obj); } @@ -291,10 +296,6 @@ export class JsonSchema extends Map implements NestedGenerics { return this; } - get hasDiscriminator() { - return !!this.#discriminator; - } - discriminator() { this.isDiscriminator = true; return (this.#discriminator = @@ -592,10 +593,23 @@ export class JsonSchema extends Map implements NestedGenerics { * @see https://tools.ietf.org/html/draft-wright-json-schema-validation-01#section-6.23 */ enum(...enumValues: any[]): this; - enum(enumValue: any | any[], ...enumValues: any[]): this { - const {values, types} = serializeEnumValues([enumValue, enumValues].flat()); + enum(enumSchema: JsonSchema): this; + enum(enumValue: any | any[] | JsonSchema, ...enumValues: any[]): this { + if (enumsRegistry.has(enumValue)) { + return this.enum(enumsRegistry.get(enumValue)); + } - super.set("enum", values).any(...types); + if (enumValue instanceof JsonSchema) { + if (enumValue.getName()) { + super.set("enum", enumValue); + } else { + super.set("enum", enumValue.get("enum")).any(...enumValue.getJsonType()); + } + } else { + const {values, types} = serializeEnumValues([enumValue, enumValues].flat()); + + super.set("enum", values).any(...types); + } return this; } @@ -857,7 +871,6 @@ export class JsonSchema extends Map implements NestedGenerics { }) ); } else { - // TODO when OS3 will the only minimal supported version, we'll can remove this code if (types.length) { types = uniq(types).map(getJsonType); @@ -1004,7 +1017,7 @@ export class JsonSchema extends Map implements NestedGenerics { * Get the symbolic name of the entity */ getName() { - return this.get("name") || (this.#target ? nameOf(classOf(this.getComputedType())) : ""); + return this.get("name") || (isClass(this.#target) ? nameOf(classOf(this.getComputedType())) : ""); } clone() { diff --git a/packages/specs/schema/src/index.ts b/packages/specs/schema/src/index.ts index 77f4a745957..cec33cac9b3 100644 --- a/packages/specs/schema/src/index.ts +++ b/packages/specs/schema/src/index.ts @@ -119,6 +119,7 @@ export * from "./interfaces/JsonHookContext"; export * from "./interfaces/JsonOpenSpec"; export * from "./interfaces/JsonSchemaOptions"; export * from "./registries/JsonSchemaMapperContainer"; +export * from "./registries/enumRegistries"; export * from "./utils/buildPath"; export * from "./utils/concatParameters"; export * from "./utils/concatPath"; diff --git a/packages/specs/schema/src/registries/enumRegistries.ts b/packages/specs/schema/src/registries/enumRegistries.ts new file mode 100644 index 00000000000..a383bd1159e --- /dev/null +++ b/packages/specs/schema/src/registries/enumRegistries.ts @@ -0,0 +1,3 @@ +import {JsonSchema} from "../domain/JsonSchema"; + +export const enumsRegistry = new Map(); diff --git a/packages/specs/schema/src/utils/from.ts b/packages/specs/schema/src/utils/from.ts index 0ca4d269e78..48d440b30d9 100644 --- a/packages/specs/schema/src/utils/from.ts +++ b/packages/specs/schema/src/utils/from.ts @@ -3,6 +3,7 @@ import {JsonEntityStore} from "../domain/JsonEntityStore"; import {JsonFormatTypes} from "../domain/JsonFormatTypes"; import {JsonLazyRef} from "../domain/JsonLazyRef"; import {JsonSchema} from "../domain/JsonSchema"; +import {enumsRegistry} from "../registries/enumRegistries"; import {getJsonEntityStore} from "./getJsonEntityStore"; /** @@ -271,3 +272,9 @@ export function lazyRef(cb: () => Type) { return new JsonLazyRef(cb); } +export function enums(e: Record) { + const schema = string().enum(e); + enumsRegistry.set(e, schema); + + return schema; +} diff --git a/packages/specs/schema/src/utils/ref.ts b/packages/specs/schema/src/utils/ref.ts index f03406dce14..188228ca9d5 100644 --- a/packages/specs/schema/src/utils/ref.ts +++ b/packages/specs/schema/src/utils/ref.ts @@ -65,7 +65,7 @@ export function createRef(name: string, schema: JsonSchema, options: JsonSchemaO export function toRef(value: JsonSchema, schema: any, options: JsonSchemaOptions) { const name = createRefName(value.getName(), options); - options.schemas![value.getName()] = schema; + options.schemas![name] = schema; return createRef(name, value, options); }