From 7effa6d8afb3511a1fde74389e6cc85695d68866 Mon Sep 17 00:00:00 2001 From: Zach Bimson Date: Sat, 15 Aug 2026 19:30:08 +0100 Subject: [PATCH] [openapi3] emit additionalProperties for a declared Record indexer in 3.1 A declared `Record` indexer describes a dictionary. Sealing, and a model that declares an indexer while also extending another model, both have to account for the properties evaluated by the `allOf` subschema holding the base model, so those keep `unevaluatedProperties`. Everything else now emits `additionalProperties`. Measured with Ajv 2020-12: in the position this changes the two keywords accept and reject the same instances; in the position it excludes they differ, and `additionalProperties` would make the schema unsatisfiable. A dictionary that does not compose now emits the same schema in 3.0 and 3.1, which the new cross-version tests use as an oracle, with the two exclusions pinned in the other direction. --- ...additional-properties-2026-8-15-19-45-0.md | 7 + packages/openapi3/src/schema-emitter-3-1.ts | 11 +- .../test/additional-properties.test.ts | 165 ++++++++++++++++-- .../openapi3/test/nullable-properties.test.ts | 2 +- packages/openapi3/test/record.test.ts | 83 ++++++++- packages/openapi3/test/return-types.test.ts | 6 +- packages/openapi3/test/test-host.ts | 3 +- packages/openapi3/test/versioning.test.ts | 29 +++ packages/openapi3/test/works-for.ts | 7 + .../typespec-for-openapi-dev.md | 2 +- 10 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 .chronus/changes/openapi3-record-additional-properties-2026-8-15-19-45-0.md diff --git a/.chronus/changes/openapi3-record-additional-properties-2026-8-15-19-45-0.md b/.chronus/changes/openapi3-record-additional-properties-2026-8-15-19-45-0.md new file mode 100644 index 00000000000..f9cb3c0c9e1 --- /dev/null +++ b/.chronus/changes/openapi3-record-additional-properties-2026-8-15-19-45-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +OpenAPI 3.1 and 3.2 now emit `additionalProperties` for a `Record` indexer, unless the model also extends another model or the schema is sealed, which still use `unevaluatedProperties`. diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index 407592550ae..db78fba0ac7 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -161,10 +161,17 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase { it("links to an allOf of the Record schema", async () => { const res = await oapiForModel("Pet", `model Pet extends Record {};`); - deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", [objectSchemaIndexer]: {} }]); + deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", additionalProperties: {} }]); }); it("include model properties", async () => { @@ -24,14 +24,14 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { }); describe("is Record", () => { - it(`set ${objectSchemaIndexer} on model itself`, async () => { + it("set additionalProperties on model itself", async () => { const res = await oapiForModel("Pet", `model Pet is Record {};`); - deepStrictEqual(res.schemas.Pet[objectSchemaIndexer], {}); + deepStrictEqual(res.schemas.Pet.additionalProperties, {}); }); it("set additional properties type", async () => { const res = await oapiForModel("Pet", `model Pet is Record {};`); - deepStrictEqual(res.schemas.Pet[objectSchemaIndexer], { + deepStrictEqual(res.schemas.Pet.additionalProperties, { type: "string", }); }); @@ -45,7 +45,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { }); describe("referencing Record", () => { - it(`add ${objectSchemaIndexer} inline for property of type Record`, async () => { + it("add additionalProperties inline for property of type Record", async () => { const res = await oapiForModel( "Pet", ` @@ -57,7 +57,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { ok(res.schemas.Pet, "expected definition named Pet"); deepStrictEqual(res.schemas.Pet.properties.details, { type: "object", - [objectSchemaIndexer]: {}, + additionalProperties: {}, }); }); @@ -79,7 +79,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { }); describe("spreading Record", () => { - it(`add ${objectSchemaIndexer} of type Record`, async () => { + it("add additionalProperties of type Record", async () => { const res = await oapiForModel( "Pet", ` @@ -89,7 +89,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { ok(res.isRef); ok(res.schemas.Pet, "expected definition named Pet"); - deepStrictEqual(res.schemas.Pet[objectSchemaIndexer], {}); + deepStrictEqual(res.schemas.Pet.additionalProperties, {}); }); it(`add ${objectSchemaIndexer} of type Record as "{ not: {} }"`, async () => { @@ -111,7 +111,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { }); }); - it(`set ${objectSchemaIndexer} if model extends Record with leaf type`, async () => { + it("set additionalProperties if model extends Record with leaf type", async () => { const res = await oapiForModel( "Pet", ` @@ -123,7 +123,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { ok(res.isRef); ok(res.schemas.Pet, "expected definition named Pet"); - deepStrictEqual(res.schemas.Pet[objectSchemaIndexer], { + deepStrictEqual(res.schemas.Pet.additionalProperties, { $ref: "#/components/schemas/Value", }); }); @@ -197,13 +197,13 @@ worksFor(supportedVersions, ({ oapiForModel: baseOapiForMopdel, objectSchemaInde }); }); - it(`does not seal object schemas that already have ${objectSchemaIndexer} set`, async () => { + it("does not seal object schemas that already have additionalProperties set", async () => { const res = await oapiForModel("Pet", `model Pet { name: string; ...Record; };`); deepStrictEqual(res.schemas.Pet, { type: "object", required: ["name"], properties: { name: { type: "string" } }, - [objectSchemaIndexer]: { type: "string" }, + additionalProperties: { type: "string" }, }); }); @@ -228,3 +228,144 @@ worksFor(supportedVersions, ({ oapiForModel: baseOapiForMopdel, objectSchemaInde }); }); }); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { + describe("which indexer keyword is used", () => { + it("uses additionalProperties for a declared indexer", async () => { + const res = await oapiForModel("Pet", `model Pet { details: Record };`); + deepStrictEqual(res.schemas.Pet.properties.details, { + type: "object", + additionalProperties: { type: "string" }, + }); + }); + + it("uses unevaluatedProperties for a declared indexer on a model with a base model", async () => { + const res = await oapiForModel( + "Dict", + ` + model Base { id: int32; } + model Dict extends Base { ...Record; } + `, + ); + deepStrictEqual(res.schemas.Dict, { + type: "object", + unevaluatedProperties: { type: "string" }, + allOf: [{ $ref: "#/components/schemas/Base" }], + }); + }); + + it("uses additionalProperties for a declared indexer that other models extend", async () => { + const res = await oapiForModel( + "Sub", + ` + model Dict { ...Record; } + model Sub extends Dict { extra: string; } + `, + ); + deepStrictEqual(res.schemas.Dict, { + type: "object", + additionalProperties: { type: "string" }, + }); + }); + + it("uses unevaluatedProperties to seal a model", async () => { + const res = await oapiForModel("Pet", `model Pet { name: string; };`, { + "seal-object-schemas": true, + }); + deepStrictEqual(res.schemas.Pet, { + type: "object", + required: ["name"], + properties: { name: { type: "string" } }, + unevaluatedProperties: { not: {} }, + }); + }); + + it("uses unevaluatedProperties to seal a model that has a base model", async () => { + const res = await oapiForModel( + "Leaf", + ` + model Base { id: string; } + model Leaf extends Base { name: string; } + `, + { "seal-object-schemas": true }, + ); + deepStrictEqual(res.schemas.Leaf, { + type: "object", + required: ["name"], + properties: { name: { type: "string" } }, + unevaluatedProperties: { not: {} }, + allOf: [{ $ref: "#/components/schemas/Base" }], + }); + }); + + it("keeps unevaluatedProperties for a Record property, which seals", async () => { + const res = await oapiForModel("Pet", `model Pet { empty: Record };`); + deepStrictEqual(res.schemas.Pet.properties.empty, { + type: "object", + unevaluatedProperties: { not: {} }, + }); + }); + + it("uses unevaluatedProperties at every level of an inheritance chain that declares an indexer", async () => { + const res = await oapiForModel( + "C", + ` + model A { ...Record; } + model B extends A { b: string; ...Record; } + model C extends B { c: string; } + `, + ); + // Only `A` is free of a base model, so only `A` uses additionalProperties. + deepStrictEqual(res.schemas.A, { + type: "object", + additionalProperties: { type: "string" }, + }); + deepStrictEqual(res.schemas.B.unevaluatedProperties, { type: "string" }); + deepStrictEqual(res.schemas.B.allOf, [{ $ref: "#/components/schemas/A" }]); + }); + }); +}); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { + describe("shapes the indexer keyword must not reach", () => { + it("applies to the item of an array of dictionaries, not to the array", async () => { + const res = await oapiForModel("Pet", `model Pet { tags: Record[]; };`); + deepStrictEqual(res.schemas.Pet.properties.tags, { + type: "array", + items: { type: "object", additionalProperties: { type: "string" } }, + }); + }); + + it("applies at every level of a nested dictionary", async () => { + const res = await oapiForModel("Pet", `model Pet { nested: Record>; };`); + deepStrictEqual(res.schemas.Pet.properties.nested, { + type: "object", + additionalProperties: { + type: "object", + additionalProperties: { type: "string" }, + }, + }); + }); + }); +}); + +worksFor(["3.1.0", "3.2.0"], ({ oapiForModel }) => { + // `attachExtensions` writes arbitrary keys onto the schema after `applyModelIndexer` has run, so + // an `@extension` that injects an in-place applicator is invisible to the keyword decision. The + // `x-` prefix convention is documented for `@extension` but not enforced, so this is reachable. + // Pinned rather than guarded: the emitter cannot see the injected applicator from where the + // decision is made, and the same escape hatch has always behaved this way in OpenAPI 3.0. + it("does not account for an in-place applicator injected by @extension", async () => { + const res = await oapiForModel( + "Dict", + ` + @extension("allOf", #[#{ type: "object", properties: #{ flag: #{ type: "boolean" } } }]) + model Dict { ...Record; } + `, + ); + deepStrictEqual(res.schemas.Dict.additionalProperties, { type: "string" }); + deepStrictEqual(res.schemas.Dict.allOf, [ + { type: "object", properties: { flag: { type: "boolean" } } }, + ]); + }); +}); diff --git a/packages/openapi3/test/nullable-properties.test.ts b/packages/openapi3/test/nullable-properties.test.ts index 51498235192..55cd3e0555b 100644 --- a/packages/openapi3/test/nullable-properties.test.ts +++ b/packages/openapi3/test/nullable-properties.test.ts @@ -132,7 +132,7 @@ worksFor(["3.1.0"], ({ oapiForModel, openApiFor }) => { it("keep nullable Record inline", async () => { await expectInCircularReference("Record", { anyOf: [ - { type: "object", unevaluatedProperties: { $ref: "#/components/schemas/Test" } }, + { type: "object", additionalProperties: { $ref: "#/components/schemas/Test" } }, { type: "null" }, ], }); diff --git a/packages/openapi3/test/record.test.ts b/packages/openapi3/test/record.test.ts index fb8ef22ab60..91d5a4a1432 100644 --- a/packages/openapi3/test/record.test.ts +++ b/packages/openapi3/test/record.test.ts @@ -1,8 +1,9 @@ import { deepStrictEqual, ok } from "assert"; import { it } from "vitest"; +import { openapisFor } from "./test-host.js"; import { supportedVersions, worksFor } from "./works-for.js"; -worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { +worksFor(supportedVersions, ({ oapiForModel }) => { it("defines record inline", async () => { const res = await oapiForModel( "Pet", @@ -15,7 +16,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { ok(res.schemas.Pet, "expected definition named Pet"); deepStrictEqual(res.schemas.Pet.properties.foodScores, { type: "object", - [objectSchemaIndexer]: { type: "integer", format: "int32" }, + additionalProperties: { type: "integer", format: "int32" }, }); }); @@ -33,11 +34,11 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { ok(res.schemas.Pet, "expected definition named Pet"); deepStrictEqual(res.schemas.FoodScores, { type: "object", - [objectSchemaIndexer]: { type: "integer", format: "int32" }, + additionalProperties: { type: "integer", format: "int32" }, }); }); - it(`specify ${objectSchemaIndexer} when "...Record"`, async () => { + it(`specify additionalProperties when "...Record"`, async () => { const res = await oapiForModel( "Person", ` @@ -48,12 +49,12 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { deepStrictEqual(res.schemas.Person, { type: "object", properties: { age: { type: "integer", format: "int32" } }, - [objectSchemaIndexer]: { type: "string" }, + additionalProperties: { type: "string" }, required: ["age"], }); }); - it(`specify ${objectSchemaIndexer} of anyOf when multiple "...Record"`, async () => { + it(`specify additionalProperties of anyOf when multiple "...Record"`, async () => { const res = await oapiForModel( "Person", ` @@ -64,8 +65,76 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => { deepStrictEqual(res.schemas.Person, { type: "object", properties: { age: { type: "integer", format: "int32" } }, - [objectSchemaIndexer]: { anyOf: [{ type: "string" }, { type: "boolean" }] }, + additionalProperties: { anyOf: [{ type: "string" }, { type: "boolean" }] }, required: ["age"], }); }); }); + +// A dictionary whose value type is not `never`, on a model that does not extend another model, is +// the same schema in every spec version, so the 3.0 output is an oracle for the 3.1 one. The two +// exclusions are real: a model that composes is deliberately different, and `Record` routes +// through the sealing branch, which keeps the composition aware keyword in 3.1. Both are asserted +// below so the oracle's preconditions are pinned rather than assumed. +it("emits the same dictionary schema for 3.0 and 3.1 when the model does not compose", async () => { + const outputs = await openapisFor( + ` + model Labels is Record; + model Holder { labels: Labels; inline: Record; } + @route("/h") @post op h(@body body: Holder): Holder; + `, + { "openapi-versions": ["3.0.0", "3.1.0"] }, + ); + + const v30 = outputs["3.0.0/openapi.json"]; + const v31 = outputs["3.1.0/openapi.json"]; + ok(v30 && v31, "expected a document for each spec version"); + + deepStrictEqual(v31.components!.schemas!.Labels, v30.components!.schemas!.Labels); + deepStrictEqual(v31.components!.schemas!.Holder, v30.components!.schemas!.Holder); + deepStrictEqual(v31.components!.schemas!.Labels, { + type: "object", + additionalProperties: { type: "string" }, + }); +}); + +it("still differs between 3.0 and 3.1 when the model composes", async () => { + const outputs = await openapisFor( + ` + model Base { id: int32; } + model Dict extends Base { ...Record; } + @route("/d") @post op d(@body body: Dict): Dict; + `, + { "openapi-versions": ["3.0.0", "3.1.0"] }, + ); + + deepStrictEqual(outputs["3.0.0/openapi.json"].components!.schemas!.Dict, { + type: "object", + additionalProperties: { type: "string" }, + allOf: [{ $ref: "#/components/schemas/Base" }], + }); + deepStrictEqual(outputs["3.1.0/openapi.json"].components!.schemas!.Dict, { + type: "object", + unevaluatedProperties: { type: "string" }, + allOf: [{ $ref: "#/components/schemas/Base" }], + }); +}); + +it("still differs between 3.0 and 3.1 for Record, which seals rather than describing a map", async () => { + const outputs = await openapisFor( + ` + model Holder { empty: Record; } + @route("/h") @post op h(@body body: Holder): Holder; + `, + { "openapi-versions": ["3.0.0", "3.1.0"] }, + ); + + deepStrictEqual(outputs["3.0.0/openapi.json"].components!.schemas!.Holder!.properties!.empty, { + type: "object", + additionalProperties: { not: {} }, + }); + deepStrictEqual(outputs["3.1.0/openapi.json"].components!.schemas!.Holder!.properties!.empty, { + type: "object", + unevaluatedProperties: { not: {} }, + }); +}); diff --git a/packages/openapi3/test/return-types.test.ts b/packages/openapi3/test/return-types.test.ts index bb9b3f3b0a4..fcd7fb4ea19 100644 --- a/packages/openapi3/test/return-types.test.ts +++ b/packages/openapi3/test/return-types.test.ts @@ -3,7 +3,7 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { supportedVersions, worksFor } from "./works-for.js"; -worksFor(supportedVersions, ({ checkFor, openApiFor, objectSchemaIndexer }) => { +worksFor(supportedVersions, ({ checkFor, openApiFor }) => { it("model used with @body and without shouldn't conflict if it contains no metadata", async () => { const res = await openApiFor( ` @@ -316,7 +316,7 @@ worksFor(supportedVersions, ({ checkFor, openApiFor, objectSchemaIndexer }) => { ); }); - it(`produce ${objectSchemaIndexer} schema if response is Record`, async () => { + it(`produce additionalProperties schema if response is Record`, async () => { const res = await openApiFor( ` @get op test(): Record; @@ -329,7 +329,7 @@ worksFor(supportedVersions, ({ checkFor, openApiFor, objectSchemaIndexer }) => { "application/json": { schema: { type: "object", - [objectSchemaIndexer]: { + additionalProperties: { type: "string", }, }, diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 40a76acc723..3ddf86c6aa0 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -114,12 +114,13 @@ export async function openapisFor( export async function openApiForVersions( code: string, versions: T[], + options: OpenAPI3EmitterOptions = {}, ): Promise> { const host = await TesterWithVersioning.createInstance(); const outPath = "{emitter-output-dir}/{version}.openapi.json"; const { outputs } = await host.compile(code, { compilerOptions: { - options: { "@typespec/openapi3": { "output-file": outPath } }, + options: { "@typespec/openapi3": { ...options, "output-file": outPath } }, }, }); diff --git a/packages/openapi3/test/versioning.test.ts b/packages/openapi3/test/versioning.test.ts index 12652705817..d079915cdf1 100644 --- a/packages/openapi3/test/versioning.test.ts +++ b/packages/openapi3/test/versioning.test.ts @@ -268,3 +268,32 @@ worksFor(supportedVersions, ({ openApiFor, version: specVersion }) => { }); }); }); + +// The versioning emitter runs each version through a mutator, which clones the model graph. The +// indexer keyword is chosen from `model.baseModel`, so a clone that lost its base model would +// silently change the keyword between two versions of the same API. +it("keeps the indexer keyword stable across versions in OpenAPI 3.1", async () => { + const { v1, v2 } = await openApiForVersions( + ` + @versioned(Versions) + @service(#{title: "My Service"}) + namespace MyService; + enum Versions { v1, v2 } + model Dict { ...Record; } + model Sub extends Dict { @added(Versions.v2) extra: string; } + @route("/d") @post op d(@body body: Dict): Dict; + @route("/s") @post op s(@body body: Sub): Sub; + `, + ["v1", "v2"], + { "openapi-versions": ["3.1.0"] }, + ); + + for (const doc of [v1, v2]) { + strictEqual(doc.openapi, "3.1.0"); + deepStrictEqual(doc.components!.schemas!.Dict, { + type: "object", + additionalProperties: { type: "string" }, + }); + deepStrictEqual(doc.components!.schemas!.Sub!.allOf, [{ $ref: "#/components/schemas/Dict" }]); + } +}); diff --git a/packages/openapi3/test/works-for.ts b/packages/openapi3/test/works-for.ts index 694392d1498..f94ba8c83d1 100644 --- a/packages/openapi3/test/works-for.ts +++ b/packages/openapi3/test/works-for.ts @@ -26,6 +26,13 @@ export type SpecHelper = { checkFor: typeof diagnoseOpenApiFor; diagnoseOpenApiFor: typeof diagnoseOpenApiFor; emitOpenApiWithDiagnostics: typeof emitOpenApiWithDiagnostics; + /** + * The keyword a schema uses when it has to account for the properties evaluated by the `allOf` + * subschema holding the base model, which is why it differs by spec version. That is the case + * when a schema is sealed, and when a model with a declared indexer also extends another model. + * A declared `Record` indexer on a model that does neither uses `additionalProperties` in + * every spec version, and does not use this. + */ objectSchemaIndexer: ObjectSchemaIndexer; }; diff --git a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md index f0a5b75b3ac..8d578b8afc0 100644 --- a/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md +++ b/website/src/content/docs/docs/getting-started/typespec-for-openapi-dev.md @@ -536,7 +536,7 @@ model Snake { You can generate a schema with `additionalProperties` with the TypeSpec `Record` construct. -**Note:** `unevaluatedProperties` is used instead of `additionalProperties` when emitting Open API 3.1 specs. +**Note:** when emitting Open API 3.1 and 3.2 specs, `unevaluatedProperties` is used instead of `additionalProperties` for schemas that compose with `allOf`: sealed schemas, and models that declare a `Record` indexer while also extending another model. `additionalProperties` cannot see the properties evaluated by the `allOf` subschema, so it would constrain the inherited ones. ```typespec bar: Record;