Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new @tagMetadata decorator to OpenAPI library #4834

Merged
merged 66 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
559b63a
initial
Oct 21, 2024
30c2415
Merge branch 'microsoft:main' into tagMetadata
skywing918 Oct 22, 2024
e6c1f29
test initial
Oct 22, 2024
ccdbe98
Merge branch 'microsoft:main' into tagMetadata
skywing918 Oct 23, 2024
15a8077
Merge branch 'microsoft:main' into tagMetadata
skywing918 Oct 23, 2024
cffc5e0
update case
Oct 23, 2024
c966215
update check x-
Oct 23, 2024
65a5ba3
add change log
Oct 23, 2024
39f791a
update
Oct 23, 2024
e398261
fix build
Oct 23, 2024
81b5457
fix buiild
Oct 23, 2024
59cc624
Merge branch 'main' into tagMetadata
skywing918 Oct 24, 2024
a6c8d59
update logic
Oct 24, 2024
ba58f27
update doc
Oct 24, 2024
3e8a4a9
update
Oct 24, 2024
23c110a
update
Oct 24, 2024
e3949b6
Merge branch 'main' into tagMetadata
skywing918 Oct 25, 2024
7aad065
refactor validateAdditionalInfoModel
Oct 25, 2024
f22c587
update
Oct 25, 2024
3ce0486
add cases
Oct 25, 2024
146cdba
Merge branch 'main' into tagMetadata
skywing918 Oct 28, 2024
d6e1297
update
Oct 28, 2024
b87657c
up
Oct 28, 2024
dd95f10
up
Oct 28, 2024
2ff5d4c
up
Oct 28, 2024
a27732a
Merge branch 'main' into tagMetadata
skywing918 Oct 29, 2024
9fbdebd
update
Oct 29, 2024
8e6e1ba
update cases
Oct 29, 2024
dbc429e
Merge branch 'main' into tagMetadata
skywing918 Oct 30, 2024
f13846d
Merge branch 'main' into tagMetadata
skywing918 Oct 30, 2024
dd54601
update case name
Oct 30, 2024
f32367b
Update .chronus/changes/tagMetadata-2024-9-23-16-55-56.md
skywing918 Oct 31, 2024
7b69494
Update packages/openapi3/src/lib.ts
skywing918 Oct 31, 2024
b39c13e
Merge branch 'main' into tagMetadata
skywing918 Oct 31, 2024
c1366a4
move @tagmetadata decorator to openapi lib
Oct 31, 2024
b255e62
update
Oct 31, 2024
acb2e97
Merge branch 'main' into tagMetadata
skywing918 Oct 31, 2024
c30e0cb
Merge branch 'tagMetadata' of https://github.com/skywing918/typespec …
Oct 31, 2024
c95c6d1
up
Oct 31, 2024
71161a7
update change log
Oct 31, 2024
c52eacf
update
Oct 31, 2024
6daaf4f
validate this is the service namespace.
Oct 31, 2024
48ce431
udpate cases
Oct 31, 2024
555b6e3
Update packages/openapi/src/decorators.ts
skywing918 Oct 31, 2024
d2e672c
Update packages/openapi/src/lib.ts
skywing918 Oct 31, 2024
e6996a1
Update packages/openapi/src/lib.ts
skywing918 Oct 31, 2024
a024b64
Update packages/openapi/src/lib.ts
skywing918 Oct 31, 2024
f3dae26
Update packages/openapi/test/decorators.test.ts
skywing918 Oct 31, 2024
7dbf852
update
Oct 31, 2024
7b49b15
update
Nov 1, 2024
e881343
Merge remote-tracking branch 'origin/main' into tagMetadata
Nov 1, 2024
45736f0
Merge branch 'main' into tagMetadata
skywing918 Nov 1, 2024
4e108b6
spread
Nov 1, 2024
2d29dcd
Merge branch 'tagMetadata' of https://github.com/skywing918/typespec …
Nov 1, 2024
5e4a83f
up
Nov 1, 2024
c62f735
up
Nov 2, 2024
f74036a
update doc
Nov 2, 2024
f3cc874
Merge branch 'main' into tagMetadata
skywing918 Nov 2, 2024
741a78d
fix format
Nov 2, 2024
b768698
Merge branch 'tagMetadata' of https://github.com/skywing918/typespec …
Nov 2, 2024
bcc98bd
update
Nov 2, 2024
1536c11
up
Nov 4, 2024
d11aa87
Merge branch 'microsoft:main' into tagMetadata
skywing918 Nov 4, 2024
736f4eb
Merge branch 'tagMetadata' of https://github.com/skywing918/typespec …
Nov 4, 2024
92e830e
up
Nov 4, 2024
8307e31
Merge branch 'main' into tagMetadata
skywing918 Nov 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?: TypeSpec.OpenAPI.TagMetadata)
```

##### Target

`Namespace`

##### Parameters

| Name | Type | Description |
| ----------- | ----------------------------- | ----------- |
| name | `valueof string` | tag name |
| tagMetadata | [`TagMetadata`](#tagmetadata) | |
25 changes: 25 additions & 0 deletions packages/openapi/generated-defs/TypeSpec.OpenAPI.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -79,10 +89,25 @@ 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?: Type,
additional?: TagMetadata,
) => void;

export type TypeSpecOpenAPIDecorators = {
operationId: OperationIdDecorator;
extension: ExtensionDecorator;
defaultResponse: DefaultResponseDecorator;
externalDocs: ExternalDocsDecorator;
info: InfoDecorator;
tagMetadata: TagMetadataDecorator;
};
30 changes: 30 additions & 0 deletions packages/openapi/lib/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,33 @@ 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 tagMetadata Additional information
*/
extern dec tagMetadata(
target: Namespace,
name: valueof string,
tagMetadata?: TagMetadata,
additional?: valueof TagMetadata
);
171 changes: 113 additions & 58 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)!,
model as Model,
"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,102 @@ 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?: TypeSpecValue,
) => {
// 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;
}

// 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;
}
}

function validateAdditionalInfoModel(context: DecoratorContext, typespecType: TypeSpecValue) {
const propertyModel = context.program.resolveTypeReference(
"TypeSpec.OpenAPI.AdditionalInfo",
)[0]! as Model;
// Initialize metadata with the tag name
let metadata: TagMetadata = {};

if (typeof typespecType === "object" && propertyModel) {
const diagnostics = checkNoAdditionalProperties(
typespecType,
// Process tag metadata if provided
if (tagMetadata) {
// Convert TypeSpecValue to JSON and capture diagnostics
const [data, diagnostics] = typespecTypeToJson<TagMetadata & Record<ExtensionKey, unknown>>(
tagMetadata,
context.getArgumentTarget(0)!,
propertyModel,
);

// Report any diagnostics found during conversion
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);
// Abort if data conversion failed
if (data === undefined) {
return;
}

// Validate the additionalInfo model
if (
!validateAdditionalInfoModel(
context.program,
context.getArgumentTarget(0)!,
tagMetadata as Model,
"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;
}
} else if (!isOpenAPIExtensionKey(name)) {
diagnostics.push(
createDiagnostic({
code: "invalid-extension-key",
format: { value: name },
target,
}),
);
}

// Merge data into metadata
metadata = { ...data };
}

return diagnostics;
}
// Update the tags metadata with the new tag
tags[name] = metadata;
setTagsMetadata(context.program, entity, tags);
};

export { getTagsMetadata };
Loading
Loading