From 559b63a961521f9419cb58e6907c39d889b5e497 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 21 Oct 2024 17:11:36 +0800 Subject: [PATCH 01/46] initial --- .../generated-defs/TypeSpec.OpenAPI.ts | 23 +++++++- packages/openapi3/lib/decorators.tsp | 13 +++++ packages/openapi3/src/decorators.ts | 56 ++++++++++++++++++- packages/openapi3/src/tsp-index.ts | 3 +- packages/openapi3/src/types.ts | 13 ++++- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts index 1a9d7dc675..e36db2fb57 100644 --- a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts @@ -1,4 +1,11 @@ -import type { DecoratorContext, Model, ModelProperty, Union } from "@typespec/compiler"; +import type { + DecoratorContext, + Model, + ModelProperty, + Namespace, + Type, + Union, +} from "@typespec/compiler"; /** * Specify that `oneOf` should be used instead of `anyOf` for that union. @@ -16,7 +23,21 @@ export type UseRefDecorator = ( ref: string, ) => void; +/** + * Specify OpenAPI additional information. + * + * @param name tag name + * @param additionalTag Additional information + */ +export type TagMetadataDecorator = ( + context: DecoratorContext, + target: Namespace, + name: string, + additionalTag?: Type, +) => void; + export type TypeSpecOpenAPIDecorators = { oneOf: OneOfDecorator; useRef: UseRefDecorator; + tagMetadata: TagMetadataDecorator; }; diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 4558244148..3edc0a43a3 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -13,3 +13,16 @@ extern dec oneOf(target: Union | ModelProperty); * @param ref External reference(e.g. "../../common.json#/components/schemas/Foo") */ extern dec useRef(target: Model | ModelProperty, ref: valueof string); + +/** Additional information for the OpenAPI document. */ +model AdditionalTag { + /** A description of the API. */ + description?: string; +} + +/** + * Specify OpenAPI additional information. + * @param name tag name + * @param additionalTag Additional information + */ +extern dec tagMetadata(target: Namespace, name: valueof string, additionalTag?: AdditionalTag); diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 1fd3fc15ee..461e1e8116 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,6 +1,23 @@ -import { DecoratorContext, Model, ModelProperty, Program, Type, Union } from "@typespec/compiler"; -import { OneOfDecorator, UseRefDecorator } from "../generated-defs/TypeSpec.OpenAPI.js"; +import { + DecoratorContext, + Model, + ModelProperty, + Namespace, + Program, + Type, + TypeSpecValue, + Union, + typespecTypeToJson, +} from "@typespec/compiler"; +import { unsafe_useStateMap } from "@typespec/compiler/experimental"; +import { ExtensionKey } from "@typespec/openapi"; +import { + OneOfDecorator, + TagMetadataDecorator, + UseRefDecorator, +} from "../generated-defs/TypeSpec.OpenAPI.js"; import { createStateSymbol, reportDiagnostic } from "./lib.js"; +import { AdditionalTag, OpenAPI3Tag } from "./types.js"; const refTargetsKey = createStateSymbol("refs"); export const $useRef: UseRefDecorator = ( @@ -32,3 +49,38 @@ export const $oneOf: OneOfDecorator = ( export function getOneOf(program: Program, entity: Type): boolean { return program.stateMap(oneOfKey).get(entity); } + +const [getTagsMetadataState, setTagMetadatas] = unsafe_useStateMap( + Symbol.for("tagMetadatas"), +); +export const $tagMetadata: TagMetadataDecorator = ( + context: DecoratorContext, + entity: Namespace, + name: string, + additionalTag?: TypeSpecValue, +) => { + const curr = { + name: name, + } as OpenAPI3Tag; + if (additionalTag) { + const [data, diagnostics] = typespecTypeToJson>( + additionalTag, + context.getArgumentTarget(0)!, + ); + context.program.reportDiagnostics(diagnostics); + if (data === undefined) { + return; + } + } + + const tags = getTagsMetadataState(context.program, entity); + if (tags) { + tags.push(curr); + } else { + setTagMetadatas(context.program, entity, [curr]); + } +}; + +export function getTagMetadata(program: Program, entity: Type): OpenAPI3Tag[] { + return getTagsMetadataState(program, entity) || []; +} diff --git a/packages/openapi3/src/tsp-index.ts b/packages/openapi3/src/tsp-index.ts index c355496d7e..b828591516 100644 --- a/packages/openapi3/src/tsp-index.ts +++ b/packages/openapi3/src/tsp-index.ts @@ -1,5 +1,5 @@ import { TypeSpecOpenAPIDecorators } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { $oneOf, $useRef } from "./decorators.js"; +import { $oneOf, $tagMetadata, $useRef } from "./decorators.js"; export { $lib } from "./lib.js"; @@ -8,5 +8,6 @@ export const $decorators = { "TypeSpec.OpenAPI": { useRef: $useRef, oneOf: $oneOf, + tagMetadata: $tagMetadata, } satisfies TypeSpecOpenAPIDecorators, }; diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index 79aea98883..39f8c0bef4 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -1,5 +1,5 @@ import { Diagnostic, Service } from "@typespec/compiler"; -import { Contact, ExtensionKey, License } from "@typespec/openapi"; +import { Contact, ExtensionKey, ExternalDocs, License } from "@typespec/openapi"; export type Extensions = { [key in ExtensionKey]?: any; @@ -684,3 +684,14 @@ export interface Ref { } export type Refable = Ref | T; + +/** + * OpenAPI additional Tag information + */ +export interface AdditionalTag { + /** A description of the API. */ + description?: string; + + /** The external information for the exposed API. */ + externalDocs?: ExternalDocs; +} From e6c1f2940da27441d70b8acc93f905e2444c38a6 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Tue, 22 Oct 2024 15:46:47 +0800 Subject: [PATCH 02/46] test initial --- .../emitters/openapi3/reference/data-types.md | 21 +++ .../emitters/openapi3/reference/decorators.md | 19 +++ docs/emitters/openapi3/reference/index.mdx | 5 + packages/openapi3/README.md | 20 +++ .../generated-defs/TypeSpec.OpenAPI.ts | 4 +- packages/openapi3/lib/decorators.tsp | 2 +- packages/openapi3/src/decorators.ts | 33 ++++- packages/openapi3/src/openapi.ts | 40 ++++- packages/openapi3/test/tagmetadata.test.ts | 140 ++++++++++++++++++ 9 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 docs/emitters/openapi3/reference/data-types.md create mode 100644 packages/openapi3/test/tagmetadata.test.ts diff --git a/docs/emitters/openapi3/reference/data-types.md b/docs/emitters/openapi3/reference/data-types.md new file mode 100644 index 0000000000..520320b178 --- /dev/null +++ b/docs/emitters/openapi3/reference/data-types.md @@ -0,0 +1,21 @@ +--- +title: "Data types" +toc_min_heading_level: 2 +toc_max_heading_level: 3 +--- + +## TypeSpec.OpenAPI + +### `AdditionalTag` {#TypeSpec.OpenAPI.AdditionalTag} + +Additional information for the OpenAPI document. + +```typespec +model TypeSpec.OpenAPI.AdditionalTag +``` + +#### Properties + +| Name | Type | Description | +| ------------ | -------- | ------------------------- | +| description? | `string` | A description of the API. | diff --git a/docs/emitters/openapi3/reference/decorators.md b/docs/emitters/openapi3/reference/decorators.md index c057a1ad57..107415acdd 100644 --- a/docs/emitters/openapi3/reference/decorators.md +++ b/docs/emitters/openapi3/reference/decorators.md @@ -22,6 +22,25 @@ Specify that `oneOf` should be used instead of `anyOf` for that union. None +### `@tagMetadata` {#@TypeSpec.OpenAPI.tagMetadata} + +Specify OpenAPI additional information. + +```typespec +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, additionalTag?: TypeSpec.OpenAPI.AdditionalTag) +``` + +#### Target + +`Namespace | Interface | Operation` + +#### Parameters + +| Name | Type | Description | +| ------------- | ----------------------------------------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| additionalTag | [`AdditionalTag`](./data-types.md#TypeSpec.OpenAPI.AdditionalTag) | Additional information | + ### `@useRef` {#@TypeSpec.OpenAPI.useRef} Specify an external reference that should be used inside of emitting this type. diff --git a/docs/emitters/openapi3/reference/index.mdx b/docs/emitters/openapi3/reference/index.mdx index b1836a03fd..48d67dcf54 100644 --- a/docs/emitters/openapi3/reference/index.mdx +++ b/docs/emitters/openapi3/reference/index.mdx @@ -38,4 +38,9 @@ npm install --save-peer @typespec/openapi3 ### Decorators - [`@oneOf`](./decorators.md#@TypeSpec.OpenAPI.oneOf) +- [`@tagMetadata`](./decorators.md#@TypeSpec.OpenAPI.tagMetadata) - [`@useRef`](./decorators.md#@TypeSpec.OpenAPI.useRef) + +### Models + +- [`AdditionalTag`](./data-types.md#TypeSpec.OpenAPI.AdditionalTag) diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index cd139f049b..85ed3f7230 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -110,6 +110,7 @@ Default: `int64` ### TypeSpec.OpenAPI - [`@oneOf`](#@oneof) +- [`@tagMetadata`](#@tagmetadata) - [`@useRef`](#@useref) #### `@oneOf` @@ -128,6 +129,25 @@ Specify that `oneOf` should be used instead of `anyOf` for that union. None +#### `@tagMetadata` + +Specify OpenAPI additional information. + +```typespec +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, additionalTag?: TypeSpec.OpenAPI.AdditionalTag) +``` + +##### Target + +`Namespace | Interface | Operation` + +##### Parameters + +| Name | Type | Description | +| ------------- | --------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| additionalTag | [`AdditionalTag`](#additionaltag) | Additional information | + #### `@useRef` Specify an external reference that should be used inside of emitting this type. diff --git a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts index e36db2fb57..7e2419c12b 100644 --- a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts @@ -1,8 +1,10 @@ import type { DecoratorContext, + Interface, Model, ModelProperty, Namespace, + Operation, Type, Union, } from "@typespec/compiler"; @@ -31,7 +33,7 @@ export type UseRefDecorator = ( */ export type TagMetadataDecorator = ( context: DecoratorContext, - target: Namespace, + target: Namespace | Interface | Operation, name: string, additionalTag?: Type, ) => void; diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 3edc0a43a3..639f80b88b 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -25,4 +25,4 @@ model AdditionalTag { * @param name tag name * @param additionalTag Additional information */ -extern dec tagMetadata(target: Namespace, name: valueof string, additionalTag?: AdditionalTag); +extern dec tagMetadata(target: Namespace | Interface | Operation, name: valueof string, additionalTag?: AdditionalTag); diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 461e1e8116..aac6bb8939 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,8 +1,10 @@ import { DecoratorContext, + Interface, Model, ModelProperty, Namespace, + Operation, Program, Type, TypeSpecValue, @@ -55,7 +57,7 @@ const [getTagsMetadataState, setTagMetadatas] = unsafe_useStateMap { @@ -71,6 +73,9 @@ export const $tagMetadata: TagMetadataDecorator = ( if (data === undefined) { return; } + + curr.description = data.description; + curr.externalDocs = data.externalDocs; } const tags = getTagsMetadataState(context.program, entity); @@ -84,3 +89,29 @@ export const $tagMetadata: TagMetadataDecorator = ( export function getTagMetadata(program: Program, entity: Type): OpenAPI3Tag[] { return getTagsMetadataState(program, entity) || []; } + +// Merge the tags for a operation with the tags that are on the namespace or +// interface it resides within. +export function getAllTagMetadatas( + program: Program, + target: Namespace | Interface | Operation, +): OpenAPI3Tag[] | undefined { + const tags = new Set(); + + let current: Namespace | Interface | Operation | undefined = target; + while (current !== undefined) { + for (const t of getTagMetadata(program, current)) { + tags.add(t); + } + + // Move up to the parent + if (current.kind === "Operation") { + current = current.interface ?? current.namespace; + } else { + // Type is a namespace or interface + current = current.namespace; + } + } + + return tags.size > 0 ? Array.from(tags).reverse() : undefined; +} diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 9f714a0e3a..8434b13b63 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -85,7 +85,7 @@ import { } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; -import { getRef } from "./decorators.js"; +import { getAllTagMetadatas, getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; @@ -108,6 +108,7 @@ import { OpenAPI3ServerVariable, OpenAPI3ServiceRecord, OpenAPI3StatusCode, + OpenAPI3Tag, OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; @@ -232,6 +233,8 @@ function createOAPIEmitter( // De-dupe the per-endpoint tags that will be added into the #/tags let tags: Set; + let tagMetadatas: Set; + const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { @@ -345,6 +348,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); + tagMetadatas = new Set(); } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -629,6 +633,7 @@ function createOAPIEmitter( emitParameters(); emitSchemas(service.type); emitTags(); + emitTagMetadatas(); // Clean up empty entries if (root.components) { @@ -724,6 +729,22 @@ function createOAPIEmitter( tags.add(tag); } } + + const opTagMetadatas = getAllTagMetadatas(program, op.operation); + if (opTagMetadatas) { + const opTagNames = opTagMetadatas.map((tag) => tag.name); + const currentTags = oai3Operation.tags; + if (currentTags) { + // combine tags but eliminate duplicates + oai3Operation.tags = [...new Set([...currentTags, ...opTagNames])]; + } else { + oai3Operation.tags = opTagNames; + } + for (const tag of opTagMetadatas) { + // Add to root tags if not already there + tagMetadatas.add(tag); + } + } } // Error out if shared routes do not have consistent `@parameterVisibility`. We can @@ -785,6 +806,17 @@ function createOAPIEmitter( tags.add(tag); } } + + const currentTagMetadatas = getAllTagMetadatas(program, op); + if (currentTagMetadatas) { + const currentTagNames = currentTagMetadatas.map((tag) => tag.name); + oai3Operation.tags = currentTagNames; + for (const tag of currentTagMetadatas) { + // Add to root tags if not already there + tagMetadatas.add(tag); + } + } + applyExternalDocs(op, oai3Operation); // Set up basic endpoint fields @@ -1587,6 +1619,12 @@ function createOAPIEmitter( } } + function emitTagMetadatas() { + for (const tag of tagMetadatas) { + root.tags!.push(tag); + } + } + function getSchemaForType(type: Type, visibility: Visibility): OpenAPI3Schema | undefined { return callSchemaEmitter(type, visibility) as any; } diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts new file mode 100644 index 0000000000..23a6fac940 --- /dev/null +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -0,0 +1,140 @@ +import { Interface, Namespace, Operation } from "@typespec/compiler"; +import { TestHost } from "@typespec/compiler/testing"; +import { deepStrictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { getAllTagMetadatas } from "./../src/decorators.js"; +import { createOpenAPITestHost, openApiFor } from "./test-host.js"; + +describe("openapi3: tagMetadata", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createOpenAPITestHost(); + }); + + it("applies @tagMetadata decorator to namespaces, interfaces, and operations", async (): Promise => { + testHost.addTypeSpecFile( + "main.tsp", + ` + import "@typespec/openapi3"; + using TypeSpec.OpenAPI; + + @test + @tagMetadata("namespace") + namespace OpNamespace { + @test + @tagMetadata("namespaceOp") + op NamespaceOperation(): string; + } + + @test + @tagMetadata("interface") + interface OpInterface { + @test + @tagMetadata("interfaceOp") + InterfaceOperation(): string; + } + + @test + interface UntaggedInterface { + @test + @tagMetadata("taggedOp") + TaggedOperation(): string; + } + + @test + @tagMetadata("recursiveNamespace") + namespace RecursiveNamespace { + @test + @tagMetadata("recursiveInterface") + interface RecursiveInterface { + @test + @tagMetadata("recursiveOperation") + RecursiveOperation(): string; + } + } + `, + ); + + const { + OpNamespace, + OpInterface, + NamespaceOperation, + UntaggedInterface, + InterfaceOperation, + TaggedOperation, + RecursiveNamespace, + RecursiveInterface, + RecursiveOperation, + } = (await testHost.compile("./")) as { + OpNamespace: Namespace; + OpInterface: Interface; + UntaggedInterface: Interface; + NamespaceOperation: Operation; + InterfaceOperation: Operation; + TaggedOperation: Operation; + RecursiveNamespace: Namespace; + RecursiveInterface: Interface; + RecursiveOperation: Operation; + }; + + deepStrictEqual(getAllTagMetadatas(testHost.program, OpNamespace), [{ name: "namespace" }]); + deepStrictEqual(getAllTagMetadatas(testHost.program, OpInterface), [{ name: "interface" }]); + deepStrictEqual(getAllTagMetadatas(testHost.program, UntaggedInterface), undefined); + deepStrictEqual(getAllTagMetadatas(testHost.program, NamespaceOperation), [ + { name: "namespace" }, + { name: "namespaceOp" }, + ]); + deepStrictEqual(getAllTagMetadatas(testHost.program, InterfaceOperation), [ + { name: "interface" }, + { name: "interfaceOp" }, + ]); + deepStrictEqual(getAllTagMetadatas(testHost.program, TaggedOperation), [{ name: "taggedOp" }]); + + // Check recursive tag walking + deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveNamespace), [ + { name: "recursiveNamespace" }, + ]); + deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveInterface), [ + { name: "recursiveNamespace" }, + { name: "recursiveInterface" }, + ]); + deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveOperation), [ + { name: "recursiveNamespace" }, + { name: "recursiveInterface" }, + { name: "recursiveOperation" }, + ]); + }); + + it("set the additional information with @tagMetadata decorator", async () => { + const res = await openApiFor( + ` + @service + @tagMetadata( + "pet", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + }, + } + ) + namespace PetStore { + op NamespaceOperation(): string; + } + `, + ); + deepStrictEqual(res.paths["/"].get.tags, ["pet"]); + deepStrictEqual(res.tags, [ + { + name: "pet", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + }, + }, + ]); + }); +}); From cffc5e0849f128e42d87e057700bf2e1ae9e5ea7 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 15:43:35 +0800 Subject: [PATCH 03/46] update case --- packages/openapi3/test/tagmetadata.test.ts | 28 ++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 23a6fac940..30aed9f41c 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,9 +1,9 @@ import { Interface, Namespace, Operation } from "@typespec/compiler"; -import { TestHost } from "@typespec/compiler/testing"; +import { TestHost,expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { getAllTagMetadatas } from "./../src/decorators.js"; -import { createOpenAPITestHost, openApiFor } from "./test-host.js"; +import { createOpenAPITestHost, openApiFor,diagnoseOpenApiFor } from "./test-host.js"; describe("openapi3: tagMetadata", () => { let testHost: TestHost; @@ -106,6 +106,30 @@ describe("openapi3: tagMetadata", () => { ]); }); + it("emit diagnostic if tagName is not a string", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` + @tagMetadata(123) + namespace PetStore{}; + `); + + expectDiagnostics(diagnostics, { + code: "invalid-argument", + }); + }); + + it("emit diagnostic if description is not a string", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` + @tagMetadata("tagName", { description: 123, }) + namespace PetStore{}; + `); + + expectDiagnostics(diagnostics, { + code: "invalid-argument", + }); + }); + it("set the additional information with @tagMetadata decorator", async () => { const res = await openApiFor( ` From c96621585f67a101d6bf2eb432a24e46b1ebb62b Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 16:50:41 +0800 Subject: [PATCH 04/46] update check x- --- packages/openapi/src/decorators.ts | 44 +------------------ packages/openapi/src/helpers.ts | 51 +++++++++++++++++++++- packages/openapi/src/index.ts | 1 + packages/openapi3/lib/decorators.tsp | 8 ++++ packages/openapi3/src/decorators.ts | 19 +++++++- packages/openapi3/test/tagmetadata.test.ts | 10 +++-- 6 files changed, 84 insertions(+), 49 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 378db454f8..c81c55eb6d 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -1,10 +1,6 @@ import { - compilerAssert, DecoratorContext, - Diagnostic, - DiagnosticTarget, getDoc, - getProperty, getService, getSummary, Model, @@ -23,8 +19,9 @@ import { InfoDecorator, OperationIdDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { createDiagnostic, createStateSymbol, reportDiagnostic } from "./lib.js"; +import { createStateSymbol, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; +import { checkNoAdditionalProperties, isOpenAPIExtensionKey }from "./helpers.js" const operationIdsKey = createStateSymbol("operationIds"); /** @@ -114,10 +111,6 @@ export function getExtensions(program: Program, entity: Type): ReadonlyMap(); } -function isOpenAPIExtensionKey(key: string): key is ExtensionKey { - return key.startsWith("x-"); -} - /** * The @defaultResponse decorator can be applied to a model. When that model is used * as the return type of an operation, this return type will be the default response. @@ -253,36 +246,3 @@ function validateAdditionalInfoModel(context: DecoratorContext, typespecType: Ty context.program.reportDiagnostics(diagnostics); } } - -function checkNoAdditionalProperties( - typespecType: Type, - target: DiagnosticTarget, - source: Model, -): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - compilerAssert(typespecType.kind === "Model", "Expected type to be a Model."); - - for (const [name, type] of typespecType.properties.entries()) { - const sourceProperty = getProperty(source, name); - if (sourceProperty) { - if (sourceProperty.type.kind === "Model") { - const nestedDiagnostics = checkNoAdditionalProperties( - type.type, - target, - sourceProperty.type, - ); - diagnostics.push(...nestedDiagnostics); - } - } else if (!isOpenAPIExtensionKey(name)) { - diagnostics.push( - createDiagnostic({ - code: "invalid-extension-key", - format: { value: name }, - target, - }), - ); - } - } - - return diagnostics; -} diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 65d45a6c22..056b142184 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -1,10 +1,15 @@ import { + compilerAssert, + Diagnostic, + DiagnosticTarget, getFriendlyName, + getProperty, getTypeName, getVisibility, isGlobalNamespace, isService, isTemplateInstance, + Model, ModelProperty, Operation, Program, @@ -12,7 +17,8 @@ import { TypeNameOptions, } from "@typespec/compiler"; import { getOperationId } from "./decorators.js"; -import { reportDiagnostic } from "./lib.js"; +import { createDiagnostic, reportDiagnostic } from "./lib.js"; +import { ExtensionKey } from "./types.js"; /** * Determines whether a type will be inlined in OpenAPI rather than defined @@ -164,3 +170,46 @@ export function isReadonlyProperty(program: Program, property: ModelProperty) { // readonly: true, but using separate schemas. return visibility?.length === 1 && visibility[0] === "read"; } + +/** + * Determines if a OpenAPIExtensionKey is start with `x-`. + */ +export function isOpenAPIExtensionKey(key: string): key is ExtensionKey { + return key.startsWith("x-"); +} + +/** + * Check Additional Properties + */ +export function checkNoAdditionalProperties( + typespecType: Type, + target: DiagnosticTarget, + source: Model, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + compilerAssert(typespecType.kind === "Model", "Expected type to be a Model."); + + for (const [name, type] of typespecType.properties.entries()) { + const sourceProperty = getProperty(source, name); + if (sourceProperty) { + if (sourceProperty.type.kind === "Model") { + const nestedDiagnostics = checkNoAdditionalProperties( + type.type, + target, + sourceProperty.type, + ); + diagnostics.push(...nestedDiagnostics); + } + } else if (!isOpenAPIExtensionKey(name)) { + diagnostics.push( + createDiagnostic({ + code: "invalid-extension-key", + format: { value: name }, + target, + }), + ); + } + } + + return diagnostics; +} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 38e073db51..93f60de747 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -21,6 +21,7 @@ export { } from "./decorators.js"; export { checkDuplicateTypeName, + checkNoAdditionalProperties, getOpenAPITypeName, getParameterKey, isReadonlyProperty, diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 639f80b88b..4e675ccb3a 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -18,6 +18,14 @@ extern dec useRef(target: Model | ModelProperty, ref: valueof string); model AdditionalTag { /** A description of the API. */ description?: string; + externalDocs?: ExternalDocs +} + +model ExternalDocs { + /** Documentation url */ + url: string; + /** Optional description */ + description?: string; } /** diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index aac6bb8939..8237846008 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -12,7 +12,7 @@ import { typespecTypeToJson, } from "@typespec/compiler"; import { unsafe_useStateMap } from "@typespec/compiler/experimental"; -import { ExtensionKey } from "@typespec/openapi"; +import { ExtensionKey, checkNoAdditionalProperties } from "@typespec/openapi"; import { OneOfDecorator, TagMetadataDecorator, @@ -73,7 +73,7 @@ export const $tagMetadata: TagMetadataDecorator = ( if (data === undefined) { return; } - + validateAdditionalInfoModel(context, additionalTag); curr.description = data.description; curr.externalDocs = data.externalDocs; } @@ -115,3 +115,18 @@ export function getAllTagMetadatas( return tags.size > 0 ? Array.from(tags).reverse() : undefined; } + +function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { + const propertyModel = context.program.resolveTypeReference( + "TypeSpec.OpenAPI.AdditionalTag", + )[0]! as Model; + + if (typeof typespecType === "object" && propertyModel) { + const diagnostics = checkNoAdditionalProperties( + typespecType, + context.getArgumentTarget(0)!, + propertyModel, + ); + context.program.reportDiagnostics(diagnostics); + } +} diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 30aed9f41c..a86e1de025 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,9 +1,9 @@ import { Interface, Namespace, Operation } from "@typespec/compiler"; -import { TestHost,expectDiagnostics } from "@typespec/compiler/testing"; +import { TestHost, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { getAllTagMetadatas } from "./../src/decorators.js"; -import { createOpenAPITestHost, openApiFor,diagnoseOpenApiFor } from "./test-host.js"; +import { createOpenAPITestHost, diagnoseOpenApiFor, openApiFor } from "./test-host.js"; describe("openapi3: tagMetadata", () => { let testHost: TestHost; @@ -111,7 +111,8 @@ describe("openapi3: tagMetadata", () => { ` @tagMetadata(123) namespace PetStore{}; - `); + `, + ); expectDiagnostics(diagnostics, { code: "invalid-argument", @@ -123,7 +124,8 @@ describe("openapi3: tagMetadata", () => { ` @tagMetadata("tagName", { description: 123, }) namespace PetStore{}; - `); + `, + ); expectDiagnostics(diagnostics, { code: "invalid-argument", From 65a5ba334a2fae9e9970da79ba50cd09af21fe68 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 16:56:04 +0800 Subject: [PATCH 05/46] add change log --- .chronus/changes/tagMetadata-2024-9-23-16-55-56.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/tagMetadata-2024-9-23-16-55-56.md diff --git a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md new file mode 100644 index 0000000000..a383a967e7 --- /dev/null +++ b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" + - "@typespec/openapi3" +--- + +a decorator for specify OpenAPI tag properties \ No newline at end of file From 39f791ac016c4c030e7bb2fb6f021146d00049cc Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 17:19:10 +0800 Subject: [PATCH 06/46] update --- packages/openapi3/src/decorators.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 8237846008..e9556091ee 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -61,9 +61,7 @@ export const $tagMetadata: TagMetadataDecorator = ( name: string, additionalTag?: TypeSpecValue, ) => { - const curr = { - name: name, - } as OpenAPI3Tag; + const metadata: OpenAPI3Tag = { name }; if (additionalTag) { const [data, diagnostics] = typespecTypeToJson>( additionalTag, @@ -74,15 +72,17 @@ export const $tagMetadata: TagMetadataDecorator = ( return; } validateAdditionalInfoModel(context, additionalTag); - curr.description = data.description; - curr.externalDocs = data.externalDocs; + if (data) { + metadata.description = data.description; + metadata.externalDocs = data.externalDocs; + } } const tags = getTagsMetadataState(context.program, entity); if (tags) { - tags.push(curr); + tags.push(metadata); } else { - setTagMetadatas(context.program, entity, [curr]); + setTagMetadatas(context.program, entity, [metadata]); } }; From e3982614c09140a0476e401b735ac8bbfc064ba0 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 17:30:35 +0800 Subject: [PATCH 07/46] fix build --- packages/openapi/src/decorators.ts | 2 +- packages/openapi3/lib/decorators.tsp | 10 ++++++++-- packages/openapi3/src/decorators.ts | 8 ++++---- packages/openapi3/src/openapi.ts | 6 +++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index c81c55eb6d..eb845705b9 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -19,9 +19,9 @@ import { InfoDecorator, OperationIdDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; +import { checkNoAdditionalProperties, isOpenAPIExtensionKey } from "./helpers.js"; import { createStateSymbol, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; -import { checkNoAdditionalProperties, isOpenAPIExtensionKey }from "./helpers.js" const operationIdsKey = createStateSymbol("operationIds"); /** diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 4e675ccb3a..27debb7904 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -18,12 +18,14 @@ extern dec useRef(target: Model | ModelProperty, ref: valueof string); model AdditionalTag { /** A description of the API. */ description?: string; - externalDocs?: ExternalDocs + + externalDocs?: ExternalDocs; } model ExternalDocs { /** Documentation url */ url: string; + /** Optional description */ description?: string; } @@ -33,4 +35,8 @@ model ExternalDocs { * @param name tag name * @param additionalTag Additional information */ -extern dec tagMetadata(target: Namespace | Interface | Operation, name: valueof string, additionalTag?: AdditionalTag); +extern dec tagMetadata( + target: Namespace | Interface | Operation, + name: valueof string, + additionalTag?: AdditionalTag +); diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index e9556091ee..0c630a0661 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -52,8 +52,8 @@ export function getOneOf(program: Program, entity: Type): boolean { return program.stateMap(oneOfKey).get(entity); } -const [getTagsMetadataState, setTagMetadatas] = unsafe_useStateMap( - Symbol.for("tagMetadatas"), +const [getTagsMetadataState, setTagsMetadata] = unsafe_useStateMap( + Symbol.for("tagsMetadata"), ); export const $tagMetadata: TagMetadataDecorator = ( context: DecoratorContext, @@ -82,7 +82,7 @@ export const $tagMetadata: TagMetadataDecorator = ( if (tags) { tags.push(metadata); } else { - setTagMetadatas(context.program, entity, [metadata]); + setTagsMetadata(context.program, entity, [metadata]); } }; @@ -92,7 +92,7 @@ export function getTagMetadata(program: Program, entity: Type): OpenAPI3Tag[] { // Merge the tags for a operation with the tags that are on the namespace or // interface it resides within. -export function getAllTagMetadatas( +export function getAllTagsMetadata( program: Program, target: Namespace | Interface | Operation, ): OpenAPI3Tag[] | undefined { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 8434b13b63..30abbfa280 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -85,7 +85,7 @@ import { } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; -import { getAllTagMetadatas, getRef } from "./decorators.js"; +import { getAllTagsMetadata, getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; @@ -730,7 +730,7 @@ function createOAPIEmitter( } } - const opTagMetadatas = getAllTagMetadatas(program, op.operation); + const opTagMetadatas = getAllTagsMetadata(program, op.operation); if (opTagMetadatas) { const opTagNames = opTagMetadatas.map((tag) => tag.name); const currentTags = oai3Operation.tags; @@ -807,7 +807,7 @@ function createOAPIEmitter( } } - const currentTagMetadatas = getAllTagMetadatas(program, op); + const currentTagMetadatas = getAllTagsMetadata(program, op); if (currentTagMetadatas) { const currentTagNames = currentTagMetadatas.map((tag) => tag.name); oai3Operation.tags = currentTagNames; From 81b5457e833fcb59405d5f310a8563c4615b5bb6 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 23 Oct 2024 17:35:33 +0800 Subject: [PATCH 08/46] fix buiild --- packages/openapi3/src/openapi.ts | 30 +++++++++++----------- packages/openapi3/test/tagmetadata.test.ts | 20 +++++++-------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 30abbfa280..8246391161 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -233,7 +233,7 @@ function createOAPIEmitter( // De-dupe the per-endpoint tags that will be added into the #/tags let tags: Set; - let tagMetadatas: Set; + let tagsMetadata: Set; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace @@ -348,7 +348,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagMetadatas = new Set(); + tagsMetadata = new Set(); } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -633,7 +633,7 @@ function createOAPIEmitter( emitParameters(); emitSchemas(service.type); emitTags(); - emitTagMetadatas(); + emitTagsMetadata(); // Clean up empty entries if (root.components) { @@ -730,9 +730,9 @@ function createOAPIEmitter( } } - const opTagMetadatas = getAllTagsMetadata(program, op.operation); - if (opTagMetadatas) { - const opTagNames = opTagMetadatas.map((tag) => tag.name); + const opTagsMetadata = getAllTagsMetadata(program, op.operation); + if (opTagsMetadata) { + const opTagNames = opTagsMetadata.map((tag) => tag.name); const currentTags = oai3Operation.tags; if (currentTags) { // combine tags but eliminate duplicates @@ -740,9 +740,9 @@ function createOAPIEmitter( } else { oai3Operation.tags = opTagNames; } - for (const tag of opTagMetadatas) { + for (const tag of opTagsMetadata) { // Add to root tags if not already there - tagMetadatas.add(tag); + tagsMetadata.add(tag); } } } @@ -807,13 +807,13 @@ function createOAPIEmitter( } } - const currentTagMetadatas = getAllTagsMetadata(program, op); - if (currentTagMetadatas) { - const currentTagNames = currentTagMetadatas.map((tag) => tag.name); + const currentTagsMetadata = getAllTagsMetadata(program, op); + if (currentTagsMetadata) { + const currentTagNames = currentTagsMetadata.map((tag) => tag.name); oai3Operation.tags = currentTagNames; - for (const tag of currentTagMetadatas) { + for (const tag of currentTagsMetadata) { // Add to root tags if not already there - tagMetadatas.add(tag); + tagsMetadata.add(tag); } } @@ -1619,8 +1619,8 @@ function createOAPIEmitter( } } - function emitTagMetadatas() { - for (const tag of tagMetadatas) { + function emitTagsMetadata() { + for (const tag of tagsMetadata) { root.tags!.push(tag); } } diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index a86e1de025..ec93e143f9 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -2,7 +2,7 @@ import { Interface, Namespace, Operation } from "@typespec/compiler"; import { TestHost, expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; -import { getAllTagMetadatas } from "./../src/decorators.js"; +import { getAllTagsMetadata } from "./../src/decorators.js"; import { createOpenAPITestHost, diagnoseOpenApiFor, openApiFor } from "./test-host.js"; describe("openapi3: tagMetadata", () => { @@ -78,28 +78,28 @@ describe("openapi3: tagMetadata", () => { RecursiveOperation: Operation; }; - deepStrictEqual(getAllTagMetadatas(testHost.program, OpNamespace), [{ name: "namespace" }]); - deepStrictEqual(getAllTagMetadatas(testHost.program, OpInterface), [{ name: "interface" }]); - deepStrictEqual(getAllTagMetadatas(testHost.program, UntaggedInterface), undefined); - deepStrictEqual(getAllTagMetadatas(testHost.program, NamespaceOperation), [ + deepStrictEqual(getAllTagsMetadata(testHost.program, OpNamespace), [{ name: "namespace" }]); + deepStrictEqual(getAllTagsMetadata(testHost.program, OpInterface), [{ name: "interface" }]); + deepStrictEqual(getAllTagsMetadata(testHost.program, UntaggedInterface), undefined); + deepStrictEqual(getAllTagsMetadata(testHost.program, NamespaceOperation), [ { name: "namespace" }, { name: "namespaceOp" }, ]); - deepStrictEqual(getAllTagMetadatas(testHost.program, InterfaceOperation), [ + deepStrictEqual(getAllTagsMetadata(testHost.program, InterfaceOperation), [ { name: "interface" }, { name: "interfaceOp" }, ]); - deepStrictEqual(getAllTagMetadatas(testHost.program, TaggedOperation), [{ name: "taggedOp" }]); + deepStrictEqual(getAllTagsMetadata(testHost.program, TaggedOperation), [{ name: "taggedOp" }]); // Check recursive tag walking - deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveNamespace), [ + deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveNamespace), [ { name: "recursiveNamespace" }, ]); - deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveInterface), [ + deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveInterface), [ { name: "recursiveNamespace" }, { name: "recursiveInterface" }, ]); - deepStrictEqual(getAllTagMetadatas(testHost.program, RecursiveOperation), [ + deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveOperation), [ { name: "recursiveNamespace" }, { name: "recursiveInterface" }, { name: "recursiveOperation" }, From a6c8d591788b29eb6c1024ba28723fc115477b94 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 24 Oct 2024 15:21:45 +0800 Subject: [PATCH 09/46] update logic --- .../generated-defs/TypeSpec.OpenAPI.ts | 6 +- packages/openapi3/lib/decorators.tsp | 10 +- packages/openapi3/src/decorators.ts | 53 ++---- packages/openapi3/src/openapi.ts | 46 ++--- packages/openapi3/src/types.ts | 13 +- packages/openapi3/test/tagmetadata.test.ts | 167 ++++-------------- 6 files changed, 60 insertions(+), 235 deletions(-) diff --git a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts index 7e2419c12b..5e572d7151 100644 --- a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts @@ -1,10 +1,8 @@ import type { DecoratorContext, - Interface, Model, ModelProperty, Namespace, - Operation, Type, Union, } from "@typespec/compiler"; @@ -33,9 +31,9 @@ export type UseRefDecorator = ( */ export type TagMetadataDecorator = ( context: DecoratorContext, - target: Namespace | Interface | Operation, + target: Namespace, name: string, - additionalTag?: Type, + tagMetadata?: Type, ) => void; export type TypeSpecOpenAPIDecorators = { diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 27debb7904..3c9c4f9c56 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -15,13 +15,15 @@ extern dec oneOf(target: Union | ModelProperty); extern dec useRef(target: Model | ModelProperty, ref: valueof string); /** Additional information for the OpenAPI document. */ -model AdditionalTag { +model TagMetadata { /** A description of the API. */ description?: string; + /** a external Docs information of the API. */ externalDocs?: ExternalDocs; } +/** External Docs information. */ model ExternalDocs { /** Documentation url */ url: string; @@ -35,8 +37,4 @@ model ExternalDocs { * @param name tag name * @param additionalTag Additional information */ -extern dec tagMetadata( - target: Namespace | Interface | Operation, - name: valueof string, - additionalTag?: AdditionalTag -); +extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata); diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 0c630a0661..d0ddac0154 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,10 +1,8 @@ import { DecoratorContext, - Interface, Model, ModelProperty, Namespace, - Operation, Program, Type, TypeSpecValue, @@ -19,7 +17,7 @@ import { UseRefDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; import { createStateSymbol, reportDiagnostic } from "./lib.js"; -import { AdditionalTag, OpenAPI3Tag } from "./types.js"; +import { OpenAPI3Tag } from "./types.js"; const refTargetsKey = createStateSymbol("refs"); export const $useRef: UseRefDecorator = ( @@ -57,25 +55,22 @@ const [getTagsMetadataState, setTagsMetadata] = unsafe_useStateMap { - const metadata: OpenAPI3Tag = { name }; - if (additionalTag) { - const [data, diagnostics] = typespecTypeToJson>( - additionalTag, + let metadata: OpenAPI3Tag = { name }; + if (tagMetadata) { + const [data, diagnostics] = typespecTypeToJson>( + tagMetadata, context.getArgumentTarget(0)!, ); context.program.reportDiagnostics(diagnostics); if (data === undefined) { return; } - validateAdditionalInfoModel(context, additionalTag); - if (data) { - metadata.description = data.description; - metadata.externalDocs = data.externalDocs; - } + validateAdditionalInfoModel(context, tagMetadata); + metadata = { ...data, ...metadata }; } const tags = getTagsMetadataState(context.program, entity); @@ -86,39 +81,13 @@ export const $tagMetadata: TagMetadataDecorator = ( } }; -export function getTagMetadata(program: Program, entity: Type): OpenAPI3Tag[] { +export function getTagsMetadata(program: Program, entity: Type): OpenAPI3Tag[] { return getTagsMetadataState(program, entity) || []; } -// Merge the tags for a operation with the tags that are on the namespace or -// interface it resides within. -export function getAllTagsMetadata( - program: Program, - target: Namespace | Interface | Operation, -): OpenAPI3Tag[] | undefined { - const tags = new Set(); - - let current: Namespace | Interface | Operation | undefined = target; - while (current !== undefined) { - for (const t of getTagMetadata(program, current)) { - tags.add(t); - } - - // Move up to the parent - if (current.kind === "Operation") { - current = current.interface ?? current.namespace; - } else { - // Type is a namespace or interface - current = current.namespace; - } - } - - return tags.size > 0 ? Array.from(tags).reverse() : undefined; -} - function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { const propertyModel = context.program.resolveTypeReference( - "TypeSpec.OpenAPI.AdditionalTag", + "TypeSpec.OpenAPI.TagMetadata", )[0]! as Model; if (typeof typespecType === "object" && propertyModel) { diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 8246391161..7d5a1de7b7 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -85,7 +85,7 @@ import { } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; -import { getAllTagsMetadata, getRef } from "./decorators.js"; +import { getRef, getTagsMetadata } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; @@ -233,8 +233,6 @@ function createOAPIEmitter( // De-dupe the per-endpoint tags that will be added into the #/tags let tags: Set; - let tagsMetadata: Set; - const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { @@ -348,7 +346,6 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagsMetadata = new Set(); } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -632,8 +629,9 @@ function createOAPIEmitter( } emitParameters(); emitSchemas(service.type); - emitTags(); - emitTagsMetadata(); + + const tagsMetadata = getTagsMetadata(program, service.type); + emitTags(tagsMetadata); // Clean up empty entries if (root.components) { @@ -729,22 +727,6 @@ function createOAPIEmitter( tags.add(tag); } } - - const opTagsMetadata = getAllTagsMetadata(program, op.operation); - if (opTagsMetadata) { - const opTagNames = opTagsMetadata.map((tag) => tag.name); - const currentTags = oai3Operation.tags; - if (currentTags) { - // combine tags but eliminate duplicates - oai3Operation.tags = [...new Set([...currentTags, ...opTagNames])]; - } else { - oai3Operation.tags = opTagNames; - } - for (const tag of opTagsMetadata) { - // Add to root tags if not already there - tagsMetadata.add(tag); - } - } } // Error out if shared routes do not have consistent `@parameterVisibility`. We can @@ -807,16 +789,6 @@ function createOAPIEmitter( } } - const currentTagsMetadata = getAllTagsMetadata(program, op); - if (currentTagsMetadata) { - const currentTagNames = currentTagsMetadata.map((tag) => tag.name); - oai3Operation.tags = currentTagNames; - for (const tag of currentTagsMetadata) { - // Add to root tags if not already there - tagsMetadata.add(tag); - } - } - applyExternalDocs(op, oai3Operation); // Set up basic endpoint fields @@ -1613,13 +1585,15 @@ function createOAPIEmitter( } } - function emitTags() { + function emitTags(tagsMetadata: OpenAPI3Tag[]) { + const tagSet = new Set(tagsMetadata.map((t) => t.name)); + // emit Tag from op for (const tag of tags) { - root.tags!.push({ name: tag }); + if (!tagSet.has(tag)) { + root.tags!.push({ name: tag }); + } } - } - function emitTagsMetadata() { for (const tag of tagsMetadata) { root.tags!.push(tag); } diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index 39f8c0bef4..79aea98883 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -1,5 +1,5 @@ import { Diagnostic, Service } from "@typespec/compiler"; -import { Contact, ExtensionKey, ExternalDocs, License } from "@typespec/openapi"; +import { Contact, ExtensionKey, License } from "@typespec/openapi"; export type Extensions = { [key in ExtensionKey]?: any; @@ -684,14 +684,3 @@ export interface Ref { } export type Refable = Ref | T; - -/** - * OpenAPI additional Tag information - */ -export interface AdditionalTag { - /** A description of the API. */ - description?: string; - - /** The external information for the exposed API. */ - externalDocs?: ExternalDocs; -} diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index ec93e143f9..d475ce02d5 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,140 +1,37 @@ -import { Interface, Namespace, Operation } from "@typespec/compiler"; -import { TestHost, expectDiagnostics } from "@typespec/compiler/testing"; +import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { beforeEach, describe, it } from "vitest"; -import { getAllTagsMetadata } from "./../src/decorators.js"; -import { createOpenAPITestHost, diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { it } from "vitest"; +import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; -describe("openapi3: tagMetadata", () => { - let testHost: TestHost; - - beforeEach(async () => { - testHost = await createOpenAPITestHost(); - }); - - it("applies @tagMetadata decorator to namespaces, interfaces, and operations", async (): Promise => { - testHost.addTypeSpecFile( - "main.tsp", - ` - import "@typespec/openapi3"; - using TypeSpec.OpenAPI; - - @test - @tagMetadata("namespace") - namespace OpNamespace { - @test - @tagMetadata("namespaceOp") - op NamespaceOperation(): string; - } - - @test - @tagMetadata("interface") - interface OpInterface { - @test - @tagMetadata("interfaceOp") - InterfaceOperation(): string; - } - - @test - interface UntaggedInterface { - @test - @tagMetadata("taggedOp") - TaggedOperation(): string; - } - - @test - @tagMetadata("recursiveNamespace") - namespace RecursiveNamespace { - @test - @tagMetadata("recursiveInterface") - interface RecursiveInterface { - @test - @tagMetadata("recursiveOperation") - RecursiveOperation(): string; - } - } - `, - ); - - const { - OpNamespace, - OpInterface, - NamespaceOperation, - UntaggedInterface, - InterfaceOperation, - TaggedOperation, - RecursiveNamespace, - RecursiveInterface, - RecursiveOperation, - } = (await testHost.compile("./")) as { - OpNamespace: Namespace; - OpInterface: Interface; - UntaggedInterface: Interface; - NamespaceOperation: Operation; - InterfaceOperation: Operation; - TaggedOperation: Operation; - RecursiveNamespace: Namespace; - RecursiveInterface: Interface; - RecursiveOperation: Operation; - }; - - deepStrictEqual(getAllTagsMetadata(testHost.program, OpNamespace), [{ name: "namespace" }]); - deepStrictEqual(getAllTagsMetadata(testHost.program, OpInterface), [{ name: "interface" }]); - deepStrictEqual(getAllTagsMetadata(testHost.program, UntaggedInterface), undefined); - deepStrictEqual(getAllTagsMetadata(testHost.program, NamespaceOperation), [ - { name: "namespace" }, - { name: "namespaceOp" }, - ]); - deepStrictEqual(getAllTagsMetadata(testHost.program, InterfaceOperation), [ - { name: "interface" }, - { name: "interfaceOp" }, - ]); - deepStrictEqual(getAllTagsMetadata(testHost.program, TaggedOperation), [{ name: "taggedOp" }]); - - // Check recursive tag walking - deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveNamespace), [ - { name: "recursiveNamespace" }, - ]); - deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveInterface), [ - { name: "recursiveNamespace" }, - { name: "recursiveInterface" }, - ]); - deepStrictEqual(getAllTagsMetadata(testHost.program, RecursiveOperation), [ - { name: "recursiveNamespace" }, - { name: "recursiveInterface" }, - { name: "recursiveOperation" }, - ]); - }); - - it("emit diagnostic if tagName is not a string", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` +it("emit diagnostic if tagName is not a string", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` @tagMetadata(123) namespace PetStore{}; `, - ); + ); - expectDiagnostics(diagnostics, { - code: "invalid-argument", - }); + expectDiagnostics(diagnostics, { + code: "invalid-argument", }); +}); - it("emit diagnostic if description is not a string", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` +it("emit diagnostic if description is not a string", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` @tagMetadata("tagName", { description: 123, }) namespace PetStore{}; `, - ); + ); - expectDiagnostics(diagnostics, { - code: "invalid-argument", - }); + expectDiagnostics(diagnostics, { + code: "invalid-argument", }); +}); - it("set the additional information with @tagMetadata decorator", async () => { - const res = await openApiFor( - ` +it("set the additional information with @tagMetadata decorator", async () => { + const res = await openApiFor( + ` @service @tagMetadata( "pet", @@ -147,20 +44,20 @@ describe("openapi3: tagMetadata", () => { } ) namespace PetStore { + @tag("pet") op NamespaceOperation(): string; } `, - ); - deepStrictEqual(res.paths["/"].get.tags, ["pet"]); - deepStrictEqual(res.tags, [ - { - name: "pet", - description: "Pets operations", - externalDocs: { - description: "More info.", - url: "https://example.com", - }, + ); + deepStrictEqual(res.paths["/"].get.tags, ["pet"]); + deepStrictEqual(res.tags, [ + { + name: "pet", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", }, - ]); - }); + }, + ]); }); From ba58f27b5b30bc45a58bfd0332a2f6c2c02f3003 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 24 Oct 2024 15:58:31 +0800 Subject: [PATCH 10/46] update doc --- .../emitters/openapi3/reference/data-types.md | 28 ++++++++++++++----- .../emitters/openapi3/reference/decorators.md | 12 ++++---- docs/emitters/openapi3/reference/index.mdx | 3 +- packages/openapi3/README.md | 12 ++++---- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/emitters/openapi3/reference/data-types.md b/docs/emitters/openapi3/reference/data-types.md index 520320b178..62c98d47f0 100644 --- a/docs/emitters/openapi3/reference/data-types.md +++ b/docs/emitters/openapi3/reference/data-types.md @@ -1,21 +1,35 @@ --- title: "Data types" -toc_min_heading_level: 2 -toc_max_heading_level: 3 --- ## TypeSpec.OpenAPI -### `AdditionalTag` {#TypeSpec.OpenAPI.AdditionalTag} +### `ExternalDocs` {#TypeSpec.OpenAPI.ExternalDocs} + +External Docs information. + +```typespec +model TypeSpec.OpenAPI.ExternalDocs +``` + +#### Properties + +| Name | Type | Description | +| ------------ | -------- | -------------------- | +| url | `string` | Documentation url | +| description? | `string` | Optional description | + +### `TagMetadata` {#TypeSpec.OpenAPI.TagMetadata} Additional information for the OpenAPI document. ```typespec -model TypeSpec.OpenAPI.AdditionalTag +model TypeSpec.OpenAPI.TagMetadata ``` #### Properties -| Name | Type | Description | -| ------------ | -------- | ------------------------- | -| description? | `string` | A description of the API. | +| Name | Type | Description | +| ------------- | --------------------------------------------------------------- | --------------------------------------- | +| description? | `string` | A description of the API. | +| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | a external Docs information of the API. | diff --git a/docs/emitters/openapi3/reference/decorators.md b/docs/emitters/openapi3/reference/decorators.md index 107415acdd..f32db7f117 100644 --- a/docs/emitters/openapi3/reference/decorators.md +++ b/docs/emitters/openapi3/reference/decorators.md @@ -27,19 +27,19 @@ None Specify OpenAPI additional information. ```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, additionalTag?: TypeSpec.OpenAPI.AdditionalTag) +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) ``` #### Target -`Namespace | Interface | Operation` +`Namespace` #### Parameters -| Name | Type | Description | -| ------------- | ----------------------------------------------------------------- | ---------------------- | -| name | `valueof string` | tag name | -| additionalTag | [`AdditionalTag`](./data-types.md#TypeSpec.OpenAPI.AdditionalTag) | Additional information | +| Name | Type | Description | +| ----------- | ------------------------------------------------------------- | ----------- | +| name | `valueof string` | tag name | +| tagMetadata | [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | | ### `@useRef` {#@TypeSpec.OpenAPI.useRef} diff --git a/docs/emitters/openapi3/reference/index.mdx b/docs/emitters/openapi3/reference/index.mdx index 48d67dcf54..3264814bb6 100644 --- a/docs/emitters/openapi3/reference/index.mdx +++ b/docs/emitters/openapi3/reference/index.mdx @@ -43,4 +43,5 @@ npm install --save-peer @typespec/openapi3 ### Models -- [`AdditionalTag`](./data-types.md#TypeSpec.OpenAPI.AdditionalTag) +- [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) +- [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index 85ed3f7230..d376817447 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -134,19 +134,19 @@ None Specify OpenAPI additional information. ```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, additionalTag?: TypeSpec.OpenAPI.AdditionalTag) +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) ``` ##### Target -`Namespace | Interface | Operation` +`Namespace` ##### Parameters -| Name | Type | Description | -| ------------- | --------------------------------- | ---------------------- | -| name | `valueof string` | tag name | -| additionalTag | [`AdditionalTag`](#additionaltag) | Additional information | +| Name | Type | Description | +| ----------- | ----------------------------- | ----------- | +| name | `valueof string` | tag name | +| tagMetadata | [`TagMetadata`](#tagmetadata) | | #### `@useRef` From 3e8a4a986b43641aca6e6d5cb29ac307ed3df357 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 24 Oct 2024 17:57:14 +0800 Subject: [PATCH 11/46] update --- packages/openapi/src/decorators.ts | 25 +-- packages/openapi/src/helpers.ts | 28 +++ packages/openapi/src/index.ts | 1 + packages/openapi3/src/decorators.ts | 26 ++- packages/openapi3/src/lib.ts | 6 + packages/openapi3/test/tagmetadata.test.ts | 216 ++++++++++++++++++--- 6 files changed, 259 insertions(+), 43 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index eb845705b9..655d8f77c7 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -19,7 +19,7 @@ import { InfoDecorator, OperationIdDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { checkNoAdditionalProperties, isOpenAPIExtensionKey } from "./helpers.js"; +import { checkNoAdditionalProperties, isOpenAPIExtensionKey, validateIsUri } from "./helpers.js"; import { createStateSymbol, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; @@ -184,9 +184,12 @@ export const $info: InfoDecorator = ( } validateAdditionalInfoModel(context, model); if (data.termsOfService) { - if (!validateIsUri(context, data.termsOfService, "TermsOfService")) { - return; - } + const diagnostics = validateIsUri( + context.getArgumentTarget(0)!, + data.termsOfService, + "TermsOfService", + ); + context.program.reportDiagnostics(diagnostics); } setInfo(context.program, entity, data); }; @@ -218,20 +221,6 @@ function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } -function validateIsUri(context: DecoratorContext, url: string, propertyName: string) { - try { - new URL(url); - return true; - } catch { - reportDiagnostic(context.program, { - code: "not-url", - target: context.getArgumentTarget(0)!, - format: { property: propertyName, value: url }, - }); - return false; - } -} - function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.AdditionalInfo", diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 056b142184..35273e0395 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -213,3 +213,31 @@ export function checkNoAdditionalProperties( return diagnostics; } + +/** + * Validate a string as a URI. + * @param target The target of the diagnostic + * @param url The URL to validate + * @param propertyName The name of the property being validated + */ +export function validateIsUri( + target: DiagnosticTarget, + url: string, + propertyName: string, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + try { + // Attempt to construct a new URL + new URL(url); + } catch { + // If the construction fails, create a diagnostic + diagnostics.push( + createDiagnostic({ + code: "not-url", + format: { property: propertyName, value: url }, + target, + }), + ); + } + return diagnostics; +} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 93f60de747..049ed42e65 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -27,6 +27,7 @@ export { isReadonlyProperty, resolveOperationId, shouldInline, + validateIsUri, } from "./helpers.js"; export { AdditionalInfo, Contact, ExtensionKey, ExternalDocs, License } from "./types.js"; diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index d0ddac0154..8281bc4d6c 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -10,7 +10,7 @@ import { typespecTypeToJson, } from "@typespec/compiler"; import { unsafe_useStateMap } from "@typespec/compiler/experimental"; -import { ExtensionKey, checkNoAdditionalProperties } from "@typespec/openapi"; +import { ExtensionKey, checkNoAdditionalProperties, validateIsUri } from "@typespec/openapi"; import { OneOfDecorator, TagMetadataDecorator, @@ -59,6 +59,18 @@ export const $tagMetadata: TagMetadataDecorator = ( name: string, tagMetadata?: TypeSpecValue, ) => { + const tags = getTagsMetadataState(context.program, entity); + if (tags) { + const tagNamesSet = new Set(tags.map((t) => t.name)); + if (tagNamesSet.has(name)) { + reportDiagnostic(context.program, { + code: "duplicate-tag", + format: { tagName: name }, + target: context.getArgumentTarget(0)!, + }); + } + } + let metadata: OpenAPI3Tag = { name }; if (tagMetadata) { const [data, diagnostics] = typespecTypeToJson>( @@ -70,10 +82,18 @@ export const $tagMetadata: TagMetadataDecorator = ( return; } validateAdditionalInfoModel(context, tagMetadata); - metadata = { ...data, ...metadata }; + if (data.externalDocs?.url) { + const diagnostics = validateIsUri( + context.getArgumentTarget(0)!, + data.externalDocs?.url, + "externalDocs.url", + ); + context.program.reportDiagnostics(diagnostics); + } + + metadata = { ...data, name }; } - const tags = getTagsMetadataState(context.program, entity); if (tags) { tags.push(metadata); } else { diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index ffcb14991c..ecb8966a58 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -263,6 +263,12 @@ export const libDef = { default: paramMessage`Authentication "${"authType"}" is not a known authentication by the openapi3 emitter, it will be ignored.`, }, }, + "duplicate-tag": { + severity: "error", + messages: { + default: paramMessage`Duplicate tag ${"tagName"}`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index d475ce02d5..920c283705 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,14 +1,19 @@ import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; -import { it } from "vitest"; -import { diagnoseOpenApiFor, openApiFor } from "./test-host.js"; +import { describe, it } from "vitest"; +import { getTagsMetadata } from "../src/decorators.js"; +import { createOpenAPITestRunner, diagnoseOpenApiFor, openApiFor } from "./test-host.js"; -it("emit diagnostic if tagName is not a string", async () => { +it.each([ + ["tagName is not a string", `@tagMetadata(123)`], + ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], + ["description is not a string", `@tagMetadata("tagName", { description: 123, })`], +])("%s", async (_, code) => { const diagnostics = await diagnoseOpenApiFor( ` - @tagMetadata(123) - namespace PetStore{}; - `, + ${code} + namespace PetStore{}; + `, ); expectDiagnostics(diagnostics, { @@ -16,48 +21,215 @@ it("emit diagnostic if tagName is not a string", async () => { }); }); -it("emit diagnostic if description is not a string", async () => { +it("emit diagnostic if dup tagName", async () => { const diagnostics = await diagnoseOpenApiFor( ` - @tagMetadata("tagName", { description: 123, }) + @tagMetadata("tagName") + @tagMetadata("tagName") + namespace PetStore{}; + `, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/duplicate-tag", + }); +}); + +describe("emit diagnostics when passing extension key not starting with `x-` in additionalInfo", () => { + it.each([ + ["root", `{ foo:"Bar" }`], + ["externalDocs", `{ externalDocs:{ url: "https://example.com", foo:"Bar"} }`], + ["complex", `{ externalDocs:{ url: "https://example.com", "x-custom": "string" }, foo:"Bar" }`], + ])("%s", async (_, code) => { + const diagnostics = await diagnoseOpenApiFor( + ` + @tagMetadata("tagName", ${code}) namespace PetStore{}; `, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo'`, + }); + }); + + it("multiple", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` + @tagMetadata("tagName",{ + externalDocs: { url: "https://example.com", foo1:"Bar" }, + foo2:"Bar" + }) + @test namespace Service{}; + `, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo1'`, + }, + { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo2'`, + }, + ]); + }); +}); + +it("emit diagnostic if externalDocs.url is not a valid url", async () => { + const diagnostics = await diagnoseOpenApiFor( + ` + @tagMetadata("tagName", { + externalDocs: { url: "notvalidurl"}, + }) + @test namespace Service {} + `, ); expectDiagnostics(diagnostics, { - code: "invalid-argument", + code: "@typespec/openapi/not-url", + message: "externalDocs.url: notvalidurl is not a valid URL.", }); }); -it("set the additional information with @tagMetadata decorator", async () => { - const res = await openApiFor( +it("emit diagnostic if use on non namespace", async () => { + const diagnostics = await diagnoseOpenApiFor( ` - @service - @tagMetadata( - "pet", + @tagMetadata("tagName",{}) + model Foo {} + `, + ); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: "Cannot apply @tagMetadata decorator to Foo since it is not assignable to Namespace", + }); +}); + +it("set the tagMetadata with @tagMetadata", async () => { + const runner = await createOpenAPITestRunner(); + const { PetStore } = await runner.compile( + ` + @tagMetadata( + "tagName1", { description: "Pets operations", externalDocs: { url: "https://example.com", description: "More info.", + "x-custom": "string" + }, + } + ) + @tagMetadata( + "tagName2", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", }, + "x-custom": "string" } ) - namespace PetStore { - @tag("pet") - op NamespaceOperation(): string; - } - `, + @test + namespace PetStore {} + `, ); - deepStrictEqual(res.paths["/"].get.tags, ["pet"]); - deepStrictEqual(res.tags, [ + + deepStrictEqual(getTagsMetadata(runner.program, PetStore), [ { - name: "pet", + name: "tagName2", description: "Pets operations", externalDocs: { + url: "https://example.com", description: "More info.", + }, + "x-custom": "string", + }, + { + name: "tagName1", + description: "Pets operations", + externalDocs: { url: "https://example.com", + description: "More info.", + "x-custom": "string", }, }, ]); }); + +it.each([ + [ + "set the additional information with @tagMetadata decorator", + `@tag("TagName") op NamespaceOperation(): string;`, + [ + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, + "x-custom": "string", + }, + ], + ], + [ + "add tag with @tagMetadata decorator", + ``, + [ + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, + "x-custom": "string", + }, + ], + ], + [ + "add tags with @tagMetadata decorator and @tag decorator", + `@tag("opTag") op NamespaceOperation(): string;`, + [ + { name: "opTag" }, + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, + "x-custom": "string", + }, + ], + ], +])("%s", async (_, code, expected) => { + const res = await openApiFor( + ` + @service + @tagMetadata( + "TagName", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + "x-custom": "string" + }, + "x-custom": "string" + } + ) + namespace PetStore{${code}}; + `, + ); + + deepStrictEqual(res.tags, expected); +}); From 23c110a3e2a8f16cfc210326a237e20a2d02e025 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 24 Oct 2024 18:38:30 +0800 Subject: [PATCH 12/46] update --- packages/openapi3/src/openapi.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 7d5a1de7b7..0b34f475ab 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -233,6 +233,9 @@ function createOAPIEmitter( // De-dupe the per-endpoint tags that will be added into the #/tags let tags: Set; + // The per-endpoint tags that will be added into the #/tags + let tagsMetadata: OpenAPI3Tag[]; + const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { @@ -346,6 +349,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); + tagsMetadata = getTagsMetadata(program, service.type); } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -629,9 +633,7 @@ function createOAPIEmitter( } emitParameters(); emitSchemas(service.type); - - const tagsMetadata = getTagsMetadata(program, service.type); - emitTags(tagsMetadata); + emitTags(); // Clean up empty entries if (root.components) { @@ -1585,11 +1587,11 @@ function createOAPIEmitter( } } - function emitTags(tagsMetadata: OpenAPI3Tag[]) { - const tagSet = new Set(tagsMetadata.map((t) => t.name)); + function emitTags() { + const tagsNameSet = new Set(tagsMetadata.map((t) => t.name)); // emit Tag from op for (const tag of tags) { - if (!tagSet.has(tag)) { + if (!tagsNameSet.has(tag)) { root.tags!.push({ name: tag }); } } From 7aad0652ae898e99191b0077b165e0c2f11df33b Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 25 Oct 2024 11:25:13 +0800 Subject: [PATCH 13/46] refactor validateAdditionalInfoModel --- packages/openapi/src/decorators.ts | 32 ++++++++++---------- packages/openapi3/src/decorators.ts | 45 ++++++++++++++--------------- packages/openapi3/src/lib.ts | 10 ++++++- packages/openapi3/src/openapi.ts | 2 +- 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 655d8f77c7..ba8a108123 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -1,5 +1,6 @@ import { DecoratorContext, + Diagnostic, getDoc, getService, getSummary, @@ -182,15 +183,7 @@ export const $info: InfoDecorator = ( if (data === undefined) { return; } - validateAdditionalInfoModel(context, model); - if (data.termsOfService) { - const diagnostics = validateIsUri( - context.getArgumentTarget(0)!, - data.termsOfService, - "TermsOfService", - ); - context.program.reportDiagnostics(diagnostics); - } + validateAdditionalInfoModel(context, model, data); setInfo(context.program, entity, data); }; @@ -221,17 +214,24 @@ function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } -function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { +function validateAdditionalInfoModel( + context: DecoratorContext, + typespecType: TypeSpecValue, + data: AdditionalInfo & Record<`x-${string}`, unknown>, +) { const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.AdditionalInfo", )[0]! as Model; - + const diagnostics: Diagnostic[] = []; if (typeof typespecType === "object" && propertyModel) { - const diagnostics = checkNoAdditionalProperties( - typespecType, - context.getArgumentTarget(0)!, - propertyModel, + diagnostics.push( + ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), + ); + } + if (data.termsOfService) { + diagnostics.push( + ...validateIsUri(context.getArgumentTarget(0)!, data.termsOfService, "TermsOfService"), ); - context.program.reportDiagnostics(diagnostics); } + context.program.reportDiagnostics(diagnostics); } diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 8281bc4d6c..5d6812af23 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,5 +1,6 @@ import { DecoratorContext, + Diagnostic, Model, ModelProperty, Namespace, @@ -16,7 +17,7 @@ import { TagMetadataDecorator, UseRefDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { createStateSymbol, reportDiagnostic } from "./lib.js"; +import { OpenAPI3Keys, createStateSymbol, reportDiagnostic } from "./lib.js"; import { OpenAPI3Tag } from "./types.js"; const refTargetsKey = createStateSymbol("refs"); @@ -50,8 +51,8 @@ export function getOneOf(program: Program, entity: Type): boolean { return program.stateMap(oneOfKey).get(entity); } -const [getTagsMetadataState, setTagsMetadata] = unsafe_useStateMap( - Symbol.for("tagsMetadata"), +const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap( + OpenAPI3Keys.tagsMetadata, ); export const $tagMetadata: TagMetadataDecorator = ( context: DecoratorContext, @@ -59,7 +60,7 @@ export const $tagMetadata: TagMetadataDecorator = ( name: string, tagMetadata?: TypeSpecValue, ) => { - const tags = getTagsMetadataState(context.program, entity); + const tags = getTagsMetadata(context.program, entity); if (tags) { const tagNamesSet = new Set(tags.map((t) => t.name)); if (tagNamesSet.has(name)) { @@ -81,16 +82,7 @@ export const $tagMetadata: TagMetadataDecorator = ( if (data === undefined) { return; } - validateAdditionalInfoModel(context, tagMetadata); - if (data.externalDocs?.url) { - const diagnostics = validateIsUri( - context.getArgumentTarget(0)!, - data.externalDocs?.url, - "externalDocs.url", - ); - context.program.reportDiagnostics(diagnostics); - } - + validateAdditionalInfoModel(context, tagMetadata, data); metadata = { ...data, name }; } @@ -101,21 +93,26 @@ export const $tagMetadata: TagMetadataDecorator = ( } }; -export function getTagsMetadata(program: Program, entity: Type): OpenAPI3Tag[] { - return getTagsMetadataState(program, entity) || []; -} +export { getTagsMetadata }; -function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { +function validateAdditionalInfoModel( + context: DecoratorContext, + typespecType: TypeSpecValue, + data: OpenAPI3Tag & Record<`x-${string}`, unknown>, +) { const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.TagMetadata", )[0]! as Model; - + const diagnostics: Diagnostic[] = []; if (typeof typespecType === "object" && propertyModel) { - const diagnostics = checkNoAdditionalProperties( - typespecType, - context.getArgumentTarget(0)!, - propertyModel, + diagnostics.push( + ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), + ); + } + if (data.externalDocs?.url) { + diagnostics.push( + ...validateIsUri(context.getArgumentTarget(0)!, data.externalDocs?.url, "externalDocs.url"), ); - context.program.reportDiagnostics(diagnostics); } + context.program.reportDiagnostics(diagnostics); } diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index ecb8966a58..e3bec091eb 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -273,9 +273,17 @@ export const libDef = { emitter: { options: EmitterOptionsSchema as JSONSchemaType, }, + state: { + tagsMetadata: { description: "State for the @tagMetadata decorator." }, + }, } as const; export const $lib = createTypeSpecLibrary(libDef); -export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; +export const { + createDiagnostic, + reportDiagnostic, + createStateSymbol, + stateKeys: OpenAPI3Keys, +} = $lib; export type OpenAPILibrary = typeof $lib; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 0b34f475ab..ae9fa61d3b 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -349,7 +349,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagsMetadata = getTagsMetadata(program, service.type); + tagsMetadata = getTagsMetadata(program, service.type) || []; } function isValidServerVariableType(program: Program, type: Type): boolean { From f22c587d269fec93b86d9de2bd91597e1f1d28ff Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 25 Oct 2024 15:58:57 +0800 Subject: [PATCH 14/46] update --- packages/openapi3/src/decorators.ts | 31 +++++++++------------- packages/openapi3/src/openapi.ts | 11 ++++---- packages/openapi3/test/tagmetadata.test.ts | 17 ++++++------ 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 5d6812af23..c20ed17aa6 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -51,9 +51,11 @@ export function getOneOf(program: Program, entity: Type): boolean { return program.stateMap(oneOfKey).get(entity); } -const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap( - OpenAPI3Keys.tagsMetadata, -); +const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap< + Type, + { [name: string]: OpenAPI3Tag } +>(OpenAPI3Keys.tagsMetadata); + export const $tagMetadata: TagMetadataDecorator = ( context: DecoratorContext, entity: Namespace, @@ -61,17 +63,13 @@ export const $tagMetadata: TagMetadataDecorator = ( tagMetadata?: TypeSpecValue, ) => { const tags = getTagsMetadata(context.program, entity); - if (tags) { - const tagNamesSet = new Set(tags.map((t) => t.name)); - if (tagNamesSet.has(name)) { - reportDiagnostic(context.program, { - code: "duplicate-tag", - format: { tagName: name }, - target: context.getArgumentTarget(0)!, - }); - } + if (tags && tags[name]) { + reportDiagnostic(context.program, { + code: "duplicate-tag", + format: { tagName: name }, + target: context.getArgumentTarget(0)!, + }); } - let metadata: OpenAPI3Tag = { name }; if (tagMetadata) { const [data, diagnostics] = typespecTypeToJson>( @@ -86,11 +84,8 @@ export const $tagMetadata: TagMetadataDecorator = ( metadata = { ...data, name }; } - if (tags) { - tags.push(metadata); - } else { - setTagsMetadata(context.program, entity, [metadata]); - } + const newTags = { ...tags, [name]: metadata }; + setTagsMetadata(context.program, entity, newTags); }; export { getTagsMetadata }; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index ae9fa61d3b..797d565060 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -234,7 +234,7 @@ function createOAPIEmitter( let tags: Set; // The per-endpoint tags that will be added into the #/tags - let tagsMetadata: OpenAPI3Tag[]; + let tagsMetadata: { [name: string]: OpenAPI3Tag }; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace @@ -349,7 +349,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagsMetadata = getTagsMetadata(program, service.type) || []; + tagsMetadata = getTagsMetadata(program, service.type) || {}; } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -1588,16 +1588,15 @@ function createOAPIEmitter( } function emitTags() { - const tagsNameSet = new Set(tagsMetadata.map((t) => t.name)); // emit Tag from op for (const tag of tags) { - if (!tagsNameSet.has(tag)) { + if (!tagsMetadata[tag]) { root.tags!.push({ name: tag }); } } - for (const tag of tagsMetadata) { - root.tags!.push(tag); + for (const key in tagsMetadata) { + root.tags!.push(tagsMetadata[key]); } } diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 920c283705..76f1e30fe3 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -139,26 +139,27 @@ it("set the tagMetadata with @tagMetadata", async () => { `, ); - deepStrictEqual(getTagsMetadata(runner.program, PetStore), [ - { - name: "tagName2", + deepStrictEqual(getTagsMetadata(runner.program, PetStore), { + tagName1: { + name: "tagName1", description: "Pets operations", externalDocs: { url: "https://example.com", description: "More info.", + "x-custom": "string", }, - "x-custom": "string", }, - { - name: "tagName1", + + tagName2: { + name: "tagName2", description: "Pets operations", externalDocs: { url: "https://example.com", description: "More info.", - "x-custom": "string", }, + "x-custom": "string", }, - ]); + }); }); it.each([ From 3ce048636805e7e596361db81fd214a17914cca3 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 25 Oct 2024 16:53:09 +0800 Subject: [PATCH 15/46] add cases --- packages/openapi3/test/tagmetadata.test.ts | 75 +++++++++++++--------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 76f1e30fe3..64f167c7ac 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -108,17 +108,25 @@ it("emit diagnostic if use on non namespace", async () => { }); }); -it("set the tagMetadata with @tagMetadata", async () => { - const runner = await createOpenAPITestRunner(); - const { PetStore } = await runner.compile( - ` - @tagMetadata( +it.each([ + [ + "tagMetadata without additionalInfo", + `@tagMetadata("tagName")`, + { tagName: { name: "tagName" } }, + ], + [ + "tagMetadata without externalDocs", + `@tagMetadata("tagName",{description: "Pets operations"})`, + { tagName: { name: "tagName", description: "Pets operations" } }, + ], + [ + "multiple tagsMetadata", + `@tagMetadata( "tagName1", { description: "Pets operations", externalDocs: { url: "https://example.com", - description: "More info.", "x-custom": "string" }, } @@ -133,33 +141,38 @@ it("set the tagMetadata with @tagMetadata", async () => { }, "x-custom": "string" } - ) - @test - namespace PetStore {} - `, - ); - - deepStrictEqual(getTagsMetadata(runner.program, PetStore), { - tagName1: { - name: "tagName1", - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", - "x-custom": "string", + )`, + { + tagName1: { + name: "tagName1", + description: "Pets operations", + externalDocs: { + url: "https://example.com", + "x-custom": "string", + }, }, - }, - tagName2: { - name: "tagName2", - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", + tagName2: { + name: "tagName2", + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + }, + "x-custom": "string", }, - "x-custom": "string", }, - }); + ], +])("%s", async (_, code, expected) => { + const runner = await createOpenAPITestRunner(); + const { PetStore } = await runner.compile( + ` + ${code} + @test + namespace PetStore {} + `, + ); + deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); }); it.each([ @@ -180,7 +193,7 @@ it.each([ ], ], [ - "add tag with @tagMetadata decorator", + "set tag with @tagMetadata decorator", ``, [ { @@ -196,7 +209,7 @@ it.each([ ], ], [ - "add tags with @tagMetadata decorator and @tag decorator", + "set tags with @tagMetadata decorator and @tag decorator", `@tag("opTag") op NamespaceOperation(): string;`, [ { name: "opTag" }, From d6e129712cbfffa85d85c22e14df2f9c8b2226cb Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 28 Oct 2024 16:32:11 +0800 Subject: [PATCH 16/46] update --- packages/openapi/src/decorators.ts | 10 +++++-- packages/openapi3/lib/decorators.tsp | 2 +- packages/openapi3/src/decorators.ts | 39 ++++++++++++++++++++++++---- packages/openapi3/src/lib.ts | 2 +- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index ba8a108123..9cc11616c2 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -183,7 +183,9 @@ export const $info: InfoDecorator = ( if (data === undefined) { return; } - validateAdditionalInfoModel(context, model, data); + if (!validateAdditionalInfoModel(context, model, data)) { + return; + } setInfo(context.program, entity, data); }; @@ -218,7 +220,7 @@ function validateAdditionalInfoModel( context: DecoratorContext, typespecType: TypeSpecValue, data: AdditionalInfo & Record<`x-${string}`, unknown>, -) { +): boolean { const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.AdditionalInfo", )[0]! as Model; @@ -234,4 +236,8 @@ function validateAdditionalInfoModel( ); } context.program.reportDiagnostics(diagnostics); + if (diagnostics.length > 0) { + return false; + } + return true; } diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 3c9c4f9c56..9d1b20f045 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -19,7 +19,7 @@ model TagMetadata { /** A description of the API. */ description?: string; - /** a external Docs information of the API. */ + /** An external Docs information of the API. */ externalDocs?: ExternalDocs; } diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index c20ed17aa6..567e53c6a3 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -56,36 +56,61 @@ const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap< { [name: string]: OpenAPI3Tag } >(OpenAPI3Keys.tagsMetadata); +/** + * Decorator to add metadata to a tag associated with a namespace. + * @param context - The decorator context. + * @param entity - The namespace entity to associate the tag with. + * @param name - The name of the tag. + * @param tagMetadata - Optional metadata for the tag. + */ export const $tagMetadata: TagMetadataDecorator = ( context: DecoratorContext, entity: Namespace, name: string, tagMetadata?: TypeSpecValue, ) => { - const tags = getTagsMetadata(context.program, entity); + // Retrieve existing tags metadata or initialize an empty object + const tags = getTagsMetadata(context.program, entity) || {}; + + // Check for duplicate tag names if (tags && tags[name]) { reportDiagnostic(context.program, { code: "duplicate-tag", format: { tagName: name }, target: context.getArgumentTarget(0)!, }); + return; } + let metadata: OpenAPI3Tag = { name }; + + // Process tag metadata if provided if (tagMetadata) { const [data, diagnostics] = typespecTypeToJson>( tagMetadata, context.getArgumentTarget(0)!, ); + + // Report any diagnostics found during conversion context.program.reportDiagnostics(diagnostics); + + // Abort if data conversion failed if (data === undefined) { return; } - validateAdditionalInfoModel(context, tagMetadata, data); + + // Validate additional information model; abort if invalid + if (!validateAdditionalInfoModel(context, tagMetadata, data)) { + return; + } + + // Merge data into metadata metadata = { ...data, name }; } - const newTags = { ...tags, [name]: metadata }; - setTagsMetadata(context.program, entity, newTags); + // Update the tags metadata with the new tag + tags[name] = metadata; + setTagsMetadata(context.program, entity, tags); }; export { getTagsMetadata }; @@ -94,7 +119,7 @@ function validateAdditionalInfoModel( context: DecoratorContext, typespecType: TypeSpecValue, data: OpenAPI3Tag & Record<`x-${string}`, unknown>, -) { +): boolean { const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.TagMetadata", )[0]! as Model; @@ -110,4 +135,8 @@ function validateAdditionalInfoModel( ); } context.program.reportDiagnostics(diagnostics); + if (diagnostics.length > 0) { + return false; + } + return true; } diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index e3bec091eb..45ff49451d 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -266,7 +266,7 @@ export const libDef = { "duplicate-tag": { severity: "error", messages: { - default: paramMessage`Duplicate tag ${"tagName"}`, + default: paramMessage`"Duplicate tag '${"tagName"}' found. Tag names must be unique in OpenAPI3."`, }, }, }, From b87657c18923494c080dd0ba2e15c662865534ea Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 28 Oct 2024 16:37:39 +0800 Subject: [PATCH 17/46] up --- packages/openapi3/src/decorators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 567e53c6a3..8ad3b9e76a 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -73,7 +73,7 @@ export const $tagMetadata: TagMetadataDecorator = ( const tags = getTagsMetadata(context.program, entity) || {}; // Check for duplicate tag names - if (tags && tags[name]) { + if (tags[name]) { reportDiagnostic(context.program, { code: "duplicate-tag", format: { tagName: name }, From dd95f109d49575a941190c3c53969ec2f01bf95f Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 28 Oct 2024 16:54:28 +0800 Subject: [PATCH 18/46] up --- .../emitters/openapi3/reference/data-types.md | 8 +++--- packages/openapi/src/decorators.ts | 26 +++++++++++++++---- packages/openapi3/src/decorators.ts | 21 +++++++++++++-- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/docs/emitters/openapi3/reference/data-types.md b/docs/emitters/openapi3/reference/data-types.md index 62c98d47f0..9172b6f9ad 100644 --- a/docs/emitters/openapi3/reference/data-types.md +++ b/docs/emitters/openapi3/reference/data-types.md @@ -29,7 +29,7 @@ model TypeSpec.OpenAPI.TagMetadata #### Properties -| Name | Type | Description | -| ------------- | --------------------------------------------------------------- | --------------------------------------- | -| description? | `string` | A description of the API. | -| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | a external Docs information of the API. | +| Name | Type | Description | +| ------------- | --------------------------------------------------------------- | ---------------------------------------- | +| description? | `string` | A description of the API. | +| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | An external Docs information of the API. | diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 9cc11616c2..ebcc8eed61 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -216,28 +216,44 @@ function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } +/** + * Validates the additional information model for OpenAPI. + * Ensures that there are no additional properties and that specific fields are valid. + * + * @param context - The decorator context. + * @param typespecType - The type specification value to validate. + * @param data - The additional information data including custom extensions. + * @returns `true` if the validation is successful, `false` otherwise. + */ function validateAdditionalInfoModel( context: DecoratorContext, typespecType: TypeSpecValue, data: AdditionalInfo & Record<`x-${string}`, unknown>, ): boolean { + const diagnostics: Diagnostic[] = []; + + // Resolve the expected AdditionalInfo model const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.AdditionalInfo", )[0]! as Model; - const diagnostics: Diagnostic[] = []; + + // Check for additional properties not defined in the model if (typeof typespecType === "object" && propertyModel) { diagnostics.push( ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), ); } + + // Validate the termsOfService field as a URI if it exists if (data.termsOfService) { diagnostics.push( ...validateIsUri(context.getArgumentTarget(0)!, data.termsOfService, "TermsOfService"), ); } + + // Report any diagnostics that were collected context.program.reportDiagnostics(diagnostics); - if (diagnostics.length > 0) { - return false; - } - return true; + + // Return false if any diagnostics were found, true otherwise + return diagnostics.length === 0; } diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 8ad3b9e76a..f034dc1bba 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -115,26 +115,43 @@ export const $tagMetadata: TagMetadataDecorator = ( export { getTagsMetadata }; +/** + * Validates the additional information model for tags. + * @param context - The decorator context. + * @param typespecType - The type of the tag metadata. + * @param data - The tag metadata as an object. + * @returns `true` if the validation was successful, `false` otherwise. + */ function validateAdditionalInfoModel( context: DecoratorContext, typespecType: TypeSpecValue, data: OpenAPI3Tag & Record<`x-${string}`, unknown>, ): boolean { + const diagnostics: Diagnostic[] = []; + + // Resolve the TagMetadata model const propertyModel = context.program.resolveTypeReference( "TypeSpec.OpenAPI.TagMetadata", )[0]! as Model; - const diagnostics: Diagnostic[] = []; + + // Check that the type matches the model if (typeof typespecType === "object" && propertyModel) { diagnostics.push( ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), ); } + + // Validate the externalDocs.url property if (data.externalDocs?.url) { diagnostics.push( - ...validateIsUri(context.getArgumentTarget(0)!, data.externalDocs?.url, "externalDocs.url"), + ...validateIsUri(context.getArgumentTarget(0)!, data.externalDocs.url, "externalDocs.url"), ); } + + // Report any diagnostics found during validation context.program.reportDiagnostics(diagnostics); + + // Abort if any diagnostics were found if (diagnostics.length > 0) { return false; } From 2ff5d4cfafd68cbc35570ded0ee0090dfdb39535 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 28 Oct 2024 16:57:57 +0800 Subject: [PATCH 19/46] up --- packages/openapi/src/decorators.ts | 10 ++++++---- packages/openapi3/src/decorators.ts | 5 ++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index ebcc8eed61..b75e50a783 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -251,9 +251,11 @@ function validateAdditionalInfoModel( ); } - // Report any diagnostics that were collected - context.program.reportDiagnostics(diagnostics); - // Return false if any diagnostics were found, true otherwise - return diagnostics.length === 0; + if (diagnostics.length > 0) { + // Report any diagnostics that were collected + context.program.reportDiagnostics(diagnostics); + return false; + } + return true; } diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index f034dc1bba..1270ae28b7 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -148,11 +148,10 @@ function validateAdditionalInfoModel( ); } - // Report any diagnostics found during validation - context.program.reportDiagnostics(diagnostics); - // Abort if any diagnostics were found if (diagnostics.length > 0) { + // Report any diagnostics found during validation + context.program.reportDiagnostics(diagnostics); return false; } return true; From 9fbdebd47c6fb4d7f6a31c1355767739fa3e3cba Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Tue, 29 Oct 2024 17:22:48 +0800 Subject: [PATCH 20/46] update --- .../emitters/openapi3/reference/data-types.md | 2 +- packages/openapi/src/decorators.ts | 72 +++++--------- packages/openapi/src/helpers.ts | 98 +++++++++++++------ packages/openapi/src/index.ts | 2 +- packages/openapi3/lib/decorators.tsp | 2 +- packages/openapi3/src/decorators.ts | 69 +++++-------- packages/openapi3/test/tagmetadata.test.ts | 1 + 7 files changed, 121 insertions(+), 125 deletions(-) diff --git a/docs/emitters/openapi3/reference/data-types.md b/docs/emitters/openapi3/reference/data-types.md index 9172b6f9ad..ca451450e3 100644 --- a/docs/emitters/openapi3/reference/data-types.md +++ b/docs/emitters/openapi3/reference/data-types.md @@ -21,7 +21,7 @@ model TypeSpec.OpenAPI.ExternalDocs ### `TagMetadata` {#TypeSpec.OpenAPI.TagMetadata} -Additional information for the OpenAPI document. +Metadata to a single tag that is used by operations. ```typespec model TypeSpec.OpenAPI.TagMetadata diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index b75e50a783..63381e5401 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -1,6 +1,5 @@ import { DecoratorContext, - Diagnostic, getDoc, getService, getSummary, @@ -20,7 +19,7 @@ import { InfoDecorator, OperationIdDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { checkNoAdditionalProperties, isOpenAPIExtensionKey, validateIsUri } from "./helpers.js"; +import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; import { createStateSymbol, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; @@ -183,9 +182,32 @@ export const $info: InfoDecorator = ( if (data === undefined) { return; } - if (!validateAdditionalInfoModel(context, model, data)) { + + // Validate the AdditionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + model, + "TypeSpec.OpenAPI.AdditionalInfo", + ) + ) { return; } + + // Validate termsOfService + if (data.termsOfService) { + if ( + !validateIsUri( + context.program, + context.getArgumentTarget(0)!, + data.termsOfService, + "TermsOfService", + ) + ) { + return; + } + } setInfo(context.program, entity, data); }; @@ -215,47 +237,3 @@ export function resolveInfo(program: Program, entity: Namespace): AdditionalInfo function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } - -/** - * Validates the additional information model for OpenAPI. - * Ensures that there are no additional properties and that specific fields are valid. - * - * @param context - The decorator context. - * @param typespecType - The type specification value to validate. - * @param data - The additional information data including custom extensions. - * @returns `true` if the validation is successful, `false` otherwise. - */ -function validateAdditionalInfoModel( - context: DecoratorContext, - typespecType: TypeSpecValue, - data: AdditionalInfo & Record<`x-${string}`, unknown>, -): boolean { - const diagnostics: Diagnostic[] = []; - - // Resolve the expected AdditionalInfo model - const propertyModel = context.program.resolveTypeReference( - "TypeSpec.OpenAPI.AdditionalInfo", - )[0]! as Model; - - // Check for additional properties not defined in the model - if (typeof typespecType === "object" && propertyModel) { - diagnostics.push( - ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), - ); - } - - // Validate the termsOfService field as a URI if it exists - if (data.termsOfService) { - diagnostics.push( - ...validateIsUri(context.getArgumentTarget(0)!, data.termsOfService, "TermsOfService"), - ); - } - - // Return false if any diagnostics were found, true otherwise - if (diagnostics.length > 0) { - // Report any diagnostics that were collected - context.program.reportDiagnostics(diagnostics); - return false; - } - return true; -} diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 35273e0395..697e619900 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -15,6 +15,7 @@ import { Program, Type, TypeNameOptions, + TypeSpecValue, } from "@typespec/compiler"; import { getOperationId } from "./decorators.js"; import { createDiagnostic, reportDiagnostic } from "./lib.js"; @@ -178,10 +179,77 @@ export function isOpenAPIExtensionKey(key: string): key is ExtensionKey { return key.startsWith("x-"); } +/** + * Validate that the given string is a valid URL. + * @param program Program + * @param target Diagnostic target for any diagnostics that are reported + * @param url The URL to validate + * @param propertyName The name of the property that the URL is associated with + * @returns true if the URL is valid, false otherwise + */ +export function validateIsUri( + program: Program, + target: DiagnosticTarget, + url: string, + propertyName: string, +): boolean { + try { + // Attempt to create a URL object from the given string. If + // successful, the URL is valid. + new URL(url); + return true; + } catch { + // If the URL is invalid, report a diagnostic with the given + // target, property name and value. + reportDiagnostic(program, { + code: "not-url", + target: target, + format: { property: propertyName, value: url }, + }); + return false; + } +} + +/** + * Validate the AdditionalInfo model against a reference. + * + * This function checks that the properties of the given AdditionalInfo object + * are a subset of the properties defined in the AdditionalInfo model. + * + * @param program - The TypeSpec Program instance + * @param target - Diagnostic target for reporting any diagnostics + * @param typespecType - The AdditionalInfo object to validate + * @param reference - The reference string to resolve the model + * @returns true if the AdditionalInfo object is valid, false otherwise + */ +export function validateAdditionalInfoModel( + program: Program, + target: DiagnosticTarget, + typespecType: TypeSpecValue, + reference: string, +): boolean { + // Resolve the reference to get the corresponding model + const propertyModel = program.resolveTypeReference(reference)[0]! as Model; + + // Check if typespecType is an object and propertyModel is defined + if (typeof typespecType === "object" && propertyModel) { + // Validate that the properties of typespecType do not exceed those in propertyModel + const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel); + program.reportDiagnostics(diagnostics); + // Return false if any diagnostics were reported, indicating a validation failure + if (diagnostics.length > 0) { + return false; + } + } + + // Return true if validation is successful + return true; +} + /** * Check Additional Properties */ -export function checkNoAdditionalProperties( +function checkNoAdditionalProperties( typespecType: Type, target: DiagnosticTarget, source: Model, @@ -213,31 +281,3 @@ export function checkNoAdditionalProperties( return diagnostics; } - -/** - * Validate a string as a URI. - * @param target The target of the diagnostic - * @param url The URL to validate - * @param propertyName The name of the property being validated - */ -export function validateIsUri( - target: DiagnosticTarget, - url: string, - propertyName: string, -): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - try { - // Attempt to construct a new URL - new URL(url); - } catch { - // If the construction fails, create a diagnostic - diagnostics.push( - createDiagnostic({ - code: "not-url", - format: { property: propertyName, value: url }, - target, - }), - ); - } - return diagnostics; -} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 049ed42e65..f8792d4fa7 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -21,12 +21,12 @@ export { } from "./decorators.js"; export { checkDuplicateTypeName, - checkNoAdditionalProperties, getOpenAPITypeName, getParameterKey, isReadonlyProperty, resolveOperationId, shouldInline, + validateAdditionalInfoModel, validateIsUri, } from "./helpers.js"; export { AdditionalInfo, Contact, ExtensionKey, ExternalDocs, License } from "./types.js"; diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index 9d1b20f045..b3802511f9 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -14,7 +14,7 @@ extern dec oneOf(target: Union | ModelProperty); */ extern dec useRef(target: Model | ModelProperty, ref: valueof string); -/** Additional information for the OpenAPI document. */ +/** Metadata to a single tag that is used by operations. */ model TagMetadata { /** A description of the API. */ description?: string; diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 1270ae28b7..92f1167f0b 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,6 +1,5 @@ import { DecoratorContext, - Diagnostic, Model, ModelProperty, Namespace, @@ -11,7 +10,7 @@ import { typespecTypeToJson, } from "@typespec/compiler"; import { unsafe_useStateMap } from "@typespec/compiler/experimental"; -import { ExtensionKey, checkNoAdditionalProperties, validateIsUri } from "@typespec/openapi"; +import { ExtensionKey, validateAdditionalInfoModel, validateIsUri } from "@typespec/openapi"; import { OneOfDecorator, TagMetadataDecorator, @@ -99,11 +98,31 @@ export const $tagMetadata: TagMetadataDecorator = ( return; } - // Validate additional information model; abort if invalid - if (!validateAdditionalInfoModel(context, tagMetadata, data)) { + // Validate the additionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + tagMetadata, + "TypeSpec.OpenAPI.TagMetadata", + ) + ) { return; } + // Validate the externalDocs.url property + if (data.externalDocs?.url) { + if ( + !validateIsUri( + context.program, + context.getArgumentTarget(0)!, + data.externalDocs.url, + "externalDocs.url", + ) + ) { + return; + } + } // Merge data into metadata metadata = { ...data, name }; } @@ -114,45 +133,3 @@ export const $tagMetadata: TagMetadataDecorator = ( }; export { getTagsMetadata }; - -/** - * Validates the additional information model for tags. - * @param context - The decorator context. - * @param typespecType - The type of the tag metadata. - * @param data - The tag metadata as an object. - * @returns `true` if the validation was successful, `false` otherwise. - */ -function validateAdditionalInfoModel( - context: DecoratorContext, - typespecType: TypeSpecValue, - data: OpenAPI3Tag & Record<`x-${string}`, unknown>, -): boolean { - const diagnostics: Diagnostic[] = []; - - // Resolve the TagMetadata model - const propertyModel = context.program.resolveTypeReference( - "TypeSpec.OpenAPI.TagMetadata", - )[0]! as Model; - - // Check that the type matches the model - if (typeof typespecType === "object" && propertyModel) { - diagnostics.push( - ...checkNoAdditionalProperties(typespecType, context.getArgumentTarget(0)!, propertyModel), - ); - } - - // Validate the externalDocs.url property - if (data.externalDocs?.url) { - diagnostics.push( - ...validateIsUri(context.getArgumentTarget(0)!, data.externalDocs.url, "externalDocs.url"), - ); - } - - // Abort if any diagnostics were found - if (diagnostics.length > 0) { - // Report any diagnostics found during validation - context.program.reportDiagnostics(diagnostics); - return false; - } - return true; -} diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 64f167c7ac..f1756aeb1b 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -8,6 +8,7 @@ it.each([ ["tagName is not a string", `@tagMetadata(123)`], ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], ["description is not a string", `@tagMetadata("tagName", { description: 123, })`], + ["externalDocs is not an object", `@tagMetadata("tagName", { externalDocs: 123, })`], ])("%s", async (_, code) => { const diagnostics = await diagnoseOpenApiFor( ` From 8e6e1ba780add5169dcb554eb5cbec10353a0846 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Tue, 29 Oct 2024 18:11:08 +0800 Subject: [PATCH 21/46] update cases --- packages/openapi3/test/tagmetadata.test.ts | 281 +++++++++++++-------- 1 file changed, 170 insertions(+), 111 deletions(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index f1756aeb1b..7d3cbcc71f 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -109,142 +109,201 @@ it("emit diagnostic if use on non namespace", async () => { }); }); -it.each([ - [ - "tagMetadata without additionalInfo", - `@tagMetadata("tagName")`, - { tagName: { name: "tagName" } }, - ], - [ - "tagMetadata without externalDocs", - `@tagMetadata("tagName",{description: "Pets operations"})`, - { tagName: { name: "tagName", description: "Pets operations" } }, - ], - [ - "multiple tagsMetadata", - `@tagMetadata( - "tagName1", - { +describe("getTagsMetadata return value", () => { + const testCases: [string, string, any][] = [ + [ + "tagMetadata without additionalInfo", + `@tagMetadata("tagName")`, + { tagName: { name: "tagName" } }, + ], + [ + "tagMetadata without externalDocs", + `@tagMetadata("tagName",{description: "Pets operations"})`, + { tagName: { name: "tagName", description: "Pets operations" } }, + ], + [ + "multiple tagsMetadata", + `@tagMetadata( + "tagName1", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + "x-custom": "string" + }, + } + ) + @tagMetadata( + "tagName2", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + }, + "x-custom": "string" + } + )`, + { + tagName1: { + name: "tagName1", description: "Pets operations", externalDocs: { url: "https://example.com", - "x-custom": "string" - }, - } - ) - @tagMetadata( - "tagName2", - { + "x-custom": "string", + }, + }, + + tagName2: { + name: "tagName2", description: "Pets operations", externalDocs: { url: "https://example.com", - description: "More info.", + description: "More info.", }, - "x-custom": "string" - } - )`, - { - tagName1: { - name: "tagName1", - description: "Pets operations", - externalDocs: { - url: "https://example.com", "x-custom": "string", }, }, - - tagName2: { - name: "tagName2", - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", - }, - "x-custom": "string", - }, - }, - ], -])("%s", async (_, code, expected) => { - const runner = await createOpenAPITestRunner(); - const { PetStore } = await runner.compile( - ` - ${code} - @test - namespace PetStore {} - `, - ); - deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); + ], + ]; + it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { + const runner = await createOpenAPITestRunner(); + const { PetStore } = await runner.compile( + ` + ${tagMetaDecorator} + @test + namespace PetStore {} + `, + ); + deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); + }); }); -it.each([ - [ - "set the additional information with @tagMetadata decorator", - `@tag("TagName") op NamespaceOperation(): string;`, +describe("set value with @tagMetadata decorator", () => { + const testCases: [string, string, string, any][] = [ [ - { - name: "TagName", - description: "Pets operations", - externalDocs: { - description: "More info.", - url: "https://example.com", + "additional information", + `@tagMetadata( + "TagName", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + "x-custom": "string" + }, + "x-custom": "string" + } + )`, + `@tag("TagName") op NamespaceOperation(): string;`, + [ + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, "x-custom": "string", }, - "x-custom": "string", - }, + ], ], - ], - [ - "set tag with @tagMetadata decorator", - ``, [ - { - name: "TagName", - description: "Pets operations", - externalDocs: { - description: "More info.", - url: "https://example.com", + "only tag metadata", + `@tagMetadata( + "TagName", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + "x-custom": "string" + }, + "x-custom": "string" + } + )`, + ``, + [ + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, "x-custom": "string", }, - "x-custom": "string", - }, + ], ], - ], - [ - "set tags with @tagMetadata decorator and @tag decorator", - `@tag("opTag") op NamespaceOperation(): string;`, [ - { name: "opTag" }, - { - name: "TagName", - description: "Pets operations", - externalDocs: { - description: "More info.", - url: "https://example.com", + "tag and tag metadata with different name", + `@tagMetadata( + "TagName", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + "x-custom": "string" + }, + "x-custom": "string" + } + )`, + `@tag("opTag") op NamespaceOperation(): string;`, + [ + { name: "opTag" }, + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, "x-custom": "string", }, - "x-custom": "string", - }, + ], ], - ], -])("%s", async (_, code, expected) => { - const res = await openApiFor( - ` - @service - @tagMetadata( - "TagName", - { - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", + [ + "tag and tag metadata with same name", + `@tagMetadata( + "TagName", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + "x-custom": "string" + }, "x-custom": "string" + } + )`, + `@tag("TagName") op NamespaceOperation(): string;`, + [ + { + name: "TagName", + description: "Pets operations", + externalDocs: { + description: "More info.", + url: "https://example.com", + "x-custom": "string", + }, + "x-custom": "string", }, - "x-custom": "string" - } - ) - namespace PetStore{${code}}; - `, - ); + ], + ], + ]; + it.each(testCases)("%s", async (_, tagMetaDecorator, operationDeclaration, expected) => { + const res = await openApiFor( + ` + @service + ${tagMetaDecorator} + namespace PetStore{${operationDeclaration}}; + `, + ); - deepStrictEqual(res.tags, expected); + deepStrictEqual(res.tags, expected); + }); }); From dd5460131f1edb1ac8c8b08d2e2d9d5097fa9e7e Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Wed, 30 Oct 2024 17:05:03 +0800 Subject: [PATCH 22/46] update case name --- packages/openapi3/test/tagmetadata.test.ts | 52 +++++----------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index 7d3cbcc71f..c78cd67aa7 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -36,7 +36,7 @@ it("emit diagnostic if dup tagName", async () => { }); }); -describe("emit diagnostics when passing extension key not starting with `x-` in additionalInfo", () => { +describe("emit diagnostics when passing extension key not starting with `x-` in metadata", () => { it.each([ ["root", `{ foo:"Bar" }`], ["externalDocs", `{ externalDocs:{ url: "https://example.com", foo:"Bar"} }`], @@ -109,20 +109,20 @@ it("emit diagnostic if use on non namespace", async () => { }); }); -describe("getTagsMetadata return value", () => { +describe("getTagsMetadata returns", () => { const testCases: [string, string, any][] = [ [ - "tagMetadata without additionalInfo", + "set tagMetadata without additionalInfo", `@tagMetadata("tagName")`, { tagName: { name: "tagName" } }, ], [ - "tagMetadata without externalDocs", + "set tagMetadata without externalDocs", `@tagMetadata("tagName",{description: "Pets operations"})`, { tagName: { name: "tagName", description: "Pets operations" } }, ], [ - "multiple tagsMetadata", + "set multiple tagsMetadata", `@tagMetadata( "tagName1", { @@ -179,38 +179,10 @@ describe("getTagsMetadata return value", () => { }); }); -describe("set value with @tagMetadata decorator", () => { +describe("emit results when set value with @tagMetadata decorator", () => { const testCases: [string, string, string, any][] = [ [ - "additional information", - `@tagMetadata( - "TagName", - { - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", - "x-custom": "string" - }, - "x-custom": "string" - } - )`, - `@tag("TagName") op NamespaceOperation(): string;`, - [ - { - name: "TagName", - description: "Pets operations", - externalDocs: { - description: "More info.", - url: "https://example.com", - "x-custom": "string", - }, - "x-custom": "string", - }, - ], - ], - [ - "only tag metadata", + "set tag metadata", `@tagMetadata( "TagName", { @@ -238,7 +210,7 @@ describe("set value with @tagMetadata decorator", () => { ], ], [ - "tag and tag metadata with different name", + "add additional information for tag", `@tagMetadata( "TagName", { @@ -251,9 +223,8 @@ describe("set value with @tagMetadata decorator", () => { "x-custom": "string" } )`, - `@tag("opTag") op NamespaceOperation(): string;`, + `@tag("TagName") op NamespaceOperation(): string;`, [ - { name: "opTag" }, { name: "TagName", description: "Pets operations", @@ -267,7 +238,7 @@ describe("set value with @tagMetadata decorator", () => { ], ], [ - "tag and tag metadata with same name", + "set tag and tag metadata with different name", `@tagMetadata( "TagName", { @@ -280,8 +251,9 @@ describe("set value with @tagMetadata decorator", () => { "x-custom": "string" } )`, - `@tag("TagName") op NamespaceOperation(): string;`, + `@tag("opTag") op NamespaceOperation(): string;`, [ + { name: "opTag" }, { name: "TagName", description: "Pets operations", From f32367b38226720f55e5c62c58b5fedfeb7ddbfc Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 10:29:52 +0800 Subject: [PATCH 23/46] Update .chronus/changes/tagMetadata-2024-9-23-16-55-56.md Co-authored-by: Timothee Guerin --- .chronus/changes/tagMetadata-2024-9-23-16-55-56.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md index a383a967e7..dfaf9a6c26 100644 --- a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md +++ b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md @@ -5,4 +5,4 @@ packages: - "@typespec/openapi3" --- -a decorator for specify OpenAPI tag properties \ No newline at end of file +Add new `@tagMetadata` decorator to specify OpenAPI tag properties \ No newline at end of file From 7b69494da46068f559db44b5fe2626e4d8fe3a63 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 10:31:09 +0800 Subject: [PATCH 24/46] Update packages/openapi3/src/lib.ts Co-authored-by: Timothee Guerin --- packages/openapi3/src/lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 8b1e12c822..7409bb182c 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -278,7 +278,7 @@ export const libDef = { "duplicate-tag": { severity: "error", messages: { - default: paramMessage`"Duplicate tag '${"tagName"}' found. Tag names must be unique in OpenAPI3."`, + default: paramMessage`"Metadata for tag '${"tagName"}' was specified twice."`, }, }, }, From c1366a4e11c8c060d6a57b153576e01cd9bd61aa Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 11:51:11 +0800 Subject: [PATCH 25/46] move @tagmetadata decorator to openapi lib --- .../changes/tagMetadata-2024-9-23-16-55-56.md | 3 +- .../emitters/openapi3/reference/decorators.md | 19 -- docs/emitters/openapi3/reference/index.mdx | 6 - .../libraries/openapi/reference/data-types.md | 30 +++ .../libraries/openapi/reference/decorators.md | 19 ++ docs/libraries/openapi/reference/index.mdx | 3 + packages/openapi/README.md | 20 ++ .../generated-defs/TypeSpec.OpenAPI.ts | 14 ++ packages/openapi/lib/decorators.tsp | 25 +++ packages/openapi/src/decorators.ts | 89 ++++++++- packages/openapi/src/index.ts | 3 +- packages/openapi/src/lib.ts | 16 +- packages/openapi/src/tsp-index.ts | 10 +- packages/openapi/test/decorators.test.ts | 180 ++++++++++++++++++ packages/openapi3/README.md | 20 -- .../generated-defs/TypeSpec.OpenAPI.ts | 23 +-- packages/openapi3/lib/decorators.tsp | 25 --- packages/openapi3/src/decorators.ts | 107 +---------- packages/openapi3/src/lib.ts | 16 +- packages/openapi3/src/openapi.ts | 3 +- packages/openapi3/src/tsp-index.ts | 3 +- packages/openapi3/test/tagmetadata.test.ts | 178 +---------------- 22 files changed, 414 insertions(+), 398 deletions(-) diff --git a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md index dfaf9a6c26..af9afdd98f 100644 --- a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md +++ b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md @@ -2,7 +2,6 @@ changeKind: feature packages: - "@typespec/openapi" - - "@typespec/openapi3" --- -Add new `@tagMetadata` decorator to specify OpenAPI tag properties \ No newline at end of file +Add new `@tagMetadata` decorator to specify OpenAPI tag properties diff --git a/docs/emitters/openapi3/reference/decorators.md b/docs/emitters/openapi3/reference/decorators.md index f32db7f117..c057a1ad57 100644 --- a/docs/emitters/openapi3/reference/decorators.md +++ b/docs/emitters/openapi3/reference/decorators.md @@ -22,25 +22,6 @@ Specify that `oneOf` should be used instead of `anyOf` for that union. None -### `@tagMetadata` {#@TypeSpec.OpenAPI.tagMetadata} - -Specify OpenAPI additional information. - -```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) -``` - -#### Target - -`Namespace` - -#### Parameters - -| Name | Type | Description | -| ----------- | ------------------------------------------------------------- | ----------- | -| name | `valueof string` | tag name | -| tagMetadata | [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | | - ### `@useRef` {#@TypeSpec.OpenAPI.useRef} Specify an external reference that should be used inside of emitting this type. diff --git a/docs/emitters/openapi3/reference/index.mdx b/docs/emitters/openapi3/reference/index.mdx index 3264814bb6..b1836a03fd 100644 --- a/docs/emitters/openapi3/reference/index.mdx +++ b/docs/emitters/openapi3/reference/index.mdx @@ -38,10 +38,4 @@ npm install --save-peer @typespec/openapi3 ### Decorators - [`@oneOf`](./decorators.md#@TypeSpec.OpenAPI.oneOf) -- [`@tagMetadata`](./decorators.md#@TypeSpec.OpenAPI.tagMetadata) - [`@useRef`](./decorators.md#@TypeSpec.OpenAPI.useRef) - -### Models - -- [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) -- [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) diff --git a/docs/libraries/openapi/reference/data-types.md b/docs/libraries/openapi/reference/data-types.md index 829f28db6e..09cb3ade8b 100644 --- a/docs/libraries/openapi/reference/data-types.md +++ b/docs/libraries/openapi/reference/data-types.md @@ -39,6 +39,21 @@ model TypeSpec.OpenAPI.Contact | url? | `url` | The URL pointing to the contact information. MUST be in the format of a URL. | | email? | `string` | The email address of the contact person/organization. MUST be in the format of an email address. | +### `ExternalDocs` {#TypeSpec.OpenAPI.ExternalDocs} + +External Docs information. + +```typespec +model TypeSpec.OpenAPI.ExternalDocs +``` + +#### Properties + +| Name | Type | Description | +| ------------ | -------- | -------------------- | +| url | `string` | Documentation url | +| description? | `string` | Optional description | + ### `License` {#TypeSpec.OpenAPI.License} License information for the exposed API. @@ -53,3 +68,18 @@ model TypeSpec.OpenAPI.License | ---- | -------- | ---------------------------------------------------------------------- | | name | `string` | The license name used for the API. | | url? | `url` | A URL to the license used for the API. MUST be in the format of a URL. | + +### `TagMetadata` {#TypeSpec.OpenAPI.TagMetadata} + +Metadata to a single tag that is used by operations. + +```typespec +model TypeSpec.OpenAPI.TagMetadata +``` + +#### Properties + +| Name | Type | Description | +| ------------- | --------------------------------------------------------------- | ---------------------------------------- | +| description? | `string` | A description of the API. | +| externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | An external Docs information of the API. | diff --git a/docs/libraries/openapi/reference/decorators.md b/docs/libraries/openapi/reference/decorators.md index be7cf18311..59d3ada507 100644 --- a/docs/libraries/openapi/reference/decorators.md +++ b/docs/libraries/openapi/reference/decorators.md @@ -136,3 +136,22 @@ Specify the OpenAPI `operationId` property for this operation. @operationId("download") op read(): string; ``` + +### `@tagMetadata` {#@TypeSpec.OpenAPI.tagMetadata} + +Specify OpenAPI additional information. + +```typespec +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) +``` + +#### Target + +`Namespace` + +#### Parameters + +| Name | Type | Description | +| ----------- | ------------------------------------------------------------- | ----------- | +| name | `valueof string` | tag name | +| tagMetadata | [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | | diff --git a/docs/libraries/openapi/reference/index.mdx b/docs/libraries/openapi/reference/index.mdx index 6510dee917..2409692726 100644 --- a/docs/libraries/openapi/reference/index.mdx +++ b/docs/libraries/openapi/reference/index.mdx @@ -38,9 +38,12 @@ npm install --save-peer @typespec/openapi - [`@externalDocs`](./decorators.md#@TypeSpec.OpenAPI.externalDocs) - [`@info`](./decorators.md#@TypeSpec.OpenAPI.info) - [`@operationId`](./decorators.md#@TypeSpec.OpenAPI.operationId) +- [`@tagMetadata`](./decorators.md#@TypeSpec.OpenAPI.tagMetadata) ### Models - [`AdditionalInfo`](./data-types.md#TypeSpec.OpenAPI.AdditionalInfo) - [`Contact`](./data-types.md#TypeSpec.OpenAPI.Contact) +- [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) - [`License`](./data-types.md#TypeSpec.OpenAPI.License) +- [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 3e5a84e4b0..49c73c1dc5 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -17,6 +17,7 @@ npm install @typespec/openapi - [`@externalDocs`](#@externaldocs) - [`@info`](#@info) - [`@operationId`](#@operationid) +- [`@tagMetadata`](#@tagmetadata) #### `@defaultResponse` @@ -148,3 +149,22 @@ Specify the OpenAPI `operationId` property for this operation. @operationId("download") op read(): string; ``` + +#### `@tagMetadata` + +Specify OpenAPI additional information. + +```typespec +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) +``` + +##### Target + +`Namespace` + +##### Parameters + +| Name | Type | Description | +| ----------- | ----------------------------- | ----------- | +| name | `valueof string` | tag name | +| tagMetadata | [`TagMetadata`](#tagmetadata) | | diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 9c51929bc4..0bf0c9c0c9 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -79,10 +79,24 @@ export type InfoDecorator = ( additionalInfo: Type, ) => void; +/** + * Specify OpenAPI additional information. + * + * @param name tag name + * @param additionalTag Additional information + */ +export type TagMetadataDecorator = ( + context: DecoratorContext, + target: Namespace, + name: string, + tagMetadata?: Type, +) => void; + export type TypeSpecOpenAPIDecorators = { operationId: OperationIdDecorator; extension: ExtensionDecorator; defaultResponse: DefaultResponseDecorator; externalDocs: ExternalDocsDecorator; info: InfoDecorator; + tagMetadata: TagMetadataDecorator; }; diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index 53bfec4a60..6cf0835f69 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -109,3 +109,28 @@ model License { * @param additionalInfo Additional information */ extern dec info(target: Namespace, additionalInfo: AdditionalInfo); + +/** Metadata to a single tag that is used by operations. */ +model TagMetadata { + /** A description of the API. */ + description?: string; + + /** An external Docs information of the API. */ + externalDocs?: ExternalDocs; +} + +/** External Docs information. */ +model ExternalDocs { + /** Documentation url */ + url: string; + + /** Optional description */ + description?: string; +} + +/** + * Specify OpenAPI additional information. + * @param name tag name + * @param additionalTag Additional information + */ +extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata); diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 63381e5401..e51974eae4 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -11,6 +11,7 @@ import { typespecTypeToJson, TypeSpecValue, } from "@typespec/compiler"; +import { unsafe_useStateMap } from "@typespec/compiler/experimental"; import { setStatusCode } from "@typespec/http"; import { DefaultResponseDecorator, @@ -18,9 +19,10 @@ import { ExternalDocsDecorator, InfoDecorator, OperationIdDecorator, + TagMetadataDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; -import { createStateSymbol, reportDiagnostic } from "./lib.js"; +import { createStateSymbol, OpenAPIKeys, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; const operationIdsKey = createStateSymbol("operationIds"); @@ -237,3 +239,88 @@ export function resolveInfo(program: Program, entity: Namespace): AdditionalInfo function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } + +const [ + /** Get TagsMetadata set with `@tagMetadata` decorator */ + getTagsMetadata, + setTagsMetadata, +] = unsafe_useStateMap(OpenAPIKeys.tagsMetadata); + +/** + * Decorator to add metadata to a tag associated with a namespace. + * @param context - The decorator context. + * @param entity - The namespace entity to associate the tag with. + * @param name - The name of the tag. + * @param tagMetadata - Optional metadata for the tag. + */ +export const tagMetadataDecorator: TagMetadataDecorator = ( + context: DecoratorContext, + entity: Namespace, + name: string, + tagMetadata?: TypeSpecValue, +) => { + // Retrieve existing tags metadata or initialize an empty object + const tags = getTagsMetadata(context.program, entity) ?? {}; + + // Check for duplicate tag names + if (tags[name]) { + reportDiagnostic(context.program, { + code: "duplicate-tag", + format: { tagName: name }, + target: context.getArgumentTarget(0)!, + }); + return; + } + + let metadata: any = { name }; + + // Process tag metadata if provided + if (tagMetadata) { + const [data, diagnostics] = typespecTypeToJson>( + tagMetadata, + context.getArgumentTarget(0)!, + ); + + // Report any diagnostics found during conversion + context.program.reportDiagnostics(diagnostics); + + // Abort if data conversion failed + if (data === undefined) { + return; + } + + // Validate the additionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + tagMetadata, + "TypeSpec.OpenAPI.TagMetadata", + ) + ) { + return; + } + + // Validate the externalDocs.url property + if (data.externalDocs?.url) { + if ( + !validateIsUri( + context.program, + context.getArgumentTarget(0)!, + data.externalDocs.url, + "externalDocs.url", + ) + ) { + return; + } + } + // Merge data into metadata + metadata = { ...data, name }; + } + + // Update the tags metadata with the new tag + tags[name] = metadata; + setTagsMetadata(context.program, entity, tags); +}; + +export { getTagsMetadata }; diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index f8792d4fa7..714fbba3c6 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -14,6 +14,7 @@ export { getExternalDocs, getInfo, getOperationId, + getTagsMetadata, isDefaultResponse, resolveInfo, setExtension, @@ -26,8 +27,6 @@ export { isReadonlyProperty, resolveOperationId, shouldInline, - validateAdditionalInfoModel, - validateIsUri, } from "./helpers.js"; export { AdditionalInfo, Contact, ExtensionKey, ExternalDocs, License } from "./types.js"; diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 0a9b1cc9b4..04a6f9f58c 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -22,7 +22,21 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`${"property"}: ${"value"} is not a valid URL.`, }, }, + "duplicate-tag": { + severity: "error", + messages: { + default: paramMessage`"Metadata for tag '${"tagName"}' was specified twice."`, + }, + }, + }, + state: { + tagsMetadata: { description: "State for the @tagMetadata decorator." }, }, }); -export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; +export const { + createDiagnostic, + reportDiagnostic, + createStateSymbol, + stateKeys: OpenAPIKeys, +} = $lib; diff --git a/packages/openapi/src/tsp-index.ts b/packages/openapi/src/tsp-index.ts index a29d5b785e..1af5f9cb68 100644 --- a/packages/openapi/src/tsp-index.ts +++ b/packages/openapi/src/tsp-index.ts @@ -1,5 +1,12 @@ import { TypeSpecOpenAPIDecorators } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { $defaultResponse, $extension, $externalDocs, $info, $operationId } from "./decorators.js"; +import { + $defaultResponse, + $extension, + $externalDocs, + $info, + $operationId, + tagMetadataDecorator, +} from "./decorators.js"; export { $lib } from "./lib.js"; @@ -11,5 +18,6 @@ export const $decorators = { externalDocs: $externalDocs, info: $info, operationId: $operationId, + tagMetadata: tagMetadataDecorator, } satisfies TypeSpecOpenAPIDecorators, }; diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 41a7bbf3cd..a9189f4901 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -6,6 +6,7 @@ import { getExtensions, getExternalDocs, getInfo, + getTagsMetadata, resolveInfo, setInfo, } from "../src/decorators.js"; @@ -339,4 +340,183 @@ describe("openapi: decorators", () => { }); }); }); + + describe("@tagMetadata", () => { + it.each([ + ["tagName is not a string", `@tagMetadata(123)`], + ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], + ["description is not a string", `@tagMetadata("tagName", { description: 123, })`], + ["externalDocs is not an object", `@tagMetadata("tagName", { externalDocs: 123, })`], + ])("%s", async (_, code) => { + const diagnostics = await runner.diagnose( + ` + ${code} + namespace PetStore{}; + `, + ); + + expectDiagnostics(diagnostics, { + code: "invalid-argument", + }); + }); + + it("emit diagnostic if dup tagName", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName") + @tagMetadata("tagName") + namespace PetStore{}; + `, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/duplicate-tag", + }); + }); + + describe("emit diagnostics when passing extension key not starting with `x-` in metadata", () => { + it.each([ + ["root", `{ foo:"Bar" }`], + ["externalDocs", `{ externalDocs:{ url: "https://example.com", foo:"Bar"} }`], + [ + "complex", + `{ externalDocs:{ url: "https://example.com", "x-custom": "string" }, foo:"Bar" }`, + ], + ])("%s", async (_, code) => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName", ${code}) + namespace PetStore{}; + `, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo'`, + }); + }); + + it("multiple", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName",{ + externalDocs: { url: "https://example.com", foo1:"Bar" }, + foo2:"Bar" + }) + @test namespace Service{}; + `, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo1'`, + }, + { + code: "@typespec/openapi/invalid-extension-key", + message: `OpenAPI extension must start with 'x-' but was 'foo2'`, + }, + ]); + }); + }); + + it("emit diagnostic if externalDocs.url is not a valid url", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName", { + externalDocs: { url: "notvalidurl"}, + }) + @test namespace Service {} + `, + ); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/not-url", + message: "externalDocs.url: notvalidurl is not a valid URL.", + }); + }); + + it("emit diagnostic if use on non namespace", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName",{}) + model Foo {} + `, + ); + + expectDiagnostics(diagnostics, { + code: "decorator-wrong-target", + message: + "Cannot apply @tagMetadata decorator to Foo since it is not assignable to Namespace", + }); + }); + + const testCases: [string, string, any][] = [ + [ + "set tagMetadata without additionalInfo", + `@tagMetadata("tagName")`, + { tagName: { name: "tagName" } }, + ], + [ + "set tagMetadata without externalDocs", + `@tagMetadata("tagName",{description: "Pets operations"})`, + { tagName: { name: "tagName", description: "Pets operations" } }, + ], + [ + "set multiple tagsMetadata", + `@tagMetadata( + "tagName1", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + "x-custom": "string" + }, + } + ) + @tagMetadata( + "tagName2", + { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + }, + "x-custom": "string" + } + )`, + { + tagName1: { + name: "tagName1", + description: "Pets operations", + externalDocs: { + url: "https://example.com", + "x-custom": "string", + }, + }, + + tagName2: { + name: "tagName2", + description: "Pets operations", + externalDocs: { + url: "https://example.com", + description: "More info.", + }, + "x-custom": "string", + }, + }, + ], + ]; + it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { + const runner = await createOpenAPITestRunner(); + const { PetStore } = await runner.compile( + ` + ${tagMetaDecorator} + @test + namespace PetStore {} + `, + ); + deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); + }); + }); }); diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index d376817447..cd139f049b 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -110,7 +110,6 @@ Default: `int64` ### TypeSpec.OpenAPI - [`@oneOf`](#@oneof) -- [`@tagMetadata`](#@tagmetadata) - [`@useRef`](#@useref) #### `@oneOf` @@ -129,25 +128,6 @@ Specify that `oneOf` should be used instead of `anyOf` for that union. None -#### `@tagMetadata` - -Specify OpenAPI additional information. - -```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) -``` - -##### Target - -`Namespace` - -##### Parameters - -| Name | Type | Description | -| ----------- | ----------------------------- | ----------- | -| name | `valueof string` | tag name | -| tagMetadata | [`TagMetadata`](#tagmetadata) | | - #### `@useRef` Specify an external reference that should be used inside of emitting this type. diff --git a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts index 5e572d7151..1a9d7dc675 100644 --- a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts @@ -1,11 +1,4 @@ -import type { - DecoratorContext, - Model, - ModelProperty, - Namespace, - Type, - Union, -} from "@typespec/compiler"; +import type { DecoratorContext, Model, ModelProperty, Union } from "@typespec/compiler"; /** * Specify that `oneOf` should be used instead of `anyOf` for that union. @@ -23,21 +16,7 @@ export type UseRefDecorator = ( ref: string, ) => void; -/** - * Specify OpenAPI additional information. - * - * @param name tag name - * @param additionalTag Additional information - */ -export type TagMetadataDecorator = ( - context: DecoratorContext, - target: Namespace, - name: string, - tagMetadata?: Type, -) => void; - export type TypeSpecOpenAPIDecorators = { oneOf: OneOfDecorator; useRef: UseRefDecorator; - tagMetadata: TagMetadataDecorator; }; diff --git a/packages/openapi3/lib/decorators.tsp b/packages/openapi3/lib/decorators.tsp index b3802511f9..4558244148 100644 --- a/packages/openapi3/lib/decorators.tsp +++ b/packages/openapi3/lib/decorators.tsp @@ -13,28 +13,3 @@ extern dec oneOf(target: Union | ModelProperty); * @param ref External reference(e.g. "../../common.json#/components/schemas/Foo") */ extern dec useRef(target: Model | ModelProperty, ref: valueof string); - -/** Metadata to a single tag that is used by operations. */ -model TagMetadata { - /** A description of the API. */ - description?: string; - - /** An external Docs information of the API. */ - externalDocs?: ExternalDocs; -} - -/** External Docs information. */ -model ExternalDocs { - /** Documentation url */ - url: string; - - /** Optional description */ - description?: string; -} - -/** - * Specify OpenAPI additional information. - * @param name tag name - * @param additionalTag Additional information - */ -extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata); diff --git a/packages/openapi3/src/decorators.ts b/packages/openapi3/src/decorators.ts index 92f1167f0b..1fd3fc15ee 100644 --- a/packages/openapi3/src/decorators.ts +++ b/packages/openapi3/src/decorators.ts @@ -1,23 +1,6 @@ -import { - DecoratorContext, - Model, - ModelProperty, - Namespace, - Program, - Type, - TypeSpecValue, - Union, - typespecTypeToJson, -} from "@typespec/compiler"; -import { unsafe_useStateMap } from "@typespec/compiler/experimental"; -import { ExtensionKey, validateAdditionalInfoModel, validateIsUri } from "@typespec/openapi"; -import { - OneOfDecorator, - TagMetadataDecorator, - UseRefDecorator, -} from "../generated-defs/TypeSpec.OpenAPI.js"; -import { OpenAPI3Keys, createStateSymbol, reportDiagnostic } from "./lib.js"; -import { OpenAPI3Tag } from "./types.js"; +import { DecoratorContext, Model, ModelProperty, Program, Type, Union } from "@typespec/compiler"; +import { OneOfDecorator, UseRefDecorator } from "../generated-defs/TypeSpec.OpenAPI.js"; +import { createStateSymbol, reportDiagnostic } from "./lib.js"; const refTargetsKey = createStateSymbol("refs"); export const $useRef: UseRefDecorator = ( @@ -49,87 +32,3 @@ export const $oneOf: OneOfDecorator = ( export function getOneOf(program: Program, entity: Type): boolean { return program.stateMap(oneOfKey).get(entity); } - -const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap< - Type, - { [name: string]: OpenAPI3Tag } ->(OpenAPI3Keys.tagsMetadata); - -/** - * Decorator to add metadata to a tag associated with a namespace. - * @param context - The decorator context. - * @param entity - The namespace entity to associate the tag with. - * @param name - The name of the tag. - * @param tagMetadata - Optional metadata for the tag. - */ -export const $tagMetadata: TagMetadataDecorator = ( - context: DecoratorContext, - entity: Namespace, - name: string, - tagMetadata?: TypeSpecValue, -) => { - // Retrieve existing tags metadata or initialize an empty object - const tags = getTagsMetadata(context.program, entity) || {}; - - // Check for duplicate tag names - if (tags[name]) { - reportDiagnostic(context.program, { - code: "duplicate-tag", - format: { tagName: name }, - target: context.getArgumentTarget(0)!, - }); - return; - } - - let metadata: OpenAPI3Tag = { name }; - - // Process tag metadata if provided - if (tagMetadata) { - const [data, diagnostics] = typespecTypeToJson>( - tagMetadata, - context.getArgumentTarget(0)!, - ); - - // Report any diagnostics found during conversion - context.program.reportDiagnostics(diagnostics); - - // Abort if data conversion failed - if (data === undefined) { - return; - } - - // Validate the additionalInfo model - if ( - !validateAdditionalInfoModel( - context.program, - context.getArgumentTarget(0)!, - tagMetadata, - "TypeSpec.OpenAPI.TagMetadata", - ) - ) { - return; - } - - // Validate the externalDocs.url property - if (data.externalDocs?.url) { - if ( - !validateIsUri( - context.program, - context.getArgumentTarget(0)!, - data.externalDocs.url, - "externalDocs.url", - ) - ) { - return; - } - } - // Merge data into metadata - metadata = { ...data, name }; - } - - // Update the tags metadata with the new tag - tags[name] = metadata; - setTagsMetadata(context.program, entity, tags); -}; - -export { getTagsMetadata }; diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 7409bb182c..b8a8c9e0bc 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -275,27 +275,13 @@ export const libDef = { default: paramMessage`XML \`@unwrapped\` can only used on array properties or primitive ones in the OpenAPI 3 emitter, Property '${"name"}' will be ignored.`, }, }, - "duplicate-tag": { - severity: "error", - messages: { - default: paramMessage`"Metadata for tag '${"tagName"}' was specified twice."`, - }, - }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, }, - state: { - tagsMetadata: { description: "State for the @tagMetadata decorator." }, - }, } as const; export const $lib = createTypeSpecLibrary(libDef); -export const { - createDiagnostic, - reportDiagnostic, - createStateSymbol, - stateKeys: OpenAPI3Keys, -} = $lib; +export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; export type OpenAPILibrary = typeof $lib; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index a541bfcc01..7026feead9 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -78,6 +78,7 @@ import { getExternalDocs, getOpenAPITypeName, getParameterKey, + getTagsMetadata, isReadonlyProperty, resolveInfo, resolveOperationId, @@ -85,7 +86,7 @@ import { } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; -import { getRef, getTagsMetadata } from "./decorators.js"; +import { getRef } from "./decorators.js"; import { applyEncoding } from "./encoding.js"; import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js"; import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; diff --git a/packages/openapi3/src/tsp-index.ts b/packages/openapi3/src/tsp-index.ts index b828591516..c355496d7e 100644 --- a/packages/openapi3/src/tsp-index.ts +++ b/packages/openapi3/src/tsp-index.ts @@ -1,5 +1,5 @@ import { TypeSpecOpenAPIDecorators } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { $oneOf, $tagMetadata, $useRef } from "./decorators.js"; +import { $oneOf, $useRef } from "./decorators.js"; export { $lib } from "./lib.js"; @@ -8,6 +8,5 @@ export const $decorators = { "TypeSpec.OpenAPI": { useRef: $useRef, oneOf: $oneOf, - tagMetadata: $tagMetadata, } satisfies TypeSpecOpenAPIDecorators, }; diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index c78cd67aa7..fc80fa82ce 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -1,183 +1,7 @@ -import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual } from "assert"; import { describe, it } from "vitest"; -import { getTagsMetadata } from "../src/decorators.js"; -import { createOpenAPITestRunner, diagnoseOpenApiFor, openApiFor } from "./test-host.js"; -it.each([ - ["tagName is not a string", `@tagMetadata(123)`], - ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], - ["description is not a string", `@tagMetadata("tagName", { description: 123, })`], - ["externalDocs is not an object", `@tagMetadata("tagName", { externalDocs: 123, })`], -])("%s", async (_, code) => { - const diagnostics = await diagnoseOpenApiFor( - ` - ${code} - namespace PetStore{}; - `, - ); - - expectDiagnostics(diagnostics, { - code: "invalid-argument", - }); -}); - -it("emit diagnostic if dup tagName", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` - @tagMetadata("tagName") - @tagMetadata("tagName") - namespace PetStore{}; - `, - ); - - expectDiagnostics(diagnostics, { - code: "@typespec/openapi3/duplicate-tag", - }); -}); - -describe("emit diagnostics when passing extension key not starting with `x-` in metadata", () => { - it.each([ - ["root", `{ foo:"Bar" }`], - ["externalDocs", `{ externalDocs:{ url: "https://example.com", foo:"Bar"} }`], - ["complex", `{ externalDocs:{ url: "https://example.com", "x-custom": "string" }, foo:"Bar" }`], - ])("%s", async (_, code) => { - const diagnostics = await diagnoseOpenApiFor( - ` - @tagMetadata("tagName", ${code}) - namespace PetStore{}; - `, - ); - - expectDiagnostics(diagnostics, { - code: "@typespec/openapi/invalid-extension-key", - message: `OpenAPI extension must start with 'x-' but was 'foo'`, - }); - }); - - it("multiple", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` - @tagMetadata("tagName",{ - externalDocs: { url: "https://example.com", foo1:"Bar" }, - foo2:"Bar" - }) - @test namespace Service{}; - `, - ); - - expectDiagnostics(diagnostics, [ - { - code: "@typespec/openapi/invalid-extension-key", - message: `OpenAPI extension must start with 'x-' but was 'foo1'`, - }, - { - code: "@typespec/openapi/invalid-extension-key", - message: `OpenAPI extension must start with 'x-' but was 'foo2'`, - }, - ]); - }); -}); - -it("emit diagnostic if externalDocs.url is not a valid url", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` - @tagMetadata("tagName", { - externalDocs: { url: "notvalidurl"}, - }) - @test namespace Service {} - `, - ); - - expectDiagnostics(diagnostics, { - code: "@typespec/openapi/not-url", - message: "externalDocs.url: notvalidurl is not a valid URL.", - }); -}); - -it("emit diagnostic if use on non namespace", async () => { - const diagnostics = await diagnoseOpenApiFor( - ` - @tagMetadata("tagName",{}) - model Foo {} - `, - ); - - expectDiagnostics(diagnostics, { - code: "decorator-wrong-target", - message: "Cannot apply @tagMetadata decorator to Foo since it is not assignable to Namespace", - }); -}); - -describe("getTagsMetadata returns", () => { - const testCases: [string, string, any][] = [ - [ - "set tagMetadata without additionalInfo", - `@tagMetadata("tagName")`, - { tagName: { name: "tagName" } }, - ], - [ - "set tagMetadata without externalDocs", - `@tagMetadata("tagName",{description: "Pets operations"})`, - { tagName: { name: "tagName", description: "Pets operations" } }, - ], - [ - "set multiple tagsMetadata", - `@tagMetadata( - "tagName1", - { - description: "Pets operations", - externalDocs: { - url: "https://example.com", - "x-custom": "string" - }, - } - ) - @tagMetadata( - "tagName2", - { - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", - }, - "x-custom": "string" - } - )`, - { - tagName1: { - name: "tagName1", - description: "Pets operations", - externalDocs: { - url: "https://example.com", - "x-custom": "string", - }, - }, - - tagName2: { - name: "tagName2", - description: "Pets operations", - externalDocs: { - url: "https://example.com", - description: "More info.", - }, - "x-custom": "string", - }, - }, - ], - ]; - it.each(testCases)("%s", async (_, tagMetaDecorator, expected) => { - const runner = await createOpenAPITestRunner(); - const { PetStore } = await runner.compile( - ` - ${tagMetaDecorator} - @test - namespace PetStore {} - `, - ); - deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); - }); -}); +import { openApiFor } from "./test-host.js"; describe("emit results when set value with @tagMetadata decorator", () => { const testCases: [string, string, string, any][] = [ From b255e62792fba7db012f7110e860a9becbcbda43 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 12:40:04 +0800 Subject: [PATCH 26/46] update --- packages/openapi/src/decorators.ts | 14 +++++++------- packages/openapi/src/index.ts | 2 +- packages/openapi/src/types.ts | 10 ++++++++++ packages/openapi3/src/openapi.ts | 9 +++++---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index e51974eae4..c0f144f7a5 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -23,7 +23,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, TagMetadata } from "./types.js"; const operationIdsKey = createStateSymbol("operationIds"); /** @@ -240,11 +240,11 @@ function omitUndefined>(data: T): T { return Object.fromEntries(Object.entries(data).filter(([k, v]) => v !== undefined)) as any; } -const [ - /** Get TagsMetadata set with `@tagMetadata` decorator */ - getTagsMetadata, - setTagsMetadata, -] = unsafe_useStateMap(OpenAPIKeys.tagsMetadata); +/** Get TagsMetadata set with `@tagMetadata` decorator */ +const [getTagsMetadata, setTagsMetadata] = unsafe_useStateMap< + Type, + { [name: string]: TagMetadata } +>(OpenAPIKeys.tagsMetadata); /** * Decorator to add metadata to a tag associated with a namespace. @@ -276,7 +276,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( // Process tag metadata if provided if (tagMetadata) { - const [data, diagnostics] = typespecTypeToJson>( + const [data, diagnostics] = typespecTypeToJson>( tagMetadata, context.getArgumentTarget(0)!, ); diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 714fbba3c6..06201a1423 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -28,7 +28,7 @@ export { resolveOperationId, shouldInline, } from "./helpers.js"; -export { AdditionalInfo, Contact, ExtensionKey, ExternalDocs, License } from "./types.js"; +export { AdditionalInfo, Contact, ExtensionKey, License, TagMetadata } from "./types.js"; /** @internal */ export { $decorators } from "./tsp-index.js"; diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index 9a8b600f93..e795c2eb02 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -64,3 +64,13 @@ export interface ExternalDocs { /** Optional description */ description?: string; } + +/** + * Metadata to a single tag that is used by operations. + */ +export interface TagMetadata { + /** A description of the API. */ + description?: string; + /** An external Docs information of the API. */ + externalDocs?: ExternalDocs; +} diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 7026feead9..b070480116 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -83,6 +83,7 @@ import { resolveInfo, resolveOperationId, shouldInline, + TagMetadata, } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; @@ -109,7 +110,6 @@ import { OpenAPI3ServerVariable, OpenAPI3ServiceRecord, OpenAPI3StatusCode, - OpenAPI3Tag, OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; @@ -236,7 +236,7 @@ function createOAPIEmitter( let tags: Set; // The per-endpoint tags that will be added into the #/tags - let tagsMetadata: { [name: string]: OpenAPI3Tag }; + let tagsMetadata: { [name: string]: TagMetadata }; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace @@ -353,7 +353,7 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagsMetadata = getTagsMetadata(program, service.type) || {}; + tagsMetadata = getTagsMetadata(program, service.type) ?? {}; } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -1601,7 +1601,8 @@ function createOAPIEmitter( } for (const key in tagsMetadata) { - root.tags!.push(tagsMetadata[key]); + const tagData = { name: key, ...tagsMetadata[key] }; + root.tags!.push(tagData); } } From c95c6d19e57c69413a4f69ec6d6d97a173bd53d2 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 13:09:17 +0800 Subject: [PATCH 27/46] up --- packages/openapi/src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 06201a1423..6eb6dc682f 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -28,7 +28,14 @@ export { resolveOperationId, shouldInline, } from "./helpers.js"; -export { AdditionalInfo, Contact, ExtensionKey, License, TagMetadata } from "./types.js"; +export { + AdditionalInfo, + Contact, + ExtensionKey, + ExternalDocs, + License, + TagMetadata, +} from "./types.js"; /** @internal */ export { $decorators } from "./tsp-index.js"; From 71161a7409024cd2ae394b5b0e5f543529b210b9 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 13:16:12 +0800 Subject: [PATCH 28/46] update change log --- .chronus/changes/tagMetadata-2024-9-23-16-55-56.md | 4 ++-- .chronus/changes/tagMetadata-2024-9-31-13-14-32.md | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/tagMetadata-2024-9-31-13-14-32.md diff --git a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md index af9afdd98f..4cdc38d13b 100644 --- a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md +++ b/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md @@ -1,7 +1,7 @@ --- changeKind: feature packages: - - "@typespec/openapi" + - "@typespec/openapi3" --- -Add new `@tagMetadata` decorator to specify OpenAPI tag properties +Add support for `@tagMetadata` decorator diff --git a/.chronus/changes/tagMetadata-2024-9-31-13-14-32.md b/.chronus/changes/tagMetadata-2024-9-31-13-14-32.md new file mode 100644 index 0000000000..95c12248d6 --- /dev/null +++ b/.chronus/changes/tagMetadata-2024-9-31-13-14-32.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" +--- + +Add new `@tagMetadata` decorator to specify OpenAPI tag properties \ No newline at end of file From c52eacf373ba9bfb6b615b1a045457e31bad1d89 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 13:19:22 +0800 Subject: [PATCH 29/46] update --- ...ta-2024-9-23-16-55-56.md => tagMetadata-2024-9-23-12-55-56.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .chronus/changes/{tagMetadata-2024-9-23-16-55-56.md => tagMetadata-2024-9-23-12-55-56.md} (100%) diff --git a/.chronus/changes/tagMetadata-2024-9-23-16-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-12-55-56.md similarity index 100% rename from .chronus/changes/tagMetadata-2024-9-23-16-55-56.md rename to .chronus/changes/tagMetadata-2024-9-23-12-55-56.md From 6daaf4f14eb42f3ec8d153e769d97a251725a721 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 14:19:10 +0800 Subject: [PATCH 30/46] validate this is the service namespace. --- packages/openapi/src/decorators.ts | 14 ++++++++++++++ packages/openapi/src/lib.ts | 6 ++++++ packages/openapi/test/decorators.test.ts | 14 ++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index c0f144f7a5..159dad9de4 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -1,4 +1,5 @@ import { + $service, DecoratorContext, getDoc, getService, @@ -259,6 +260,17 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( name: string, tagMetadata?: TypeSpecValue, ) => { + // Check if the namespace is a service namespace + if (!entity.decorators.some((decorator) => decorator.decorator === $service)) { + reportDiagnostic(context.program, { + code: "no-service-found", + format: { + namespace: entity.name, + }, + target: context.getArgumentTarget(0)!, + }); + } + // Retrieve existing tags metadata or initialize an empty object const tags = getTagsMetadata(context.program, entity) ?? {}; @@ -272,6 +284,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( return; } + // Initialize metadata with the tag name let metadata: any = { name }; // Process tag metadata if provided @@ -314,6 +327,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( return; } } + // Merge data into metadata metadata = { ...data, name }; } diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 04a6f9f58c..a6ebbcded8 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -28,6 +28,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`"Metadata for tag '${"tagName"}' was specified twice."`, }, }, + "no-service-found": { + severity: "warning", + messages: { + default: paramMessage`No namespace with '@service' was found, but Namespace '${"namespace"}' contains tagMetadata. Did you mean to annotate this with '@service'?`, + }, + }, }, state: { tagsMetadata: { description: "State for the @tagMetadata decorator." }, diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index a9189f4901..59679e8515 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -342,6 +342,20 @@ describe("openapi: decorators", () => { }); describe("@tagMetadata", () => { + it("emit a warning if a non-service namespace", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName") + namespace Test {} + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi/no-service-found", + }, + ]); + }); + it.each([ ["tagName is not a string", `@tagMetadata(123)`], ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], From 48ce43154704f9a595436b64736945cd043a5a8c Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Thu, 31 Oct 2024 14:30:45 +0800 Subject: [PATCH 31/46] udpate cases --- packages/openapi/test/decorators.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 59679e8515..d6f081779d 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -377,6 +377,7 @@ describe("openapi: decorators", () => { it("emit diagnostic if dup tagName", async () => { const diagnostics = await runner.diagnose( ` + @service() @tagMetadata("tagName") @tagMetadata("tagName") namespace PetStore{}; @@ -399,6 +400,7 @@ describe("openapi: decorators", () => { ])("%s", async (_, code) => { const diagnostics = await runner.diagnose( ` + @service() @tagMetadata("tagName", ${code}) namespace PetStore{}; `, @@ -413,6 +415,7 @@ describe("openapi: decorators", () => { it("multiple", async () => { const diagnostics = await runner.diagnose( ` + @service() @tagMetadata("tagName",{ externalDocs: { url: "https://example.com", foo1:"Bar" }, foo2:"Bar" @@ -437,6 +440,7 @@ describe("openapi: decorators", () => { it("emit diagnostic if externalDocs.url is not a valid url", async () => { const diagnostics = await runner.diagnose( ` + @service() @tagMetadata("tagName", { externalDocs: { url: "notvalidurl"}, }) @@ -525,6 +529,7 @@ describe("openapi: decorators", () => { const runner = await createOpenAPITestRunner(); const { PetStore } = await runner.compile( ` + @service() ${tagMetaDecorator} @test namespace PetStore {} From 555b6e37736aabf83fc9b60bff6e0a0f212043ca Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:27:21 +0800 Subject: [PATCH 32/46] Update packages/openapi/src/decorators.ts Co-authored-by: Timothee Guerin --- packages/openapi/src/decorators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 159dad9de4..856b6caf56 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -285,7 +285,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( } // Initialize metadata with the tag name - let metadata: any = { name }; + let metadata: TagMetadata = { name }; // Process tag metadata if provided if (tagMetadata) { From d2e672ce712c6a4e9f7566afea69ddf2a7f8f983 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:29:05 +0800 Subject: [PATCH 33/46] Update packages/openapi/src/lib.ts Co-authored-by: Timothee Guerin --- packages/openapi/src/lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index a6ebbcded8..d5f9448693 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -29,7 +29,7 @@ export const $lib = createTypeSpecLibrary({ }, }, "no-service-found": { - severity: "warning", + severity: "error", messages: { default: paramMessage`No namespace with '@service' was found, but Namespace '${"namespace"}' contains tagMetadata. Did you mean to annotate this with '@service'?`, }, From e6996a100065f0e8bf645ea851c40ec8c7f827eb Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:29:17 +0800 Subject: [PATCH 34/46] Update packages/openapi/src/lib.ts Co-authored-by: Timothee Guerin --- packages/openapi/src/lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index d5f9448693..9fab12da98 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -31,7 +31,7 @@ export const $lib = createTypeSpecLibrary({ "no-service-found": { severity: "error", messages: { - default: paramMessage`No namespace with '@service' was found, but Namespace '${"namespace"}' contains tagMetadata. Did you mean to annotate this with '@service'?`, + default: paramMessage`@tagMetadata must be used on the service namespace. Did you mean to annotate '${"namespace"}' with '@service'?`, }, }, }, From a024b6444ea5bf724fd1d562ac33116551c5b88f Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:29:31 +0800 Subject: [PATCH 35/46] Update packages/openapi/src/lib.ts Co-authored-by: Timothee Guerin --- packages/openapi/src/lib.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 9fab12da98..889d282b31 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -28,7 +28,7 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`"Metadata for tag '${"tagName"}' was specified twice."`, }, }, - "no-service-found": { + "tag-metadata-target-service": { severity: "error", messages: { default: paramMessage`@tagMetadata must be used on the service namespace. Did you mean to annotate '${"namespace"}' with '@service'?`, From f3dae26a91d3738fcae00a85957665f02a369fdc Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:29:39 +0800 Subject: [PATCH 36/46] Update packages/openapi/test/decorators.test.ts Co-authored-by: Timothee Guerin --- 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 d6f081779d..425de005ca 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -342,7 +342,7 @@ describe("openapi: decorators", () => { }); describe("@tagMetadata", () => { - it("emit a warning if a non-service namespace", async () => { + it("emit an error if a non-service namespace", async () => { const diagnostics = await runner.diagnose( ` @tagMetadata("tagName") From 7dbf852499370d10e64ddc1c2391a4288ba582bd Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 07:37:10 +0800 Subject: [PATCH 37/46] update --- packages/openapi/src/decorators.ts | 6 +++--- packages/openapi/test/decorators.test.ts | 12 +++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 856b6caf56..fdb9a02642 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -263,7 +263,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( // Check if the namespace is a service namespace if (!entity.decorators.some((decorator) => decorator.decorator === $service)) { reportDiagnostic(context.program, { - code: "no-service-found", + code: "tag-metadata-target-service", format: { namespace: entity.name, }, @@ -285,7 +285,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( } // Initialize metadata with the tag name - let metadata: TagMetadata = { name }; + let metadata: TagMetadata = {}; // Process tag metadata if provided if (tagMetadata) { @@ -329,7 +329,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( } // Merge data into metadata - metadata = { ...data, name }; + metadata = { ...data }; } // Update the tags metadata with the new tag diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 425de005ca..4bc48b92e0 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -351,7 +351,7 @@ describe("openapi: decorators", () => { ); expectDiagnostics(diagnostics, [ { - code: "@typespec/openapi/no-service-found", + code: "@typespec/openapi/tag-metadata-target-service", }, ]); }); @@ -470,15 +470,11 @@ describe("openapi: decorators", () => { }); const testCases: [string, string, any][] = [ - [ - "set tagMetadata without additionalInfo", - `@tagMetadata("tagName")`, - { tagName: { name: "tagName" } }, - ], + ["set tagMetadata without additionalInfo", `@tagMetadata("tagName")`, { tagName: {} }], [ "set tagMetadata without externalDocs", `@tagMetadata("tagName",{description: "Pets operations"})`, - { tagName: { name: "tagName", description: "Pets operations" } }, + { tagName: { description: "Pets operations" } }, ], [ "set multiple tagsMetadata", @@ -505,7 +501,6 @@ describe("openapi: decorators", () => { )`, { tagName1: { - name: "tagName1", description: "Pets operations", externalDocs: { url: "https://example.com", @@ -514,7 +509,6 @@ describe("openapi: decorators", () => { }, tagName2: { - name: "tagName2", description: "Pets operations", externalDocs: { url: "https://example.com", From 7b49b150abdf5e5884bb9ae24a3de7a0982ff82e Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 08:12:05 +0800 Subject: [PATCH 38/46] update --- packages/openapi/src/decorators.ts | 6 ++++-- packages/openapi/src/helpers.ts | 10 +++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index fdb9a02642..3b8b622cdd 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -191,7 +191,7 @@ export const $info: InfoDecorator = ( !validateAdditionalInfoModel( context.program, context.getArgumentTarget(0)!, - model, + model as Model, "TypeSpec.OpenAPI.AdditionalInfo", ) ) { @@ -269,6 +269,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( }, target: context.getArgumentTarget(0)!, }); + return; } // Retrieve existing tags metadata or initialize an empty object @@ -289,6 +290,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( // Process tag metadata if provided if (tagMetadata) { + // Convert TypeSpecValue to JSON and capture diagnostics const [data, diagnostics] = typespecTypeToJson>( tagMetadata, context.getArgumentTarget(0)!, @@ -307,7 +309,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( !validateAdditionalInfoModel( context.program, context.getArgumentTarget(0)!, - tagMetadata, + tagMetadata as Model, "TypeSpec.OpenAPI.TagMetadata", ) ) { diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 697e619900..1b7eefc9d8 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -1,5 +1,4 @@ import { - compilerAssert, Diagnostic, DiagnosticTarget, getFriendlyName, @@ -15,7 +14,6 @@ import { Program, Type, TypeNameOptions, - TypeSpecValue, } from "@typespec/compiler"; import { getOperationId } from "./decorators.js"; import { createDiagnostic, reportDiagnostic } from "./lib.js"; @@ -225,7 +223,7 @@ export function validateIsUri( export function validateAdditionalInfoModel( program: Program, target: DiagnosticTarget, - typespecType: TypeSpecValue, + typespecType: Model, reference: string, ): boolean { // Resolve the reference to get the corresponding model @@ -250,19 +248,17 @@ export function validateAdditionalInfoModel( * Check Additional Properties */ function checkNoAdditionalProperties( - typespecType: Type, + typespecType: Model, target: DiagnosticTarget, source: Model, ): Diagnostic[] { const diagnostics: Diagnostic[] = []; - compilerAssert(typespecType.kind === "Model", "Expected type to be a Model."); - for (const [name, type] of typespecType.properties.entries()) { const sourceProperty = getProperty(source, name); if (sourceProperty) { if (sourceProperty.type.kind === "Model") { const nestedDiagnostics = checkNoAdditionalProperties( - type.type, + type.type as Model, target, sourceProperty.type, ); From 4e108b6e7aa2974783a6b240abe3935f787d5f74 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 16:33:15 +0800 Subject: [PATCH 39/46] spread --- packages/openapi/generated-defs/TypeSpec.OpenAPI.ts | 13 ++++++++++++- packages/openapi/lib/decorators.tsp | 4 ++-- packages/openapi/src/decorators.ts | 3 ++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 0bf0c9c0c9..47f5555ada 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -1,5 +1,15 @@ import type { DecoratorContext, Model, Namespace, Operation, Type } from "@typespec/compiler"; +export interface TagMetadata { + readonly description?: string; + readonly externalDocs?: ExternalDocs; +} + +export interface ExternalDocs { + readonly url: string; + readonly description?: string; +} + /** * Specify the OpenAPI `operationId` property for this operation. * @@ -83,13 +93,14 @@ export type InfoDecorator = ( * Specify OpenAPI additional information. * * @param name tag name - * @param additionalTag Additional information + * @param tagMetadata Additional information */ export type TagMetadataDecorator = ( context: DecoratorContext, target: Namespace, name: string, tagMetadata?: Type, + additional?: TagMetadata, ) => void; export type TypeSpecOpenAPIDecorators = { diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index 6cf0835f69..5a5d8833bd 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -131,6 +131,6 @@ model ExternalDocs { /** * Specify OpenAPI additional information. * @param name tag name - * @param additionalTag Additional information + * @param tagMetadata Additional information */ -extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata); +extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata, additional?: valueof TagMetadata); diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 3b8b622cdd..43d6f1a713 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -20,11 +20,12 @@ import { ExternalDocsDecorator, InfoDecorator, OperationIdDecorator, + TagMetadata, TagMetadataDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; import { createStateSymbol, OpenAPIKeys, reportDiagnostic } from "./lib.js"; -import { AdditionalInfo, ExtensionKey, ExternalDocs, TagMetadata } from "./types.js"; +import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; const operationIdsKey = createStateSymbol("operationIds"); /** From 5e4a83f48952c8f8a58e932a6bc5ce196968b178 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Fri, 1 Nov 2024 16:39:21 +0800 Subject: [PATCH 40/46] up --- packages/openapi/lib/decorators.tsp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index 5a5d8833bd..711161cdd0 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -133,4 +133,9 @@ model ExternalDocs { * @param name tag name * @param tagMetadata Additional information */ -extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: TagMetadata, additional?: valueof TagMetadata); +extern dec tagMetadata( + target: Namespace, + name: valueof string, + tagMetadata?: TagMetadata, + additional?: valueof TagMetadata +); From c62f7353a9ba003d4a30a52e5a7df7d2c70972d6 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Sat, 2 Nov 2024 14:18:29 +0800 Subject: [PATCH 41/46] up --- .../generated-defs/TypeSpec.OpenAPI.ts | 5 ++- packages/openapi/lib/decorators.tsp | 7 ++- packages/openapi/src/decorators.ts | 25 +++-------- packages/openapi/src/helpers.ts | 26 ++++++++--- packages/openapi/test/decorators.test.ts | 43 +++++++++++-------- packages/openapi3/test/tagmetadata.test.ts | 24 +++++------ 6 files changed, 71 insertions(+), 59 deletions(-) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 47f5555ada..9457755dec 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -1,11 +1,13 @@ import type { DecoratorContext, Model, Namespace, Operation, Type } from "@typespec/compiler"; export interface TagMetadata { + readonly [key: string]: unknown; readonly description?: string; readonly externalDocs?: ExternalDocs; } export interface ExternalDocs { + readonly [key: string]: unknown; readonly url: string; readonly description?: string; } @@ -99,8 +101,7 @@ export type TagMetadataDecorator = ( context: DecoratorContext, target: Namespace, name: string, - tagMetadata?: Type, - additional?: TagMetadata, + tagMetadata?: TagMetadata, ) => void; export type TypeSpecOpenAPIDecorators = { diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index 711161cdd0..f07b72af83 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -117,6 +117,8 @@ model TagMetadata { /** An external Docs information of the API. */ externalDocs?: ExternalDocs; + + ...Record } /** External Docs information. */ @@ -126,6 +128,8 @@ model ExternalDocs { /** Optional description */ description?: string; + + ...Record; } /** @@ -136,6 +140,5 @@ model ExternalDocs { extern dec tagMetadata( target: Namespace, name: valueof string, - tagMetadata?: TagMetadata, - additional?: valueof TagMetadata + tagMetadata?: valueof TagMetadata ); diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 43d6f1a713..9ea46f1895 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -259,7 +259,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( context: DecoratorContext, entity: Namespace, name: string, - tagMetadata?: TypeSpecValue, + tagMetadata?: TagMetadata, ) => { // Check if the namespace is a service namespace if (!entity.decorators.some((decorator) => decorator.decorator === $service)) { @@ -291,39 +291,26 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( // Process tag metadata if provided if (tagMetadata) { - // Convert TypeSpecValue to JSON and capture diagnostics - const [data, diagnostics] = typespecTypeToJson>( - tagMetadata, - context.getArgumentTarget(0)!, - ); - - // Report any diagnostics found during conversion - context.program.reportDiagnostics(diagnostics); - - // Abort if data conversion failed - if (data === undefined) { - return; - } - // Validate the additionalInfo model if ( !validateAdditionalInfoModel( context.program, context.getArgumentTarget(0)!, - tagMetadata as Model, + tagMetadata, "TypeSpec.OpenAPI.TagMetadata", + false, ) ) { return; } // Validate the externalDocs.url property - if (data.externalDocs?.url) { + if (tagMetadata.externalDocs?.url) { if ( !validateIsUri( context.program, context.getArgumentTarget(0)!, - data.externalDocs.url, + tagMetadata.externalDocs.url, "externalDocs.url", ) ) { @@ -332,7 +319,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( } // Merge data into metadata - metadata = { ...data }; + metadata = { ...tagMetadata }; } // Update the tags metadata with the new tag diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 1b7eefc9d8..547d12f58e 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -15,6 +15,7 @@ import { Type, TypeNameOptions, } from "@typespec/compiler"; +import { TagMetadata } from "../generated-defs/TypeSpec.OpenAPI.js"; import { getOperationId } from "./decorators.js"; import { createDiagnostic, reportDiagnostic } from "./lib.js"; import { ExtensionKey } from "./types.js"; @@ -223,8 +224,9 @@ export function validateIsUri( export function validateAdditionalInfoModel( program: Program, target: DiagnosticTarget, - typespecType: Model, + typespecType: Model | TagMetadata, reference: string, + isModel: boolean = true, ): boolean { // Resolve the reference to get the corresponding model const propertyModel = program.resolveTypeReference(reference)[0]! as Model; @@ -232,7 +234,7 @@ export function validateAdditionalInfoModel( // Check if typespecType is an object and propertyModel is defined if (typeof typespecType === "object" && propertyModel) { // Validate that the properties of typespecType do not exceed those in propertyModel - const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel); + const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel, isModel); program.reportDiagnostics(diagnostics); // Return false if any diagnostics were reported, indicating a validation failure if (diagnostics.length > 0) { @@ -248,19 +250,33 @@ export function validateAdditionalInfoModel( * Check Additional Properties */ function checkNoAdditionalProperties( - typespecType: Model, + typespecType: Model | TagMetadata, target: DiagnosticTarget, source: Model, + isModel: boolean, ): Diagnostic[] { const diagnostics: Diagnostic[] = []; - for (const [name, type] of typespecType.properties.entries()) { + + let properties: IterableIterator<[string, ModelProperty]>; + if (isModel) { + properties = (typespecType as Model).properties.entries(); + } else { + properties = Object.keys(typespecType).map((key) => [ + key, + (typespecType as TagMetadata)[key], + ]) as unknown as IterableIterator<[string, ModelProperty]>; + } + + for (const [name, type] of properties) { const sourceProperty = getProperty(source, name); if (sourceProperty) { if (sourceProperty.type.kind === "Model") { + const currValue = isModel ? type.type : type; const nestedDiagnostics = checkNoAdditionalProperties( - type.type as Model, + currValue as Model, target, sourceProperty.type, + isModel, ); diagnostics.push(...nestedDiagnostics); } diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 4bc48b92e0..c3da757eb2 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -359,8 +359,8 @@ describe("openapi: decorators", () => { it.each([ ["tagName is not a string", `@tagMetadata(123)`], ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], - ["description is not a string", `@tagMetadata("tagName", { description: 123, })`], - ["externalDocs is not an object", `@tagMetadata("tagName", { externalDocs: 123, })`], + ["description is not a string", `@tagMetadata("tagName", #{ description: 123, })`], + ["externalDocs is not an object", `@tagMetadata("tagName", #{ externalDocs: 123, })`], ])("%s", async (_, code) => { const diagnostics = await runner.diagnose( ` @@ -391,11 +391,11 @@ describe("openapi: decorators", () => { describe("emit diagnostics when passing extension key not starting with `x-` in metadata", () => { it.each([ - ["root", `{ foo:"Bar" }`], - ["externalDocs", `{ externalDocs:{ url: "https://example.com", foo:"Bar"} }`], + ["root", `#{ foo:"Bar" }`], + ["externalDocs", `#{ externalDocs: #{ url: "https://example.com", foo:"Bar"} }`], [ "complex", - `{ externalDocs:{ url: "https://example.com", "x-custom": "string" }, foo:"Bar" }`, + `#{ externalDocs: #{ url: "https://example.com", \`x-custom\`: "string" }, foo:"Bar" }`, ], ])("%s", async (_, code) => { const diagnostics = await runner.diagnose( @@ -416,8 +416,8 @@ describe("openapi: decorators", () => { const diagnostics = await runner.diagnose( ` @service() - @tagMetadata("tagName",{ - externalDocs: { url: "https://example.com", foo1:"Bar" }, + @tagMetadata("tagName", #{ + externalDocs: #{ url: "https://example.com", foo1:"Bar" }, foo2:"Bar" }) @test namespace Service{}; @@ -441,8 +441,8 @@ describe("openapi: decorators", () => { const diagnostics = await runner.diagnose( ` @service() - @tagMetadata("tagName", { - externalDocs: { url: "notvalidurl"}, + @tagMetadata("tagName", #{ + externalDocs: #{ url: "notvalidurl"}, }) @test namespace Service {} `, @@ -457,7 +457,7 @@ describe("openapi: decorators", () => { it("emit diagnostic if use on non namespace", async () => { const diagnostics = await runner.diagnose( ` - @tagMetadata("tagName",{}) + @tagMetadata("tagName") model Foo {} `, ); @@ -473,30 +473,35 @@ describe("openapi: decorators", () => { ["set tagMetadata without additionalInfo", `@tagMetadata("tagName")`, { tagName: {} }], [ "set tagMetadata without externalDocs", - `@tagMetadata("tagName",{description: "Pets operations"})`, + `@tagMetadata("tagName", #{ description: "Pets operations" })`, { tagName: { description: "Pets operations" } }, ], + [ + "set tagMetadata additionalInfo", + `@tagMetadata("tagName", #{ \`x-custom\`: "string" })`, + { tagName: { "x-custom": "string" } }, + ], [ "set multiple tagsMetadata", `@tagMetadata( "tagName1", - { + #{ description: "Pets operations", - externalDocs: { + externalDocs: #{ url: "https://example.com", - "x-custom": "string" - }, + \`x-custom\`: "string" + } } ) @tagMetadata( "tagName2", - { + #{ description: "Pets operations", - externalDocs: { + externalDocs: #{ url: "https://example.com", - description: "More info.", + description: "More info." }, - "x-custom": "string" + \`x-custom\`: "string" } )`, { diff --git a/packages/openapi3/test/tagmetadata.test.ts b/packages/openapi3/test/tagmetadata.test.ts index fc80fa82ce..d5380a23ac 100644 --- a/packages/openapi3/test/tagmetadata.test.ts +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -9,14 +9,14 @@ describe("emit results when set value with @tagMetadata decorator", () => { "set tag metadata", `@tagMetadata( "TagName", - { + #{ description: "Pets operations", - externalDocs: { + externalDocs: #{ url: "https://example.com", description: "More info.", - "x-custom": "string" + \`x-custom\`: "string" }, - "x-custom": "string" + \`x-custom\`: "string" } )`, ``, @@ -37,14 +37,14 @@ describe("emit results when set value with @tagMetadata decorator", () => { "add additional information for tag", `@tagMetadata( "TagName", - { + #{ description: "Pets operations", - externalDocs: { + externalDocs: #{ url: "https://example.com", description: "More info.", - "x-custom": "string" + \`x-custom\`: "string" }, - "x-custom": "string" + \`x-custom\`: "string" } )`, `@tag("TagName") op NamespaceOperation(): string;`, @@ -65,14 +65,14 @@ describe("emit results when set value with @tagMetadata decorator", () => { "set tag and tag metadata with different name", `@tagMetadata( "TagName", - { + #{ description: "Pets operations", - externalDocs: { + externalDocs: #{ url: "https://example.com", description: "More info.", - "x-custom": "string" + \`x-custom\`: "string" }, - "x-custom": "string" + \`x-custom\`: "string" } )`, `@tag("opTag") op NamespaceOperation(): string;`, From f74036ad457de571e88023af31a2939ac09a2cf4 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Sat, 2 Nov 2024 14:34:02 +0800 Subject: [PATCH 42/46] update doc --- packages/openapi/README.md | 10 +++++----- .../docs/libraries/openapi/reference/data-types.md | 10 ++++++---- .../docs/libraries/openapi/reference/decorators.md | 10 +++++----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 49c73c1dc5..401ad9ff57 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -155,7 +155,7 @@ op read(): string; Specify OpenAPI additional information. ```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: valueof TypeSpec.OpenAPI.TagMetadata) ``` ##### Target @@ -164,7 +164,7 @@ Specify OpenAPI additional information. ##### Parameters -| Name | Type | Description | -| ----------- | ----------------------------- | ----------- | -| name | `valueof string` | tag name | -| tagMetadata | [`TagMetadata`](#tagmetadata) | | +| Name | Type | Description | +| ----------- | ------------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| tagMetadata | [valueof `TagMetadata`](#tagmetadata) | Additional information | diff --git a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md index 09cb3ade8b..89aafe6fc6 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md @@ -49,10 +49,11 @@ model TypeSpec.OpenAPI.ExternalDocs #### Properties -| Name | Type | Description | -| ------------ | -------- | -------------------- | -| url | `string` | Documentation url | -| description? | `string` | Optional description | +| Name | Type | Description | +| ------------ | --------- | --------------------- | +| url | `string` | Documentation url | +| description? | `string` | Optional description | +| | `unknown` | Additional properties | ### `License` {#TypeSpec.OpenAPI.License} @@ -83,3 +84,4 @@ model TypeSpec.OpenAPI.TagMetadata | ------------- | --------------------------------------------------------------- | ---------------------------------------- | | description? | `string` | A description of the API. | | externalDocs? | [`ExternalDocs`](./data-types.md#TypeSpec.OpenAPI.ExternalDocs) | An external Docs information of the API. | +| | `unknown` | Additional properties | 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 59d3ada507..5fa2035fbe 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/decorators.md @@ -142,7 +142,7 @@ op read(): string; Specify OpenAPI additional information. ```typespec -@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: TypeSpec.OpenAPI.TagMetadata) +@TypeSpec.OpenAPI.tagMetadata(name: valueof string, tagMetadata?: valueof TypeSpec.OpenAPI.TagMetadata) ``` #### Target @@ -151,7 +151,7 @@ Specify OpenAPI additional information. #### Parameters -| Name | Type | Description | -| ----------- | ------------------------------------------------------------- | ----------- | -| name | `valueof string` | tag name | -| tagMetadata | [`TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | | +| Name | Type | Description | +| ----------- | --------------------------------------------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| tagMetadata | [valueof `TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | Additional information | From 741a78dab4b7af4c4d46f283fa00d10162294e24 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Sat, 2 Nov 2024 15:10:11 +0800 Subject: [PATCH 43/46] fix format --- packages/openapi/lib/decorators.tsp | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index f07b72af83..ed302f0032 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -118,7 +118,7 @@ model TagMetadata { /** An external Docs information of the API. */ externalDocs?: ExternalDocs; - ...Record + ...Record; } /** External Docs information. */ @@ -137,8 +137,4 @@ model ExternalDocs { * @param name tag name * @param tagMetadata Additional information */ -extern dec tagMetadata( - target: Namespace, - name: valueof string, - tagMetadata?: valueof TagMetadata -); +extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: valueof TagMetadata); From bcc98bd2662b13ccf3d134b29bd7a18e1e982cca Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Sat, 2 Nov 2024 23:49:46 +0800 Subject: [PATCH 44/46] update --- .../generated-defs/TypeSpec.OpenAPI.ts | 2 +- packages/openapi/lib/decorators.tsp | 2 +- packages/openapi/src/decorators.ts | 48 ++++++++----------- packages/openapi/src/helpers.ts | 27 +++-------- packages/openapi/src/index.ts | 9 +--- packages/openapi/src/types.ts | 10 ---- packages/openapi/test/decorators.test.ts | 12 ++--- packages/openapi3/src/openapi.ts | 3 +- 8 files changed, 35 insertions(+), 78 deletions(-) diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 9457755dec..31b6e9d2fa 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -101,7 +101,7 @@ export type TagMetadataDecorator = ( context: DecoratorContext, target: Namespace, name: string, - tagMetadata?: TagMetadata, + tagMetadata: TagMetadata, ) => void; export type TypeSpecOpenAPIDecorators = { diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index ed302f0032..eac9e05aa4 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -137,4 +137,4 @@ model ExternalDocs { * @param name tag name * @param tagMetadata Additional information */ -extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata?: valueof TagMetadata); +extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata: valueof TagMetadata); diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index 9ea46f1895..f7adbf1caf 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -192,7 +192,7 @@ export const $info: InfoDecorator = ( !validateAdditionalInfoModel( context.program, context.getArgumentTarget(0)!, - model as Model, + data, "TypeSpec.OpenAPI.AdditionalInfo", ) ) { @@ -259,7 +259,7 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( context: DecoratorContext, entity: Namespace, name: string, - tagMetadata?: TagMetadata, + tagMetadata: TagMetadata, ) => { // Check if the namespace is a service namespace if (!entity.decorators.some((decorator) => decorator.decorator === $service)) { @@ -286,44 +286,34 @@ export const tagMetadataDecorator: TagMetadataDecorator = ( return; } - // Initialize metadata with the tag name - let metadata: TagMetadata = {}; + // Validate the additionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + tagMetadata, + "TypeSpec.OpenAPI.TagMetadata", + ) + ) { + return; + } - // Process tag metadata if provided - if (tagMetadata) { - // Validate the additionalInfo model + // Validate the externalDocs.url property + if (tagMetadata.externalDocs?.url) { if ( - !validateAdditionalInfoModel( + !validateIsUri( context.program, context.getArgumentTarget(0)!, - tagMetadata, - "TypeSpec.OpenAPI.TagMetadata", - false, + tagMetadata.externalDocs.url, + "externalDocs.url", ) ) { return; } - - // Validate the externalDocs.url property - if (tagMetadata.externalDocs?.url) { - if ( - !validateIsUri( - context.program, - context.getArgumentTarget(0)!, - tagMetadata.externalDocs.url, - "externalDocs.url", - ) - ) { - return; - } - } - - // Merge data into metadata - metadata = { ...tagMetadata }; } // Update the tags metadata with the new tag - tags[name] = metadata; + tags[name] = tagMetadata; setTagsMetadata(context.program, entity, tags); }; diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 547d12f58e..b909a14e4b 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -15,7 +15,6 @@ import { Type, TypeNameOptions, } from "@typespec/compiler"; -import { TagMetadata } from "../generated-defs/TypeSpec.OpenAPI.js"; import { getOperationId } from "./decorators.js"; import { createDiagnostic, reportDiagnostic } from "./lib.js"; import { ExtensionKey } from "./types.js"; @@ -224,17 +223,16 @@ export function validateIsUri( export function validateAdditionalInfoModel( program: Program, target: DiagnosticTarget, - typespecType: Model | TagMetadata, + typespecType: object, reference: string, - isModel: boolean = true, ): boolean { // Resolve the reference to get the corresponding model const propertyModel = program.resolveTypeReference(reference)[0]! as Model; // Check if typespecType is an object and propertyModel is defined - if (typeof typespecType === "object" && propertyModel) { + if (typespecType && propertyModel) { // Validate that the properties of typespecType do not exceed those in propertyModel - const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel, isModel); + const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel); program.reportDiagnostics(diagnostics); // Return false if any diagnostics were reported, indicating a validation failure if (diagnostics.length > 0) { @@ -250,33 +248,20 @@ export function validateAdditionalInfoModel( * Check Additional Properties */ function checkNoAdditionalProperties( - typespecType: Model | TagMetadata, + typespecType: any, target: DiagnosticTarget, source: Model, - isModel: boolean, ): Diagnostic[] { const diagnostics: Diagnostic[] = []; - let properties: IterableIterator<[string, ModelProperty]>; - if (isModel) { - properties = (typespecType as Model).properties.entries(); - } else { - properties = Object.keys(typespecType).map((key) => [ - key, - (typespecType as TagMetadata)[key], - ]) as unknown as IterableIterator<[string, ModelProperty]>; - } - - for (const [name, type] of properties) { + for (const name of Object.keys(typespecType)) { const sourceProperty = getProperty(source, name); if (sourceProperty) { if (sourceProperty.type.kind === "Model") { - const currValue = isModel ? type.type : type; const nestedDiagnostics = checkNoAdditionalProperties( - currValue as Model, + typespecType[name], target, sourceProperty.type, - isModel, ); diagnostics.push(...nestedDiagnostics); } diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 6eb6dc682f..714fbba3c6 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -28,14 +28,7 @@ export { resolveOperationId, shouldInline, } from "./helpers.js"; -export { - AdditionalInfo, - Contact, - ExtensionKey, - ExternalDocs, - License, - TagMetadata, -} from "./types.js"; +export { AdditionalInfo, Contact, ExtensionKey, ExternalDocs, License } from "./types.js"; /** @internal */ export { $decorators } from "./tsp-index.js"; diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index e795c2eb02..9a8b600f93 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -64,13 +64,3 @@ export interface ExternalDocs { /** Optional description */ description?: string; } - -/** - * Metadata to a single tag that is used by operations. - */ -export interface TagMetadata { - /** A description of the API. */ - description?: string; - /** An external Docs information of the API. */ - externalDocs?: ExternalDocs; -} diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index c3da757eb2..ecb93e76f9 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -345,7 +345,7 @@ describe("openapi: decorators", () => { it("emit an error if a non-service namespace", async () => { const diagnostics = await runner.diagnose( ` - @tagMetadata("tagName") + @tagMetadata("tagName", #{}) namespace Test {} `, ); @@ -357,7 +357,7 @@ describe("openapi: decorators", () => { }); it.each([ - ["tagName is not a string", `@tagMetadata(123)`], + ["tagName is not a string", `@tagMetadata(123, #{})`], ["tagMetdata parameter is not an object", `@tagMetadata("tagName", 123)`], ["description is not a string", `@tagMetadata("tagName", #{ description: 123, })`], ["externalDocs is not an object", `@tagMetadata("tagName", #{ externalDocs: 123, })`], @@ -378,8 +378,8 @@ describe("openapi: decorators", () => { const diagnostics = await runner.diagnose( ` @service() - @tagMetadata("tagName") - @tagMetadata("tagName") + @tagMetadata("tagName", #{}) + @tagMetadata("tagName", #{}) namespace PetStore{}; `, ); @@ -457,7 +457,7 @@ describe("openapi: decorators", () => { it("emit diagnostic if use on non namespace", async () => { const diagnostics = await runner.diagnose( ` - @tagMetadata("tagName") + @tagMetadata("tagName", #{}) model Foo {} `, ); @@ -470,7 +470,7 @@ describe("openapi: decorators", () => { }); const testCases: [string, string, any][] = [ - ["set tagMetadata without additionalInfo", `@tagMetadata("tagName")`, { tagName: {} }], + ["set tagMetadata without additionalInfo", `@tagMetadata("tagName", #{})`, { tagName: {} }], [ "set tagMetadata without externalDocs", `@tagMetadata("tagName", #{ description: "Pets operations" })`, diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index a3c5126563..4b3466fa79 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -83,7 +83,6 @@ import { resolveInfo, resolveOperationId, shouldInline, - TagMetadata, } from "@typespec/openapi"; import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; @@ -236,7 +235,7 @@ function createOAPIEmitter( let tags: Set; // The per-endpoint tags that will be added into the #/tags - let tagsMetadata: { [name: string]: TagMetadata }; + let tagsMetadata: { [name: string]: any }; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace From 1536c111126a06ef99f00f2641bcfeb386707fe3 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 4 Nov 2024 10:30:17 +0800 Subject: [PATCH 45/46] up --- packages/openapi/src/helpers.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index b909a14e4b..7bc1b1fe6c 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -216,23 +216,23 @@ export function validateIsUri( * * @param program - The TypeSpec Program instance * @param target - Diagnostic target for reporting any diagnostics - * @param typespecType - The AdditionalInfo object to validate + * @param jsonObject - The AdditionalInfo object to validate * @param reference - The reference string to resolve the model * @returns true if the AdditionalInfo object is valid, false otherwise */ export function validateAdditionalInfoModel( program: Program, target: DiagnosticTarget, - typespecType: object, + jsonObject: object, reference: string, ): boolean { // Resolve the reference to get the corresponding model const propertyModel = program.resolveTypeReference(reference)[0]! as Model; - // Check if typespecType is an object and propertyModel is defined - if (typespecType && propertyModel) { + // Check if jsonObject and propertyModel are defined + if (jsonObject && propertyModel) { // Validate that the properties of typespecType do not exceed those in propertyModel - const diagnostics = checkNoAdditionalProperties(typespecType, target, propertyModel); + const diagnostics = checkNoAdditionalProperties(jsonObject, target, propertyModel); program.reportDiagnostics(diagnostics); // Return false if any diagnostics were reported, indicating a validation failure if (diagnostics.length > 0) { @@ -248,18 +248,18 @@ export function validateAdditionalInfoModel( * Check Additional Properties */ function checkNoAdditionalProperties( - typespecType: any, + jsonObject: any, target: DiagnosticTarget, source: Model, ): Diagnostic[] { const diagnostics: Diagnostic[] = []; - for (const name of Object.keys(typespecType)) { + for (const name of Object.keys(jsonObject)) { const sourceProperty = getProperty(source, name); if (sourceProperty) { if (sourceProperty.type.kind === "Model") { const nestedDiagnostics = checkNoAdditionalProperties( - typespecType[name], + jsonObject[name], target, sourceProperty.type, ); From 92e830e03aafb4dfedb3880a72427cdf54c297a2 Mon Sep 17 00:00:00 2001 From: Kyle Zhang Date: Mon, 4 Nov 2024 15:12:37 +0800 Subject: [PATCH 46/46] up --- packages/openapi3/src/openapi.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 4b3466fa79..6659ccdb19 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -109,6 +109,7 @@ import { OpenAPI3ServerVariable, OpenAPI3ServiceRecord, OpenAPI3StatusCode, + OpenAPI3Tag, OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; @@ -235,7 +236,7 @@ function createOAPIEmitter( let tags: Set; // The per-endpoint tags that will be added into the #/tags - let tagsMetadata: { [name: string]: any }; + const tagsMetadata: { [name: string]: OpenAPI3Tag } = {}; const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace @@ -352,7 +353,15 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); - tagsMetadata = getTagsMetadata(program, service.type) ?? {}; + + // Get Tags Metadata + const metadata = getTagsMetadata(program, service.type); + if (metadata) { + for (const [name, tag] of Object.entries(metadata)) { + const tagData: OpenAPI3Tag = { name: name, ...tag }; + tagsMetadata[name] = tagData; + } + } } function isValidServerVariableType(program: Program, type: Type): boolean { @@ -1604,8 +1613,7 @@ function createOAPIEmitter( } for (const key in tagsMetadata) { - const tagData = { name: key, ...tagsMetadata[key] }; - root.tags!.push(tagData); + root.tags!.push(tagsMetadata[key]); } }