diff --git a/.chronus/changes/tagMetadata-2024-9-23-12-55-56.md b/.chronus/changes/tagMetadata-2024-9-23-12-55-56.md new file mode 100644 index 0000000000..4cdc38d13b --- /dev/null +++ b/.chronus/changes/tagMetadata-2024-9-23-12-55-56.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +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 diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 3e5a84e4b0..401ad9ff57 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?: valueof TypeSpec.OpenAPI.TagMetadata) +``` + +##### Target + +`Namespace` + +##### Parameters + +| Name | Type | Description | +| ----------- | ------------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| tagMetadata | [valueof `TagMetadata`](#tagmetadata) | Additional information | diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index 9c51929bc4..31b6e9d2fa 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -1,5 +1,17 @@ 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; +} + /** * Specify the OpenAPI `operationId` property for this operation. * @@ -79,10 +91,24 @@ export type InfoDecorator = ( additionalInfo: Type, ) => void; +/** + * Specify OpenAPI additional information. + * + * @param name tag name + * @param tagMetadata Additional information + */ +export type TagMetadataDecorator = ( + context: DecoratorContext, + target: Namespace, + name: string, + tagMetadata: TagMetadata, +) => 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..eac9e05aa4 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -109,3 +109,32 @@ 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; + + ...Record; +} + +/** External Docs information. */ +model ExternalDocs { + /** Documentation url */ + url: string; + + /** Optional description */ + description?: string; + + ...Record; +} + +/** + * Specify OpenAPI additional information. + * @param name tag name + * @param tagMetadata Additional information + */ +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 378db454f8..f7adbf1caf 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -1,10 +1,7 @@ import { - compilerAssert, + $service, DecoratorContext, - Diagnostic, - DiagnosticTarget, getDoc, - getProperty, getService, getSummary, Model, @@ -15,6 +12,7 @@ import { typespecTypeToJson, TypeSpecValue, } from "@typespec/compiler"; +import { unsafe_useStateMap } from "@typespec/compiler/experimental"; import { setStatusCode } from "@typespec/http"; import { DefaultResponseDecorator, @@ -22,8 +20,11 @@ import { ExternalDocsDecorator, InfoDecorator, OperationIdDecorator, + TagMetadata, + TagMetadataDecorator, } from "../generated-defs/TypeSpec.OpenAPI.js"; -import { createDiagnostic, createStateSymbol, reportDiagnostic } from "./lib.js"; +import { isOpenAPIExtensionKey, validateAdditionalInfoModel, validateIsUri } from "./helpers.js"; +import { createStateSymbol, OpenAPIKeys, reportDiagnostic } from "./lib.js"; import { AdditionalInfo, ExtensionKey, ExternalDocs } from "./types.js"; const operationIdsKey = createStateSymbol("operationIds"); @@ -114,10 +115,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. @@ -189,9 +186,29 @@ export const $info: InfoDecorator = ( if (data === undefined) { return; } - validateAdditionalInfoModel(context, model); + + // Validate the AdditionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + data, + "TypeSpec.OpenAPI.AdditionalInfo", + ) + ) { + return; + } + + // Validate termsOfService if (data.termsOfService) { - if (!validateIsUri(context, data.termsOfService, "TermsOfService")) { + if ( + !validateIsUri( + context.program, + context.getArgumentTarget(0)!, + data.termsOfService, + "TermsOfService", + ) + ) { return; } } @@ -225,64 +242,79 @@ 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 { +/** 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. + * @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: TagMetadata, +) => { + // Check if the namespace is a service namespace + if (!entity.decorators.some((decorator) => decorator.decorator === $service)) { reportDiagnostic(context.program, { - code: "not-url", + code: "tag-metadata-target-service", + format: { + namespace: entity.name, + }, target: context.getArgumentTarget(0)!, - format: { property: propertyName, value: url }, }); - return false; + return; } -} -function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) { - const propertyModel = context.program.resolveTypeReference( - "TypeSpec.OpenAPI.AdditionalInfo", - )[0]! as Model; + // Retrieve existing tags metadata or initialize an empty object + const tags = getTagsMetadata(context.program, entity) ?? {}; - if (typeof typespecType === "object" && propertyModel) { - const diagnostics = checkNoAdditionalProperties( - typespecType, - context.getArgumentTarget(0)!, - propertyModel, - ); - context.program.reportDiagnostics(diagnostics); + // Check for duplicate tag names + if (tags[name]) { + reportDiagnostic(context.program, { + code: "duplicate-tag", + format: { tagName: name }, + target: context.getArgumentTarget(0)!, + }); + return; } -} -function checkNoAdditionalProperties( - typespecType: Type, - target: DiagnosticTarget, - source: Model, -): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - compilerAssert(typespecType.kind === "Model", "Expected type to be a Model."); + // Validate the additionalInfo model + if ( + !validateAdditionalInfoModel( + context.program, + context.getArgumentTarget(0)!, + tagMetadata, + "TypeSpec.OpenAPI.TagMetadata", + ) + ) { + return; + } - 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, - }), - ); + // Validate the externalDocs.url property + if (tagMetadata.externalDocs?.url) { + if ( + !validateIsUri( + context.program, + context.getArgumentTarget(0)!, + tagMetadata.externalDocs.url, + "externalDocs.url", + ) + ) { + return; } } - return diagnostics; -} + // Update the tags metadata with the new tag + tags[name] = tagMetadata; + setTagsMetadata(context.program, entity, tags); +}; + +export { getTagsMetadata }; diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index 65d45a6c22..7bc1b1fe6c 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -1,10 +1,14 @@ import { + Diagnostic, + DiagnosticTarget, getFriendlyName, + getProperty, getTypeName, getVisibility, isGlobalNamespace, isService, isTemplateInstance, + Model, ModelProperty, Operation, Program, @@ -12,7 +16,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 +169,112 @@ 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-"); +} + +/** + * 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 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, + jsonObject: object, + reference: string, +): boolean { + // Resolve the reference to get the corresponding model + const propertyModel = program.resolveTypeReference(reference)[0]! as Model; + + // 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(jsonObject, 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 + */ +function checkNoAdditionalProperties( + jsonObject: any, + target: DiagnosticTarget, + source: Model, +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + for (const name of Object.keys(jsonObject)) { + const sourceProperty = getProperty(source, name); + if (sourceProperty) { + if (sourceProperty.type.kind === "Model") { + const nestedDiagnostics = checkNoAdditionalProperties( + jsonObject[name], + 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..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, diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index 0a9b1cc9b4..889d282b31 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -22,7 +22,27 @@ 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."`, + }, + }, + "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'?`, + }, + }, + }, + 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..ecb93e76f9 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,201 @@ describe("openapi: decorators", () => { }); }); }); + + describe("@tagMetadata", () => { + it("emit an error if a non-service namespace", async () => { + const diagnostics = await runner.diagnose( + ` + @tagMetadata("tagName", #{}) + namespace Test {} + `, + ); + expectDiagnostics(diagnostics, [ + { + code: "@typespec/openapi/tag-metadata-target-service", + }, + ]); + }); + + 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( + ` + @service() + @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( + ` + @service() + @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( + ` + @service() + @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( + ` + @service() + @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: {} }], + [ + "set tagMetadata without externalDocs", + `@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: #{ + url: "https://example.com", + \`x-custom\`: "string" + } + } + ) + @tagMetadata( + "tagName2", + #{ + description: "Pets operations", + externalDocs: #{ + url: "https://example.com", + description: "More info." + }, + \`x-custom\`: "string" + } + )`, + { + tagName1: { + description: "Pets operations", + externalDocs: { + url: "https://example.com", + "x-custom": "string", + }, + }, + + 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( + ` + @service() + ${tagMetaDecorator} + @test + namespace PetStore {} + `, + ); + deepStrictEqual(getTagsMetadata(runner.program, PetStore), expected); + }); + }); }); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index ff48b979fa..6659ccdb19 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, @@ -108,6 +109,7 @@ import { OpenAPI3ServerVariable, OpenAPI3ServiceRecord, OpenAPI3StatusCode, + OpenAPI3Tag, OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; @@ -233,6 +235,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 + const tagsMetadata: { [name: string]: OpenAPI3Tag } = {}; + const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { @@ -348,6 +353,15 @@ function createOAPIEmitter( params = new Map(); paramModels = new Set(); tags = new Set(); + + // 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 { @@ -789,6 +803,7 @@ function createOAPIEmitter( tags.add(tag); } } + applyExternalDocs(op, oai3Operation); // Set up basic endpoint fields @@ -1590,8 +1605,15 @@ function createOAPIEmitter( } function emitTags() { + // emit Tag from op for (const tag of tags) { - root.tags!.push({ name: tag }); + if (!tagsMetadata[tag]) { + root.tags!.push({ name: 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 new file mode 100644 index 0000000000..d5380a23ac --- /dev/null +++ b/packages/openapi3/test/tagmetadata.test.ts @@ -0,0 +1,105 @@ +import { deepStrictEqual } from "assert"; +import { describe, it } from "vitest"; + +import { openApiFor } from "./test-host.js"; + +describe("emit results when set value with @tagMetadata decorator", () => { + const testCases: [string, string, string, any][] = [ + [ + "set 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", + }, + ], + ], + [ + "add additional information for tag", + `@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", + }, + ], + ], + [ + "set 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", + }, + ], + ], + ]; + it.each(testCases)("%s", async (_, tagMetaDecorator, operationDeclaration, expected) => { + const res = await openApiFor( + ` + @service + ${tagMetaDecorator} + namespace PetStore{${operationDeclaration}}; + `, + ); + + deepStrictEqual(res.tags, expected); + }); +}); diff --git a/website/src/content/docs/docs/emitters/openapi3/reference/data-types.md b/website/src/content/docs/docs/emitters/openapi3/reference/data-types.md new file mode 100644 index 0000000000..ca451450e3 --- /dev/null +++ b/website/src/content/docs/docs/emitters/openapi3/reference/data-types.md @@ -0,0 +1,35 @@ +--- +title: "Data types" +--- + +## TypeSpec.OpenAPI + +### `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} + +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/website/src/content/docs/docs/libraries/openapi/reference/data-types.md b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md index 829f28db6e..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 @@ -39,6 +39,22 @@ 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 | +| | `unknown` | Additional properties | + ### `License` {#TypeSpec.OpenAPI.License} License information for the exposed API. @@ -53,3 +69,19 @@ 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. | +| | `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 be7cf18311..5fa2035fbe 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/decorators.md +++ b/website/src/content/docs/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?: valueof TypeSpec.OpenAPI.TagMetadata) +``` + +#### Target + +`Namespace` + +#### Parameters + +| Name | Type | Description | +| ----------- | --------------------------------------------------------------------- | ---------------------- | +| name | `valueof string` | tag name | +| tagMetadata | [valueof `TagMetadata`](./data-types.md#TypeSpec.OpenAPI.TagMetadata) | Additional information | diff --git a/website/src/content/docs/docs/libraries/openapi/reference/index.mdx b/website/src/content/docs/docs/libraries/openapi/reference/index.mdx index 6510dee917..2409692726 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/index.mdx +++ b/website/src/content/docs/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)