Skip to content

feat(compilers/openapi): populate the operation streaming fields - #352

Merged
OmarAlJarrah merged 4 commits into
mainfrom
feat/openapi-streaming-media
Aug 11, 2026
Merged

feat(compilers/openapi): populate the operation streaming fields#352
OmarAlJarrah merged 4 commits into
mainfrom
feat/openapi-streaming-media

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

Operation.Streaming, Operation.RequestStream and Operation.ResponseStream were written by no
compiler at any version. An SSE or NDJSON API declares what it does by naming a media type, and the
media type was copied to Content.MediaType and never read; a 3.2 itemSchema set Content.Item
and stopped there. Compiling either shape produced streaming: null with no diagnostic, so nothing
downstream could tell a stream from an ordinary body.

This adds the reading, in two halves that are deliberately not the same kind of fact:

  • A 3.2 itemSchema declares the stream. The keyword states that the body is a sequence of
    items, so the operation streams and the declared item schema is the element type. Nothing is
    guessed, so no heuristic marker is stamped.
  • A media type implies one. text/event-stream, application/x-ndjson and application/jsonl
    are read as frame formats through a new Options.StreamingMedia policy — default on, replaceable,
    and switchable off — with Provenance.Inferred naming the heuristic. That is architecture
    principle 6: the list is a default, not a standard, and a caller who wants only what a document
    declares turns it off. Matching ignores case and media-type parameters.

For a frame format the schema under the media type describes one frame rather than the whole body,
which is the opposite of how an ordinary content is read; StreamDetail.Events is what says so.
Where a direction offers several streaming contents the direction still streams but the element type
is left unnamed, with an info diagnostic: StreamDetail holds one Events per direction while the
payload keeps every media type, so electing one of them would be the primary-content selection a
compiler must not make.

Two notes on the issue as filed. Its acceptance item about an itemSchema "not double-applying"
assumed an existing 3.2 writer — there was none, so the gap was wider than reported rather than
narrower, and the two paths here cannot both fire because a declaration at a content wins over the
reading of its media type. And Provenance.Inferred holds a single string that path-prefix grouping
already used, so an operation reached by both heuristics now lists both rather than losing one.

Merge ordering: #345 also rewrites testdata/conformance/openapi/unwitnessed.golden.txt, and #341
adds DecodeOptions to the compiler contract. Whichever of the three lands first, the others rebase;
once #341 is in, StreamingMedia should gain textual option names there so the CLI's -opt path can
reach it.

Test plan

  • New corpus rows streaming-media-30 and streaming-media-31 cover a 3.0 media-type-only stream, a
    bidirectional operation, a charset parameter on the media type, and the several-contents refusal.
  • unwitnessed.golden.txt loses five entries — Operation.Streaming, Operation.RequestStream,
    Operation.ResponseStream, StreamDetail.Events and Provenance.Inferred, the last of which no
    corpus spec witnessed at all before.
  • sequential-media now records the stream its itemSchema declares; its assertion pins that it is
    declared rather than inferred.
  • A two-order test compiles the two streaming media types either way round and requires the streaming
    fields to agree, which is what an implementation electing the first candidate would fail.
  • Policy tests cover the off switch, a caller's list replacing the defaults rather than extending
    them, and the exported default list being the one the lowering applies.
  • Full gate: gofmt, vet, golangci-lint, build, and the 100% coverage gate.

Closes #250

lowering.New gained a parameter on each side — the budgets on main (#349), the
streaming media policy here — so the merged signature takes both, and the field
comment that called Grouping "one of the two" caller policies no longer counts
them: it names the other two instead, since a count in prose is what just went
stale.

The conformance table gained its capability column on main (#342), so both
streaming rows come back with the matrix rows they witness.

streaming-media-31 witnesses more than it may claim. Its NDJSON request body
lowers to a populated RequestStream and the golden reads streaming=bidi, but
ir-spec-matrix.md marks streaming-client absent for OpenAPI, and the matrix
contract refuses a witness for a row a format is said not to reach. The row
claims streaming-server alone, which is true and checkable; whether the matrix
should now say OpenAPI reaches client streaming is a question about that
document rather than about this merge.
Reading a request body's media type as a frame format populates
Operation.RequestStream, so an NDJSON body now lowers to streaming: bidi. The
matrix still said OpenAPI reaches client streaming in no way at all, which the
conformance contract enforces from the other side: it refused the fixture that
witnesses it, because a format marked absent for a row may have no witness.

The cell reads ⚠ rather than ✅, which is the legend's "expressible
indirectly": nothing in the document declares the stream. It is read from a
media type, stamped Provenance.Inferred, and a caller who turns the policy off
loses the capability entirely — none of which ✅ would convey.

The two now hold each other up. Reverting the cell and keeping the claim fails
with the row marked absent yet witnessed; keeping the cell and dropping the
claim fails with the row unwitnessed. Neither can drift without the other
saying so.
classifyStream refused to name an element type whenever a direction had more
than one streaming content, without ever comparing them. A response offering
one frame as both text/event-stream and application/x-ndjson — content
negotiation, and the commonest streaming shape there is — therefore reached the
IR with ResponseStream.Events unset, although both contents lowered to the same
TypeID. Two success responses sharing one media type and one schema did the
same.

The refusal is right where the contents disagree: StreamDetail holds one Events
per direction, so naming one of two differing elements would be the
primary-content selection invariant 2 forbids. It does not reach a set that
names one element between them — there is nothing to elect, and leaving it
unnamed says less than the source did rather than declining to choose. The
candidates are compared now; ir.TypeRef is a TypeID and a nullability bit, so
== is the whole of what agreement means.

The diagnostic counted media types, which was wrong twice over: it fired for
contents that agreed, and called one media type on two responses several. It
names the disagreement instead.

streaming-media-31 gains the negotiated shape beside the one it already had, so
the corpus holds both sides of the rule: reverting to the count reddens the new
operation and leaves the refusal green.
@OmarAlJarrah
OmarAlJarrah merged commit b8d53fa into main Aug 11, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the feat/openapi-streaming-media branch August 11, 2026 08:33
OmarAlJarrah added a commit that referenced this pull request Aug 11, 2026
lowering.New now takes three caller policies: the budgets and the streaming
media list arrived on main (#349, #352), the promotion mapping here. The field
comment naming Grouping "one of the two" no longer counts them, and both policy
types had called themselves "the second injectable-policy seam" — with Grouping
there are three, so neither is second.

Two lowerings this branch hooked into were refactored under it. An operation's
extensions are gathered by applyOperationExtensions now, and a security
scheme's by applySchemeExtensions, so the promotion runs after each rather than
beside the inline reads it used to follow — it reads the assembled map, so it
has to.

The conformance table gained its capability column on main (#342);
extension-promotion names the deprecation row, whose members are the fields it
fills. It does not name vendor-extensions: extensions-x witnesses that already,
and the x-* here are the means rather than the subject.
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.

openapi: SSE and NDJSON streaming is invisible below OpenAPI 3.2

1 participant