From 95883a274ca62cb9cea156f621aaecd95be0fe79 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 21 Nov 2024 17:05:21 +0800 Subject: [PATCH 01/12] initial --- packages/openapi/src/decorators.ts | 5 +- packages/openapi/test/decorators.test.ts | 27 ++++---- packages/openapi3/src/openapi.ts | 13 +++- packages/openapi3/test/extension.test.ts | 83 ++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 packages/openapi3/test/extension.test.ts diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index f7adbf1caf..eee94767c9 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -58,7 +58,10 @@ export const $extension: ExtensionDecorator = ( extensionName: string, value: TypeSpecValue, ) => { - if (!isOpenAPIExtensionKey(extensionName)) { + if ( + !["minProperties", "maxProperties", "uniqueItems", "multipleOf"].includes(extensionName) && + !isOpenAPIExtensionKey(extensionName) + ) { reportDiagnostic(context.program, { code: "invalid-extension-key", format: { value: extensionName }, diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index ecb93e76f9..ef21bf91bc 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -46,19 +46,22 @@ describe("openapi: decorators", () => { }); describe("@extension", () => { - it("apply extension on model", async () => { - const { Foo } = await runner.compile(` - @extension("x-custom", "Bar") - @test - model Foo { - prop: string - } - `); + it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf", "x-custom"])( + "apply extension on model with %s", + async (key) => { + const { Foo } = await runner.compile(` + @extension("${key}", "Bar") + @test + model Foo { + prop: string + } + `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { - "x-custom": "Bar", - }); - }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + [key]: "Bar", + }); + }, + ); it("apply extension with complex value", async () => { const { Foo } = await runner.compile(` diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index f70ef06230..658a080cc2 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1643,11 +1643,20 @@ function createOAPIEmitter( return callSchemaEmitter(type, visibility) as any; } - function attachExtensions(program: Program, type: Type, emitObject: any) { + function attachExtensions( + program: Program, + type: Type, + emitObject: any, + enabled: boolean = false, + ) { + const schemaExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; // Attach any OpenAPI extensions const extensions = getExtensions(program, type); if (extensions) { for (const key of extensions.keys()) { + if (!enabled && schemaExtensions.includes(key)) { + continue; + } emitObject[key] = extensions.get(key); } } @@ -1728,7 +1737,7 @@ function createOAPIEmitter( }; } - attachExtensions(program, typespecType, newTarget); + attachExtensions(program, typespecType, newTarget, true); return newTarget; } diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts new file mode 100644 index 0000000000..1dc04d9251 --- /dev/null +++ b/packages/openapi3/test/extension.test.ts @@ -0,0 +1,83 @@ +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { describe, expect, it } from "vitest"; + +import { oapiForModel, openApiFor } from "./test-host.js"; + +const extensionKeys = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; +describe("inline adds an extension to a parameter", () => { + it.each(extensionKeys)("%s", async (key) => { + const oapi = await openApiFor( + ` + op get( + @path + @extension("${key}", "foobaz") + petId: string; + ): void; + `, + ); + strictEqual(oapi.paths["/{petId}"].get.parameters[0]["schema"][key], "foobaz"); + strictEqual(oapi.paths["/{petId}"].get.parameters[0][key], undefined); + }); +}); + +describe("adds an extension to a parameter", () => { + it.each(extensionKeys)("%s", async (key) => { + const oapi = await openApiFor( + ` + model Pet { + name: string; + } + model PetId { + @path + @extension("${key}", "foobaz") + petId: string; + } + @route("/Pets") + @get() + op get(... PetId): Pet; + `, + ); + ok(oapi.paths["/Pets/{petId}"].get); + strictEqual( + oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"], + "#/components/parameters/PetId", + ); + strictEqual(oapi.components.parameters.PetId.name, "petId"); + strictEqual(oapi.components.parameters.PetId.schema[key], "foobaz"); + strictEqual(oapi.components.parameters.PetId[key], undefined); + }); +}); + +describe("adds an extension to a model", () => { + it.each(extensionKeys)("%s", async (key) => { + const res = await oapiForModel( + "Foo", + `model Foo { + @extension("${key}", "foobaz") + x: int32; + };`, + ); + + expect(res.schemas.Foo).toMatchObject({ + required: ["x"], + properties: { + x: { [key]: "foobaz" }, + }, + }); + }); +}); + +describe("adds an extension to a scalar", () => { + it.each(extensionKeys)("%s", async (key) => { + const res = await oapiForModel( + "Pet", + `@extension("${key}", "my-value") + scalar Pet extends string;`, + ); + + deepStrictEqual(res.schemas.Pet, { + type: "string", + [key]: "my-value", + }); + }); +}); From 0d97bb920b93d27413dc8ecff2cc386df0eefe13 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 21 Nov 2024 20:32:54 +0800 Subject: [PATCH 02/12] update --- .../generated-defs/TypeSpec.OpenAPI.ts | 9 +++- packages/openapi/lib/decorators.tsp | 9 +++- packages/openapi/src/decorators.ts | 42 ++++++++++++++++--- packages/openapi/src/lib.ts | 6 +++ packages/openapi/test/decorators.test.ts | 39 ++++++++++++----- packages/openapi3/test/extension.test.ts | 42 +++++++------------ 6 files changed, 102 insertions(+), 45 deletions(-) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 62b959e71b..ed745b8837 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -35,6 +35,13 @@ export type OperationIdDecorator = ( * @param value Extension value. * @example * ```typespec + * model Foo { + * @extension("uniqueItems") + * x: int32; + * }; + * ``` + * + * ```typespec * @extension("x-custom", "My value") * @extension("x-pageable", {nextLink: "x-next-link"}) * op read(): string; @@ -44,7 +51,7 @@ export type ExtensionDecorator = ( context: DecoratorContext, target: Type, key: string, - value: Type, + value?: Type, ) => void; /** diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index fbeacb1909..bb21a78f6d 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -25,12 +25,19 @@ extern dec operationId(target: Operation, operationId: valueof string); * @example * * ```typespec + * model Foo { + * @extension("uniqueItems") + * x: int32; + * }; + * ``` + * + * ```typespec * @extension("x-custom", "My value") * @extension("x-pageable", {nextLink: "x-next-link"}) * op read(): string; * ``` */ -extern dec extension(target: unknown, key: valueof string, value: unknown); +extern dec extension(target: unknown, key: valueof string, value?: unknown); /** * Specify that this model is to be treated as the OpenAPI `default` response. diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index eee94767c9..ac2e22d743 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -56,10 +56,13 @@ export const $extension: ExtensionDecorator = ( context: DecoratorContext, entity: Type, extensionName: string, - value: TypeSpecValue, + value?: TypeSpecValue, ) => { if ( - !["minProperties", "maxProperties", "uniqueItems", "multipleOf"].includes(extensionName) && + !( + ["minProperties", "maxProperties", "uniqueItems", "multipleOf"].includes(extensionName) && + entity.kind === "ModelProperty" + ) && !isOpenAPIExtensionKey(extensionName) ) { reportDiagnostic(context.program, { @@ -69,11 +72,38 @@ export const $extension: ExtensionDecorator = ( }); } - const [data, diagnostics] = typespecTypeToJson(value, entity); - if (diagnostics.length > 0) { - context.program.reportDiagnostics(diagnostics); + let inputData: any = true; + if (value !== undefined) { + const [data, diagnostics] = typespecTypeToJson(value, entity); + if (diagnostics.length > 0) { + context.program.reportDiagnostics(diagnostics); + } + + if ( + "minProperties" === extensionName || + "maxProperties" === extensionName || + "multipleOf" === extensionName + ) { + if (isNaN(Number(data))) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + format: { value: extensionName }, + target: entity, + }); + } + } + + switch (extensionName) { + case "minProperties": + case "maxProperties": + case "multipleOf": + inputData = Number(data); + break; + default: + inputData = data; + } } - setExtension(context.program, entity, extensionName as ExtensionKey, data); + setExtension(context.program, entity, extensionName as ExtensionKey, inputData); }; /** diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 889d282b31..fd7b990db6 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -9,6 +9,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`OpenAPI extension must start with 'x-' but was '${"value"}'`, }, }, + "invalid-extension-value": { + severity: "error", + messages: { + default: paramMessage`'minProperties'/'maxProperties'/'multipleOf' must number but '${"value"}' is not a number.'`, + }, + }, "duplicate-type-name": { severity: "error", messages: { diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index ef21bf91bc..a1b3f658e4 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -46,22 +46,39 @@ describe("openapi: decorators", () => { }); describe("@extension", () => { - it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf", "x-custom"])( - "apply extension on model with %s", - async (key) => { - const { Foo } = await runner.compile(` - @extension("${key}", "Bar") - @test + const extensionKeys: [string, any][] = [ + ["minProperties", 1], + ["maxProperties", 1], + ["uniqueItems", true], + ["multipleOf", 1], + ]; + it.each(extensionKeys)("apply extension on model prop with %s", async (key, value) => { + const { prop } = await runner.compile(` model Foo { + @extension("${key}", ${value}) + @test prop: string } `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { - [key]: "Bar", - }); - }, - ); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), { + [key]: value, + }); + }); + + it("apply extension on model", async () => { + const { Foo } = await runner.compile(` + @extension("x-custom", "Bar") + @test + model Foo { + prop: string + } + `); + + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), { + "x-custom": "Bar", + }); + }); it("apply extension with complex value", async () => { const { Foo } = await runner.compile(` diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts index 1dc04d9251..ca7c5c454d 100644 --- a/packages/openapi3/test/extension.test.ts +++ b/packages/openapi3/test/extension.test.ts @@ -1,27 +1,32 @@ -import { deepStrictEqual, ok, strictEqual } from "assert"; +import { ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { oapiForModel, openApiFor } from "./test-host.js"; -const extensionKeys = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; +const extensionKeys: [string, any][] = [ + ["minProperties", 1], + ["maxProperties", 1], + ["uniqueItems", true], + ["multipleOf", 1], +]; describe("inline adds an extension to a parameter", () => { - it.each(extensionKeys)("%s", async (key) => { + it.each(extensionKeys)("%s", async (key, value) => { const oapi = await openApiFor( ` op get( @path - @extension("${key}", "foobaz") + @extension("${key}", ${value}) petId: string; ): void; `, ); - strictEqual(oapi.paths["/{petId}"].get.parameters[0]["schema"][key], "foobaz"); + strictEqual(oapi.paths["/{petId}"].get.parameters[0]["schema"][key], value); strictEqual(oapi.paths["/{petId}"].get.parameters[0][key], undefined); }); }); describe("adds an extension to a parameter", () => { - it.each(extensionKeys)("%s", async (key) => { + it.each(extensionKeys)("%s", async (key, value) => { const oapi = await openApiFor( ` model Pet { @@ -29,7 +34,7 @@ describe("adds an extension to a parameter", () => { } model PetId { @path - @extension("${key}", "foobaz") + @extension("${key}", ${value}) petId: string; } @route("/Pets") @@ -43,17 +48,17 @@ describe("adds an extension to a parameter", () => { "#/components/parameters/PetId", ); strictEqual(oapi.components.parameters.PetId.name, "petId"); - strictEqual(oapi.components.parameters.PetId.schema[key], "foobaz"); + strictEqual(oapi.components.parameters.PetId.schema[key], value); strictEqual(oapi.components.parameters.PetId[key], undefined); }); }); describe("adds an extension to a model", () => { - it.each(extensionKeys)("%s", async (key) => { + it.each(extensionKeys)("%s", async (key, value) => { const res = await oapiForModel( "Foo", `model Foo { - @extension("${key}", "foobaz") + @extension("${key}", ${value}) x: int32; };`, ); @@ -61,23 +66,8 @@ describe("adds an extension to a model", () => { expect(res.schemas.Foo).toMatchObject({ required: ["x"], properties: { - x: { [key]: "foobaz" }, + x: { [key]: value }, }, }); }); }); - -describe("adds an extension to a scalar", () => { - it.each(extensionKeys)("%s", async (key) => { - const res = await oapiForModel( - "Pet", - `@extension("${key}", "my-value") - scalar Pet extends string;`, - ); - - deepStrictEqual(res.schemas.Pet, { - type: "string", - [key]: "my-value", - }); - }); -}); From 5591d33ccbc3d1fcc0771edd7ad6293570cc7a62 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 22 Nov 2024 15:44:56 +0800 Subject: [PATCH 03/12] update --- .../MissingDecorators-2024-10-22-15-39-24.md | 7 ++ .../MissingDecorators-2024-10-22-15-44-1.md | 7 ++ packages/openapi/README.md | 46 ++++++++++-- .../generated-defs/TypeSpec.OpenAPI.ts | 31 ++++++-- packages/openapi/lib/decorators.tsp | 32 +++++++-- packages/openapi/src/decorators.ts | 71 +++++++++++++------ packages/openapi/src/lib.ts | 10 ++- packages/openapi/src/types.ts | 1 + packages/openapi/test/decorators.test.ts | 22 +++++- .../libraries/openapi/reference/decorators.md | 46 ++++++++++-- 10 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 .chronus/changes/MissingDecorators-2024-10-22-15-39-24.md create mode 100644 .chronus/changes/MissingDecorators-2024-10-22-15-44-1.md diff --git a/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md b/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md new file mode 100644 index 0000000000..bd7cf931a2 --- /dev/null +++ b/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" +--- + +@extension decorator supports multipleOf, uniqueItems, maxProperties, and minProperties diff --git a/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md b/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md new file mode 100644 index 0000000000..6313149f9a --- /dev/null +++ b/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +@extension decorator supports keywords: multipleOf, uniqueItems, maxProperties, and minProperties,apply to properties in the Schema Object. diff --git a/packages/openapi/README.md b/packages/openapi/README.md index f76112a717..f1c64936e1 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -50,7 +50,7 @@ op listPets(): Pet[] | PetStoreResponse; Attach some custom data to the OpenAPI element generated from this type. ```typespec -@TypeSpec.OpenAPI.extension(key: valueof string, value: unknown) +@TypeSpec.OpenAPI.extension(key: valueof string, value?: unknown) ``` ##### Target @@ -59,10 +59,10 @@ Attach some custom data to the OpenAPI element generated from this type. ##### Parameters -| Name | Type | Description | -| ----- | ---------------- | ----------------------------------- | -| key | `valueof string` | Extension key. Must start with `x-` | -| value | `unknown` | Extension value. | +| Name | Type | Description | +| ----- | ---------------- | ----------------------------------------------------------------------------------------------------------- | +| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` | +| value | `unknown` | Extension value. | ##### Examples @@ -77,6 +77,42 @@ Attach some custom data to the OpenAPI element generated from this type. op read(): string; ``` +###### Specify that every item in the array must be unique. + +```typespec +model Foo { + @extension("uniqueItems") + x: unknown[]; +} +``` + +###### Specify that the numeric type must be a multiple of some numeric value. + +```typespec +model Foo { + @extension("multipleOf", 1) + x: int32; +} +``` + +###### Specify the maximum number of properties this object can have. + +```typespec +model Foo { + @extension("maxProperties", 1) + x: int32; +} +``` + +###### Specify the minimum number of properties this object can have. + +```typespec +model Foo { + @extension("minProperties", 1) + x: int32; +} +``` + #### `@externalDocs` Specify the OpenAPI `externalDocs` property for this type. diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index ed745b8837..c01379c37d 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -31,20 +31,41 @@ export type OperationIdDecorator = ( /** * Attach some custom data to the OpenAPI element generated from this type. * - * @param key Extension key. Must start with `x-` + * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` * @param value Extension value. * @example * ```typespec + * @extension("x-custom", "My value") + * @extension("x-pageable", {nextLink: "x-next-link"}) + * op read(): string; + * ``` + * @example Specify that every item in the array must be unique. + * ```typespec * model Foo { * @extension("uniqueItems") + * x: unknown[]; + * }; + * ``` + * @example Specify that the numeric type must be a multiple of some numeric value. + * ```typespec + * model Foo { + * @extension("multipleOf", 1) * x: int32; * }; * ``` - * + * @example Specify the maximum number of properties this object can have. * ```typespec - * @extension("x-custom", "My value") - * @extension("x-pageable", {nextLink: "x-next-link"}) - * op read(): string; + * model Foo { + * @extension("maxProperties", 1) + * x: int32; + * }; + * ``` + * @example Specify the minimum number of properties this object can have. + * ```typespec + * model Foo { + * @extension("minProperties", 1) + * x: int32; + * }; * ``` */ export type ExtensionDecorator = ( diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index bb21a78f6d..d03cc11419 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -19,22 +19,46 @@ extern dec operationId(target: Operation, operationId: valueof string); /** * Attach some custom data to the OpenAPI element generated from this type. * - * @param key Extension key. Must start with `x-` + * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` * @param value Extension value. * * @example + * ```typespec + * @extension("x-custom", "My value") + * @extension("x-pageable", {nextLink: "x-next-link"}) + * op read(): string; + * ``` * + * @example Specify that every item in the array must be unique. * ```typespec * model Foo { * @extension("uniqueItems") + * x: unknown[]; + * }; + * ``` + * + * @example Specify that the numeric type must be a multiple of some numeric value. + * ```typespec + * model Foo { + * @extension("multipleOf", 1) * x: int32; * }; * ``` * + * @example Specify the maximum number of properties this object can have. * ```typespec - * @extension("x-custom", "My value") - * @extension("x-pageable", {nextLink: "x-next-link"}) - * op read(): string; + * model Foo { + * @extension("maxProperties", 1) + * x: int32; + * }; + * ``` + * + * @example Specify the minimum number of properties this object can have. + * ```typespec + * model Foo { + * @extension("minProperties", 1) + * x: int32; + * }; * ``` */ extern dec extension(target: unknown, key: valueof string, value?: unknown); diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index ac2e22d743..8f9243fae1 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -25,7 +25,7 @@ import { } from "../generated-defs/TypeSpec.OpenAPI.js"; import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; import { createStateSymbol, OpenAPIKeys, reportDiagnostic } from "./lib.js"; -import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; +import { AdditionalInfo, ExtensionKey, ExternalDocs, SchemaExtensionKey } from "./types.js"; const operationIdsKey = createStateSymbol("operationIds"); /** @@ -58,39 +58,52 @@ export const $extension: ExtensionDecorator = ( extensionName: string, value?: TypeSpecValue, ) => { + const validExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; + const isModelProperty = entity.kind === "ModelProperty"; + if ( - !( - ["minProperties", "maxProperties", "uniqueItems", "multipleOf"].includes(extensionName) && - entity.kind === "ModelProperty" - ) && + !(validExtensions.includes(extensionName) && isModelProperty) && !isOpenAPIExtensionKey(extensionName) ) { reportDiagnostic(context.program, { code: "invalid-extension-key", + messageId: "decorator", format: { value: extensionName }, target: entity, }); + return; + } + + if (extensionName !== "uniqueItems" && value === undefined) { + reportDiagnostic(context.program, { + code: "missing-extension-value", + format: { extension: extensionName }, + target: entity, + }); + return; } let inputData: any = true; if (value !== undefined) { const [data, diagnostics] = typespecTypeToJson(value, entity); - if (diagnostics.length > 0) { - context.program.reportDiagnostics(diagnostics); + const numberExtensions = ["minProperties", "maxProperties", "multipleOf"]; + if (numberExtensions.includes(extensionName) && isNaN(Number(data))) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + format: { extensionName: extensionName }, + target: entity, + }); + return; } - if ( - "minProperties" === extensionName || - "maxProperties" === extensionName || - "multipleOf" === extensionName - ) { - if (isNaN(Number(data))) { - reportDiagnostic(context.program, { - code: "invalid-extension-value", - format: { value: extensionName }, - target: entity, - }); - } + if (extensionName === "uniqueItems" && data !== true && data !== false) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + messageId: "uniqueItems", + format: { extensionName: extensionName }, + target: entity, + }); + return; } switch (extensionName) { @@ -99,11 +112,22 @@ export const $extension: ExtensionDecorator = ( case "multipleOf": inputData = Number(data); break; + case "uniqueItems": + inputData = data === true ? true : false; + break; default: + if (diagnostics.length > 0) { + context.program.reportDiagnostics(diagnostics); + } inputData = data; } } - setExtension(context.program, entity, extensionName as ExtensionKey, inputData); + setExtension( + context.program, + entity, + extensionName as ExtensionKey | SchemaExtensionKey, + inputData, + ); }; /** @@ -130,7 +154,7 @@ export function setInfo( export function setExtension( program: Program, entity: Type, - extensionName: ExtensionKey, + extensionName: ExtensionKey | SchemaExtensionKey, data: unknown, ) { const openApiExtensions = program.stateMap(openApiExtensionKey); @@ -144,7 +168,10 @@ export function setExtension( * @param program Program * @param entity Type */ -export function getExtensions(program: Program, entity: Type): ReadonlyMap { +export function getExtensions( + program: Program, + entity: Type, +): ReadonlyMap { return program.stateMap(openApiExtensionKey).get(entity) ?? new Map(); } diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index fd7b990db6..b43d74dcb4 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -7,12 +7,20 @@ export const $lib = createTypeSpecLibrary({ severity: "error", messages: { default: paramMessage`OpenAPI extension must start with 'x-' but was '${"value"}'`, + decorator: paramMessage`extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was '${"value"}'`, + }, + }, + "missing-extension-value": { + severity: "error", + messages: { + default: paramMessage`extension should have a value for '${"extension"}'`, }, }, "invalid-extension-value": { severity: "error", messages: { - default: paramMessage`'minProperties'/'maxProperties'/'multipleOf' must number but '${"value"}' is not a number.'`, + default: paramMessage`'${"extensionName"}' must number.'`, + uniqueItems: paramMessage`${"extensionName"}' must boolean.`, }, }, "duplicate-type-name": { diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 9a8b600f93..e81c1ce050 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -4,6 +4,7 @@ */ export type ExtensionKey = `x-${string}`; +export type SchemaExtensionKey = "minProperties" | "maxProperties" | "uniqueItems" | "multipleOf"; /** * OpenAPI additional information */ diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index a1b3f658e4..17db01a0b7 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -46,13 +46,12 @@ describe("openapi: decorators", () => { }); describe("@extension", () => { - const extensionKeys: [string, any][] = [ + it.each([ ["minProperties", 1], ["maxProperties", 1], ["uniqueItems", true], ["multipleOf", 1], - ]; - it.each(extensionKeys)("apply extension on model prop with %s", async (key, value) => { + ])("apply extension on model prop with %s", async (key, value) => { const { prop } = await runner.compile(` model Foo { @extension("${key}", ${value}) @@ -66,6 +65,23 @@ describe("openapi: decorators", () => { }); }); + it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])( + "%s, emit diagnostics when passing invalid extension value", + async (key) => { + const diagnostics = await runner.diagnose(` + model Foo { + @extension("${key}", "string") + @test + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-value", + }); + }, + ); + it("apply extension on model", async () => { const { Foo } = await runner.compile(` @extension("x-custom", "Bar") diff --git a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md index 97611a9a27..be6d0351ac 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -37,7 +37,7 @@ op listPets(): Pet[] | PetStoreResponse; Attach some custom data to the OpenAPI element generated from this type. ```typespec -@TypeSpec.OpenAPI.extension(key: valueof string, value: unknown) +@TypeSpec.OpenAPI.extension(key: valueof string, value?: unknown) ``` #### Target @@ -46,10 +46,10 @@ Attach some custom data to the OpenAPI element generated from this type. #### Parameters -| Name | Type | Description | -| ----- | ---------------- | ----------------------------------- | -| key | `valueof string` | Extension key. Must start with `x-` | -| value | `unknown` | Extension value. | +| Name | Type | Description | +| ----- | ---------------- | ----------------------------------------------------------------------------------------------------------- | +| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` | +| value | `unknown` | Extension value. | #### Examples @@ -64,6 +64,42 @@ Attach some custom data to the OpenAPI element generated from this type. op read(): string; ``` +##### Specify that every item in the array must be unique. + +```typespec +model Foo { + @extension("uniqueItems") + x: unknown[]; +} +``` + +##### Specify that the numeric type must be a multiple of some numeric value. + +```typespec +model Foo { + @extension("multipleOf", 1) + x: int32; +} +``` + +##### Specify the maximum number of properties this object can have. + +```typespec +model Foo { + @extension("maxProperties", 1) + x: int32; +} +``` + +##### Specify the minimum number of properties this object can have. + +```typespec +model Foo { + @extension("minProperties", 1) + x: int32; +} +``` + ### `@externalDocs` {#@TypeSpec.OpenAPI.externalDocs} Specify the OpenAPI `externalDocs` property for this type. From 5059191ce460b840706675d7bdef8e0bcac6ebb2 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 25 Nov 2024 11:00:45 +0800 Subject: [PATCH 04/12] up --- packages/openapi/test/decorators.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 17db01a0b7..7bf8a9f0d9 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -135,7 +135,7 @@ describe("openapi: decorators", () => { expectDiagnostics(diagnostics, { code: "@typespec/openapi/invalid-extension-key", - message: `OpenAPI extension must start with 'x-' but was 'foo'`, + message: `extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was 'foo'`, }); }); }); From 037dc16bcd1bf53094936f3127d83b3037c6fd30 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 2 Dec 2024 15:22:54 +0800 Subject: [PATCH 05/12] change the defind and message --- .chronus/changes/MissingDecorators-2024-10-22-15-39-24.md | 3 ++- .chronus/changes/MissingDecorators-2024-10-22-15-44-1.md | 7 ------- packages/openapi/README.md | 6 +++--- packages/openapi/generated-defs/TypeSpec.OpenAPI.ts | 6 +++--- packages/openapi/lib/decorators.tsp | 6 +++--- packages/openapi/src/decorators.ts | 2 +- packages/openapi/src/lib.ts | 4 ++-- packages/openapi/test/decorators.test.ts | 2 +- .../docs/docs/libraries/openapi/reference/decorators.md | 6 +++--- 9 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 .chronus/changes/MissingDecorators-2024-10-22-15-44-1.md diff --git a/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md b/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md index bd7cf931a2..8c33bc6e77 100644 --- a/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md +++ b/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md @@ -2,6 +2,7 @@ changeKind: feature packages: - "@typespec/openapi" + - "@typespec/openapi3" --- -@extension decorator supports multipleOf, uniqueItems, maxProperties, and minProperties +@extension decorator supports multipleOf, uniqueItems, maxProperties, and minProperties, apply to properties in the Schema Object. diff --git a/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md b/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md deleted file mode 100644 index 6313149f9a..0000000000 --- a/.chronus/changes/MissingDecorators-2024-10-22-15-44-1.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/openapi3" ---- - -@extension decorator supports keywords: multipleOf, uniqueItems, maxProperties, and minProperties,apply to properties in the Schema Object. diff --git a/packages/openapi/README.md b/packages/openapi/README.md index f1c64936e1..5c85a41fb8 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -50,7 +50,7 @@ op listPets(): Pet[] | PetStoreResponse; Attach some custom data to the OpenAPI element generated from this type. ```typespec -@TypeSpec.OpenAPI.extension(key: valueof string, value?: unknown) +@TypeSpec.OpenAPI.extension(key: valueof string, value: unknown) ``` ##### Target @@ -61,7 +61,7 @@ Attach some custom data to the OpenAPI element generated from this type. | Name | Type | Description | | ----- | ---------------- | ----------------------------------------------------------------------------------------------------------- | -| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` | +| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. The extension key must start with `x-` | | value | `unknown` | Extension value. | ##### Examples @@ -81,7 +81,7 @@ op read(): string; ```typespec model Foo { - @extension("uniqueItems") + @extension("uniqueItems", true) x: unknown[]; } ``` diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index c01379c37d..e76b1b557b 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -31,7 +31,7 @@ export type OperationIdDecorator = ( /** * Attach some custom data to the OpenAPI element generated from this type. * - * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` + * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. The extension key must start with `x-` * @param value Extension value. * @example * ```typespec @@ -42,7 +42,7 @@ export type OperationIdDecorator = ( * @example Specify that every item in the array must be unique. * ```typespec * model Foo { - * @extension("uniqueItems") + * @extension("uniqueItems", true) * x: unknown[]; * }; * ``` @@ -72,7 +72,7 @@ export type ExtensionDecorator = ( context: DecoratorContext, target: Type, key: string, - value?: Type, + value: Type, ) => void; /** diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index d03cc11419..fd6c70cf22 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -19,7 +19,7 @@ extern dec operationId(target: Operation, operationId: valueof string); /** * Attach some custom data to the OpenAPI element generated from this type. * - * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` + * @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. The extension key must start with `x-` * @param value Extension value. * * @example @@ -32,7 +32,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * @example Specify that every item in the array must be unique. * ```typespec * model Foo { - * @extension("uniqueItems") + * @extension("uniqueItems", true) * x: unknown[]; * }; * ``` @@ -61,7 +61,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * }; * ``` */ -extern dec extension(target: unknown, key: valueof string, value?: unknown); +extern dec extension(target: unknown, key: valueof string, value: unknown); /** * Specify that this model is to be treated as the OpenAPI `default` response. diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 8f9243fae1..1a42cbd8ed 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -56,7 +56,7 @@ export const $extension: ExtensionDecorator = ( context: DecoratorContext, entity: Type, extensionName: string, - value?: TypeSpecValue, + value: TypeSpecValue, ) => { const validExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; const isModelProperty = entity.kind === "ModelProperty"; diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index b43d74dcb4..5e8e31701f 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -7,13 +7,13 @@ export const $lib = createTypeSpecLibrary({ severity: "error", messages: { default: paramMessage`OpenAPI extension must start with 'x-' but was '${"value"}'`, - decorator: paramMessage`extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was '${"value"}'`, + decorator: paramMessage`Extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was '${"value"}'`, }, }, "missing-extension-value": { severity: "error", messages: { - default: paramMessage`extension should have a value for '${"extension"}'`, + default: paramMessage`Extension should have a value for '${"extension"}'`, }, }, "invalid-extension-value": { diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 7bf8a9f0d9..08152205dd 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -135,7 +135,7 @@ describe("openapi: decorators", () => { expectDiagnostics(diagnostics, { code: "@typespec/openapi/invalid-extension-key", - message: `extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was 'foo'`, + message: `Extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was 'foo'`, }); }); }); diff --git a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md index be6d0351ac..be0666b1ee 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -37,7 +37,7 @@ op listPets(): Pet[] | PetStoreResponse; Attach some custom data to the OpenAPI element generated from this type. ```typespec -@TypeSpec.OpenAPI.extension(key: valueof string, value?: unknown) +@TypeSpec.OpenAPI.extension(key: valueof string, value: unknown) ``` #### Target @@ -48,7 +48,7 @@ Attach some custom data to the OpenAPI element generated from this type. | Name | Type | Description | | ----- | ---------------- | ----------------------------------------------------------------------------------------------------------- | -| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` | +| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. The extension key must start with `x-` | | value | `unknown` | Extension value. | #### Examples @@ -68,7 +68,7 @@ op read(): string; ```typespec model Foo { - @extension("uniqueItems") + @extension("uniqueItems", true) x: unknown[]; } ``` From a86ab0995997667cb47ccda1419574e127c50691 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 2 Dec 2024 18:06:59 +0800 Subject: [PATCH 06/12] refact get for decorator --- packages/openapi/README.md | 8 +- .../generated-defs/TypeSpec.OpenAPI.ts | 8 +- packages/openapi/lib/decorators.tsp | 8 +- packages/openapi/src/decorators.ts | 124 ++++++++++++------ packages/openapi/src/index.ts | 1 + packages/openapi/src/lib.ts | 6 + packages/openapi/test/decorators.test.ts | 20 ++- packages/openapi3/src/openapi.ts | 26 ++-- packages/openapi3/src/schema-emitter.ts | 12 ++ packages/openapi3/test/extension.test.ts | 6 +- .../libraries/openapi/reference/decorators.md | 8 +- 11 files changed, 154 insertions(+), 73 deletions(-) diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 5c85a41fb8..fbe4101f53 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -77,7 +77,7 @@ Attach some custom data to the OpenAPI element generated from this type. op read(): string; ``` -###### Specify that every item in the array must be unique. +###### A schema can ensure that each of the items in an array is unique. ```typespec model Foo { @@ -86,7 +86,7 @@ model Foo { } ``` -###### Specify that the numeric type must be a multiple of some numeric value. +###### Numbers can be restricted to a multiple of a given number, using the multipleOf keyword. It may be set to any positive number. ```typespec model Foo { @@ -95,7 +95,7 @@ model Foo { } ``` -###### Specify the maximum number of properties this object can have. +###### The number of properties on an object can be restricted using the maxProperties keyword. ```typespec model Foo { @@ -104,7 +104,7 @@ model Foo { } ``` -###### Specify the minimum number of properties this object can have. +###### The number of properties on an object can be restricted using the minProperties keyword. ```typespec model Foo { diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index e76b1b557b..886b697394 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -39,28 +39,28 @@ export type OperationIdDecorator = ( * @extension("x-pageable", {nextLink: "x-next-link"}) * op read(): string; * ``` - * @example Specify that every item in the array must be unique. + * @example A schema can ensure that each of the items in an array is unique. * ```typespec * model Foo { * @extension("uniqueItems", true) * x: unknown[]; * }; * ``` - * @example Specify that the numeric type must be a multiple of some numeric value. + * @example Numbers can be restricted to a multiple of a given number, using the multipleOf keyword. It may be set to any positive number. * ```typespec * model Foo { * @extension("multipleOf", 1) * x: int32; * }; * ``` - * @example Specify the maximum number of properties this object can have. + * @example The number of properties on an object can be restricted using the maxProperties keyword. * ```typespec * model Foo { * @extension("maxProperties", 1) * x: int32; * }; * ``` - * @example Specify the minimum number of properties this object can have. + * @example The number of properties on an object can be restricted using the minProperties keyword. * ```typespec * model Foo { * @extension("minProperties", 1) diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index fd6c70cf22..5e9075aeb5 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -29,7 +29,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * op read(): string; * ``` * - * @example Specify that every item in the array must be unique. + * @example A schema can ensure that each of the items in an array is unique. * ```typespec * model Foo { * @extension("uniqueItems", true) @@ -37,7 +37,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * }; * ``` * - * @example Specify that the numeric type must be a multiple of some numeric value. + * @example Numbers can be restricted to a multiple of a given number, using the multipleOf keyword. It may be set to any positive number. * ```typespec * model Foo { * @extension("multipleOf", 1) @@ -45,7 +45,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * }; * ``` * - * @example Specify the maximum number of properties this object can have. + * @example The number of properties on an object can be restricted using the maxProperties keyword. * ```typespec * model Foo { * @extension("maxProperties", 1) @@ -53,7 +53,7 @@ extern dec operationId(target: Operation, operationId: valueof string); * }; * ``` * - * @example Specify the minimum number of properties this object can have. + * @example The number of properties on an object can be restricted using the minProperties keyword. * ```typespec * model Foo { * @extension("minProperties", 1) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 1a42cbd8ed..47db670e08 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -4,6 +4,7 @@ import { getDoc, getService, getSummary, + isArrayModelType, Model, Namespace, Operation, @@ -26,6 +27,7 @@ import { import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; import { createStateSymbol, OpenAPIKeys, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs, SchemaExtensionKey } from "./types.js"; +const schemaExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; const operationIdsKey = createStateSymbol("operationIds"); /** @@ -58,11 +60,10 @@ export const $extension: ExtensionDecorator = ( extensionName: string, value: TypeSpecValue, ) => { - const validExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; const isModelProperty = entity.kind === "ModelProperty"; if ( - !(validExtensions.includes(extensionName) && isModelProperty) && + !(schemaExtensions.includes(extensionName) && isModelProperty) && !isOpenAPIExtensionKey(extensionName) ) { reportDiagnostic(context.program, { @@ -83,45 +84,58 @@ export const $extension: ExtensionDecorator = ( return; } - let inputData: any = true; - if (value !== undefined) { - const [data, diagnostics] = typespecTypeToJson(value, entity); - const numberExtensions = ["minProperties", "maxProperties", "multipleOf"]; - if (numberExtensions.includes(extensionName) && isNaN(Number(data))) { - reportDiagnostic(context.program, { - code: "invalid-extension-value", - format: { extensionName: extensionName }, - target: entity, - }); - return; - } + const [data, diagnostics] = typespecTypeToJson(value, entity); + const numberExtensions = ["minProperties", "maxProperties", "multipleOf"]; + if (numberExtensions.includes(extensionName) && isNaN(Number(data))) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + format: { extensionName: extensionName }, + target: entity, + }); + return; + } - if (extensionName === "uniqueItems" && data !== true && data !== false) { - reportDiagnostic(context.program, { - code: "invalid-extension-value", - messageId: "uniqueItems", - format: { extensionName: extensionName }, - target: entity, - }); - return; - } + if (extensionName === "uniqueItems" && data !== true && data !== false) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + messageId: "uniqueItems", + format: { extensionName: extensionName }, + target: entity, + }); + return; + } - switch (extensionName) { - case "minProperties": - case "maxProperties": - case "multipleOf": - inputData = Number(data); - break; - case "uniqueItems": - inputData = data === true ? true : false; - break; - default: - if (diagnostics.length > 0) { - context.program.reportDiagnostics(diagnostics); - } - inputData = data; - } + if ( + extensionName === "uniqueItems" && + isModelProperty && + ((entity.type.kind === "Model" && !isArrayModelType(context.program, entity.type)) || + entity.type.kind === "Scalar") + ) { + reportDiagnostic(context.program, { + code: "invalid-target-uniqueItems", + format: { paramName: entity.kind }, + target: entity, + }); + return; } + + let inputData: any; + switch (extensionName) { + case "minProperties": + case "maxProperties": + case "multipleOf": + inputData = Number(data); + break; + case "uniqueItems": + inputData = data === true ? true : false; + break; + default: + if (diagnostics.length > 0) { + context.program.reportDiagnostics(diagnostics); + } + inputData = data; + } + setExtension( context.program, entity, @@ -168,11 +182,39 @@ export function setExtension( * @param program Program * @param entity Type */ -export function getExtensions( +export function getExtensions(program: Program, entity: Type): ReadonlyMap { + const allExtensions = program.stateMap(openApiExtensionKey).get(entity); + return allExtensions + ? filterSchemaExtensions(allExtensions, false) + : new Map(); +} + +/** + * Get schema extensions set for the given type. + * @param program Program + * @param entity Type + */ +export function getSchemaExtensions( program: Program, entity: Type, -): ReadonlyMap { - return program.stateMap(openApiExtensionKey).get(entity) ?? new Map(); +): ReadonlyMap { + const allExtensions = program.stateMap(openApiExtensionKey).get(entity); + return allExtensions + ? filterSchemaExtensions(allExtensions, true) + : new Map(); +} + +function filterSchemaExtensions( + extensions: Map, + isSchema: boolean, +): ReadonlyMap { + const result = new Map(); + for (const [key, value] of extensions) { + if (schemaExtensions.includes(key) === isSchema) { + result.set(key as T, value); + } + } + return result; } /** diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 714fbba3c6..0789ee4808 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -14,6 +14,7 @@ export { getExternalDocs, getInfo, getOperationId, + getSchemaExtensions, getTagsMetadata, isDefaultResponse, resolveInfo, diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 5e8e31701f..d92acf609c 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -23,6 +23,12 @@ export const $lib = createTypeSpecLibrary({ uniqueItems: paramMessage`${"extensionName"}' must boolean.`, }, }, + "invalid-target-uniqueItems": { + severity: "error", + messages: { + default: paramMessage`'uniqueItems can only apply to arrays, but ${"paramName"} is not an array`, + }, + }, "duplicate-type-name": { severity: "error", messages: { diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 08152205dd..007e12c79b 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -6,6 +6,7 @@ import { getExtensions, getExternalDocs, getInfo, + getSchemaExtensions, getTagsMetadata, resolveInfo, setInfo, @@ -56,13 +57,14 @@ describe("openapi: decorators", () => { model Foo { @extension("${key}", ${value}) @test - prop: string + prop: string[] } `); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), { + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, prop)), { [key]: value, }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), {}); }); it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])( @@ -82,6 +84,20 @@ describe("openapi: decorators", () => { }, ); + it("uniqueItems can only apply to arrays", async () => { + const diagnostics = await runner.diagnose(` + model Foo { + @extension("uniqueItems", true) + @test + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-target-uniqueItems", + }); + }); + it("apply extension on model", async () => { const { Foo } = await runner.compile(` @extension("x-custom", "Bar") diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 658a080cc2..1c8870f543 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -78,6 +78,7 @@ import { getExternalDocs, getOpenAPITypeName, getParameterKey, + getSchemaExtensions, getTagsMetadata, isReadonlyProperty, resolveInfo, @@ -1643,20 +1644,21 @@ function createOAPIEmitter( return callSchemaEmitter(type, visibility) as any; } - function attachExtensions( - program: Program, - type: Type, - emitObject: any, - enabled: boolean = false, - ) { - const schemaExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; + function attachExtensions(program: Program, type: Type, emitObject: any) { // Attach any OpenAPI extensions const extensions = getExtensions(program, type); if (extensions) { for (const key of extensions.keys()) { - if (!enabled && schemaExtensions.includes(key)) { - continue; - } + emitObject[key] = extensions.get(key); + } + } + } + + function attachSchemaExtensions(program: Program, type: Type, emitObject: any) { + // Attach any OpenAPI extensions + const extensions = getSchemaExtensions(program, type); + if (extensions) { + for (const key of extensions.keys()) { emitObject[key] = extensions.get(key); } } @@ -1737,7 +1739,9 @@ function createOAPIEmitter( }; } - attachExtensions(program, typespecType, newTarget, true); + attachExtensions(program, typespecType, newTarget); + // attach schema extensions + attachSchemaExtensions(program, typespecType, newTarget); return newTarget; } diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 4ecf08b542..f4870a2058 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -69,6 +69,7 @@ import { getExtensions, getExternalDocs, getOpenAPITypeName, + getSchemaExtensions, isReadonlyProperty, shouldInline, } from "@typespec/openapi"; @@ -397,6 +398,7 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< // Attach any additional OpenAPI extensions this.#attachExtensions(program, prop, additionalProps); + this.#attachSchemaExtensions(program, prop, additionalProps); if (schema && isRef && !(prop.type.kind === "Model" && isArrayModelType(program, prop.type))) { if (Object.keys(additionalProps).length === 0) { @@ -680,6 +682,16 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< } } + #attachSchemaExtensions(program: Program, type: Type, emitObject: OpenAPI3Schema) { + // Attach any OpenAPI extensions + const extensions = getSchemaExtensions(program, type); + if (extensions) { + for (const key of extensions.keys()) { + emitObject[key] = extensions.get(key); + } + } + } + reference( targetDeclaration: Declaration>, pathUp: Scope>[], diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts index ca7c5c454d..8b86393371 100644 --- a/packages/openapi3/test/extension.test.ts +++ b/packages/openapi3/test/extension.test.ts @@ -16,7 +16,7 @@ describe("inline adds an extension to a parameter", () => { op get( @path @extension("${key}", ${value}) - petId: string; + petId: string[]; ): void; `, ); @@ -35,7 +35,7 @@ describe("adds an extension to a parameter", () => { model PetId { @path @extension("${key}", ${value}) - petId: string; + petId: string[]; } @route("/Pets") @get() @@ -59,7 +59,7 @@ describe("adds an extension to a model", () => { "Foo", `model Foo { @extension("${key}", ${value}) - x: int32; + x: int32[]; };`, ); diff --git a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md index be0666b1ee..d3d74c3d37 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -64,7 +64,7 @@ Attach some custom data to the OpenAPI element generated from this type. op read(): string; ``` -##### Specify that every item in the array must be unique. +##### A schema can ensure that each of the items in an array is unique. ```typespec model Foo { @@ -73,7 +73,7 @@ model Foo { } ``` -##### Specify that the numeric type must be a multiple of some numeric value. +##### Numbers can be restricted to a multiple of a given number, using the multipleOf keyword. It may be set to any positive number. ```typespec model Foo { @@ -82,7 +82,7 @@ model Foo { } ``` -##### Specify the maximum number of properties this object can have. +##### The number of properties on an object can be restricted using the maxProperties keyword. ```typespec model Foo { @@ -91,7 +91,7 @@ model Foo { } ``` -##### Specify the minimum number of properties this object can have. +##### The number of properties on an object can be restricted using the minProperties keyword. ```typespec model Foo { From 1bfe9bb52fe0e4c113088009080dcf78a23edba7 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Tue, 3 Dec 2024 16:11:48 +0800 Subject: [PATCH 07/12] up --- packages/openapi/src/decorators.ts | 9 --------- packages/openapi/src/lib.ts | 6 ------ 2 files changed, 15 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 47db670e08..bd7e352a34 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -75,15 +75,6 @@ export const $extension: ExtensionDecorator = ( return; } - if (extensionName !== "uniqueItems" && value === undefined) { - reportDiagnostic(context.program, { - code: "missing-extension-value", - format: { extension: extensionName }, - target: entity, - }); - return; - } - const [data, diagnostics] = typespecTypeToJson(value, entity); const numberExtensions = ["minProperties", "maxProperties", "multipleOf"]; if (numberExtensions.includes(extensionName) && isNaN(Number(data))) { diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index d92acf609c..cdf16f63d9 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -10,12 +10,6 @@ export const $lib = createTypeSpecLibrary({ decorator: paramMessage`Extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was '${"value"}'`, }, }, - "missing-extension-value": { - severity: "error", - messages: { - default: paramMessage`Extension should have a value for '${"extension"}'`, - }, - }, "invalid-extension-value": { severity: "error", messages: { From 62055a0e827a2dc3ea35d9abcb266d48300f7093 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 4 Dec 2024 13:05:23 +0800 Subject: [PATCH 08/12] up --- packages/openapi/src/decorators.ts | 122 ++++++++++++----------- packages/openapi/src/lib.ts | 5 +- packages/openapi/test/decorators.test.ts | 21 +++- packages/openapi3/src/openapi.ts | 24 +++-- packages/openapi3/src/schema-emitter.ts | 13 +-- 5 files changed, 106 insertions(+), 79 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index bd7e352a34..a541a488ac 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -60,79 +60,89 @@ export const $extension: ExtensionDecorator = ( extensionName: string, value: TypeSpecValue, ) => { + // Convert the TypeSpec value to JSON and collect any diagnostics + const [data, diagnostics] = typespecTypeToJson(value, entity); const isModelProperty = entity.kind === "ModelProperty"; - if ( - !(schemaExtensions.includes(extensionName) && isModelProperty) && - !isOpenAPIExtensionKey(extensionName) - ) { - reportDiagnostic(context.program, { - code: "invalid-extension-key", - messageId: "decorator", - format: { value: extensionName }, - target: entity, - }); - return; - } + // Handle the "uniqueItems" extension + if (extensionName === "uniqueItems") { + // Check invalid target + if ( + !( + isModelProperty && + entity.type.kind === "Model" && + isArrayModelType(context.program, entity.type) + ) + ) { + // Report diagnostic if the target is invalid for "uniqueItems" + reportDiagnostic(context.program, { + code: "invalid-extension-target", + messageId: "uniqueItems", + format: { paramName: entity.kind }, + target: entity, + }); + return; + } - const [data, diagnostics] = typespecTypeToJson(value, entity); - const numberExtensions = ["minProperties", "maxProperties", "multipleOf"]; - if (numberExtensions.includes(extensionName) && isNaN(Number(data))) { - reportDiagnostic(context.program, { - code: "invalid-extension-value", - format: { extensionName: extensionName }, - target: entity, - }); + // Check invalid data, Report diagnostic if the extension value is not a boolean + if (data !== true && data !== false) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + messageId: "uniqueItems", + format: { extensionName: extensionName }, + target: entity, + }); + return; + } + // Set the extension for "uniqueItems" + setExtension(context.program, entity, extensionName, data); return; } - if (extensionName === "uniqueItems" && data !== true && data !== false) { - reportDiagnostic(context.program, { - code: "invalid-extension-value", - messageId: "uniqueItems", - format: { extensionName: extensionName }, - target: entity, - }); + // Handle other schema extensions + if (schemaExtensions.includes(extensionName) && extensionName !== "uniqueItems") { + // Check invalid target, Report diagnostic if the target is invalid for the schema extension + if (!isModelProperty) { + reportDiagnostic(context.program, { + code: "invalid-extension-target", + format: { paramName: entity.kind }, + target: entity, + }); + return; + } + // Check invalid data, Report diagnostic if the extension value is not a number + if (isNaN(Number(data))) { + reportDiagnostic(context.program, { + code: "invalid-extension-value", + format: { extensionName: extensionName }, + target: entity, + }); + return; + } + // Set the extension for the schema extension + setExtension(context.program, entity, extensionName as SchemaExtensionKey, Number(data)); return; } - if ( - extensionName === "uniqueItems" && - isModelProperty && - ((entity.type.kind === "Model" && !isArrayModelType(context.program, entity.type)) || - entity.type.kind === "Scalar") - ) { + // Check if the extensionName is a valid OpenAPI extension + if (!isOpenAPIExtensionKey(extensionName)) { reportDiagnostic(context.program, { - code: "invalid-target-uniqueItems", - format: { paramName: entity.kind }, + code: "invalid-extension-key", + messageId: "decorator", + format: { value: extensionName }, target: entity, }); return; } - let inputData: any; - switch (extensionName) { - case "minProperties": - case "maxProperties": - case "multipleOf": - inputData = Number(data); - break; - case "uniqueItems": - inputData = data === true ? true : false; - break; - default: - if (diagnostics.length > 0) { - context.program.reportDiagnostics(diagnostics); - } - inputData = data; + // Report diagnostics if invalid data is found + if (diagnostics.length > 0) { + context.program.reportDiagnostics(diagnostics); + return; } - setExtension( - context.program, - entity, - extensionName as ExtensionKey | SchemaExtensionKey, - inputData, - ); + // Set the extension for valid OpenAPI extensions + setExtension(context.program, entity, extensionName, data); }; /** diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index cdf16f63d9..0b6bd4624e 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -17,10 +17,11 @@ export const $lib = createTypeSpecLibrary({ uniqueItems: paramMessage`${"extensionName"}' must boolean.`, }, }, - "invalid-target-uniqueItems": { + "invalid-extension-target": { severity: "error", messages: { - default: paramMessage`'uniqueItems can only apply to arrays, but ${"paramName"} is not an array`, + default: paramMessage`'minProperties/maxProperties/uniqueItems/multipleOf can only apply to property, but ${"paramName"} is not`, + uniqueItems: paramMessage`'uniqueItems can only apply to arrays, but ${"paramName"} is not an array`, }, }, "duplicate-type-name": { diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 007e12c79b..9fef71b241 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -74,7 +74,7 @@ describe("openapi: decorators", () => { model Foo { @extension("${key}", "string") @test - prop: string + prop: string[] } `); @@ -84,6 +84,23 @@ describe("openapi: decorators", () => { }, ); + it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])( + "%s, emit diagnostics when passing invalid target", + async (key) => { + const diagnostics = await runner.diagnose(` + @extension("${key}", "string") + @test + model Foo { + prop: string[] + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-target", + }); + }, + ); + it("uniqueItems can only apply to arrays", async () => { const diagnostics = await runner.diagnose(` model Foo { @@ -94,7 +111,7 @@ describe("openapi: decorators", () => { `); expectDiagnostics(diagnostics, { - code: "@typespec/openapi/invalid-target-uniqueItems", + code: "@typespec/openapi/invalid-extension-target", }); }); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 1c8870f543..2081cffa8e 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1644,7 +1644,12 @@ function createOAPIEmitter( return callSchemaEmitter(type, visibility) as any; } - function attachExtensions(program: Program, type: Type, emitObject: any) { + function attachExtensions( + program: Program, + type: Type, + emitObject: any, + ignoreSchemaExtensions = false, + ) { // Attach any OpenAPI extensions const extensions = getExtensions(program, type); if (extensions) { @@ -1652,14 +1657,13 @@ function createOAPIEmitter( emitObject[key] = extensions.get(key); } } - } - function attachSchemaExtensions(program: Program, type: Type, emitObject: any) { - // Attach any OpenAPI extensions - const extensions = getSchemaExtensions(program, type); - if (extensions) { - for (const key of extensions.keys()) { - emitObject[key] = extensions.get(key); + if (ignoreSchemaExtensions) { + const schemaextensions = getSchemaExtensions(program, type); + if (schemaextensions) { + for (const key of schemaextensions.keys()) { + emitObject[key] = schemaextensions.get(key); + } } } } @@ -1739,9 +1743,7 @@ function createOAPIEmitter( }; } - attachExtensions(program, typespecType, newTarget); - // attach schema extensions - attachSchemaExtensions(program, typespecType, newTarget); + attachExtensions(program, typespecType, newTarget, true); return newTarget; } diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index f4870a2058..2319e59509 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -398,7 +398,6 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< // Attach any additional OpenAPI extensions this.#attachExtensions(program, prop, additionalProps); - this.#attachSchemaExtensions(program, prop, additionalProps); if (schema && isRef && !(prop.type.kind === "Model" && isArrayModelType(program, prop.type))) { if (Object.keys(additionalProps).length === 0) { @@ -680,14 +679,12 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< emitObject[key] = extensions.get(key); } } - } - #attachSchemaExtensions(program: Program, type: Type, emitObject: OpenAPI3Schema) { - // Attach any OpenAPI extensions - const extensions = getSchemaExtensions(program, type); - if (extensions) { - for (const key of extensions.keys()) { - emitObject[key] = extensions.get(key); + // Attach any OpenAPI schema extensions + const schemaExtensions = getSchemaExtensions(program, type); + if (schemaExtensions) { + for (const key of schemaExtensions.keys()) { + emitObject[key] = schemaExtensions.get(key); } } } From eef3a421ab6e2dfbd8fc2b83a8179461eb3467f4 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 4 Dec 2024 14:35:18 +0800 Subject: [PATCH 09/12] up --- packages/openapi/src/lib.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 0b6bd4624e..8beba97d7e 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -20,8 +20,8 @@ export const $lib = createTypeSpecLibrary({ "invalid-extension-target": { severity: "error", messages: { - default: paramMessage`'minProperties/maxProperties/uniqueItems/multipleOf can only apply to property, but ${"paramName"} is not`, - uniqueItems: paramMessage`'uniqueItems can only apply to arrays, but ${"paramName"} is not an array`, + default: paramMessage`'minProperties/maxProperties/multipleOf' can only apply to property, but ${"paramName"} is not`, + uniqueItems: paramMessage`'uniqueItems' can only be apply to properties that are arrays, but ${"paramName"} is not`, }, }, "duplicate-type-name": { From 756d9cecddd1d6da91fd8afafad79aa25c9ceb19 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 6 Dec 2024 15:50:21 +0800 Subject: [PATCH 10/12] check target for minProperties/maxProperties/multipleOf --- packages/openapi/src/decorators.ts | 37 ++++-- packages/openapi/src/lib.ts | 3 +- packages/openapi/test/decorators.test.ts | 149 ++++++++++++++++++++--- 3 files changed, 162 insertions(+), 27 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index a541a488ac..130217d8b4 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -63,6 +63,8 @@ export const $extension: ExtensionDecorator = ( // Convert the TypeSpec value to JSON and collect any diagnostics const [data, diagnostics] = typespecTypeToJson(value, entity); const isModelProperty = entity.kind === "ModelProperty"; + const isMode = entity.kind === "Model"; + const isScalar = entity.kind === "Scalar"; // Handle the "uniqueItems" extension if (extensionName === "uniqueItems") { @@ -101,15 +103,34 @@ export const $extension: ExtensionDecorator = ( // Handle other schema extensions if (schemaExtensions.includes(extensionName) && extensionName !== "uniqueItems") { - // Check invalid target, Report diagnostic if the target is invalid for the schema extension - if (!isModelProperty) { - reportDiagnostic(context.program, { - code: "invalid-extension-target", - format: { paramName: entity.kind }, - target: entity, - }); - return; + // Handle the "multipleOf" extension + const isNumber = (name: string) => name !== "string" && name !== "boolean"; + if (extensionName === "multipleOf") { + if ( + isMode || + (isScalar && !isNumber(entity.name)) || + (isModelProperty && !(entity.type.kind === "Scalar" && isNumber(entity.type.name))) + ) { + reportDiagnostic(context.program, { + code: "invalid-extension-target", + messageId: "multipleOf", + format: { paramName: entity.kind }, + target: entity, + }); + return; + } + } else { + // Handle the "minProperties/maxProperties" extension + if (!isMode) { + reportDiagnostic(context.program, { + code: "invalid-extension-target", + format: { paramName: entity.kind }, + target: entity, + }); + return; + } } + // Check invalid data, Report diagnostic if the extension value is not a number if (isNaN(Number(data))) { reportDiagnostic(context.program, { diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 8beba97d7e..9acd667ee1 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -20,8 +20,9 @@ export const $lib = createTypeSpecLibrary({ "invalid-extension-target": { severity: "error", messages: { - default: paramMessage`'minProperties/maxProperties/multipleOf' can only apply to property, but ${"paramName"} is not`, + default: paramMessage`'minProperties/maxProperties' can only apply to model, but ${"paramName"} is not`, uniqueItems: paramMessage`'uniqueItems' can only be apply to properties that are arrays, but ${"paramName"} is not`, + multipleOf: paramMessage`'multipleOf' can only be apply to properties that are number, but ${"paramName"} is not`, }, }, "duplicate-type-name": { diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 9fef71b241..8f791761c6 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -47,34 +47,120 @@ describe("openapi: decorators", () => { }); describe("@extension", () => { + const scalarNumberTypes = [ + "integer", + "int8", + "int16", + "int32", + "int64", + "safeint", + "uint8", + "uint16", + "uint32", + "uint64", + "float", + "float32", + "float64", + "decimal", + "decimal128", + ]; it.each([ ["minProperties", 1], ["maxProperties", 1], - ["uniqueItems", true], - ["multipleOf", 1], - ])("apply extension on model prop with %s", async (key, value) => { - const { prop } = await runner.compile(` + ])("apply extension on model with %s", async (key, value) => { + const { Foo } = await runner.compile(` + @extension("${key}", ${value}) + @test model Foo { - @extension("${key}", ${value}) - @test - prop: string[] + prop: integer } `); - deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, prop)), { + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, Foo)), { [key]: value, }); - deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), {}); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), {}); }); - it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])( + // multipleOf + it.each(scalarNumberTypes)( + "apply multipleOf extension on scalar, type is %s", + async (targetType) => { + const { a } = await runner.compile(` + @extension("multipleOf", 1) + @test + scalar a extends ${targetType}; + `); + + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, a)), { + multipleOf: 1, + }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, a)), {}); + }, + ); + + it.each(scalarNumberTypes)( + "apply multipleOf extension on model prop, type is %s", + async (targetType) => { + const { prop } = await runner.compile(` + model Foo { + @extension("multipleOf", 1) + @test + prop: ${targetType} + } + `); + + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, prop)), { + multipleOf: 1, + }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), {}); + }, + ); + + it.each(["numeric[]", "string[]"])( + "apply uniqueItems extension on model prop with %s", + async (targetType) => { + const { prop } = await runner.compile(` + model Foo { + @extension("uniqueItems", true) + @test + prop: ${targetType} + } + `); + + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, prop)), { + uniqueItems: true, + }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), {}); + }, + ); + + it.each(["minProperties", "maxProperties"])( "%s, emit diagnostics when passing invalid extension value", async (key) => { + const diagnostics = await runner.diagnose(` + @extension("${key}", "string") + model Foo { + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-value", + }); + }, + ); + + it.each([ + ["uniqueItems", 1, "string[]"], + ["multipleOf", "string", "integer"], + ])( + "%s, emit diagnostics when passing invalid extension value", + async (key, value, targetType) => { const diagnostics = await runner.diagnose(` model Foo { - @extension("${key}", "string") - @test - prop: string[] + @extension("${key}", ${value}) + prop: ${targetType} } `); @@ -84,14 +170,13 @@ describe("openapi: decorators", () => { }, ); - it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])( - "%s, emit diagnostics when passing invalid target", + it.each(["minProperties", "maxProperties"])( + "%s, emit diagnostics when passing invalid target - Model Prop", async (key) => { const diagnostics = await runner.diagnose(` - @extension("${key}", "string") - @test model Foo { - prop: string[] + @extension("${key}", 1) + prop: string } `); @@ -101,6 +186,20 @@ describe("openapi: decorators", () => { }, ); + it.each(["minProperties", "maxProperties"])( + "%s, emit diagnostics when passing invalid target - Scalar", + async (key) => { + const diagnostics = await runner.diagnose(` + @extension("${key}", 1) + scalar Foo extends string; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-target", + }); + }, + ); + it("uniqueItems can only apply to arrays", async () => { const diagnostics = await runner.diagnose(` model Foo { @@ -115,6 +214,20 @@ describe("openapi: decorators", () => { }); }); + it("multipleOf can only apply to number", async () => { + const diagnostics = await runner.diagnose(` + model Foo { + @extension("multipleOf", 1) + @test + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-target", + }); + }); + it("apply extension on model", async () => { const { Foo } = await runner.compile(` @extension("x-custom", "Bar") From c853c312586a43be1cd0ddc8b964e075bf90809f Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 6 Dec 2024 17:15:37 +0800 Subject: [PATCH 11/12] up --- packages/openapi3/test/extension.test.ts | 53 ++++++++++++++++++------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts index 8b86393371..8b596615b0 100644 --- a/packages/openapi3/test/extension.test.ts +++ b/packages/openapi3/test/extension.test.ts @@ -3,20 +3,20 @@ import { describe, expect, it } from "vitest"; import { oapiForModel, openApiFor } from "./test-host.js"; -const extensionKeys: [string, any][] = [ - ["minProperties", 1], - ["maxProperties", 1], - ["uniqueItems", true], - ["multipleOf", 1], +const extensionKeysForObject: string[] = ["minProperties", "maxProperties"]; + +const extensionKeysForModelProperties: [string, any, string][] = [ + ["uniqueItems", true, "string[]"], + ["multipleOf", 1, "integer"], ]; describe("inline adds an extension to a parameter", () => { - it.each(extensionKeys)("%s", async (key, value) => { + it.each(extensionKeysForModelProperties)("%s", async (key, value, targetType) => { const oapi = await openApiFor( ` op get( @path @extension("${key}", ${value}) - petId: string[]; + petId: ${targetType}; ): void; `, ); @@ -26,7 +26,7 @@ describe("inline adds an extension to a parameter", () => { }); describe("adds an extension to a parameter", () => { - it.each(extensionKeys)("%s", async (key, value) => { + it.each(extensionKeysForModelProperties)("%s", async (key, value, targetType) => { const oapi = await openApiFor( ` model Pet { @@ -35,7 +35,7 @@ describe("adds an extension to a parameter", () => { model PetId { @path @extension("${key}", ${value}) - petId: string[]; + petId: ${targetType}; } @route("/Pets") @get() @@ -53,13 +53,13 @@ describe("adds an extension to a parameter", () => { }); }); -describe("adds an extension to a model", () => { - it.each(extensionKeys)("%s", async (key, value) => { +describe("adds an extension", () => { + it.each(extensionKeysForModelProperties)("%s to a model prop", async (key, value, targetType) => { const res = await oapiForModel( "Foo", `model Foo { @extension("${key}", ${value}) - x: int32[]; + x: ${targetType}; };`, ); @@ -70,4 +70,33 @@ describe("adds an extension to a model", () => { }, }); }); + + it.each(extensionKeysForObject)("%s to a model", async (key) => { + const res = await oapiForModel( + "Foo", + ` + @extension("${key}", 1) + model Foo { + x: string; + };`, + ); + + expect(res.schemas.Foo).toMatchObject({ + required: ["x"], + [key]: 1, + }); + }); + + it("apply multipleOf extension on scalar", async () => { + const res = await oapiForModel( + "a", + ` + @extension("multipleOf", 1) + scalar a extends integer;`, + ); + + expect(res.schemas.a).toMatchObject({ + multipleOf: 1, + }); + }); }); From 7223ea67f12a6fa5c876976aeadd9030ca569815 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 6 Dec 2024 19:45:03 +0800 Subject: [PATCH 12/12] add warning if set minProperties/maxProperties on components.parameters --- packages/openapi3/src/lib.ts | 6 ++++++ packages/openapi3/src/openapi.ts | 16 ++++++++++++++++ packages/openapi3/test/extension.test.ts | 22 ++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index bd994c9a85..4a743e96dd 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -281,6 +281,12 @@ export const libDef = { default: paramMessage`Invalid key '${"value"}' used in a fixed field of the Component object. Only alphanumerics, dot (.), hyphen (-), and underscore (_) characters are allowed in keys.`, }, }, + "minmaxProperties-invalid-model": { + severity: "warning", + messages: { + default: paramMessage` \`@extension(${"key"}, ${"value"})\` is only used to specify the inclusive number of properties allowed in an object instance. currently it is not object so will be ignored.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 2081cffa8e..3101be4977 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1570,6 +1570,22 @@ function createOAPIEmitter( typeNameOptions, ); validateComponentFixedFieldKey(property, key); + const parent = property.model!; + const schemaextensions = getSchemaExtensions(program, parent); + if (schemaextensions) { + for (const key of schemaextensions.keys()) { + program.reportDiagnostic( + createDiagnostic({ + code: "minmaxProperties-invalid-model", + format: { + key: key, + value: schemaextensions.get(key), + }, + target: parent, + }), + ); + } + } root.components!.parameters![key] = { ...param }; for (const key of Object.keys(param)) { diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts index 8b596615b0..f213dd9e82 100644 --- a/packages/openapi3/test/extension.test.ts +++ b/packages/openapi3/test/extension.test.ts @@ -1,8 +1,8 @@ import { ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; -import { oapiForModel, openApiFor } from "./test-host.js"; - +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { diagnoseOpenApiFor, oapiForModel, openApiFor } from "./test-host.js"; const extensionKeysForObject: string[] = ["minProperties", "maxProperties"]; const extensionKeysForModelProperties: [string, any, string][] = [ @@ -51,6 +51,24 @@ describe("adds an extension to a parameter", () => { strictEqual(oapi.components.parameters.PetId.schema[key], value); strictEqual(oapi.components.parameters.PetId[key], undefined); }); + + it.each(extensionKeysForObject)("%s", async (key) => { + const diagnostics = await diagnoseOpenApiFor( + ` + @extension("minProperties", 1) + model Pet { + @path + path: string; + } + op get(...Pet): void; + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi3/minmaxProperties-invalid-model", + }, + ]); + }); }); describe("adds an extension", () => {