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,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

OpenAPI 3.1 and 3.2 now emit `additionalProperties` for a `Record<T>` indexer, unless the model also extends another model or the schema is sealed, which still use `unevaluatedProperties`.
11 changes: 9 additions & 2 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,17 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
const shouldSeal = this.shouldSealSchema(model);
if (!shouldSeal && !model.indexer) return;

const unevaluatedPropertiesSchema = shouldSeal
const indexerSchema = shouldSeal
? { not: {} }
: this.emitter.emitTypeReference(model.indexer!.value);
setProperty(schema, "unevaluatedProperties", unevaluatedPropertiesSchema);

// A declared indexer describes a dictionary, and `additionalProperties` is the keyword for that.
// Sealing keeps `unevaluatedProperties`, and so does a model that also extends another model:
// both have to account for the properties evaluated by the `allOf` subschema holding the base
// model, which `additionalProperties` cannot see and would therefore constrain.
const indexerKeyword =
shouldSeal || model.baseModel ? "unevaluatedProperties" : "additionalProperties";
Comment on lines +172 to +173
setProperty(schema, indexerKeyword, indexerSchema);
}

getRawBinarySchema(): OpenAPISchema3_1 {
Expand Down
165 changes: 153 additions & 12 deletions packages/openapi3/test/additional-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => {

it("links to an allOf of the Record<unknown> schema", async () => {
const res = await oapiForModel("Pet", `model Pet extends Record<unknown> {};`);
deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", [objectSchemaIndexer]: {} }]);
deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", additionalProperties: {} }]);
});

it("include model properties", async () => {
Expand All @@ -24,14 +24,14 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => {
});

describe("is Record<T>", () => {
it(`set ${objectSchemaIndexer} on model itself`, async () => {
it("set additionalProperties on model itself", async () => {
const res = await oapiForModel("Pet", `model Pet is Record<unknown> {};`);
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<string> {};`);
deepStrictEqual(res.schemas.Pet[objectSchemaIndexer], {
deepStrictEqual(res.schemas.Pet.additionalProperties, {
type: "string",
});
});
Expand All @@ -45,7 +45,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => {
});

describe("referencing Record<T>", () => {
it(`add ${objectSchemaIndexer} inline for property of type Record<unknown>`, async () => {
it("add additionalProperties inline for property of type Record<unknown>", async () => {
const res = await oapiForModel(
"Pet",
`
Expand All @@ -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: {},
});
});

Expand All @@ -79,7 +79,7 @@ worksFor(supportedVersions, ({ oapiForModel, objectSchemaIndexer }) => {
});

describe("spreading Record<T>", () => {
it(`add ${objectSchemaIndexer} of type Record<unknown>`, async () => {
it("add additionalProperties of type Record<unknown>", async () => {
const res = await oapiForModel(
"Pet",
`
Expand All @@ -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<never> as "{ not: {} }"`, async () => {
Expand All @@ -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",
`
Expand All @@ -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",
});
});
Expand Down Expand Up @@ -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<string>; };`);
deepStrictEqual(res.schemas.Pet, {
type: "object",
required: ["name"],
properties: { name: { type: "string" } },
[objectSchemaIndexer]: { type: "string" },
additionalProperties: { type: "string" },
});
});

Expand All @@ -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<string> };`);
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<string>; }
`,
);
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<string>; }
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<never> property, which seals", async () => {
const res = await oapiForModel("Pet", `model Pet { empty: Record<never> };`);
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<string>; }
model B extends A { b: string; ...Record<string>; }
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<string>[]; };`);
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<Record<string>>; };`);
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<string>; }
`,
);
deepStrictEqual(res.schemas.Dict.additionalProperties, { type: "string" });
deepStrictEqual(res.schemas.Dict.allOf, [
{ type: "object", properties: { flag: { type: "boolean" } } },
]);
});
});
2 changes: 1 addition & 1 deletion packages/openapi3/test/nullable-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ worksFor(["3.1.0"], ({ oapiForModel, openApiFor }) => {
it("keep nullable Record<T> inline", async () => {
await expectInCircularReference("Record<Test>", {
anyOf: [
{ type: "object", unevaluatedProperties: { $ref: "#/components/schemas/Test" } },
{ type: "object", additionalProperties: { $ref: "#/components/schemas/Test" } },
{ type: "null" },
],
});
Expand Down
Loading