Repository navigation
feat!: close inline objects by default - #8
Merged
maxholman merged 10 commits intoSep 23, 2026
Merged
Conversation
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
force-pushed
the
feat/close-objects-by-default
branch
from
September 23, 2026 09:28
4b91de1 to
39a648a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aoneOfof 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()addsadditionalProperties: falseto every inline object schema that declarespropertiesand sets none ofadditionalProperties,patternPropertiesorunevaluatedProperties. The walk recurses under:propertiesitemsprefixItemsoneOfandanyOfadditionalProperties, when it is a schemaThese are left as written:
additionalProperties: { type: string }) and free-form{ type: object }with noproperties.allOfmembers. The walk does not enter them, because closed members reject each other's keys and break composition.$refobjects. A referenced component is closed, or not, by its ownSchema.additionalProperties,patternPropertiesorunevaluatedPropertiesalways wins.The schema passed in is not modified. Closing works on copies, so reusing
schema1.schemainside another schema'sallOfstill sees the original.For review
type: "object", even one with noproperties. 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 declarespropertieswithouttype: "object", for exampletype: ["object", "null"], which the old check missed.$refare 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, setadditionalProperties: trueon it, or model it as a$refwhose root setsadditionalProperties: true.Tests
__tests__/close-objects.test.tscovers:allOfcasesadditionalProperties/patternProperties/unevaluatedProperties$refpassthroughThe existing fixture snapshots don't change, because their nested objects are already closed or referenced.
just checkpasses: typecheck, lint (0 diagnostics), fmt-check, tests and fallow.