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..8c33bc6e77 --- /dev/null +++ b/.chronus/changes/MissingDecorators-2024-10-22-15-39-24.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" + - "@typespec/openapi3" +--- + +@extension decorator supports 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..fbe4101f53 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -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; ``` +###### A schema can ensure that each of the items in an array is unique. + +```typespec +model Foo { + @extension("uniqueItems", true) + x: unknown[]; +} +``` + +###### 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; +} +``` + +###### The number of properties on an object can be restricted using the maxProperties keyword. + +```typespec +model Foo { + @extension("maxProperties", 1) + x: int32; +} +``` + +###### The number of properties on an object can be restricted using the minProperties keyword. + +```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 62b959e71b..886b697394 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 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 @@ -39,6 +39,34 @@ export type OperationIdDecorator = ( * @extension("x-pageable", {nextLink: "x-next-link"}) * op read(): string; * ``` + * @example A schema can ensure that each of the items in an array is unique. + * ```typespec + * model Foo { + * @extension("uniqueItems", true) + * x: unknown[]; + * }; + * ``` + * @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 The number of properties on an object can be restricted using the maxProperties keyword. + * ```typespec + * model Foo { + * @extension("maxProperties", 1) + * x: int32; + * }; + * ``` + * @example The number of properties on an object can be restricted using the minProperties keyword. + * ```typespec + * model Foo { + * @extension("minProperties", 1) + * x: int32; + * }; + * ``` */ export type ExtensionDecorator = ( context: DecoratorContext, diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index fbeacb1909..5e9075aeb5 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -19,16 +19,47 @@ 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 A schema can ensure that each of the items in an array is unique. + * ```typespec + * model Foo { + * @extension("uniqueItems", true) + * x: unknown[]; + * }; + * ``` + * + * @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 The number of properties on an object can be restricted using the maxProperties keyword. + * ```typespec + * model Foo { + * @extension("maxProperties", 1) + * x: int32; + * }; + * ``` + * + * @example The number of properties on an object can be restricted using the minProperties keyword. + * ```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 f7adbf1caf..130217d8b4 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, @@ -25,7 +26,8 @@ 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 schemaExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"]; const operationIdsKey = createStateSymbol("operationIds"); /** @@ -58,19 +60,110 @@ 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"; + const isMode = entity.kind === "Model"; + const isScalar = entity.kind === "Scalar"; + + // 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; + } + + // 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; + } + + // Handle other schema extensions + if (schemaExtensions.includes(extensionName) && extensionName !== "uniqueItems") { + // 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, { + 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; + } + + // Check if the extensionName is a valid OpenAPI extension if (!isOpenAPIExtensionKey(extensionName)) { reportDiagnostic(context.program, { code: "invalid-extension-key", + messageId: "decorator", format: { value: extensionName }, target: entity, }); + return; } - const [data, diagnostics] = typespecTypeToJson(value, entity); + // Report diagnostics if invalid data is found if (diagnostics.length > 0) { context.program.reportDiagnostics(diagnostics); + return; } - setExtension(context.program, entity, extensionName as ExtensionKey, data); + + // Set the extension for valid OpenAPI extensions + setExtension(context.program, entity, extensionName, data); }; /** @@ -97,7 +190,7 @@ export function setInfo( export function setExtension( program: Program, entity: Type, - extensionName: ExtensionKey, + extensionName: ExtensionKey | SchemaExtensionKey, data: unknown, ) { const openApiExtensions = program.stateMap(openApiExtensionKey); @@ -112,7 +205,38 @@ export function setExtension( * @param entity Type */ export function getExtensions(program: Program, entity: Type): ReadonlyMap { - return program.stateMap(openApiExtensionKey).get(entity) ?? new Map(); + 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 { + 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 889d282b31..9acd667ee1 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -7,6 +7,22 @@ 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"}'`, + }, + }, + "invalid-extension-value": { + severity: "error", + messages: { + default: paramMessage`'${"extensionName"}' must number.'`, + uniqueItems: paramMessage`${"extensionName"}' must boolean.`, + }, + }, + "invalid-extension-target": { + severity: "error", + messages: { + 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/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 ecb93e76f9..8f791761c6 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, @@ -46,6 +47,187 @@ 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], + ])("apply extension on model with %s", async (key, value) => { + const { Foo } = await runner.compile(` + @extension("${key}", ${value}) + @test + model Foo { + prop: integer + } + `); + + deepStrictEqual(Object.fromEntries(getSchemaExtensions(runner.program, Foo)), { + [key]: value, + }); + deepStrictEqual(Object.fromEntries(getExtensions(runner.program, Foo)), {}); + }); + + // 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}", ${value}) + prop: ${targetType} + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-value", + }); + }, + ); + + it.each(["minProperties", "maxProperties"])( + "%s, emit diagnostics when passing invalid target - Model Prop", + async (key) => { + const diagnostics = await runner.diagnose(` + model Foo { + @extension("${key}", 1) + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-target", + }); + }, + ); + + 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 { + @extension("uniqueItems", true) + @test + prop: string + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-target", + }); + }); + + 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") @@ -99,7 +281,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'`, }); }); }); 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 f70ef06230..3101be4977 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, @@ -1569,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)) { @@ -1643,7 +1660,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) { @@ -1651,6 +1673,15 @@ function createOAPIEmitter( 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); + } + } + } } function applyIntrinsicDecorators(typespecType: Type, target: OpenAPI3Schema): OpenAPI3Schema { @@ -1728,7 +1759,7 @@ function createOAPIEmitter( }; } - attachExtensions(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 4ecf08b542..2319e59509 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"; @@ -678,6 +679,14 @@ export class OpenAPI3SchemaEmitter extends TypeEmitter< 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); + } + } } reference( diff --git a/packages/openapi3/test/extension.test.ts b/packages/openapi3/test/extension.test.ts new file mode 100644 index 0000000000..f213dd9e82 --- /dev/null +++ b/packages/openapi3/test/extension.test.ts @@ -0,0 +1,120 @@ +import { ok, strictEqual } from "assert"; +import { describe, expect, it } from "vitest"; + +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { diagnoseOpenApiFor, oapiForModel, openApiFor } from "./test-host.js"; +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(extensionKeysForModelProperties)("%s", async (key, value, targetType) => { + const oapi = await openApiFor( + ` + op get( + @path + @extension("${key}", ${value}) + petId: ${targetType}; + ): void; + `, + ); + 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(extensionKeysForModelProperties)("%s", async (key, value, targetType) => { + const oapi = await openApiFor( + ` + model Pet { + name: string; + } + model PetId { + @path + @extension("${key}", ${value}) + petId: ${targetType}; + } + @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], 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", () => { + it.each(extensionKeysForModelProperties)("%s to a model prop", async (key, value, targetType) => { + const res = await oapiForModel( + "Foo", + `model Foo { + @extension("${key}", ${value}) + x: ${targetType}; + };`, + ); + + expect(res.schemas.Foo).toMatchObject({ + required: ["x"], + properties: { + x: { [key]: value }, + }, + }); + }); + + 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, + }); + }); +}); 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..d3d74c3d37 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -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; ``` +##### A schema can ensure that each of the items in an array is unique. + +```typespec +model Foo { + @extension("uniqueItems", true) + x: unknown[]; +} +``` + +##### 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; +} +``` + +##### The number of properties on an object can be restricted using the maxProperties keyword. + +```typespec +model Foo { + @extension("maxProperties", 1) + x: int32; +} +``` + +##### The number of properties on an object can be restricted using the minProperties keyword. + +```typespec +model Foo { + @extension("minProperties", 1) + x: int32; +} +``` + ### `@externalDocs` {#@TypeSpec.OpenAPI.externalDocs} Specify the OpenAPI `externalDocs` property for this type.