Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Extend the `enum-strategy: annotated` emitter option to unions of literals. When set to `annotated`, a union whose variants are literals is emitted as a `oneOf`/`anyOf` of `const` subschemas with per-variant `title`/`description` taken from `@summary` and `@doc`, instead of collapsing to a single lossy `enum`. Supported for OpenAPI 3.1.0 and above; emitting with OpenAPI 3.0.0 falls back to the default form and reports a warning.

For example, the following TypeSpec:

```typespec
/** Set of known error types. */
union ErrorType {
/** Common error for a bad request. */
@summary("CommonBadRequest")
commonBadRequest: "https://example.com/errors/bad-request",

/** The request body could not be parsed. */
@summary("InvalidBody")
invalidBody: "https://example.com/errors/invalid-body",
}
```

emits:

```yaml
ErrorType:
description: Set of known error types.
anyOf:
- const: https://example.com/errors/bad-request
title: CommonBadRequest
description: Common error for a bad request.
- const: https://example.com/errors/invalid-body
title: InvalidBody
description: The request body could not be parsed.
```

Use `@oneOf` on the union to emit `oneOf` instead of `anyOf`.
4 changes: 2 additions & 2 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion

**Default:** `"default"`

How to emit TypeSpec enums. Options are:
How to emit TypeSpec enums and unions of literals. Options are:

- `default`: Emit as a single schema using the `enum` keyword.
- `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`
from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.
from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.
Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.

## Decorators
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,10 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
nullable: true,
default: "default",
description: [
"How to emit TypeSpec enums. Options are:",
"How to emit TypeSpec enums and unions of literals. Options are:",
" - `default`: Emit as a single schema using the `enum` keyword.",
" - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`",
" from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.",
" from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.",
" Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.",
].join("\n"),
},
Expand Down
30 changes: 30 additions & 0 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
Tuple,
Type,
Union,
UnionVariant,
} from "@typespec/compiler";
import { MetadataInfo } from "@typespec/http";
import { shouldInline } from "@typespec/openapi";
Expand Down Expand Up @@ -226,6 +227,26 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
return this.applyConstraints(en, { oneOf });
}

// Builds an annotated `const` subschema for a single literal union variant,
// mirroring `#annotatedEnumSchema`'s per-member handling: the variant value
// becomes `const`, `@summary` becomes `title`, and the doc comment/`@doc`
// becomes `description`. Title/description are omitted when absent. Like the
// enum case, the variant name is NOT used as a title fallback.
#annotatedVariantSchema(variant: UnionVariant): OpenAPISchema3_1 {
const program = this.emitter.getProgram();
compilerAssert(isLiteralType(variant.type), "Expected a literal union variant");
const subschema: OpenAPISchema3_1 = { const: variant.type.value };
const title = getSummary(program, variant);
if (title !== undefined) {
subschema.title = title;
}
const description = getDoc(program, variant);
if (description !== undefined) {
subschema.description = description;
}
return subschema;
}

unionSchema(union: Union): ObjectBuilder<OpenAPISchema3_1> {
const program = this.emitter.getProgram();
const [discriminated] = getDiscriminatedUnion(program, union);
Expand All @@ -252,6 +273,15 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch

// 3.a. Literal types are actual values (though not Value types)
if (isLiteralType(variant.type)) {
// With the annotated enum strategy, emit each literal variant as its own
// `const` subschema carrying per-variant `title`/`description` (from
// `@summary`/`@doc`), following the OpenAPI 3.1.1 annotated enumerations
// pattern. This preserves the variant-level documentation that the
// default `enum`-merge form below discards. See `#annotatedVariantSchema`.
if (this._options.enumStrategy === "annotated") {
schemaMembers.push({ schema: this.#annotatedVariantSchema(variant), type: null });
continue;
}
// Create schemas grouped by kind (boolean, string, numeric)
// and add the literals seen to each respective `enum` array
if (!literalVariantEnumByType[variant.type.kind]) {
Expand Down
182 changes: 182 additions & 0 deletions packages/openapi3/test/union-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,3 +1007,185 @@ describe("openapi3: discriminated union defaultMapping (3.2.0)", () => {
});
});
});

worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => {
describe("enum-strategy: annotated (unions of literals)", () => {
it("emits annotated `const` subschemas for a documented literal union", async () => {
const res = await oapiForModel(
"ErrorType",
`
/** Set of known error types. */
union ErrorType {
/** Common error for a bad request. */
@summary("CommonBadRequest")
commonBadRequest: "https://example.com/errors/bad-request",

/** Body could not be parsed. */
@summary("InvalidBody")
invalidBody: "https://example.com/errors/invalid-body",
}
`,
{ "enum-strategy": "annotated" },
);

deepStrictEqual(res.schemas.ErrorType, {
description: "Set of known error types.",
anyOf: [
{
const: "https://example.com/errors/bad-request",
title: "CommonBadRequest",
description: "Common error for a bad request.",
},
{
const: "https://example.com/errors/invalid-body",
title: "InvalidBody",
description: "Body could not be parsed.",
},
],
});
});

it("uses `oneOf` when the union carries `@oneOf`", async () => {
const res = await oapiForModel(
"ErrorType",
`
@oneOf
union ErrorType {
/** A. */
a: "a",
/** B. */
b: "b",
}
`,
{ "enum-strategy": "annotated" },
);

deepStrictEqual(res.schemas.ErrorType, {
oneOf: [
{ const: "a", description: "A." },
{ const: "b", description: "B." },
],
});
});

it("omits title/description for variants without docs", async () => {
const res = await oapiForModel(
"Color",
`
union Color {
red: "red",
green: "green",
/** Blue is the warmest color. */
blue: "blue",
}
`,
{ "enum-strategy": "annotated" },
);

deepStrictEqual(res.schemas.Color, {
anyOf: [
{ const: "red" },
{ const: "green" },
{ const: "blue", description: "Blue is the warmest color." },
],
});
});

it("emits annotated `const` subschemas for number-valued literal unions", async () => {
const res = await oapiForModel(
"Priority",
`
union Priority {
/** Low priority. */
low: 1,
/** High priority. */
high: 10,
}
`,
{ "enum-strategy": "annotated" },
);

deepStrictEqual(res.schemas.Priority, {
anyOf: [
{ const: 1, description: "Low priority." },
{ const: 10, description: "High priority." },
],
});
});

it("keeps model/scalar variants alongside annotated literal `const` members", async () => {
const res = await oapiForModel(
"Mixed",
`
model Detailed { code: string; }
union Mixed {
/** Simple literal. */
@summary("Simple")
simple: "simple",
detailed: Detailed,
}
`,
{ "enum-strategy": "annotated" },
);

deepStrictEqual(res.schemas.Mixed, {
anyOf: [
{ const: "simple", title: "Simple", description: "Simple literal." },
{ $ref: "#/components/schemas/Detailed" },
],
});
});

it("does not change literal unions under the default strategy", async () => {
const res = await oapiForModel(
"ErrorType",
`
union ErrorType {
/** Common error for a bad request. */
@summary("CommonBadRequest")
commonBadRequest: "https://example.com/errors/bad-request",

/** Body could not be parsed. */
@summary("InvalidBody")
invalidBody: "https://example.com/errors/invalid-body",
}
`,
);

deepStrictEqual(res.schemas.ErrorType, {
type: "string",
enum: ["https://example.com/errors/bad-request", "https://example.com/errors/invalid-body"],
});
});
});
});

worksFor(["3.0.0"], ({ emitOpenApiWithDiagnostics }) => {
it("falls back to the default enum form for literal unions on OpenAPI 3.0.0", async () => {
const [doc, diagnostics] = await emitOpenApiWithDiagnostics(
`
@service
namespace Test;

union ErrorType {
/** A. */
a: "a",
/** B. */
b: "b",
}
op read(): ErrorType;
`,
{ "enum-strategy": "annotated" },
);

expectDiagnostics(diagnostics, {
code: "@typespec/openapi3/enum-strategy-not-supported",
severity: "warning",
});

deepStrictEqual(doc.components!.schemas!.ErrorType, {
type: "string",
enum: ["a", "b"],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,9 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion

**Default:** `"default"`

How to emit TypeSpec enums. Options are:
How to emit TypeSpec enums and unions of literals. Options are:

- `default`: Emit as a single schema using the `enum` keyword.
- `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`
from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.
from each member's/variant's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.
Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.