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
113 changes: 85 additions & 28 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func conformanceCases() []conformanceCase {
{"empty-names", assertEmptyNames, []string{"wire-name-distinct"}},
{"inline-types", assertInlineTypes, []string{"inline-anonymous"}},
{"component-reuse", assertComponentReuse, []string{"named-objects", "inline-anonymous"}},
{"shared-response-across-status", assertSharedResponseAcrossStatus, []string{"named-objects", "inline-anonymous"}},
{"allof-inheritance", assertAllOfInheritance, []string{"inheritance"}},
{"allof-mixins", assertAllOfMixins, []string{"intersection"}},
{"allof-inline-merge", assertAllOfInlineMerge, []string{"intersection"}},
Expand Down Expand Up @@ -650,6 +651,35 @@ func inlinePropTarget(t *testing.T, doc *ir.Document, id ir.TypeID, wire string)
// declared once under components and referenced from many operations. Each
// lowers at its declaration, so the shared node is interned once however many
// operations reach it, while the operations that reach it stay distinct.

// assertSharedResponseAcrossStatus reads the one shape that puts lowerResponse
// and lowerErrorCase on the same declaration: a components/responses entry
// mounted at both a success and an error status. Both intern the body type at
// the component's own pointer, so the two mints race for it and the loser's
// naming hint is discarded — the type came out hinted "response" or "error"
// depending on which status was written first, which the order-invariance oracle
// reports as an order-dependent registry.
//
// Nothing else in the corpus reaches one response component from both sides of
// that boundary (component-reuse.yaml mounts Listed only at 200s and Failure
// only at default), so without this spec the oracle never asks. The hint is now
// derived from the declaration pointer, which is one pointer whichever side
// reaches it first.
func assertSharedResponseAcrossStatus(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
op := operationAt(t, doc, "GET", "/widgets")
require.Len(t, op.Responses, 1, "the success mount")
require.Len(t, op.Errors, 1, "and the error mount, of the one component")

success := op.Responses[0].Payload.Contents[0].Type.Target
failure := op.Errors[0].Payload.Contents[0].Type.Target
assert.Equal(t, success, failure, "one declaration is one type, reached from either status")

td, ok := doc.Types[success]
require.True(t, ok)
assert.Equal(t, "envelope", td.Common().Name.Hint,
"the hint comes from the declaration, not from whichever status class minted it first")
}

func assertComponentReuse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
widgets := operationAt(t, doc, "GET", "/widgets")
gadgets := operationAt(t, doc, "GET", "/gadgets")
Expand Down Expand Up @@ -2448,50 +2478,77 @@ func assertPerStatusErrors(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
op, ok := opByName(doc, "getWidgets")
require.True(t, ok)
require.Len(t, op.Responses, 1, "the 2xx success response")
faults := map[string]ir.StatusRange{}
faults := map[ir.StatusRange]string{}
byRange := map[ir.StatusRange]ir.ErrorCase{}
var sawDefault bool
for _, ec := range op.Errors {
require.Len(t, ec.Conditions.StatusCodes, 1)
rng := ec.Conditions.StatusCodes[0]
byRange[rng] = ec
if rng.From == 0 && rng.To == 0 {
sawDefault = true
assert.Empty(t, ec.Fault, "the default catch-all is unclassified")
continue
}
faults[ec.Fault] = rng
faults[rng] = ec.Fault
}
assert.Equal(t, ir.StatusRange{From: 404, To: 404}, faults["client"])
assert.Equal(t, ir.StatusRange{From: 500, To: 599}, faults["server"])
assert.True(t, sawDefault, "the default response becomes a catch-all error case")
assert.Equal(t, map[ir.StatusRange]string{
{From: 404, To: 404}: "client",
{From: 429, To: 429}: "client",
{From: 500, To: 599}: "server",
{}: "",
}, faults, "each range classified from its own status; the default catch-all unclassified")

assertErrorMediaTypeKept(t, byRange)
assertErrorCaseIsAResponse(t, byRange)
}

// assertErrorMediaTypeKept covers what ir.ErrorCase cannot say. It holds one
// TypeRef and no media type, so an error declared as application/problem+json
// reached the IR indistinguishable from one declared as application/json — the
// single-entry half of a gap whose multi-entry half was already kept, which is
// why it read as a deliberate asymmetry rather than a loss (GitHub #39). Both
// halves are now the same rule.
// assertErrorCaseIsAResponse holds the three fields ir.ErrorCase gained in
// GitHub #422 to the same claim ir.Response already carried: every status
// spelling, every header and every media type survives, whatever the status
// class. Before them an error case held one bare TypeRef, so a 429 lost its
// Retry-After outright, an error declaring two media types kept the first schema
// and no media-type key at all, and "5XX" and "default" were told apart only by
// ranges that render {500,599} and {0,0}.
//
// The 5XX case is the control: an error response with no content at all keeps
// nothing, so the entry marks a declaration rather than appearing on every error.
func assertErrorMediaTypeKept(t *testing.T, byRange map[ir.StatusRange]ir.ErrorCase) {
// The 5XX case doubles as the control: an error response declaring no headers
// and no content gets neither, so what the other two carry marks a declaration
// rather than appearing on every error case.
func assertErrorCaseIsAResponse(t *testing.T, byRange map[ir.StatusRange]ir.ErrorCase) {
t.Helper()
hints := map[ir.StatusRange]string{}
for rng, ec := range byRange {
hints[rng] = ec.Name.Hint
}
assert.Equal(t, map[ir.StatusRange]string{
{From: 404, To: 404}: "404",
{From: 429, To: 429}: "429",
{From: 500, To: 599}: "5_xx",
{}: "default",
}, hints, "the key as declared, then neutralized; only \"default\" round-trips unchanged")

notFound, ok := byRange[ir.StatusRange{From: 404, To: 404}]
require.True(t, ok)
entry, ok := notFound.Unmodeled["openapi:content"]
require.True(t, ok, "a single-media error keeps the map that names its media type")
assert.Equal(t, ir.ReasonNoIRHome, entry.Reason)
assert.JSONEq(t,
`{"application/json":{"schema":{"$ref":"#/components/schemas/Err"}}}`, string(entry.Value))
require.NotNil(t, notFound.Payload)
require.Len(t, notFound.Payload.Contents, 1)
assert.Equal(t, "application/json", notFound.Payload.Contents[0].MediaType,
"a single-media error keeps the key it was written under")

throttled, ok := byRange[ir.StatusRange{From: 429, To: 429}]
require.True(t, ok)
require.NotNil(t, throttled.Payload)
require.Len(t, throttled.Payload.Contents, 2, "every media type is kept, none elected")
assert.Equal(t, "t/openapi/components/schemas/Problem",
string(throttled.Payload.Contents[1].Type.Target),
"the second media type keeps its own schema rather than the first's")
wire := make([]string, 0, len(throttled.Headers))
for _, h := range throttled.Headers {
wire = append(wire, h.WireName)
}
assert.Equal(t, []string{"Retry-After", "X-RateLimit-Remaining"}, wire,
"the headers that only ever appear on an error status are structural")

serverErr, ok := byRange[ir.StatusRange{From: 500, To: 599}]
require.True(t, ok)
assert.NotContains(t, serverErr.Unmodeled, "openapi:content",
"an error response declaring no content keeps no content map")
assert.Nil(t, serverErr.Payload, "an error response declaring no content gets no payload")
assert.Empty(t, serverErr.Headers, "an error response declaring no headers gets none")
for rng, ec := range byRange {
assert.NotContains(t, ec.Unmodeled, "openapi:content", "%v keeps no content map", rng)
assert.NotContains(t, ec.Unmodeled, "openapi:headers", "%v keeps no headers map", rng)
}
}

func assertWebhooks(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
Expand Down
8 changes: 8 additions & 0 deletions compilers/openapi/internal/diag/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ const (
// response still lowers, with no status condition rather than the catch-all
// range that "default" alone denotes (GitHub #262).
InvalidStatusKey = "openapi/invalid-status-key"
// DuplicateStatusKey reports two responses-map keys on one operation that
// resolve to the same status range — "4XX" beside "4xx", or "200" beside a
// second "200" a merge key introduced. The key reaches the IR neutralized, so
// both lower to one hint and one condition, and an ErrorCase carries no ID:
// name and conditions are the whole of what tells two apart. Both are kept,
// because neither key is wrong on its own and dropping one would pick a winner
// on nothing but declaration order.
DuplicateStatusKey = "openapi/duplicate-status-key"
// InvalidMethodKey reports an additionalOperations key that names no method:
// the empty string. The operation still lowers, binding the key as written, so
// nothing the entry declares is lost — what is reported is that the binding's
Expand Down
1 change: 1 addition & 0 deletions compilers/openapi/internal/diag/diag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func codes() []string {
diag.OverlayAction, diag.OverlayOriginIncomplete,
diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum,
diag.NumericPrecision, diag.ExclusiveBoundForm, diag.InvalidStatusKey,
diag.DuplicateStatusKey,
diag.InvalidMethodKey, diag.DegradedConstruct,
diag.CompositionLowering, diag.DynamicRefExpanded, diag.ConflictingRedecl,
diag.DisjointVisibility,
Expand Down
21 changes: 10 additions & 11 deletions compilers/openapi/internal/operation/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,8 @@ func TestContent_FullPipeline(t *testing.T) {
_, hasLinks := resp.Unmodeled["openapi:links"]
assert.True(t, hasLinks)

assert.True(t, openapitest.HasDiag(diags, diag.DegradedConstruct))
assert.False(t, openapitest.HasDiag(diags, diag.DegradedConstruct),
"every construct this spec declares has a typed home; got %+v", diags)
}

func TestContent_OctetAndErrorMulti(t *testing.T) {
Expand All @@ -488,16 +489,14 @@ func TestContent_OctetAndErrorMulti(t *testing.T) {
require.NotNil(t, raw.Request)
require.NotEmpty(t, raw.Request.Contents)
assert.NotNil(t, raw.Request.Contents[0].File)
// Its 400 error has two media types → content preserved raw.
require.NotEmpty(t, raw.Errors)
var multi ir.ErrorCase
for _, ec := range raw.Errors {
if len(ec.Unmodeled) > 0 {
multi = ec
}
}
_, hasContent := multi.Unmodeled["openapi:content"]
assert.True(t, hasContent, "multi-media error content preserved")
// Its 400 error declares two media types, and both are Contents (#422).
require.Len(t, raw.Errors, 1)
payload := raw.Errors[0].Payload
require.NotNil(t, payload)
require.Len(t, payload.Contents, 2, "neither media type is elected over the other")
assert.Equal(t, []string{"application/json", "application/problem+json"},
[]string{payload.Contents[0].MediaType, payload.Contents[1].MediaType})
assert.Empty(t, raw.Errors[0].Unmodeled, "nothing is kept verbatim beside the typed payload")
}

func TestContent_SequentialAndEmptyBody(t *testing.T) {
Expand Down
Loading
Loading