Skip to content

Commit

Permalink
Add new @tagMetadata decorator to OpenAPI library (microsoft#4834)
Browse files Browse the repository at this point in the history
fix microsoft#2220

---------

Co-authored-by: Kyle Zhang <[email protected]>
Co-authored-by: Timothee Guerin <[email protected]>
  • Loading branch information
3 people authored and swatikumar committed Nov 5, 2024
1 parent 8c3b345 commit c710157
Show file tree
Hide file tree
Showing 17 changed files with 742 additions and 64 deletions.
7 changes: 7 additions & 0 deletions .chronus/changes/tagMetadata-2024-9-23-12-55-56.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Add support for `@tagMetadata` decorator
7 changes: 7 additions & 0 deletions .chronus/changes/tagMetadata-2024-9-31-13-14-32.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi"
---

Add new `@tagMetadata` decorator to specify OpenAPI tag properties
20 changes: 20 additions & 0 deletions packages/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ npm install @typespec/openapi
- [`@externalDocs`](#@externaldocs)
- [`@info`](#@info)
- [`@operationId`](#@operationid)
- [`@tagMetadata`](#@tagmetadata)

#### `@defaultResponse`

Expand Down Expand Up @@ -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 |
26 changes: 26 additions & 0 deletions packages/openapi/generated-defs/TypeSpec.OpenAPI.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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;
};
29 changes: 29 additions & 0 deletions packages/openapi/lib/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
}

/** External Docs information. */
model ExternalDocs {
/** Documentation url */
url: string;

/** Optional description */
description?: string;

...Record<unknown>;
}

/**
* Specify OpenAPI additional information.
* @param name tag name
* @param tagMetadata Additional information
*/
extern dec tagMetadata(target: Namespace, name: valueof string, tagMetadata: valueof TagMetadata);
152 changes: 92 additions & 60 deletions packages/openapi/src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import {
compilerAssert,
$service,
DecoratorContext,
Diagnostic,
DiagnosticTarget,
getDoc,
getProperty,
getService,
getSummary,
Model,
Expand All @@ -15,15 +12,19 @@ import {
typespecTypeToJson,
TypeSpecValue,
} from "@typespec/compiler";
import { unsafe_useStateMap } from "@typespec/compiler/experimental";
import { setStatusCode } from "@typespec/http";
import {
DefaultResponseDecorator,
ExtensionDecorator,
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");
Expand Down Expand Up @@ -114,10 +115,6 @@ export function getExtensions(program: Program, entity: Type): ReadonlyMap<Exten
return program.stateMap(openApiExtensionKey).get(entity) ?? new Map<ExtensionKey, any>();
}

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.
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -225,64 +242,79 @@ function omitUndefined<T extends Record<string, unknown>>(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 };
Loading

0 comments on commit c710157

Please sign in to comment.