Skip to content

feat!: close inline objects by default - #8

Merged
maxholman merged 10 commits into
block65:masterfrom
maxholman:feat/close-objects-by-default
Sep 23, 2026
Merged

maxholman merged 10 commits into
block65:masterfrom
maxholman:feat/close-objects-by-default

Conversation

@maxholman

@maxholman maxholman Bot commented Sep 23, 2026

Copy link
Copy Markdown

Breaking change: this ships as 7.0.0. Stricter validation makes some requests and responses fail that pass today.

Stacked on the modernisation PR (chore/modernise). Until that merges, this PR's diff also shows its commits. Only the last commit, feat!: close inline objects that list their properties, belongs to this PR.

Why

In OAS 3.1, Schema Objects are JSON Schema 2020-12. There, leaving out additionalProperties "has the same assertion behavior as an empty schema", so any extra key passes. A request body declared as a oneOf of inline objects therefore accepts keys the API never documented, and a handler that forwards the validated body passes them on. Schema.synth() already closed a named schema's top-level object, but no deeper. This makes the construct block mass assignment (overposting) at every level by default.

What changes

Schema.synth() adds additionalProperties: false to every inline object schema that declares properties and sets none of additionalProperties, patternProperties or unevaluatedProperties. The walk recurses under:

  • properties
  • items
  • prefixItems
  • oneOf and anyOf
  • additionalProperties, when it is a schema

These are left as written:

  • Maps (additionalProperties: { type: string }) and free-form { type: object } with no properties.
  • allOf members. The walk does not enter them, because closed members reject each other's keys and break composition.
  • $ref objects. A referenced component is closed, or not, by its own Schema.
  • Explicit values. An explicit additionalProperties, patternProperties or unevaluatedProperties always wins.

The schema passed in is not modified. Closing works on copies, so reusing schema1.schema inside another schema's allOf still sees the original.

For review

  1. Inconsistency at the root, kept on purpose. The existing top-level default closes any type: "object", even one with no properties. Nested free-form objects stay open, so a free-form object is open inline and closed as a named schema. The root now also closes when it declares properties without type: "object", for example type: ["object", "null"], which the old check missed.
  2. The response side. The same schemas describe responses. There, a client that validates with the spec rejects a field the server adds later. The top-level default already did this for named schemas. The recursion extends it to every inline object nested inside a schema: object properties, array items, tuple members and oneOf/anyOf branches, including map values. Nested objects reached by $ref are unaffected, since their own top-level default already closed them. After this change, adding a field to any inline response object is breaking for strict clients. To keep a response object open, set additionalProperties: true on it, or model it as a $ref whose root sets additionalProperties: true.

Tests

__tests__/close-objects.test.ts covers:

  • each recursion site
  • several levels of nesting
  • the map, free-form and allOf cases
  • explicit additionalProperties / patternProperties / unevaluatedProperties
  • a $ref passthrough
  • the root default on a free-form object
  • that the input is not modified

The existing fixture snapshots don't change, because their nested objects are already closed or referenced.

just check passes: typecheck, lint (0 diagnostics), fmt-check, tests and fallow.

maxholman and others added 10 commits September 23, 2026 17:06
Takes the layout and tooling from rest-client: @block65/shared-config 0.5.0
for the oxlint, oxfmt and fallow configs, @block65/tsconfig for the compiler
options, a justfile in place of the Makefile, and CI that runs the gate
through just. deploy.yml stages the publish for review with pnpm stage.

Every dependency moves to its latest release. yaml, type-fest and
json-schema-to-typescript are dropped, since nothing imports them. vite 8 is
pinned because vitest 5 otherwise resolves vite 7, which fails on the null
entries the shared tsconfig uses.

Co-Authored-By: LLM <noreply@maxholman.dev>
Co-Authored-By: LLM <noreply@maxholman.dev>
Co-Authored-By: LLM <noreply@maxholman.dev>
No behaviour change: the snapshots of both fixture APIs are untouched.
ApiLowLevel.ts becomes api-low-level.ts for filename-case, data-only
interfaces become type aliases, and the eslint disables either go, being
unused, or become oxlint disables with a reason. explode is compared with
undefined rather than loosely with null, which its boolean type already
limits it to.

Co-Authored-By: LLM <noreply@maxholman.dev>
The commented-out fixtures go, and the regression test asserts that the
allOf schema constructs without throwing, which it only implied before.

Co-Authored-By: LLM <noreply@maxholman.dev>
Constructs are found through node.children and a type guard, so synth and
friends read as unused. The Header and Parameter synth() clone stays, since
the two emit explode and required differently.

Co-Authored-By: LLM <noreply@maxholman.dev>
Api builds on RootConstruct, which types the scopeless root, so the
undefined as any goes. ApiLowLevel.of finds the root through node.root and
narrows it with instanceof against the class it is called on, so Api.of
still returns an Api and throws a TypeError where the root is something
else. Operation.synth spreads each optional field in like the other
constructs, which retires stripUndefined and its cast. InferExample falls
back to unknown.

In the tests, the negative parameter cases use expectTypeOf().not, and
swagger-parser validates the emitted JSON from a file, since the openapi3-ts
and openapi-types document types disagree on server variable defaults.

Co-Authored-By: LLM <noreply@maxholman.dev>
…arser

@apidevtools/swagger-parser goes. ajv's 2020 validator checks each
synthesised document against the vendored OpenAPI 3.1 schema, and a document
with an unknown top-level key must fail it. ajv mis-resolves the schema's
$dynamicRef to #meta, so the four of them are patched to a $ref to
$defs/schema, which holds the only meta anchor. The $comment records it.

Co-Authored-By: LLM <noreply@maxholman.dev>
The OpenAPI documents are validated against the official 3.1 schema-base,
which also checks every Schema Object against the OpenAPI dialect, so the
vendored schema and its $dynamicRef patch go. The JSON Schemas are validated
against the dialect their $schema declares. Each suite also checks that an
invalid document is rejected. ajv is no longer a devDependency.

Co-Authored-By: LLM <noreply@maxholman.dev>
JSON Schema lets any key through an object that does not rule it out, so an
inline object with properties, and none of additionalProperties,
patternProperties or unevaluatedProperties, now gets additionalProperties
false. The walk goes through properties, items, prefixItems, oneOf, anyOf
and a schema-valued additionalProperties. allOf members are left open, since
closed members reject each other's keys, and $ref objects are left as
written. Maps and free-form objects stay open.

BREAKING CHANGE: a request or response carrying a key that a nested inline
object does not list now fails validation.

Co-Authored-By: LLM <noreply@maxholman.dev>
@maxholman
maxholman force-pushed the feat/close-objects-by-default branch from 4b91de1 to 39a648a Compare September 23, 2026 09:28
@maxholman
maxholman merged commit 1f8556e into block65:master Sep 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant