Skip to content

fix(compilers/openapi): keep a path item's undeclared keys - #382

Open
OmarAlJarrah wants to merge 3 commits into
feat/openapi-census-remaining-carriersfrom
fix/openapi-path-item-unknown-keys
Open

fix(compilers/openapi): keep a path item's undeclared keys#382
OmarAlJarrah wants to merge 3 commits into
feat/openapi-census-remaining-carriersfrom
fix/openapi-path-item-unknown-keys

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Stacked. Base is feat/openapi-census-remaining-carriers (#378), which is itself
based on fix/openapi-extensions-everywhere (#345) and carries a merge of
fix/openapi-unknown-key-census (#356). The undeclared-key census this extends
lives on #356/#378, and the deferral note this rewrites lives on #378, so merge
order is #345#356#378 → this. The diff here is the one commit on top of
#378; nothing else in it is mine.

Summary

Every OpenAPI object the compiler reads records the keys the specification does not
define for it, kept verbatim under Unmodeled and announced at warning. A Path Item
Object was the one exception, and the reason was upstream: the unmarshaller folds a
key it does not recognize into the item's embedded operations map rather than
recording it as undeclared, so GetUnknownProperties is empty however much the
document wrote (speakeasy-api/openapi v1.24.0).

The value written under such a key reached the IR in no form:

paths:
  /x:
    bogusPathItem: 1
    get: {operationId: getX, responses: {"200": {description: ok}}}

Before, that compiled to one diagnostic — the type mismatch the fold produces when a
scalar is unmarshalled as an Operation — and no Unmodeled entry, so the 1 was
gone. The quieter half is worse: give the key a well-formed operation as its value
and the fold succeeds, so there was no finding at all and two documents differing
only in that key compiled to the same IR.

This reads what is left of the operations map once the HTTP methods are taken out.
Everything else a path item may write — summary, description, servers,
parameters, additionalOperations, x-* — is a field of the library's model, and
a $ref is consumed by the reference wrapper around it, so nothing else reaches that
map. The value comes off the raw node, the shape dependentRequired already uses.

The predicate is the library's own IsStandardMethod, not this compiler's
httpMethods. The two answer different questions: httpMethods says what the
compiler lowers, while this asks only whether a key names a method at all. Reading
the map against httpMethods would report a valid OpenAPI 3.2 query operation as an
undeclared key, because the compiler does not lower that method yet — whether it
should is a separate question, and this leaves it exactly where it stands, along with
additionalOperations, which is a declared field and so never censused.

Entries land on every operation of the item, like its servers and its x-*, under
the key spelling (openapi:pathItem/<key>), reason (out_of_scope) and severity
(warning, openapi/unknown-object-key) the census already uses. All three routes that
lower a path item — a path, a webhook, a callback expression — reach it through the
one entry point that already applies the other two.

Refactoring, for the record: census now takes the key list and the mapping node
instead of a model, so a caller that has its own keys can share the grading rather
than restate it; the sort moved with it, since neither source of keys hands over
document order. UnknownKeysUnder is unchanged from the outside.

Test plan

gofmt, go vet, golangci-lint run (0 issues), go build ./... and
./scripts/check-coverage.sh all pass.

Compiled and inspected rather than read:

  • The reproducer above now keeps openapi:pathItem/bogusPathItem = 1 on getX,
    reason out_of_scope, pointer /paths/~1x/bogusPathItem, with a warning
    openapi/unknown-object-key at that pointer. The pre-existing type-mismatch error
    is untouched: the diagnostic list goes from one entry to two.
  • The map-valued case (bogusPathItem: {operationId: nope, …} plus an uppercase
    GET:) went from zero diagnostics and zero entries to both keys kept whole
    with a warning each.
  • The 3.2 document with get and query side by side compiles with zero
    diagnostics and no openapi:pathItem/ entry — query is not reported.
  • A document exercising $ref, summary, description, servers, parameters and
    additionalOperations reports no undeclared key either, and the $ref'd item's
    operation still lowers, so the census demonstrably ran on it.

Tests, each confirmed to fail when the behaviour is removed:

  • TestUnknownKeys_PathItemKeyKeepsItsValue — the reproducer verbatim.
  • TestUnknownKeys_PathItemDeclaredFieldsAreNotUndeclared — the control, including
    query. Swapping IsStandardMethod for a scan of httpMethods turns it red with
    key "query" is not defined by the OpenAPI object it is written on, which is the
    whole reason the library's vocabulary is the one being read.
  • TestOperations_PathItemUnknownKeyKeptOnEveryRoute — path, webhook and callback,
    each with its own marker value. Restricting the census to the paths walk reddens
    the other two rows.
  • testdata/openapi/unknown_keys.yaml gains a path-item row (an uppercase GET, the
    silent case), so it runs through the corpus oracles — no-panic, no error diagnostic,
    irverify, round-trip, determinism and the two-order diff. Deleting the line from
    the fixture reddens TestUnknownKeys_KeptAtEveryObject, and disabling the census at
    the path item reddens it too.

Closes #377

Follow-up in this branch: an item that mounts no operation

Reviewing the census's reach turned up the one path item shape it could not
cover. applyPathItem runs once per operation an item produces, so an item
producing none reached it through nothing — and lost its servers, its
extensions and its undeclared keys whole, with no diagnostic naming the loss.

paths:
  /no-op:
    servers: [{url: 'https://a.example'}]
    x-vendor: {b: 2}
    bogusKey: {c: 3}

Before: no Unmodeled entry anywhere, and the only diagnostic was the library's
type mismatch about the fold. After: all three kept on the service, plus a
warning saying why they are not on an operation.

An item mounts no operation when it declares none, when every method it declares
is one this compiler does not lower yet (#293), or when the only keys it holds
are ones the Path Item Object does not define.

Where they go. The service, which is where the Paths Object's own extensions
already go, for exactly the reason stated in LowerService: a path item lowers
to no node, so the nearest node holding an Unmodeled map is what holds them.
The key carries the item's own pointer (openapi:pathItem/paths/~1no-op/x-vendor)
because one service holds every such item and a bare prefix would let two collide,
with the survivor decided by iteration order. The operation carrier's keys are
unchanged, openapi:servers included.

Both loops that mount operations off a path item are swept — paths and webhooks.

Stated limit: a path item's summary and description are dropped here, as
they are dropped on a mounted item too (verified: neither survives anywhere
today). That is a separate, pre-existing gap; the warning names servers,
extensions and undeclared keys only, and does not claim otherwise.

Bite proof: removing both orphan branches reddens
TestUnknownKeys_PathItemWithNoOperationKeepsWhatItWrote on all four keys and
the announcement count; removing only the webhook branch reddens exactly the
webhook key and the count. TestPathItem_WithNoOperationAndNothingToKeepIsSilent
holds the other half — an item with nothing beside its operations announces
nothing.

Every OpenAPI object the compiler reads records the keys the specification
does not define for it, kept verbatim under Unmodeled and announced at
warning. A Path Item Object was the one exception, because the library
takes no census for it: the unmarshaller folds a key it does not recognize
into the item's embedded operations map rather than recording it as
undeclared, so GetUnknownProperties is empty however much the document
wrote.

The value written under such a key reached the IR in no form. For a scalar
the key was at least named, by the type mismatch the fold produces when
something that is not an operation object is unmarshalled as one; for a
well-formed operation under an undefined key there was no finding at all,
and two documents differing only in it compiled to the same IR.

Read what is left of the operations map once the HTTP methods are taken
out. Everything else a path item may write — summary, description, servers,
parameters, additionalOperations, x-* — is a field of the library's model,
and a $ref is consumed by the reference wrapper, so nothing else reaches
that map. The predicate is the library's own IsStandardMethod rather than
this compiler's httpMethods: the two answer different questions, and
reading the map against httpMethods would report a valid OpenAPI 3.2
`query` operation as an undeclared key, since the compiler does not lower
that method yet. Whether it should is a separate question and stays open.

Entries land on every operation of the item, like its servers and its x-*,
under the same key spelling, reason and severity the census already uses.
All three routes that lower a path item — a path, a webhook, a callback
expression — reach it through the one entry point that already applies the
other two.
applyPathItem runs once per operation an item produces, so an item that
produces none reached it through nothing. Its servers, its extensions
and its undeclared keys were dropped whole, and no diagnostic named the
loss -- the census added above included, since it had no carrier to
write to.

An item produces no operation when it declares none, when every method
it declares is one this compiler does not lower yet (#293), or when the
only keys it holds are ones the Path Item Object does not define.

They go on the service, which is where the Paths Object's own extensions
already go for the same reason: a path item lowers to no node, so the
nearest node holding an Unmodeled map is what holds them. The key
carries the item's own pointer, because one service holds every such
item and a bare prefix would let two collide -- the survivor decided by
iteration order. A warning says why they are there rather than on an
operation.

Both loops that mount operations off a path item are swept, paths and
webhooks. What is kept is what applyPathItem keeps anywhere; a path
item's summary and description are dropped here as they are dropped on a
mounted item, and the message does not claim otherwise.
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