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

Updates extension decorator to support value kinds and adds setExtension API to json-schema emitter #3558

Merged
merged 11 commits into from
Aug 5, 2024
34 changes: 34 additions & 0 deletions .chronus/changes/json-schema-extension-values-2024-6-16-9-43-39.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
changeKind: breaking
packages:
- "@typespec/json-schema"
---

Updates `@extension` decorator to support TypeSpec values in addition to types.

In previous versions of the json-schema emitter, the `@extension` decorator only accepted types as the value. These are emitted as JSON schemas. In order to add extensions as raw values, types had to be wrapped in the `Json<>` template when being passed to the `@extension` decorator.

This change allows setting TypeSpec values (introduced in TypeSpec 0.57.0) directly instead.

The following example demonstrates using values directly:

```tsp
@extension("x-example", #{ foo: "bar" })
model Foo {}
```

This change results in scalars being treated as values instead of types. This will result in the `@extension` decorator emitting raw values for scalar types instead of JSON schema. To preserve the previous behavior, use `typeof` when passing in a scalar value.

The following example demonstrates how to pass a scalar value that emits a JSON schema:

```tsp
@extension("x-example", "foo")
model Foo {}
```

To preserve this same behavior, the above example can be updated to the following:

```tsp
@extension("x-example", typeof "foo")
model Foo {}
```
28 changes: 19 additions & 9 deletions docs/emitters/json-schema/reference/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,23 @@ media type and encoding.
### `@extension` {#@TypeSpec.JsonSchema.extension}

Specify a custom property to add to the emitted schema. Useful for adding custom keywords
and other vendor-specific extensions. The value will be converted to a schema unless the parameter
is wrapped in the `Json<Data>` template. For example, `@extension("x-schema", { x: "value" })` will
emit a JSON schema value for `x-schema`, whereas `@extension("x-schema", Json<{x: "value"}>)` will
emit the raw JSON code `{x: "value"}`.
and other vendor-specific extensions. Scalar values need to be specified using `typeof` to be converted to a schema.

For example, `@extension("x-schema", typeof "foo")` will emit a JSON schema value for `x-schema`,
whereas `@extension("x-schema", "foo")` will emit the raw code `"foo"`.

The value will be treated as a raw value if any of the following are true:

1. The value is a scalar value (e.g. string, number, boolean, etc.)
2. The value is wrapped in the `Json<Data>` template
3. The value is provided using the value syntax (e.g. `#{}`, `#[]`)

For example, `@extension("x-schema", { x: "value" })` will emit a JSON schema value for `x-schema`,
whereas `@extension("x-schema", #{x: "value"})` and `@extension("x-schema", Json<{x: "value"}>)`
will emit the raw JSON code `{x: "value"}`.

```typespec
@TypeSpec.JsonSchema.extension(key: valueof string, value: unknown)
@TypeSpec.JsonSchema.extension(key: valueof string, value: unknown | valueof unknown)
```

#### Target
Expand All @@ -118,10 +128,10 @@ emit the raw JSON code `{x: "value"}`.

#### Parameters

| Name | Type | Description |
| ----- | ---------------- | --------------------------------------------------------------------------------------- |
| key | `valueof string` | the name of the keyword of vendor extension, e.g. `x-custom`. |
| value | `unknown` | the value of the keyword. Will be converted to a schema unless wrapped in `Json<Data>`. |
| Name | Type | Description |
| ----- | ------------------------------ | ------------------------------------------------------------- |
| key | `valueof string` | the name of the keyword of vendor extension, e.g. `x-custom`. |
| value | `unknown` \| `valueof unknown` | the value of the keyword. |

### `@id` {#@TypeSpec.JsonSchema.id}

Expand Down
28 changes: 19 additions & 9 deletions packages/json-schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,23 @@ media type and encoding.
#### `@extension`

Specify a custom property to add to the emitted schema. Useful for adding custom keywords
and other vendor-specific extensions. The value will be converted to a schema unless the parameter
is wrapped in the `Json<Data>` template. For example, `@extension("x-schema", { x: "value" })` will
emit a JSON schema value for `x-schema`, whereas `@extension("x-schema", Json<{x: "value"}>)` will
emit the raw JSON code `{x: "value"}`.
and other vendor-specific extensions. Scalar values need to be specified using `typeof` to be converted to a schema.

For example, `@extension("x-schema", typeof "foo")` will emit a JSON schema value for `x-schema`,
whereas `@extension("x-schema", "foo")` will emit the raw code `"foo"`.

The value will be treated as a raw value if any of the following are true:

1. The value is a scalar value (e.g. string, number, boolean, etc.)
2. The value is wrapped in the `Json<Data>` template
3. The value is provided using the value syntax (e.g. `#{}`, `#[]`)

For example, `@extension("x-schema", { x: "value" })` will emit a JSON schema value for `x-schema`,
whereas `@extension("x-schema", #{x: "value"})` and `@extension("x-schema", Json<{x: "value"}>)`
will emit the raw JSON code `{x: "value"}`.

```typespec
@TypeSpec.JsonSchema.extension(key: valueof string, value: unknown)
@TypeSpec.JsonSchema.extension(key: valueof string, value: unknown | valueof unknown)
```

##### Target
Expand All @@ -209,10 +219,10 @@ emit the raw JSON code `{x: "value"}`.

##### Parameters

| Name | Type | Description |
| ----- | ---------------- | --------------------------------------------------------------------------------------- |
| key | `valueof string` | the name of the keyword of vendor extension, e.g. `x-custom`. |
| value | `unknown` | the value of the keyword. Will be converted to a schema unless wrapped in `Json<Data>`. |
| Name | Type | Description |
| ----- | ------------------------------ | ------------------------------------------------------------- |
| key | `valueof string` | the name of the keyword of vendor extension, e.g. `x-custom`. |
| value | `unknown` \| `valueof unknown` | the value of the keyword. |

#### `@id`

Expand Down
21 changes: 15 additions & 6 deletions packages/json-schema/generated-defs/TypeSpec.JsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,26 @@ export type ContentSchemaDecorator = (

/**
* Specify a custom property to add to the emitted schema. Useful for adding custom keywords
* and other vendor-specific extensions. The value will be converted to a schema unless the parameter
* is wrapped in the `Json<Data>` template. For example, `@extension("x-schema", { x: "value" })` will
* emit a JSON schema value for `x-schema`, whereas `@extension("x-schema", Json<{x: "value"}>)` will
* emit the raw JSON code `{x: "value"}`.
* and other vendor-specific extensions. Scalar values need to be specified using `typeof` to be converted to a schema.
*
* For example, `@extension("x-schema", typeof "foo")` will emit a JSON schema value for `x-schema`,
* whereas `@extension("x-schema", "foo")` will emit the raw code `"foo"`.
*
* The value will be treated as a raw value if any of the following are true:
* 1. The value is a scalar value (e.g. string, number, boolean, etc.)
* 2. The value is wrapped in the `Json<Data>` template
* 3. The value is provided using the value syntax (e.g. `#{}`, `#[]`)
*
* For example, `@extension("x-schema", { x: "value" })` will emit a JSON schema value for `x-schema`,
* whereas `@extension("x-schema", #{x: "value"})` and `@extension("x-schema", Json<{x: "value"}>)`
* will emit the raw JSON code `{x: "value"}`.
*
* @param key the name of the keyword of vendor extension, e.g. `x-custom`.
* @param value the value of the keyword. Will be converted to a schema unless wrapped in `Json<Data>`.
* @param value the value of the keyword.
*/
export type ExtensionDecorator = (
context: DecoratorContext,
target: Type,
key: string,
value: Type
value: Type | unknown
) => void;
21 changes: 15 additions & 6 deletions packages/json-schema/lib/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,24 @@ extern dec contentSchema(target: string | Reflection.ModelProperty, value: unkno

/**
* Specify a custom property to add to the emitted schema. Useful for adding custom keywords
* and other vendor-specific extensions. The value will be converted to a schema unless the parameter
* is wrapped in the `Json<Data>` template. For example, `@extension("x-schema", { x: "value" })` will
* emit a JSON schema value for `x-schema`, whereas `@extension("x-schema", Json<{x: "value"}>)` will
* emit the raw JSON code `{x: "value"}`.
* and other vendor-specific extensions. Scalar values need to be specified using `typeof` to be converted to a schema.
*
* For example, `@extension("x-schema", typeof "foo")` will emit a JSON schema value for `x-schema`,
* whereas `@extension("x-schema", "foo")` will emit the raw code `"foo"`.
*
* The value will be treated as a raw value if any of the following are true:
* 1. The value is a scalar value (e.g. string, number, boolean, etc.)
* 2. The value is wrapped in the `Json<Data>` template
* 3. The value is provided using the value syntax (e.g. `#{}`, `#[]`)

* For example, `@extension("x-schema", { x: "value" })` will emit a JSON schema value for `x-schema`,
* whereas `@extension("x-schema", #{x: "value"})` and `@extension("x-schema", Json<{x: "value"}>)`
* will emit the raw JSON code `{x: "value"}`.
*
* @param key the name of the keyword of vendor extension, e.g. `x-custom`.
* @param value the value of the keyword. Will be converted to a schema unless wrapped in `Json<Data>`.
* @param value the value of the keyword.
*/
extern dec extension(target: unknown, key: valueof string, value: unknown);
extern dec extension(target: unknown, key: valueof string, value: (valueof unknown) | unknown);

/**
* Well-known JSON Schema formats.
Expand Down
40 changes: 33 additions & 7 deletions packages/json-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Tuple,
Type,
Union,
isType,
setTypeSpecNamespace,
typespecTypeToJson,
} from "@typespec/compiler";
Expand Down Expand Up @@ -284,26 +285,51 @@ export function getPrefixItems(program: Program, target: Type): Tuple | undefine

export interface ExtensionRecord {
key: string;
value: Type;
value: Type | unknown;
}

const extensionsKey = createStateSymbol("JsonSchema.extension");
export const $extension: ExtensionDecorator = (
context: DecoratorContext,
target: Type,
key: string,
value: Type
value: unknown
) => {
const stateMap = context.program.stateMap(extensionsKey) as Map<Type, ExtensionRecord[]>;
setExtension(context.program, target, key, value);
};

export function getExtensions(program: Program, target: Type): ExtensionRecord[] {
return program.stateMap(extensionsKey).get(target) ?? [];
}

export function setExtension(program: Program, target: Type, key: string, value: unknown) {
const stateMap = program.stateMap(extensionsKey) as Map<Type, ExtensionRecord[]>;
const extensions = stateMap.has(target)
? stateMap.get(target)!
: stateMap.set(target, []).get(target)!;

extensions.push({ key, value });
};
// Check if we were handed the `Json` template model
if (isJsonTemplateType(value)) {
extensions.push({
key,
value: typespecTypeToJson(value.properties.get("value")!.type, target)[0],
});
} else {
extensions.push({ key, value });
}
}

export function getExtensions(program: Program, target: Type): ExtensionRecord[] {
return program.stateMap(extensionsKey).get(target) ?? [];
function isJsonTemplateType(
value: any
): value is Type & { kind: "Model"; name: "Json"; namespace: { name: "JsonSchema" } } {
return (
typeof value === "object" &&
value !== null &&
isType(value) &&
value.kind === "Model" &&
value.name === "Json" &&
value.namespace?.name === "JsonSchema"
);
}

export const $validatesRawJson: ValidatesRawJsonDecorator = (
Expand Down
23 changes: 9 additions & 14 deletions packages/json-schema/src/json-schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ import {
getSummary,
isArrayModelType,
isNullType,
isType,
joinPaths,
typespecTypeToJson,
} from "@typespec/compiler";
import {
ArrayBuilder,
Expand Down Expand Up @@ -596,24 +596,19 @@ export class JsonSchemaEmitter extends TypeEmitter<Record<string, any>, JSONSche
}

const extensions = getExtensions(this.emitter.getProgram(), type);
for (const extension of extensions) {
// todo: fix up when we have an authoritative way to ask "am I an instantiation of that template"
if (
extension.value.kind === "Model" &&
extension.value.name === "Json" &&
extension.value.namespace?.name === "JsonSchema"
) {
// we check in a decorator
schema.set(
extension.key,
typespecTypeToJson(extension.value.properties.get("value")!.type, null as any)[0]
);
for (const { key, value } of extensions) {
if (this.#isTypeLike(value)) {
schema.set(key, this.emitter.emitTypeReference(value));
} else {
schema.set(extension.key, this.emitter.emitTypeReference(extension.value));
schema.set(key, value);
}
}
}

#isTypeLike(value: any): value is Type {
return typeof value === "object" && value !== null && isType(value);
}

#createDeclaration(type: JsonSchemaDeclaration, name: string, schema: ObjectBuilder<unknown>) {
const decl = this.emitter.result.declaration(name, schema);
const sf = (decl.scope as SourceFileScope<any>).sourceFile;
Expand Down
Loading
Loading