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

Missing decorators.. #5173

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
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/MissingDecorators-2024-10-22-15-39-24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi"
---

@extension decorator supports multipleOf, uniqueItems, maxProperties, and minProperties
7 changes: 7 additions & 0 deletions .chronus/changes/MissingDecorators-2024-10-22-15-44-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
changeKind: feature
packages:
- "@typespec/openapi3"
---

@extension decorator supports keywords: multipleOf, uniqueItems, maxProperties, and minProperties,apply to properties in the Schema Object.
46 changes: 41 additions & 5 deletions packages/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ op listPets(): Pet[] | PetStoreResponse;
Attach some custom data to the OpenAPI element generated from this type.

```typespec
@TypeSpec.OpenAPI.extension(key: valueof string, value: unknown)
@TypeSpec.OpenAPI.extension(key: valueof string, value?: unknown)
```

##### Target
Expand All @@ -59,10 +59,10 @@ Attach some custom data to the OpenAPI element generated from this type.

##### Parameters

| Name | Type | Description |
| ----- | ---------------- | ----------------------------------- |
| key | `valueof string` | Extension key. Must start with `x-` |
| value | `unknown` | Extension value. |
| Name | Type | Description |
| ----- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
| key | `valueof string` | minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-` |
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
| value | `unknown` | Extension value. |

##### Examples

Expand All @@ -77,6 +77,42 @@ Attach some custom data to the OpenAPI element generated from this type.
op read(): string;
```

###### Specify that every item in the array must be unique.

```typespec
model Foo {
@extension("uniqueItems")
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
x: unknown[];
}
```

###### Specify that the numeric type must be a multiple of some numeric value.

```typespec
model Foo {
@extension("multipleOf", 1)
x: int32;
}
```

###### Specify the maximum number of properties this object can have.

```typespec
model Foo {
@extension("maxProperties", 1)
x: int32;
}
```

###### Specify the minimum number of properties this object can have.

```typespec
model Foo {
@extension("minProperties", 1)
x: int32;
}
```

#### `@externalDocs`

Specify the OpenAPI `externalDocs` property for this type.
Expand Down
32 changes: 30 additions & 2 deletions packages/openapi/generated-defs/TypeSpec.OpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,48 @@ export type OperationIdDecorator = (
/**
* Attach some custom data to the OpenAPI element generated from this type.
*
* @param key Extension key. Must start with `x-`
* @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-`
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
* @param value Extension value.
* @example
* ```typespec
* @extension("x-custom", "My value")
* @extension("x-pageable", {nextLink: "x-next-link"})
* op read(): string;
* ```
* @example Specify that every item in the array must be unique.
* ```typespec
* model Foo {
* @extension("uniqueItems")
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
* x: unknown[];
* };
* ```
* @example Specify that the numeric type must be a multiple of some numeric value.
* ```typespec
* model Foo {
* @extension("multipleOf", 1)
* x: int32;
* };
* ```
* @example Specify the maximum number of properties this object can have.
* ```typespec
* model Foo {
* @extension("maxProperties", 1)
* x: int32;
* };
* ```
* @example Specify the minimum number of properties this object can have.
* ```typespec
* model Foo {
* @extension("minProperties", 1)
* x: int32;
* };
* ```
*/
export type ExtensionDecorator = (
context: DecoratorContext,
target: Type,
key: string,
value: Type,
value?: Type,
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
) => void;

/**
Expand Down
37 changes: 34 additions & 3 deletions packages/openapi/lib/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,49 @@ extern dec operationId(target: Operation, operationId: valueof string);
/**
* Attach some custom data to the OpenAPI element generated from this type.
*
* @param key Extension key. Must start with `x-`
* @param key minProperties/maxProperties/uniqueItems/multipleOf or Extension key. the extension key must start with `x-`
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
* @param value Extension value.
*
* @example
*
* ```typespec
* @extension("x-custom", "My value")
* @extension("x-pageable", {nextLink: "x-next-link"})
* op read(): string;
* ```
*
* @example Specify that every item in the array must be unique.
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
* ```typespec
* model Foo {
* @extension("uniqueItems")
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
* x: unknown[];
* };
* ```
*
* @example Specify that the numeric type must be a multiple of some numeric value.
* ```typespec
* model Foo {
* @extension("multipleOf", 1)
* x: int32;
* };
* ```
*
* @example Specify the maximum number of properties this object can have.
* ```typespec
* model Foo {
* @extension("maxProperties", 1)
* x: int32;
* };
* ```
*
* @example Specify the minimum number of properties this object can have.
* ```typespec
* model Foo {
* @extension("minProperties", 1)
* x: int32;
* };
* ```
*/
extern dec extension(target: unknown, key: valueof string, value: unknown);
extern dec extension(target: unknown, key: valueof string, value?: unknown);

/**
* Specify that this model is to be treated as the OpenAPI `default` response.
Expand Down
78 changes: 69 additions & 9 deletions packages/openapi/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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, SchemaExtensionKey } from "./types.js";

const operationIdsKey = createStateSymbol("operationIds");
/**
Expand Down Expand Up @@ -56,21 +56,78 @@ export const $extension: ExtensionDecorator = (
context: DecoratorContext,
entity: Type,
extensionName: string,
value: TypeSpecValue,
value?: TypeSpecValue,
) => {
if (!isOpenAPIExtensionKey(extensionName)) {
const validExtensions = ["minProperties", "maxProperties", "uniqueItems", "multipleOf"];
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
const isModelProperty = entity.kind === "ModelProperty";

if (
!(validExtensions.includes(extensionName) && isModelProperty) &&
!isOpenAPIExtensionKey(extensionName)
) {
reportDiagnostic(context.program, {
code: "invalid-extension-key",
messageId: "decorator",
format: { value: extensionName },
target: entity,
});
return;
}

if (extensionName !== "uniqueItems" && value === undefined) {
reportDiagnostic(context.program, {
code: "missing-extension-value",
format: { extension: extensionName },
target: entity,
});
return;
}

const [data, diagnostics] = typespecTypeToJson(value, entity);
if (diagnostics.length > 0) {
context.program.reportDiagnostics(diagnostics);
let inputData: any = true;
if (value !== undefined) {
const [data, diagnostics] = typespecTypeToJson(value, entity);
const numberExtensions = ["minProperties", "maxProperties", "multipleOf"];
if (numberExtensions.includes(extensionName) && isNaN(Number(data))) {
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
reportDiagnostic(context.program, {
code: "invalid-extension-value",
format: { extensionName: extensionName },
target: entity,
});
return;
}

if (extensionName === "uniqueItems" && data !== true && data !== false) {
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
reportDiagnostic(context.program, {
code: "invalid-extension-value",
messageId: "uniqueItems",
format: { extensionName: extensionName },
target: entity,
});
return;
}

switch (extensionName) {
case "minProperties":
case "maxProperties":
case "multipleOf":
inputData = Number(data);
break;
case "uniqueItems":
inputData = data === true ? true : false;
break;
default:
if (diagnostics.length > 0) {
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
context.program.reportDiagnostics(diagnostics);
}
inputData = data;
}
}
setExtension(context.program, entity, extensionName as ExtensionKey, data);
setExtension(
context.program,
entity,
extensionName as ExtensionKey | SchemaExtensionKey,
inputData,
);
};

/**
Expand All @@ -97,7 +154,7 @@ export function setInfo(
export function setExtension(
program: Program,
entity: Type,
extensionName: ExtensionKey,
extensionName: ExtensionKey | SchemaExtensionKey,
data: unknown,
) {
const openApiExtensions = program.stateMap(openApiExtensionKey);
Expand All @@ -111,7 +168,10 @@ export function setExtension(
* @param program Program
* @param entity Type
*/
export function getExtensions(program: Program, entity: Type): ReadonlyMap<ExtensionKey, any> {
export function getExtensions(
program: Program,
entity: Type,
): ReadonlyMap<ExtensionKey | SchemaExtensionKey, any> {
return program.stateMap(openApiExtensionKey).get(entity) ?? new Map<ExtensionKey, any>();
}

Expand Down
14 changes: 14 additions & 0 deletions packages/openapi/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ export const $lib = createTypeSpecLibrary({
severity: "error",
messages: {
default: paramMessage`OpenAPI extension must start with 'x-' but was '${"value"}'`,
decorator: paramMessage`extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was '${"value"}'`,
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
},
},
"missing-extension-value": {
severity: "error",
messages: {
default: paramMessage`extension should have a value for '${"extension"}'`,
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
},
},
"invalid-extension-value": {
severity: "error",
messages: {
default: paramMessage`'${"extensionName"}' must number.'`,
uniqueItems: paramMessage`${"extensionName"}' must boolean.`,
},
},
"duplicate-type-name": {
Expand Down
1 change: 1 addition & 0 deletions packages/openapi/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
export type ExtensionKey = `x-${string}`;

export type SchemaExtensionKey = "minProperties" | "maxProperties" | "uniqueItems" | "multipleOf";
/**
* OpenAPI additional information
*/
Expand Down
38 changes: 37 additions & 1 deletion packages/openapi/test/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,42 @@ describe("openapi: decorators", () => {
});

describe("@extension", () => {
it.each([
["minProperties", 1],
["maxProperties", 1],
["uniqueItems", true],
["multipleOf", 1],
])("apply extension on model prop with %s", async (key, value) => {
const { prop } = await runner.compile(`
model Foo {
@extension("${key}", ${value})
@test
prop: string
}
`);

deepStrictEqual(Object.fromEntries(getExtensions(runner.program, prop)), {
[key]: value,
});
});

it.each(["minProperties", "maxProperties", "uniqueItems", "multipleOf"])(
"%s, emit diagnostics when passing invalid extension value",
async (key) => {
const diagnostics = await runner.diagnose(`
model Foo {
@extension("${key}", "string")
@test
prop: string
}
`);

expectDiagnostics(diagnostics, {
code: "@typespec/openapi/invalid-extension-value",
});
},
);

it("apply extension on model", async () => {
const { Foo } = await runner.compile(`
@extension("x-custom", "Bar")
Expand Down Expand Up @@ -99,7 +135,7 @@ describe("openapi: decorators", () => {

expectDiagnostics(diagnostics, {
code: "@typespec/openapi/invalid-extension-key",
message: `OpenAPI extension must start with 'x-' but was 'foo'`,
message: `extension decorator only support minProperties/maxProperties/uniqueItems/multipleOf/'x-' but was 'foo'`,
skywing918 marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
Expand Down
Loading
Loading