diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 327a768a..04698533 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -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"}}, @@ -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") @@ -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) { diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index a0374270..1c8d1975 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -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 diff --git a/compilers/openapi/internal/diag/diag_test.go b/compilers/openapi/internal/diag/diag_test.go index 5697c2bd..4fd966ac 100644 --- a/compilers/openapi/internal/diag/diag_test.go +++ b/compilers/openapi/internal/diag/diag_test.go @@ -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, diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 19ffc157..9cf76f9a 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -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) { @@ -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) { diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index 3da6a03f..7ca7e2ed 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -773,6 +773,10 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd var responses []ir.Response var errs []ir.ErrorCase var diags []ir.Diagnostic + // Keys already read, by the range each resolved to: the map is keyed by the + // spelling, so two spellings of one range are two entries here and one + // condition in the IR. + seen := map[ir.StatusRange]string{} for code, rr := range resps.All() { r, rptr := resolve.ObjectAt[soa.Response](c.RefScope(), rr, opDeclPtr+ids.Ptr("responses", code)) if r == nil { @@ -781,14 +785,19 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd rng, named := statusRange(code) if !named { diags = append(diags, invalidStatusKeyDiag(c, code, rptr)) + } else if first, dup := seen[rng]; dup { + diags = append(diags, duplicateStatusKeyDiag(c, first, code, rptr)) + } else { + seen[rng] = code } // An unreadable key always takes the else branch, because statusRange pairs // a false with the zero range and that is no error range. It has to: an // ErrorCase would carry a fault classified from a range nothing derived, - // and it holds no naming to record the key under either. + // and its Conditions would assert a status the key never named — where + // statusConditions lets the success side record no status at all. // TestStatusRange_NamesNoStatus is what pins the pairing. if isErrorRange(rng) { - ec, ecDiags := lowerErrorCase(c, ts, anchors, r, rng, rptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, r, code, rng, rptr) diags = append(diags, ecDiags...) errs = append(errs, ec) } else { @@ -799,7 +808,7 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd } def, dptr := resolve.ObjectAt[soa.Response](c.RefScope(), resps.GetDefault(), opDeclPtr+ids.Ptr("responses", defaultResponseKey)) if def != nil { - ec, ecDiags := lowerErrorCase(c, ts, anchors, def, ir.StatusRange{}, dptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, def, defaultResponseKey, ir.StatusRange{}, dptr) diags = append(diags, ecDiags...) errs = append(errs, ec) } @@ -813,7 +822,7 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd // no status (see statusConditions). func lowerResponse(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, conds ir.ResponseConditions, rptr string) (ir.Response, []ir.Diagnostic) { headers, diags := lowerHeaders(c, ts, anchors, r.GetHeaders(), rptr) - payload, payloadDiags := lowerPayload(c, ts, anchors, r.GetContent(), rptr, "response") + payload, payloadDiags := lowerPayload(c, ts, anchors, r.GetContent(), rptr, ids.DeclarationHint(rptr, "response")) diags = append(diags, payloadDiags...) resp := ir.Response{ Name: responseName(code), @@ -868,98 +877,52 @@ func preserveResponseExtras(c lowering.Ctx, p *ir.Unmodeled, r *soa.Response, rp // Source stays empty even for a $ref'd response: §7.2 fills it only "for formats // with named outputs", and a components/responses key names a reusable // definition rather than this mount of it — the same component reached at two -// status codes is two responses, told apart by condition. ErrorCase carries no -// Naming at all and so has no counterpart here, and would be held by the -// presence rule at once if it gained one, since irverify does not exempt it. +// status codes is two responses, told apart by condition. +// +// The error side names itself through this same function rather than one of its +// own. ErrorCase.Name is Response.Name (GitHub #422), so a hint derived +// differently on the two sides would make the spelling depend on the status +// class — the asymmetry the field was added to end. +// +// The key reaches the IR neutralized, not as written: "5XX" becomes "5_xx" and +// only "default" survives unchanged. Source stays empty because a responses-map +// key is not a name the document declared for anything — the pairing NamingFor +// holds is for spellings an author chose. Two keys that neutralize alike are +// reported where they are read, rather than told apart here. func responseName(code string) ir.Naming { return compile.NamingHint(code) } -// lowerErrorCase lowers one error response into an ErrorCase, classifying its -// fault from the status range and lowering its error-model content. -func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, rng ir.StatusRange, rptr string) (ir.ErrorCase, []ir.Diagnostic) { +// lowerErrorCase lowers one error response into an ErrorCase: its naming, +// status condition, payload (all media types), headers, docs and fault +// classification, plus any raw links preserved for later promotion. +// +// Everything but the fault is lowerResponse's work done the same way, because +// an error response is a response (GitHub #422) — including the payload's naming +// hint, which is derived from the declaration pointer on both sides. It has to +// be: a components/responses entry mounted at a success and an error status +// interns its body once at that pointer, so a hint that differed by status class +// would be decided by whichever mount lowered first, and reversing the two keys +// would rename the type. +// +// code is the responses-map key it was declared under, which is the only record +// of how the source spelled a status its range cannot state — "4XX" and +// "default" both, though only the second reaches the IR unchanged. +func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rng ir.StatusRange, rptr string) (ir.ErrorCase, []ir.Diagnostic) { + headers, diags := lowerHeaders(c, ts, anchors, r.GetHeaders(), rptr) + payload, payloadDiags := lowerPayload(c, ts, anchors, r.GetContent(), rptr, ids.DeclarationHint(rptr, "error")) + diags = append(diags, payloadDiags...) ec := ir.ErrorCase{ + Name: responseName(code), Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{rng}}, + Payload: payload, + Headers: headers, Fault: faultFor(rng), } ec.Docs.Description = r.GetDescription() - diags := fillErrorType(c, ts, anchors, &ec, r, rptr) - diags = append(diags, preserveErrorHeaders(c, &ec, r, rptr)...) return ec, append(diags, preserveResponseExtras(c, &ec.Unmodeled, r, rptr)...) } -// preserveErrorHeaders keeps an error response's headers from being dropped: -// ir.ErrorCase has no Headers field (ir-design §7.2), so when the response -// declares headers they are kept verbatim under Unmodeled with one info -// diagnostic, mirroring the success path's structural header lowering. -func preserveErrorHeaders(c lowering.Ctx, ec *ir.ErrorCase, r *soa.Response, rptr string) []ir.Diagnostic { - headers := r.GetHeaders() - if headers == nil || headers.Len() == 0 { - return nil - } - kept, diags := schema.PreserveNode(c, &ec.Unmodeled, "openapi:headers", - annotation.RawChildNode(r.GetRootNode(), "headers"), ir.ReasonNoIRHome, rptr+ids.Ptr("headers")) - if !kept { - return diags - } - return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, rptr, - "error response headers have no ErrorCase home; kept verbatim under Unmodeled")) -} - -// fillErrorType lowers every content entry's schema into the type registry -// (nothing dropped) and points ErrorCase.Type at the first, then keeps the -// content map beside it, since ErrorCase.Type holds a single model reference -// (ir-design §7.2 clarification). -func fillErrorType(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, ec *ir.ErrorCase, r *soa.Response, rptr string) []ir.Diagnostic { - content := r.GetContent() - if content == nil || content.Len() == 0 { - return nil - } - var diags []ir.Diagnostic - first := true - for mt, media := range content.All() { - ref, refDiags := schema.Ref(c, ts, anchors, schema.TopLevelDepth, media.GetSchema(), rptr+ids.Ptr("content", mt, "schema"), "error") - diags = append(diags, refDiags...) - if first { - ec.Type = ref - first = false - } - } - return append(diags, preserveErrorContent(c, ec, r, rptr, content.Len())...) -} - -// preserveErrorContent keeps an error response's content map verbatim under -// Unmodeled, whatever its arity. -// -// ir.ErrorCase holds a TypeRef and no media type at all, so one entry loses the -// media type it was keyed by just as surely as several lose the entries past the -// first: an error declared only as application/problem+json reached the IR -// indistinguishable from one declared as application/json. Only the multi-entry -// case used to be kept, which made the single-entry loss the quieter of two -// halves of one gap rather than a different kind of thing (GitHub #39). -// -// n is the entry count, and picks which of the two the diagnostic names, so a -// reader is told what was actually lost rather than a message covering both. -func preserveErrorContent(c lowering.Ctx, ec *ir.ErrorCase, r *soa.Response, rptr string, n int) []ir.Diagnostic { - kept, diags := schema.PreserveNode(c, &ec.Unmodeled, "openapi:content", - annotation.RawChildNode(r.GetRootNode(), "content"), ir.ReasonNoIRHome, rptr+ids.Ptr("content")) - if kept { - diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, rptr, - "%s", errorContentMessage(n))) - } - return diags -} - -// errorContentMessage names which loss the kept content map stands for: entries -// past the first when there are several, and the sole media type's own key when -// there is one. -func errorContentMessage(n int) string { - if n > 1 { - return "error response has multiple media types; full content map kept under Unmodeled" - } - return "error response media type has no ErrorCase home; content map kept under Unmodeled" -} - // lowerCallbacks lowers each callback expression's path-item operations as // Operations registered in the parent's group, and binds them to the parent via // HTTPBinding.Callbacks keyed by the runtime expression (ir-design §8.1). @@ -1189,6 +1152,20 @@ func invalidStatusKeyDiag(c lowering.Ctx, code, rptr string) ir.Diagnostic { "the response is kept with no status condition", code, defaultResponseKey) } +// duplicateStatusKeyDiag reports a second responses-map key resolving to a range +// an earlier key already claimed. +// +// A warning, and both responses are kept: neither key is wrong on its own, and +// dropping one would choose a winner on declaration order — the thing every +// other tie here is written to avoid. What the caller gets told is that two of +// the entries it reads answer to one status and cannot be told apart by name or +// condition, which is otherwise only visible by counting them. +func duplicateStatusKeyDiag(c lowering.Ctx, first, code, rptr string) ir.Diagnostic { + return c.DiagAt(ir.SeverityWarning, diag.DuplicateStatusKey, rptr, + "response key %q names the status range %q already named; both are kept, "+ + "and they reach the IR with the same name and condition", code, first) +} + // isErrorRange reports whether a status range denotes an error (>= 400). func isErrorRange(r ir.StatusRange) bool { return r.From >= 400 } diff --git a/compilers/openapi/internal/operation/operations_internal_test.go b/compilers/openapi/internal/operation/operations_internal_test.go index 1baf86ba..292d7aea 100644 --- a/compilers/openapi/internal/operation/operations_internal_test.go +++ b/compilers/openapi/internal/operation/operations_internal_test.go @@ -204,18 +204,6 @@ func TestFaultFor(t *testing.T) { assert.Equal(t, "", faultFor(ir.StatusRange{})) } -func TestPreserveErrorHeaders_WithoutRootNode(t *testing.T) { - t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - headers := sequencedmap.New( - sequencedmap.NewElem("X-H", &soa.ReferencedHeader{}), - ) - ec := &ir.ErrorCase{} - diags := preserveErrorHeaders(l.ctx, ec, &soa.Response{Headers: headers}, "/r") - assert.Nil(t, ec.Unmodeled, "headers with no raw node are not preserved") - require.Empty(t, diags) -} - func TestLowerResponses_NoResponses(t *testing.T) { t.Parallel() l := newRawLowerer(&soa.OpenAPI{}) diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index d4c552b6..ee668e28 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -73,7 +73,9 @@ func TestResponses_ErrorSplitAndRanges(t *testing.T) { require.Len(t, op.Errors, 3) assert.Equal(t, []ir.StatusRange{{From: 404, To: 404}}, op.Errors[0].Conditions.StatusCodes) assert.Equal(t, "client", op.Errors[0].Fault) - assert.NotEmpty(t, op.Errors[0].Type.Target, "404 error model lowered and referenced") + require.NotNil(t, op.Errors[0].Payload) + require.Len(t, op.Errors[0].Payload.Contents, 1) + assert.NotEmpty(t, op.Errors[0].Payload.Contents[0].Type.Target, "404 error model lowered and referenced") assert.Equal(t, []ir.StatusRange{{From: 500, To: 599}}, op.Errors[1].Conditions.StatusCodes) assert.Equal(t, "server", op.Errors[1].Fault) assert.Equal(t, []ir.StatusRange{{From: 0, To: 0}}, op.Errors[2].Conditions.StatusCodes) @@ -87,8 +89,10 @@ func TestResponses_ErrorSplitAndRanges(t *testing.T) { // empty, leaving an emitter naming a per-response result type nothing to build // one from (GitHub #259). // -// The error half of the same map has no counterpart to check: ir.ErrorCase -// carries no Naming at all, so there is no channel on it to leave empty. +// The error half of the same map is named the same way and by the same +// function (GitHub #422), which is what "404" and "default" below assert: the +// spelling a wildcard or catch-all key was written under is recorded nowhere +// else, since StatusRange renders both {500,599} and {0,0} with no trace of it. func TestResponses_NamedByStatusKey(t *testing.T) { t.Parallel() spec := openapitest.PathsSpec(` /w: @@ -113,6 +117,15 @@ func TestResponses_NamedByStatusKey(t *testing.T) { } assert.Equal(t, []string{"200", "2_xx", "empty"}, hints, "the key as declared, neutralized; a key with no word in it takes the mint") + + require.Len(t, op.Errors, 2) + errHints := make([]string, 0, len(op.Errors)) + for _, ec := range op.Errors { + assert.Empty(t, ec.Name.Source, "OpenAPI declares no error name either, so Source stays empty") + errHints = append(errHints, ec.Name.Hint) + } + assert.Equal(t, []string{"404", "default"}, errHints, + "an error case records the responses-map key its range cannot state") } // TestResponses_InvalidStatusKeyIsReported is the whole of GitHub #262 at the @@ -178,10 +191,14 @@ func TestResponses_ValidStatusKeysAreNotReported(t *testing.T) { "every key here names a status; got %+v", diags) } -func TestResponses_ErrorHeadersPreserved(t *testing.T) { +// TestResponses_ErrorHeadersAreStructural pins the header half of GitHub #422. +// A 429's Retry-After and the rate-limit family live on precisely the status +// class that had no typed home for them: they were kept verbatim under +// ErrorCase.Unmodeled with an info diagnostic, so a consumer reading +// Response.Headers saw headers on a 200 and none on a 429. They are now lowered +// by lowerHeaders, exactly as the success side's are, and nothing is kept. +func TestResponses_ErrorHeadersAreStructural(t *testing.T) { t.Parallel() - // ErrorCase has no Headers field; a 429's Retry-After header must not be - // dropped silently — it is kept verbatim under Unmodeled with a diag. spec := openapitest.PathsSpec(` /w: get: operationId: w @@ -191,23 +208,28 @@ func TestResponses_ErrorHeadersPreserved(t *testing.T) { description: slow down headers: Retry-After: {schema: {type: integer}} + X-RateLimit-Remaining: {schema: {type: integer}} `) _, svc, diags := lowerServiceSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Errors, 1) - raw, ok := op.Errors[0].Unmodeled["openapi:headers"] - require.True(t, ok, "error response headers kept under Unmodeled") - assert.Contains(t, string(raw.Value), "Retry-After") - assert.Equal(t, ir.ReasonNoIRHome, raw.Reason) + ec := op.Errors[0] - found := false + require.Len(t, ec.Headers, 2, "both headers lowered structurally") + wire := make([]string, 0, len(ec.Headers)) + for _, h := range ec.Headers { + wire = append(wire, h.WireName) + assert.NotEmpty(t, h.Type.Target, "each header carries its lowered type") + } + assert.Equal(t, []string{"Retry-After", "X-RateLimit-Remaining"}, wire) + + assert.NotContains(t, ec.Unmodeled, "openapi:headers", + "a typed home means nothing is kept verbatim beside it") for _, d := range diags { - if d.Severity == ir.SeverityInfo && strings.Contains(d.Message, "error response headers") { - found = true - } + assert.NotContains(t, d.Message, "error response headers", + "nothing is degraded, so nothing announces a degradation") } - assert.True(t, found, "dropped error headers emit one info diagnostic") } func TestOperation_ExplicitlyPublicSecurity(t *testing.T) { @@ -1207,14 +1229,19 @@ func TestResponses_RefdErrorAndDefaultInternAtDeclaration(t *testing.T) { } aErrs, bErrs := byFault(getA), byFault(getB) + errType := func(ec ir.ErrorCase) ir.TypeID { + require.NotNil(t, ec.Payload) + require.Len(t, ec.Payload.Contents, 1) + return ec.Payload.Contents[0].Type.Target + } wantNotFound := ir.TypeID("t/anon/components/responses/NotFound/content/application~1json/schema") - assert.Equal(t, wantNotFound, aErrs["client"].Type.Target) - assert.Equal(t, wantNotFound, bErrs["client"].Type.Target, "the shared 4XX error model interns once") + assert.Equal(t, wantNotFound, errType(aErrs["client"])) + assert.Equal(t, wantNotFound, errType(bErrs["client"]), "the shared 4XX error model interns once") // The default response is the unclassified catch-all, so it keys on "". wantFallback := ir.TypeID("t/anon/components/responses/Fallback/content/application~1json/schema") - assert.Equal(t, wantFallback, aErrs[""].Type.Target) - assert.Equal(t, wantFallback, bErrs[""].Type.Target, "the shared default error model interns once") + assert.Equal(t, wantFallback, errType(aErrs[""])) + assert.Equal(t, wantFallback, errType(bErrs[""]), "the shared default error model interns once") for id := range doc.Types { assert.NotContains(t, string(id), "/responses/404", "no fabricated per-operation error ID") @@ -1248,7 +1275,7 @@ components: Err: description: err headers: - X-E: {schema: {type: string}} + X-E: {schema: {type: array, items: {type: string}}, explode: true} content: application/json: schema: {type: object} @@ -1257,10 +1284,15 @@ components: // TestDiag_SharedDeclarationReportsEachDefectOnce pins the consequence of // lowering a referenced component at its declaration: both operations reach the // same request body — whose scalar schema carries a `required` the lowered node -// has no field for — and the same header-bearing error response, so each defect -// now has one pointer and one message. Reported per use site they would arrive -// as byte-identical copies — nothing a reader could act on twice — and a -// component shared by twenty operations would repeat each line twenty times. +// has no field for — and the same error response, whose header declares an +// `explode` ir.Property has no field for, so each defect has one pointer and one +// message. Reported per use site they would arrive as byte-identical copies — +// nothing a reader could act on twice — and a component shared by twenty +// operations would repeat each line twenty times. +// +// The second defect sits on an error response's header on purpose: those reach +// lowerHeaders only since GitHub #422, so the case covers the shared-declaration +// rule on the path that gained them rather than on the success side alone. func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { t.Parallel() doc, diags := parseFull(t, sharedDefectiveBodySpec) @@ -1274,9 +1306,9 @@ func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { } // Every defect still surfaces — de-duplication must not silence any of them. - assert.Equal(t, 3, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "the body schema's homeless required, the homeless error headers and the homeless "+ - "error media type are three distinct defects") + assert.Equal(t, 2, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "the body schema's homeless required and the error header's homeless explode are two "+ + "distinct defects") // The shared component's own `required: false` reaches both use sites, as // two values rather than one aliased pointer: lowering it at its declaration @@ -1604,19 +1636,31 @@ func TestOperations_PathItemUnknownKeyKeptOnEveryRoute(t *testing.T) { } } -// TestErrorCase_SingleMediaTypeKeepsContentMap pins the arity-independent half of -// error-content preservation. ir.ErrorCase holds a TypeRef and no media type, so -// an error declared only as application/problem+json reached the IR -// indistinguishable from one declared as application/json — while the same -// response with a second media type beside it was kept in full. One entry losing -// its key is the same loss as several losing all but the first. -func TestErrorCase_SingleMediaTypeKeepsContentMap(t *testing.T) { +// TestErrorCase_EveryMediaTypeIsAContent pins the content half of GitHub #422. +// ir.ErrorCase held one bare TypeRef and no media type, so a 404 declaring only +// application/problem+json reached the IR indistinguishable from one declaring +// application/json, and a 400 declaring both kept the first schema and lost the +// second entirely — the whole map going verbatim to Unmodeled in either case. +// Both now lower to ErrorCase.Payload.Contents, one entry per media type, the +// same shape and by the same function as a success response's. +// +// The 409 is the control: an error declaring no content at all still gets no +// payload, so a Contents entry marks a declaration rather than appearing on +// every error case. +func TestErrorCase_EveryMediaTypeIsAContent(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.PathsSpec(` /x: get: operationId: getX responses: "200": {description: ok} + "400": + description: bad + content: + application/json: + schema: {type: object, properties: {a: {type: string}}} + application/problem+json: + schema: {type: object, properties: {b: {type: string}}} "404": description: gone content: @@ -1628,19 +1672,30 @@ func TestErrorCase_SingleMediaTypeKeepsContentMap(t *testing.T) { errs := openapitest.IndexBy(openapitest.FindOp(t, doc, "getX").Errors, func(ec ir.ErrorCase) int { return ec.Conditions.StatusCodes[0].From }) - entry, ok := errs[404].Unmodeled["openapi:content"] - require.True(t, ok, "the single-entry content map is kept") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `{"application/problem+json":{"schema":{"type":"object"}}}`, string(entry.Value), - "the media type the map is keyed by is what would otherwise be lost") - assert.Equal(t, "/paths/~1x/get/responses/404/content", entry.Provenance.Pointer) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/paths/~1x/get/responses/404"), - "media type has no ErrorCase home", - "the single-entry case names its own loss, not the multi-entry one") - - assert.NotContains(t, errs[409].Unmodeled, "openapi:content", - "an error response declaring no content keeps no content map") + multi := errs[400] + require.NotNil(t, multi.Payload) + require.Len(t, multi.Payload.Contents, 2, "every media type is kept, none elected") + assert.Equal(t, []string{"application/json", "application/problem+json"}, + []string{multi.Payload.Contents[0].MediaType, multi.Payload.Contents[1].MediaType}) + assert.NotEqual(t, multi.Payload.Contents[0].Type.Target, multi.Payload.Contents[1].Type.Target, + "the second media type keeps its own schema rather than the first's") + + single := errs[404] + require.NotNil(t, single.Payload) + require.Len(t, single.Payload.Contents, 1) + assert.Equal(t, "application/problem+json", single.Payload.Contents[0].MediaType, + "the sole media type's own key is what used to be lost") + + for status, ec := range errs { + assert.NotContains(t, ec.Unmodeled, "openapi:content", + "%d keeps no content map beside a typed payload", status) + } + for _, d := range diags { + assert.NotContains(t, d.Message, "ErrorCase home", + "nothing is degraded, so nothing announces a degradation") + } + + assert.Nil(t, errs[409].Payload, "an error response declaring no content gets no payload") } // operationServersSpec declares `servers` at both levels OpenAPI allows, on an @@ -2170,3 +2225,29 @@ paths: "an item with nothing beside its operations announces nothing") } } + +// TestResponses_TwoKeysForOneRangeAreReported pins the collision the neutralized +// hint cannot show. "4XX" and "4xx" name one range, and the key reaches the IR +// neutralized, so both responses arrive with hint "4_xx" and identical +// conditions. An ErrorCase carries no ID, so name and conditions are the whole +// of what tells one from another — two indistinguishable cases, compiled with +// exit 0 and nothing said. Source cannot hold the difference: a responses-map +// key is not a name the document declared, which TestResponses_NamedByStatusKey +// pins. So the collision is reported where the keys are read. +func TestResponses_TwoKeysForOneRangeAreReported(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /w: + get: + operationId: w + responses: + "200": {description: ok} + "4XX": {description: upper} + "4xx": {description: lower} +`) + _, svc, diags := lowerServiceSpec(t, spec) + op := openapitest.FirstOp(t, svc) + + require.Len(t, op.Errors, 2, "both are kept: neither key is wrong on its own") + assert.True(t, openapitest.HasDiag(diags, diag.DuplicateStatusKey), + "two keys resolving to one range is reported; got %v", diags) +} diff --git a/compilers/openapi/unpreservable_test.go b/compilers/openapi/unpreservable_test.go index 778f3d10..6a65221b 100644 --- a/compilers/openapi/unpreservable_test.go +++ b/compilers/openapi/unpreservable_test.go @@ -60,14 +60,6 @@ func TestUnpreservable_AnnouncementNeverOutrunsTheEntry(t *testing.T) { " - {type: string, maxLength: 3, x-t: " + unpreservableValue + "}\n"), at: "/components/schemas/M/allOf/0", }, - { - name: "error response with multiple media types", - spec: openapitest.PathsSpec(" /x:\n get:\n responses:\n" + - " \"500\":\n description: bad\n content:\n" + - " application/json: {schema: {type: string}, example: " + unpreservableValue + "}\n" + - " application/xml: {schema: {type: string}}\n"), - at: "/paths/~1x/get/responses/500/content", - }, { name: "path-item servers", spec: openapitest.PathsSpec(" /x:\n servers: [{url: 'https://a', x-t: " + unpreservableValue + "}]\n" + diff --git a/docs/emitter-design.md b/docs/emitter-design.md index 06789921..f1e4f85d 100644 --- a/docs/emitter-design.md +++ b/docs/emitter-design.md @@ -1036,11 +1036,15 @@ binding. ### 8.5 An error taxonomy (`RateLimited`, 429, retryable-throttling) -IR: `ErrorCase{ Type:RateLimited (Model, Usage.Error), Conditions:{429}, Fault:"client", -Retryable:&true, Throttling:&true }`; policy default maps `429 → "TooManyRequests"`. +IR: `ErrorCase{ Name:{Hint:"429"}, Conditions:{429}, Payload:{Contents:[{application/json → +RateLimited (Model, Usage.Error)}]}, Headers:[Retry-After], Fault:"client", Retryable:&true, +Throttling:&true }`; policy default maps `429 → "TooManyRequests"`. - **plan.** `OpPlan.Errors` = `[PlannedError{ Conditions:{429}, Type:RateLimited, Fault:"client", - Retryable:true, Throttling:true }]` — declared IR facts, carried as such. + Retryable:true, Throttling:true }]` — declared IR facts, carried as such. **Known gap (#447):** + `PlannedError` has no `Headers` field, so the `Retry-After` on the IR line above has no home in + the plan, and its single `Type` names no election where `ErrorCase.Payload.Contents` is plural — + the negotiation `PrimaryContent` states for the success side has no error-side counterpart here. - **refine.** `LowerErrors` builds the error tree: `APIError` interface → a `ClientError`/`ServerError` split from `Fault` → concrete `RateLimited` implementing `error`. The retry wiring reads `Retryable`/`Throttling` **from the IR fact** and falls back to `Policy.Retry` only where the spec diff --git a/docs/ir-design.md b/docs/ir-design.md index e6d17d37..90036610 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1275,9 +1275,11 @@ type ResponseConditions struct { StatusCodes []StatusRange // {From,To}: 200–200, 400–499 ("4XX"), 0–0 = default/catch-all } -type ErrorCase struct { - Type TypeRef // an error-flagged model +type ErrorCase struct { // a response too: Name/Conditions/Payload/Headers as on Response + Name Naming // for formats with named errors; Hint elsewhere (the status key, neutralized) Conditions ResponseConditions + Payload *Payload // nil = no body; the error-flagged models are its contents' types + Headers []Property // error metadata fields (Retry-After, rate-limit family) Fault string // "" | "client" | "server" — protocol-neutral fault classification // (Smithy @error; OpenAPI 4XX/5XX is its HTTP lowering). Drives // exception hierarchies and default status synthesis @@ -1911,7 +1913,7 @@ How each format's distinctive concepts land in the IR (full details live with ea | Format | Lowering highlights | |---|---| -| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the first); response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type the merge drops → the discarded `TypeRef` verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3; a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | +| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7); competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated; 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; `contentEncoding`/`contentMediaType` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution); `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type the merge drops → the discarded `TypeRef` verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3; a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred | | **Swagger 2.0** | lifted to OpenAPI 3.x shape first (body/formData → Payload; host/basePath/schemes → Servers; consumes/produces → content types), then the OpenAPI lowering runs | | **TypeSpec** | consumed post-check (monomorphized, `isFinished`); template instances → TypeCommon.Instantiation incl. value args → TemplateArg; models → Model w/ Base + spread provenance → Mixins; scalars → Scalar chains, constructors in values → Value.Ctor; `@encode`/`@format` → Encoding triple; `@encodedName` → WireNameByFormat at property AND type level; unions w/ named variants → Union, `@discriminated` → Discriminator.PropertyName/Envelope/EnvelopeValueName; `| null` → Nullable; visibility classes (incl. custom, `@invisible` → Visibility.None) → Visibility, op overrides → ParameterVisibility/ReturnTypeVisibility; `@patch` implicitOptionality → HTTPBinding.PatchImplicitOptionality; interfaces → OperationGroups (versionable); `@overload` → OverloadOf; `@sharedRoute` → SharedRoute; `@service` → Service; versioning decorators incl. `@typeChangedFrom`/`@madeOptional`/`@madeRequired` and add/remove cycles → Availability timeline (on members/variants/params too); pagination decorators incl. prev/first/last links and header continuation tokens → Pagination PropPaths (In:"header"); Azure.Core `@pollingOperation`/`@finalOperation` → LongRunning; multipart w/ parts → Content.Encoding/PartEncoding, `Http.File` → FileInfo (content-type set, contents chain, filename location); streams/SSE → StreamDetail + Variant.Event (contentType, terminal); `@error` → UsageFlags.Error; `@example`/`@opExample` → Examples (Input/Output pairs); `@pattern` message → Constraints.PatternMessage; `@mediaTypeHint` → TypeCommon.MediaTypeHint; `never` members deleted + diagnostic per §4.8; TCGC client-shaping decorators (`@clientName`, `@access`, `@usage`, `@scope`, `@override`, …) → namespaced Unmodeled (`out_of_scope`) consumed by emitter policy, never IR semantics; values/consts incl. enum-member refs → Values channel | | **Smithy 2.0** | structures → Model, mixins → Mixins (non-structure mixins flattened — spec-sanctioned); `document` → Any; unions → WireTagged Union, member `@jsonName` → Variant.WireName; enum/intEnum → Enum (open by default); `@sparse` → element Nullable; traits: constraints → Constraints, `@paginated` → Pagination (declared), `@retryable` → ErrorCase.Retryable + Throttling, `@error` fault → ErrorCase.Fault, `@readonly` → Idempotency safe, `@idempotent`/`@idempotencyToken` → Idempotency, `@sensitive` → Sensitive/Secret, `@tags` → Tags, `@clientOptional`/`@input` → Property.ClientOptional (+InputOnly), `@addedDefault` → DefaultAdded, root-shape `@default` pushed down to properties w/ provenance; `@streaming` blob → StreamDetail (+`@requiresLength` → RequiresLength); event streams → StreamDetail.Events union + Property.EventHeader/EventPayload + Initial messages; service-level errors → Service.CommonErrors; protocol traits → Service.Protocols; service `rename`/`version` → Service.Renames/Version; resources → OperationGroup + ResourceInfo (identifiers, properties, lifecycle incl. put/@noReplace, instance vs collection ops); http traits → HTTPBinding incl. `@endpoint`/`@hostLabel` → HostPrefix/host location (additive binding), `@httpPrefixHeaders`/`@httpQueryParams` → Prefix bindings, `@httpResponseCode` → Response.StatusCodeProp, `@requestCompression` → Compression, `@httpChecksumRequired` → ChecksumRequired; `@auth` order → priority-ordered Auth, `@optionalAuth` → empty option; `@jsonName` → WireName; `@mediaType` → Encoding.MediaType; xml traits → XMLHints at type and property level; `@examples` → Examples (Input/Output/Error); waiters + rules-engine traits → verbatim Unmodeled (`out_of_scope`, §15); `smithy.api#Unit` → nil payload / shared empty Model for tag-only variants; other traits → namespaced Unmodeled | diff --git a/engine/engine_test.go b/engine/engine_test.go index 466abb18..ff7f1521 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -392,8 +392,10 @@ func (danglingCompiler) Compile(_ context.Context, _ []compilers.Source, _ compi ID: "s/x", Groups: []ir.OperationGroup{{ Operations: []ir.Operation{{ - ID: "op/x", - Errors: []ir.ErrorCase{{Type: ir.TypeRef{Target: "t/missing"}}}, + ID: "op/x", + Errors: []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: "t/missing"}}}}, + }}, }}, }}, }}, diff --git a/ir/helpers_test.go b/ir/helpers_test.go index 7f8a6f43..f562cc08 100644 --- a/ir/helpers_test.go +++ b/ir/helpers_test.go @@ -366,6 +366,13 @@ func populatedTypeRef() ir.TypeRef { return ir.TypeRef{Target: "t/openapi/components/schemas/User", Nullable: true} } +// errorPayload returns the one-media-type body an ir.ErrorCase fixture carries, +// which is an ir.Payload for the same reason ir.Response's is: the two nodes +// spell a body identically. +func errorPayload() *ir.Payload { + return &ir.Payload{Contents: []ir.Content{{MediaType: "application/json", Type: populatedTypeRef()}}} +} + // populatedValue returns a fully populated Value of ValueKind list, itself // containing at least one member of every other ValueKind so a single // fixture exercises every payload variant (ir-design §6). diff --git a/ir/irverify/naming_test.go b/ir/irverify/naming_test.go index 0fdfa9e9..8d9f85e3 100644 --- a/ir/irverify/naming_test.go +++ b/ir/irverify/naming_test.go @@ -249,6 +249,26 @@ func respondingDoc(r ir.Response) *ir.Document { return doc } +// erroringDoc is respondingDoc's twin, mounting ec where the other mounts a +// response, so a rule can be asked the same question on both sides of the +// success/error boundary. +func erroringDoc(ec ir.ErrorCase) *ir.Document { + doc := validDoc() + doc.Services = []ir.Service{{ + ID: "s/x/S", + Name: named("s"), + Groups: []ir.OperationGroup{{ + Name: named("g"), + Operations: []ir.Operation{{ + ID: "o/x/S/op", + Name: named("op"), + Errors: []ir.ErrorCase{ec}, + }}, + }}, + }} + return doc +} + // TestVerify_AbsentNameIsAViolation is the case every content rule was // vacuously true of: an entity whose Naming carries nothing in any channel. The // empty string is uncased, is a word sequence, and straddles no letter/digit @@ -318,6 +338,12 @@ func TestVerify_NamelessServerAndResponseAreViolations(t *testing.T) { "server": {server, "doc.Servers[0].Name"}, "response": {respondingDoc(ir.Response{Conditions: ok200()}), "doc.Services[0].Groups[0].Operations[0].Responses[0].Name"}, + // The error twin. ErrorCase.Name is Response.Name (GitHub #422), and the + // rule fired on one and not the other only because nothing walked here: + // skipping ErrorCase.Name in the reflection walk left the whole suite + // green, where skipping Response.Name reddens the row above. + "error case": {erroringDoc(ir.ErrorCase{Conditions: ok200()}), + "doc.Services[0].Groups[0].Operations[0].Errors[0].Name"}, } { t.Run(name, func(t *testing.T) { t.Parallel() diff --git a/ir/irverify/verify_corpus_test.go b/ir/irverify/verify_corpus_test.go index 2c056ab5..aa483bf4 100644 --- a/ir/irverify/verify_corpus_test.go +++ b/ir/irverify/verify_corpus_test.go @@ -89,12 +89,20 @@ func TestVerify_Corpus(t *testing.T) { } // uncorpusedUnmodeled exercises the Unmodeled writes no committed fixture -// reaches: path-item servers, an error response's headers, an error response's -// second media type, and an `items` tail after `prefixItems`. The parameter's -// xml hints are reached by the corpus too, and are here so the spec covers every -// field unmodeledKeys reads — the operation itself, its parameters, its errors -// and the type registry — leaving a collector that stopped reading one of them -// to fail the precondition below rather than quietly narrow what is verified. +// reaches: path-item servers, an error response's own x-* extension, and an +// `items` tail after `prefixItems`. The parameter's xml hints are reached by the +// corpus too, and are here so the spec covers every field unmodeledKeys reads — +// the operation itself, its parameters, its errors and the type registry — +// leaving a collector that stopped reading one of them to fail the precondition +// below rather than quietly narrow what is verified. +// +// The 404 also declares headers and two media types, which write nothing here +// any more: both lower structurally onto ir.ErrorCase (GitHub #422). They stay +// for the same reason the xml hints do — the corpus reaches them too +// (per-status-errors.yaml puts Retry-After and X-RateLimit-Remaining on a 429, +// and extensions-x.yaml declares x-mark on a 404), and keeping them here is what +// makes this one spec cover every field unmodeledKeys reads rather than most of +// them. const uncorpusedUnmodeled = `openapi: 3.1.0 info: {title: UnmodeledSites, version: "1"} paths: @@ -118,6 +126,7 @@ paths: items: {type: integer} "404": description: missing + x-mark: kept headers: X-Reason: {schema: {type: string}} content: @@ -168,8 +177,7 @@ func TestVerify_UnmodeledSitesOutsideTheCorpus(t *testing.T) { doc := compile(t, "unmodeled-sites.yaml", []byte(uncorpusedUnmodeled)) require.NotNil(t, doc) require.Equal(t, []string{ - "openapi:content", "openapi:headers", "openapi:items-after-prefix", - "openapi:servers", "openapi:xml", + "openapi:items-after-prefix", "openapi:servers", "openapi:x-mark", "openapi:xml", }, unmodeledKeys(doc), "the spec must reach every preserve call this test exists for") assert.Empty(t, irverify.Verify(doc)) diff --git a/ir/operation.go b/ir/operation.go index ade6e359..51510616 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -226,11 +226,29 @@ type StatusRange struct { } // ErrorCase is a declared failure shape of an Operation (ir-design §7.2). +// +// An error case is a response, so the four fields it shares with [Response] — +// Name, Conditions, Payload, Headers — are spelled and lowered identically, and +// the failure classification below them is what separates the two nodes. +// +// The sharing is those four fields, not everything [Response] holds: +// StatusCodeProp stays success-only, because the formats that populate an output +// member from the status line (Smithy @httpResponseCode, TypeSpec's non-literal +// @statusCode) classify errors by @httpError instead, so an error case has no +// runtime status to bind a member to. type ErrorCase struct { - // Type is an error-flagged model. - Type TypeRef `json:"type"` + // Name is the error naming for formats with named errors; Hint elsewhere — + // for OpenAPI, the responses-map key as declared and then neutralized ("404", + // "5_xx", "default"). Only a key that neutralizes to itself round-trips. + Name Naming `json:"name"` // Conditions are the status codes/ranges this error maps to. Conditions ResponseConditions `json:"conditions"` + // Payload is the error body, one Content per media type; nil = no body. The + // error-flagged models an error case references are its contents' types. + Payload *Payload `json:"payload,omitempty"` + // Headers are the error response's metadata fields — Retry-After and the + // rate-limit family live here. + Headers []Property `json:"headers,omitempty"` // Fault is "" | "client" | "server" — protocol-neutral fault classification // (Smithy @error; OpenAPI 4XX/5XX is its HTTP lowering). Drives exception // hierarchies and default status synthesis. diff --git a/ir/operation_test.go b/ir/operation_test.go index 2f13f2bd..25338634 100644 --- a/ir/operation_test.go +++ b/ir/operation_test.go @@ -52,7 +52,13 @@ func TestOperation_PopulatedRoundTrip(t *testing.T) { }, }, Errors: []ir.ErrorCase{ - {Type: populatedTypeRef(), Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 400, To: 499}}}, Fault: "client"}, + { + Name: ir.Naming{Source: "client_error"}, + Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 400, To: 499}}}, + Payload: &ir.Payload{Contents: []ir.Content{{MediaType: "application/json", Type: populatedTypeRef()}}}, + Headers: []ir.Property{{ID: "p/retry-after", Name: ir.Naming{Source: "Retry-After"}, Type: populatedTypeRef()}}, + Fault: "client", + }, }, OneWay: false, Streaming: ir.StreamingBidi, @@ -343,19 +349,25 @@ func TestStatusRange_PopulatedRoundTrip(t *testing.T) { } } -// TestErrorCase_JSONContract pins ErrorCase's omitempty contract (Type, +// TestErrorCase_JSONContract pins ErrorCase's omitempty contract (Name, // Conditions, and Docs carry no omitempty; every other field is optional) -// and that a fully populated ErrorCase — fault classification, -// retryable/throttling tri-state pointers — round-trips. +// and that a fully populated ErrorCase — the four response fields it shares +// with ir.Response, fault classification, retryable/throttling tri-state +// pointers — round-trips. func TestErrorCase_JSONContract(t *testing.T) { t.Parallel() retryable := true throttling := false assertJSONContract(t, ir.ErrorCase{}, - `{"type":{"target":"","nullable":false},"conditions":{},"docs":{}}`, + `{"name":{},"conditions":{},"docs":{}}`, ir.ErrorCase{ - Type: populatedTypeRef(), + Name: ir.Naming{Source: "too_many_requests", Hint: "429"}, Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 429, To: 429}}}, + Payload: &ir.Payload{Contents: []ir.Content{ + {MediaType: "application/json", Type: populatedTypeRef()}, + {MediaType: "application/problem+json", Type: populatedTypeRef()}, + }}, + Headers: []ir.Property{{ID: "p/retry-after", Name: ir.Naming{Source: "Retry-After"}, Type: populatedTypeRef()}}, Fault: "client", Retryable: &retryable, Throttling: &throttling, diff --git a/ir/service_test.go b/ir/service_test.go index a24f6ede..0ff652c6 100644 --- a/ir/service_test.go +++ b/ir/service_test.go @@ -36,8 +36,8 @@ func TestService_JSONContract(t *testing.T) { {Schemes: []ir.SchemeUse{{Scheme: "auth/apiKey"}}}, }, CommonErrors: []ir.ErrorCase{ - {Type: populatedTypeRef(), Fault: "client"}, - {Type: populatedTypeRef(), Fault: "server"}, + {Name: ir.Naming{Source: "throttled"}, Payload: errorPayload(), Fault: "client"}, + {Name: ir.Naming{Source: "internal"}, Payload: errorPayload(), Fault: "server"}, }, Protocols: []ir.ProtocolDecl{ {Name: "aws.restJson1", Options: populatedRawConfig()}, diff --git a/pass/validate.go b/pass/validate.go index dff5fc0d..851f8a46 100644 --- a/pass/validate.go +++ b/pass/validate.go @@ -279,13 +279,21 @@ type payloadSite struct { // positions that hold none. // // The fields that carry a Payload are named here — Operation.Request, -// Response.Payload and Message.Payload — because nothing in a Payload's Go type -// says who owns one, so a new one has to be added by hand. That coupling is -// guarded: TestEncodingCarriers_NameEveryPayloadFieldInTheIR +// Response.Payload, ErrorCase.Payload and Message.Payload — because nothing in a +// Payload's Go type says who owns one, so a new one has to be added by hand. +// That coupling is guarded: TestEncodingCarriers_NameEveryPayloadFieldInTheIR // (validate_carriers_test.go) walks the IR for Payload-bearing fields and fails // the moment one of them is not walked here, and every check built on this walk // reaches a carrier the day it is added. +// +// ErrorCase.Payload is reached at both of the IR's error positions — an +// operation's own Errors and its service's CommonErrors — because the field is +// one field wherever the node hangs, and a walk that visited only the operation +// list would leave a service-level error's payload unjudged in silence. func forEachPayload(doc *ir.Document, fn func(payloadSite)) { + for _, svc := range doc.Services { + forEachErrorPayload(svc.CommonErrors, string(svc.ID)+"/commonErrors", fn) + } forEachOperation(doc, func(op ir.Operation) { if op.Request != nil { fn(payloadSite{payload: op.Request, where: string(op.ID) + "/request", request: true}) @@ -295,6 +303,7 @@ func forEachPayload(doc *ir.Document, fn func(payloadSite)) { fn(payloadSite{payload: r.Payload, where: fmt.Sprintf("%s/responses/%d", op.ID, i)}) } } + forEachErrorPayload(op.Errors, string(op.ID)+"/errors", fn) }) for _, id := range sortedKeys(doc.Messages) { msg := doc.Messages[id] @@ -302,6 +311,16 @@ func forEachPayload(doc *ir.Document, fn func(payloadSite)) { } } +// forEachErrorPayload calls fn once per error case carrying a payload; where +// locates the list the cases hang from. +func forEachErrorPayload(errs []ir.ErrorCase, where string, fn func(payloadSite)) { + for i, ec := range errs { + if ec.Payload != nil { + fn(payloadSite{payload: ec.Payload, where: fmt.Sprintf("%s/%d", where, i)}) + } + } +} + // appendEncodingKeyDiags appends to dst a diagnostic per unresolvable encoding // key in each of the payload's contents; where locates the payload's owner. func appendEncodingKeyDiags(dst []ir.Diagnostic, doc *ir.Document, payload *ir.Payload, where string) []ir.Diagnostic { diff --git a/pass/validate_edgecases_test.go b/pass/validate_edgecases_test.go index f67ed538..82c67325 100644 --- a/pass/validate_edgecases_test.go +++ b/pass/validate_edgecases_test.go @@ -101,11 +101,20 @@ func TestValidate_OperationHeadersAndItemWalked(t *testing.T) { Type: ir.TypeRef{Target: "t/ghost-hdr"}, }}, }}, - Errors: []ir.ErrorCase{{Type: ir.TypeRef{Target: "t/ghost-err"}}}, + Errors: []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: "t/ghost-err"}}}}, + // The error side's headers, which nothing reached until now: skipping + // ErrorCase.Headers in the reflection walk left the suite green, where + // skipping the Response.Headers above reddens this test. + Headers: []ir.Property{{ + ID: "p/eh", Name: ir.Naming{Source: "X-Retry"}, WireName: "X-Retry", + Type: ir.TypeRef{Target: "t/ghost-err-hdr"}, + }}, + }}, } diags := pass.Validate(docWithOperation(op)) - // item, header, and error targets are all dangling. - assert.Equal(t, 3, countCode(t, diags, "ir/dangling-type-ref")) + // item, response header, error payload and error header targets all dangle. + assert.Equal(t, 4, countCode(t, diags, "ir/dangling-type-ref")) } // TestValidate_ModelDiscriminator drives checkModelDiscriminator and every diff --git a/pass/validate_encoding_test.go b/pass/validate_encoding_test.go index e1f72507..6b65a5a4 100644 --- a/pass/validate_encoding_test.go +++ b/pass/validate_encoding_test.go @@ -45,6 +45,9 @@ func encodingCarriers() []encodingCarrier { {"Response.Payload", "op/responses/0", false, func(d *ir.Document, p ir.Payload) { firstOp(d).Responses = []ir.Response{{Name: ir.Naming{Source: "ok"}, Payload: &p}} }}, + {"ErrorCase.Payload", "op/errors/0", false, func(d *ir.Document, p ir.Payload) { + firstOp(d).Errors = []ir.ErrorCase{{Name: ir.Naming{Source: "bad_request"}, Payload: &p}} + }}, {"Message.Payload", "msg/a", false, func(d *ir.Document, p ir.Payload) { putMessage(d, func(m *ir.Message) { m.Payload = p }) }}, @@ -120,6 +123,29 @@ func TestValidate_EncodingKeysNamingRealPropertiesAreClean(t *testing.T) { assert.Empty(t, pass.Validate(doc)) } +// TestValidate_EncodingKeyThroughServiceCommonErrors covers the second position +// an ir.ErrorCase hangs from. The carrier table above plants into an operation's +// own Errors, which is one of two lists of the same node: a check reaching only +// that one would resolve a service-level error's encoding keys against nothing +// while every case above stayed green. +func TestValidate_EncodingKeyThroughServiceCommonErrors(t *testing.T) { + t.Parallel() + doc := validDoc() + service(doc).CommonErrors = []ir.ErrorCase{{ + Name: ir.Naming{Source: "throttled"}, + Payload: &ir.Payload{Contents: []ir.Content{ + multipartContent(map[ir.PropID]ir.PartEncoding{"p/m/ghost": {Multi: true}}), + }}, + }} + + found := withCode(pass.Validate(doc), "ir/encoding-key-unknown-property") + require.Len(t, found, 1, "exactly the planted key must address nothing") + assert.Equal(t, "s/commonErrors/0/contents/0/encoding/p/m/ghost", + found[0].Provenance.Pointer, + "the pointer names the service by ID, as checkServerIndices does, so one node "+ + "does not have two spellings from one package") +} + // TestValidate_EncodingKeyThroughComposition covers the three ways a body model // composes a part in. An emitter renders the flat property set (§4.3), so a key // naming an inherited part is legal IR and the check must stay silent on it — diff --git a/pass/validate_refs_test.go b/pass/validate_refs_test.go index 5d6fb402..2894fae2 100644 --- a/pass/validate_refs_test.go +++ b/pass/validate_refs_test.go @@ -100,7 +100,9 @@ func payloadAndVersioningSites() []refSite { service(d).Renames = map[ir.TypeID]ir.Naming{t: {Source: "Ghost"}} }, "t/ghost/rename-key"}, {"service common errors", ".CommonErrors[0]", func(d *ir.Document, t ir.TypeID) { - service(d).CommonErrors = []ir.ErrorCase{{Type: ir.TypeRef{Target: t}}} + service(d).CommonErrors = []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: t}}}}, + }} }, "t/ghost/common-error"}, } } @@ -271,7 +273,7 @@ var sortedRefPointers = []string{ "doc.Channels[chan/a].Params[0].Type.Target", "doc.Messages[msg/a].CorrelationID.Root.Target", "doc.Messages[msg/a].Headers.Target", - "doc.Services[0].CommonErrors[0].Type.Target", + "doc.Services[0].CommonErrors[0].Payload.Contents[0].Type.Target", "doc.Services[0].Groups[0].Operations[0].Bindings.RPC.InputType.Target", "doc.Services[0].Groups[0].Operations[0].LongRunning.FinalType.Target", "doc.Services[0].Groups[0].Operations[0].LongRunning.PollingType.Target", diff --git a/testdata/conformance/openapi/component-reuse.golden.json b/testdata/conformance/openapi/component-reuse.golden.json index fed21dbd..051fa368 100644 --- a/testdata/conformance/openapi/component-reuse.golden.json +++ b/testdata/conformance/openapi/component-reuse.golden.json @@ -319,9 +319,8 @@ ], "errors": [ { - "type": { - "target": "t/anon/components/responses/Failure/content/application~1json/schema", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -331,29 +330,19 @@ } ] }, - "docs": { - "description": "anything else" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - } + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Failure/content/application~1json/schema", + "nullable": false } - }, - "provenance": { - "source": 0, - "pointer": "/components/responses/Failure/content" } - } + ] + }, + "docs": { + "description": "anything else" } } ], @@ -585,7 +574,7 @@ "kind": "model", "id": "t/anon/components/responses/Failure/content/application~1json/schema", "name": { - "hint": "error" + "hint": "failure" }, "anonymous": true, "docs": {}, @@ -631,7 +620,7 @@ "kind": "model", "id": "t/anon/components/responses/Listed/content/application~1json/schema", "name": { - "hint": "response" + "hint": "listed" }, "anonymous": true, "docs": {}, @@ -714,17 +703,6 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/components/responses/Failure" - } - } - ], "sources": [ { "format": "openapi@3.1", diff --git a/testdata/conformance/openapi/extensions-x.golden.json b/testdata/conformance/openapi/extensions-x.golden.json index e4affc33..14e03bee 100644 --- a/testdata/conformance/openapi/extensions-x.golden.json +++ b/testdata/conformance/openapi/extensions-x.golden.json @@ -159,9 +159,8 @@ ], "errors": [ { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ diff --git a/testdata/conformance/openapi/per-status-errors.golden.json b/testdata/conformance/openapi/per-status-errors.golden.json index f587672d..efa04676 100644 --- a/testdata/conformance/openapi/per-status-errors.golden.json +++ b/testdata/conformance/openapi/per-status-errors.golden.json @@ -45,9 +45,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Err", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ @@ -57,31 +56,116 @@ } ] }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Err", + "nullable": false + } + } + ] + }, "fault": "client", "docs": { "description": "not found" + } + }, + { + "name": { + "hint": "429" }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Err" - } + "conditions": { + "statusCodes": [ + { + "from": 429, + "to": 429 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Err", + "nullable": false } }, + { + "mediaType": "application/problem+json", + "type": { + "target": "t/openapi/components/schemas/Problem", + "nullable": false + } + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1widgets/get/responses/429/headers/Retry-After", + "name": { + "source": "Retry-After", + "canonical": "retry_after" + }, + "wireName": "Retry-After", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets/get/responses/429/headers/Retry-After" + } + }, + { + "id": "p/openapi/paths/~1widgets/get/responses/429/headers/X-RateLimit-Remaining", + "name": { + "source": "X-RateLimit-Remaining", + "canonical": "x_rate_limit_remaining" + }, + "wireName": "X-RateLimit-Remaining", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, "provenance": { "source": 0, - "pointer": "/paths/~1widgets/get/responses/404/content" + "pointer": "/paths/~1widgets/get/responses/429/headers/X-RateLimit-Remaining" } } + ], + "fault": "client", + "docs": { + "description": "slow down" } }, { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "5_xx" }, "conditions": { "statusCodes": [ @@ -97,9 +181,8 @@ } }, { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -190,6 +273,92 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Problem": { + "kind": "model", + "id": "t/openapi/components/schemas/Problem", + "name": { + "source": "Problem", + "canonical": "problem" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Problem" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Problem/properties/title", + "name": { + "source": "title", + "canonical": "title" + }, + "wireName": "title", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Problem/properties/title" + } + }, + { + "id": "p/openapi/components/schemas/Problem/properties/status", + "name": { + "source": "status", + "canonical": "status" + }, + "wireName": "status", + "type": { + "target": "t/prim/integer", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Problem/properties/status" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "integer" + }, "t/prim/string": { "kind": "primitive", "id": "t/prim/string", @@ -213,22 +382,11 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1widgets/get/responses/404" - } - } - ], "sources": [ { "format": "openapi@3.1", "path": "per-status-errors.yaml", - "hash": "f688d76cbabd110346bccc9c5d4f88a504b6fc9d034295ba3d5bc5b41340aea9" + "hash": "67887ccb06ad4e724eb61d53fb79619f13709d5a459d56763bbc3184c88dac74" } ] } diff --git a/testdata/conformance/openapi/per-status-errors.yaml b/testdata/conformance/openapi/per-status-errors.yaml index 199bc478..6e95dcbc 100644 --- a/testdata/conformance/openapi/per-status-errors.yaml +++ b/testdata/conformance/openapi/per-status-errors.yaml @@ -12,6 +12,16 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/Err'} + "429": + description: slow down + headers: + Retry-After: {schema: {type: integer}} + X-RateLimit-Remaining: {schema: {type: integer}} + content: + application/json: + schema: {$ref: '#/components/schemas/Err'} + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} "5XX": description: server error default: @@ -22,3 +32,8 @@ components: type: object properties: message: {type: string} + Problem: + type: object + properties: + title: {type: string} + status: {type: integer} diff --git a/testdata/conformance/openapi/response-links.golden.json b/testdata/conformance/openapi/response-links.golden.json index d1e2f8ce..ce5b8816 100644 --- a/testdata/conformance/openapi/response-links.golden.json +++ b/testdata/conformance/openapi/response-links.golden.json @@ -62,9 +62,8 @@ ], "errors": [ { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "409" }, "conditions": { "statusCodes": [ diff --git a/testdata/conformance/openapi/shared-response-across-status.golden.json b/testdata/conformance/openapi/shared-response-across-status.golden.json new file mode 100644 index 00000000..2f283767 --- /dev/null +++ b/testdata/conformance/openapi/shared-response-across-status.golden.json @@ -0,0 +1,192 @@ +{ + "irVersion": "0.3.0", + "name": "SharedResponseAcrossStatus", + "version": "1", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "SharedResponseAcrossStatus", + "canonical": "shared_response_across_status" + }, + "docs": {}, + "groups": [ + { + "name": { + "hint": "default" + }, + "docs": {}, + "operations": [ + { + "id": "op/openapi/paths/~1widgets/get", + "name": { + "source": "listWidgets", + "canonical": "list_widgets" + }, + "docs": {}, + "responses": [ + { + "name": { + "hint": "200" + }, + "conditions": { + "statusCodes": [ + { + "from": 200, + "to": 200 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Envelope/content/application~1json/schema", + "nullable": false + } + } + ] + }, + "docs": { + "description": "the same envelope, whatever the status" + } + } + ], + "errors": [ + { + "name": { + "hint": "404" + }, + "conditions": { + "statusCodes": [ + { + "from": 404, + "to": 404 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Envelope/content/application~1json/schema", + "nullable": false + } + } + ] + }, + "fault": "client", + "docs": { + "description": "the same envelope, whatever the status" + } + } + ], + "oneWay": false, + "idempotency": {}, + "auth": null, + "bindings": { + "http": [ + { + "method": "GET", + "uriTemplate": "/widgets", + "sharedRoute": false, + "checksumRequired": false, + "isWebhook": false + } + ] + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets/get" + } + } + ] + } + ], + "auth": null, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/responses/Envelope/content/application~1json/schema": { + "kind": "model", + "id": "t/anon/components/responses/Envelope/content/application~1json/schema", + "name": { + "hint": "envelope" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/responses/Envelope/content/application~1json/schema" + }, + "properties": [ + { + "id": "p/openapi/components/responses/Envelope/content/application~1json/schema/properties/message", + "name": { + "source": "message", + "canonical": "message" + }, + "wireName": "message", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/responses/Envelope/content/application~1json/schema/properties/message" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "auth": null + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "shared-response-across-status.yaml", + "hash": "2991fe4309203821a972955894df660694b68a1de80b95f8fe4fe2f0640fc27a" + } + ] +} diff --git a/testdata/conformance/openapi/shared-response-across-status.yaml b/testdata/conformance/openapi/shared-response-across-status.yaml new file mode 100644 index 00000000..3affd5b3 --- /dev/null +++ b/testdata/conformance/openapi/shared-response-across-status.yaml @@ -0,0 +1,26 @@ +# One components/responses entry mounted at both a success and an error status. +# The two mounts lower through different functions — lowerResponse and +# lowerErrorCase — and both intern the body type at the same declaration +# pointer, so whichever runs first mints it and the other's naming hint is +# discarded. Without this fixture nothing in the corpus asks the question: no +# other committed spec reaches one response component from both sides of the +# success/error boundary, so the order-invariance oracle never fires on it. +openapi: 3.1.0 +info: {title: SharedResponseAcrossStatus, version: "1"} +paths: + /widgets: + get: + operationId: listWidgets + responses: + "200": {$ref: '#/components/responses/Envelope'} + "404": {$ref: '#/components/responses/Envelope'} +components: + responses: + Envelope: + description: the same envelope, whatever the status + content: + application/json: + schema: + type: object + properties: + message: {type: string} diff --git a/testdata/golden/openapi/petstore.golden.json b/testdata/golden/openapi/petstore.golden.json index f4b17b49..7a1b9f33 100644 --- a/testdata/golden/openapi/petstore.golden.json +++ b/testdata/golden/openapi/petstore.golden.json @@ -87,9 +87,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Error", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -99,24 +98,19 @@ } ] }, - "docs": { - "description": "Unexpected error" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Error", + "nullable": false } - }, - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/get/responses/default/content" } - } + ] + }, + "docs": { + "description": "Unexpected error" } } ], @@ -202,9 +196,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Error", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ @@ -214,25 +207,20 @@ } ] }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Error", + "nullable": false + } + } + ] + }, "fault": "client", "docs": { "description": "Not found" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/post/responses/404/content" - } - } } } ], @@ -830,26 +818,6 @@ } } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/get/responses/default" - } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/post/responses/404" - } - } - ], "sources": [ { "format": "openapi@3.1",