Skip to content
Merged
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,8 @@
---
changeKind: feature
packages:
- "@typespec/json-schema"
- "@typespec/openapi3"
---

Updates JsonSchema and Open API 3.1 emitters to use unevaluatedProperties instead of additionalProperties, and updates Open API 3 emitters to match JsonSchema behavior of treating `Record<never>` as setting `additionalProperties: { not: {} }`
4 changes: 2 additions & 2 deletions packages/json-schema/src/json-schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class JsonSchemaEmitter extends TypeEmitter<Record<string, any>, JSONSche
}

if (model.indexer) {
schema.set("additionalProperties", this.emitter.emitTypeReference(model.indexer.value));
schema.set("unevaluatedProperties", this.emitter.emitTypeReference(model.indexer.value));
}

this.#applyConstraints(model, schema);
Expand All @@ -111,7 +111,7 @@ export class JsonSchemaEmitter extends TypeEmitter<Record<string, any>, JSONSche
});

if (model.indexer) {
schema.set("additionalProperties", this.emitter.emitTypeReference(model.indexer.value));
schema.set("unevaluatedProperties", this.emitter.emitTypeReference(model.indexer.value));
}

return schema;
Expand Down
2 changes: 1 addition & 1 deletion packages/json-schema/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ it("handles types", async () => {
assert.deepStrictEqual(Foo["x-named-model"], { $ref: "Thing.json" });
assert.deepStrictEqual(Foo["x-model-template"], { $ref: "CollectionThing.json" });
assert.deepStrictEqual(Foo["x-record"], {
additionalProperties: {
unevaluatedProperties: {
properties: {
name: {
type: "string",
Expand Down
28 changes: 14 additions & 14 deletions packages/json-schema/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ describe("emitting models", () => {
);

assert.deepStrictEqual(schemas["ExtendsRecord.json"].allOf[0], { $ref: "RecordString.json" });
assert.deepStrictEqual(schemas["RecordString.json"].additionalProperties, { type: "string" });
assert.deepStrictEqual(schemas["IsRecord.json"].additionalProperties, {
assert.deepStrictEqual(schemas["RecordString.json"].unevaluatedProperties, { type: "string" });
assert.deepStrictEqual(schemas["IsRecord.json"].unevaluatedProperties, {
type: "object",
properties: {
x: {
Expand Down Expand Up @@ -181,10 +181,10 @@ describe("emitting models", () => {
{ emitAllRefs: true },
);

assert.deepStrictEqual(schemas["RecordNever.json"].additionalProperties, { not: {} });
assert.deepStrictEqual(schemas["RecordUnknown.json"].additionalProperties, {});
assert.deepStrictEqual(schemas["RecordVoid.json"].additionalProperties, { not: {} });
assert.deepStrictEqual(schemas["RecordNull.json"].additionalProperties, { type: "null" });
assert.deepStrictEqual(schemas["RecordNever.json"].unevaluatedProperties, { not: {} });
assert.deepStrictEqual(schemas["RecordUnknown.json"].unevaluatedProperties, {});
assert.deepStrictEqual(schemas["RecordVoid.json"].unevaluatedProperties, { not: {} });
assert.deepStrictEqual(schemas["RecordNull.json"].unevaluatedProperties, { type: "null" });
});

it("handles instantiations of literal types", async () => {
Expand All @@ -198,15 +198,15 @@ describe("emitting models", () => {
`,
{ emitAllRefs: true },
);
assert.deepStrictEqual(schemas["Test.json"].properties.string.additionalProperties, {
assert.deepStrictEqual(schemas["Test.json"].properties.string.unevaluatedProperties, {
type: "string",
const: "hi",
});
assert.deepStrictEqual(schemas["Test.json"].properties.number.additionalProperties, {
assert.deepStrictEqual(schemas["Test.json"].properties.number.unevaluatedProperties, {
type: "number",
const: 1.2,
});
assert.deepStrictEqual(schemas["Test.json"].properties.boolean.additionalProperties, {
assert.deepStrictEqual(schemas["Test.json"].properties.boolean.unevaluatedProperties, {
type: "boolean",
const: true,
});
Expand All @@ -227,7 +227,7 @@ describe("emitting models", () => {
{ emitAllRefs: true },
);

assert.deepStrictEqual(schemas["Test.json"].properties.union.additionalProperties, {
assert.deepStrictEqual(schemas["Test.json"].properties.union.unevaluatedProperties, {
anyOf: [
{
type: "integer",
Expand All @@ -242,7 +242,7 @@ describe("emitting models", () => {
],
});

assert.deepStrictEqual(schemas["Test.json"].properties.intersection.additionalProperties, {
assert.deepStrictEqual(schemas["Test.json"].properties.intersection.unevaluatedProperties, {
type: "object",
properties: {
x: {
Expand All @@ -260,11 +260,11 @@ describe("emitting models", () => {
});

assert.deepStrictEqual(
schemas["Test.json"].properties.unspeakableInstantiation.additionalProperties,
schemas["Test.json"].properties.unspeakableInstantiation.unevaluatedProperties,
{
type: "object",
properties: {},
additionalProperties: {
unevaluatedProperties: {
type: "object",
properties: {
x: {
Expand All @@ -282,7 +282,7 @@ describe("emitting models", () => {
},
},
);
assert.deepStrictEqual(schemas["RecordRecordInt32.json"].additionalProperties, {
assert.deepStrictEqual(schemas["RecordRecordInt32.json"].unevaluatedProperties, {
$ref: "RecordInt32.json",
});
});
Expand Down
11 changes: 11 additions & 0 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMinValueExclusive,
IntrinsicScalarName,
IntrinsicType,
isNeverType,
Model,
ModelProperty,
Program,
Expand Down Expand Up @@ -147,6 +148,16 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
return applyEncoding(this.emitter.getProgram(), typespecType, target as any, this._options);
}

applyModelIndexer(schema: ObjectBuilder<any>, model: Model): void {
if (!model.indexer) return;
const indexerType = model.indexer.value;

const unevaluatedPropertiesSchema = isNeverType(indexerType)
? { not: {} }
: this.emitter.emitTypeReference(indexerType);
schema.set("unevaluatedProperties", unevaluatedPropertiesSchema);
}

getRawBinarySchema(): OpenAPISchema3_1 {
return getRawBinarySchema();
}
Expand Down
42 changes: 36 additions & 6 deletions packages/openapi3/src/schema-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,40 @@ export class OpenAPI3SchemaEmitterBase<
}
}

applyModelIndexer(schema: ObjectBuilder<any>, model: Model): void {
if (!model.indexer) return;
const indexerType = model.indexer.value;
const isSealed = isNeverType(indexerType);

// if the indexer type is 'never' and the model extends another model,
// then we need redefine any baseModel properties
if (isSealed) {
const props = new ObjectBuilder(schema.properties ?? {});
let baseModel = model.baseModel;
while (baseModel) {
const result = this.emitter.emitModelProperties(baseModel);
baseModel = baseModel.baseModel;
if (result.kind !== "code" || !(result.value instanceof ObjectBuilder)) continue;
const baseProperties = result.value;
for (const key of Object.keys(baseProperties)) {
if (key in props) continue;
// Here we are saying that this property will always validate as true for this schema.
// This is because the `allOf` subSchema will contain the more specific validation
// for this property.
props.set(key, {});
}
}
if (Object.keys(props).length > 0) {
schema.set("properties", props);
}
}

const additionalPropertiesSchema = isSealed
? { not: {} }
: this.emitter.emitTypeReference(indexerType);
schema.set("additionalProperties", additionalPropertiesSchema);
}

modelDeclaration(model: Model, _: string): EmitterOutput<object> {
const program = this.emitter.getProgram();
const visibility = this.#getVisibilityContext();
Expand All @@ -164,9 +198,7 @@ export class OpenAPI3SchemaEmitterBase<
properties: this.emitter.emitModelProperties(model),
});

if (model.indexer) {
schema.set("additionalProperties", this.emitter.emitTypeReference(model.indexer.value));
}
this.applyModelIndexer(schema, model);

const derivedModels = model.derivedModels.filter(includeDerivedModel);
// getSchemaOrRef on all children to push them into components.schemas
Expand Down Expand Up @@ -224,9 +256,7 @@ export class OpenAPI3SchemaEmitterBase<
required: this.#requiredModelProperties(model, this.#getVisibilityContext()),
});

if (model.indexer) {
schema.set("additionalProperties", this.emitter.emitTypeReference(model.indexer.value));
}
this.applyModelIndexer(schema, model);

return schema;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/openapi3/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,15 @@ export type JsonSchema<AdditionalVocabularies extends {} = {}> = AdditionalVocab
*/
additionalProperties?: boolean | Refable<JsonSchema<AdditionalVocabularies>>;

/**
* Indicates that additional unlisted properties can exist in this schema.
* This differs from additionalProperties in that it is aware of any in-place applicators.
* This includes being aware of properties defined in sibling `allOf` sub-schemas.
*
* @see https://json-schema.org/draft/2020-12/json-schema-core#name-unevaluatedproperties
*/
unevaluatedProperties?: boolean | Refable<JsonSchema<AdditionalVocabularies>>;

/**
* Property is readonly.
*/
Expand Down
Loading