Skip to content
Merged
16 changes: 6 additions & 10 deletions compilers/openapi/internal/operation/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,10 @@ func appendValuelessExample(c lowering.Ctx, out []ir.Example, proto ir.Example,
}

// lowerRequestBody lowers an operation's request body onto op.Request and the
// binding's RequestContentTypes. The IR expresses body optionality via presence,
// so a non-required body stays present with its optionality preserved under
// Unmodeled plus one info diagnostic (ir-design §7.2 clarification). opDeclPtr
// binding's RequestContentTypes. Body optionality lands on Payload.Required,
// always set here because OpenAPI always states it — an undeclared `required`
// means false by the specification's own default, not silence, so leaving the
// field nil would report the format as unable to express optionality. opDeclPtr
// is the operation's own declaration pointer, so a $ref'd body interns its
// content once at its component pointer rather than once per mount site
// (issue #107) — and under the component's name, since the operationId hint
Expand All @@ -739,13 +740,8 @@ func lowerRequestBody(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorI
if payload == nil {
return diags
}
if !rb.GetRequired() {
schema.Preserve(c, &payload.Unmodeled, "openapi:required", ir.RawValue("false"),
ir.ReasonNoIRHome, bodyPtr+ids.Ptr("required"))

diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, bodyPtr,
"request body is not required; optionality kept under Unmodeled"))
}
required := rb.GetRequired()
payload.Required = &required
// soa.RequestBody exposes no GetExtensions at this library version, so the
// field is read directly — as XMLHints already reads its own. Both reads sit
// after the payload guard because ir.Payload is the body's only carrier: a
Expand Down
66 changes: 54 additions & 12 deletions compilers/openapi/internal/operation/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,17 +293,59 @@ func TestContent_NonRequiredRequestBody(t *testing.T) {
openapitest.RequireNoErrorDiags(t, diags)
op := openapitest.FirstOp(t, svc)
require.NotNil(t, op.Request, "a non-required body still lowers to a present Payload")
raw, ok := op.Request.Unmodeled["openapi:required"]
require.True(t, ok, "body optionality kept under Unmodeled")
assert.Equal(t, "false", string(raw.Value))
assert.Equal(t, ir.ReasonNoIRHome, raw.Reason)
found := false
require.NotNil(t, op.Request.Required, "OpenAPI always states body optionality")
assert.False(t, *op.Request.Required)
assert.NotContains(t, op.Request.Unmodeled, "openapi:required",
"the typed field carries the fact, so no sentinel is written beside it")
for _, d := range diags {
if d.Severity == ir.SeverityInfo && strings.Contains(d.Message, "request body") {
found = true
}
assert.NotContains(t, d.Message, "request body",
"a typed fact is not a degraded construct")
}
assert.True(t, found, "non-required body emits one info diagnostic")
}

// TestContent_RequiredRequestBody is TestContent_NonRequiredRequestBody's other
// arm: `required: true` must reach the same field rather than being encoded as
// the sentinel's absence, which is what made a consumer read every body alike.
func TestContent_RequiredRequestBody(t *testing.T) {
t.Parallel()
spec := openapitest.PathsSpec(` /must:
post:
operationId: must
requestBody:
required: true
content:
application/json: {schema: {type: object, properties: {n: {type: string}}}}
responses: {"200": {description: ok}}
`)
_, svc, diags := lowerServiceSpec(t, spec)
openapitest.RequireNoErrorDiags(t, diags)
op := openapitest.FirstOp(t, svc)
require.NotNil(t, op.Request)
require.NotNil(t, op.Request.Required)
assert.True(t, *op.Request.Required)
}

// TestContent_ResponsePayloadStatesNoOptionality pins the third state: only a
// request body can be omitted, so a response Payload leaves Required nil and a
// consumer reading it as "false" would be inventing a fact.
func TestContent_ResponsePayloadStatesNoOptionality(t *testing.T) {
t.Parallel()
spec := openapitest.PathsSpec(` /get:
get:
operationId: getThing
responses:
"200":
description: ok
content:
application/json: {schema: {type: object, properties: {n: {type: string}}}}
`)
_, svc, diags := lowerServiceSpec(t, spec)
openapitest.RequireNoErrorDiags(t, diags)
op := openapitest.FirstOp(t, svc)
require.Len(t, op.Responses, 1)
require.NotNil(t, op.Responses[0].Payload)
assert.Nil(t, op.Responses[0].Payload.Required,
"a response body has no optionality to state")
}

func TestContent_ArrayMultipartPartMulti(t *testing.T) {
Expand Down Expand Up @@ -405,10 +447,10 @@ func TestContent_FullPipeline(t *testing.T) {
doc, diags := parseFull(t, contentSpec)
upload := openapitest.FindOp(t, doc, "upload")

// Non-required body preserved as present with optionality under Unmodeled.
// Non-required body preserved as present, optionality on the typed field.
require.NotNil(t, upload.Request)
_, hasReq := upload.Request.Unmodeled["openapi:required"]
assert.True(t, hasReq, "non-required optionality preserved")
require.NotNil(t, upload.Request.Required, "optionality preserved")
assert.False(t, *upload.Request.Required)

// Multipart encoding: comma-split content types, header, style/explode, file flag.
hb := upload.Bindings.HTTP[0]
Expand Down
26 changes: 20 additions & 6 deletions compilers/openapi/internal/operation/operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,7 @@ func TestResponses_RefdErrorAndDefaultInternAtDeclaration(t *testing.T) {
}
}

const sharedOptionalBodySpec = `openapi: 3.1.0
const sharedDefectiveBodySpec = `openapi: 3.1.0
info: {title: T, version: "1"}
paths:
/a:
Expand All @@ -1243,7 +1243,7 @@ components:
required: false
content:
Comment thread
fuad-daoud marked this conversation as resolved.
application/json:
schema: {type: object, properties: {n: {type: string}}}
schema: {type: string, required: [n]}
responses:
Err:
description: err
Expand All @@ -1256,13 +1256,14 @@ components:

// TestDiag_SharedDeclarationReportsEachDefectOnce pins the consequence of
// lowering a referenced component at its declaration: both operations reach the
// same optional body and the same header-bearing error response, so each defect
// 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.
func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) {
t.Parallel()
_, diags := parseFull(t, sharedOptionalBodySpec)
doc, diags := parseFull(t, sharedDefectiveBodySpec)

seen := map[string]int{}
for _, d := range diags {
Expand All @@ -1274,8 +1275,21 @@ 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 optional body, the homeless error headers and the homeless error media type "+
"are three distinct defects")
"the body schema's homeless required, the homeless error headers and the homeless "+
"error media type are three 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
// de-duplicates the diagnostics, not the field.
for _, name := range []string{"postA", "postB"} {
op := openapitest.FindOp(t, doc, name)
require.NotNil(t, op.Request, "%s has a body", name)
require.NotNil(t, op.Request.Required, "%s: OpenAPI states body optionality", name)
assert.False(t, *op.Request.Required, "%s: the component declares required: false", name)
}
assert.NotSame(t, openapitest.FindOp(t, doc, "postA").Request.Required,
openapitest.FindOp(t, doc, "postB").Request.Required,
"each use site owns its flag, so an emitter mutating one cannot reach the other")
}

// TestDiag_DistinctDefectsAtOnePointerBothSurvive is the control for the rule
Expand Down
17 changes: 9 additions & 8 deletions compilers/openapi/internal/operation/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIn
func lowerParameter(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, p *soa.Parameter, pptr string) (ir.Parameter, ir.HTTPParamBinding, []ir.Diagnostic) {
name, in := p.GetName(), p.GetIn()
param := ir.Parameter{
Name: compile.NamingFor(name),
Required: p.GetRequired() || in == soa.ParameterInPath,
Name: compile.NamingFor(name),
Required: p.GetRequired() || in == soa.ParameterInPath,
Provenance: c.ProvenanceAt(pptr),
}
style, explode := resolveStyleExplode(p, in)
binding := ir.HTTPParamBinding{
Expand Down Expand Up @@ -263,11 +264,10 @@ func paramHoldsResidue(keyword string) bool {
// schema-derived annotations fillParamSchema already recorded rather than
// erasing them with an unset value.
//
// It is the one carrier of an ir.Deprecation that does not promote a vendor
// extension into it: ir.Parameter has no Provenance, so there is nowhere to
// record that the field was read by a heuristic, and ir-design §12's promotion
// rules require that before the reading. Giving Parameter a provenance is a
// change to that document, not to this file (GitHub #252).
// The extension promotion runs last, after the parameter's own extensions have
// been preserved: PromoteDeprecation reads the kept Unmodeled entries rather
// than the source node, so a parameter whose x-* keys are not in the map yet
// has nothing to promote from (GitHub #423).
func fillParamDetail(c lowering.Ctx, param *ir.Parameter, p *soa.Parameter, pptr string) []ir.Diagnostic {
if d := p.GetDescription(); d != "" {
param.Docs.Description = d
Expand All @@ -283,7 +283,8 @@ func fillParamDetail(c lowering.Ctx, param *ir.Parameter, p *soa.Parameter, pptr
diags = append(diags, extDiags...)
param.Unmodeled = annotation.MergeUnmodeled(param.Unmodeled, pExt)
diags = append(diags, annotation.UnknownKeysIn(&param.Unmodeled, p, c.SrcIndex, pptr)...)
return append(diags, preserveAllowEmptyValue(c, param, p, pptr)...)
diags = append(diags, preserveAllowEmptyValue(c, param, p, pptr)...)
return append(diags, c.PromoteDeprecation(param.Unmodeled, param.Deprecation, &param.Provenance)...)
}

// preserveAllowEmptyValue keeps a parameter's allowEmptyValue flag. It says a
Expand Down
55 changes: 55 additions & 0 deletions compilers/openapi/internal/operation/params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,61 @@ func TestParams_ComponentRefSharedAcrossOperationsInternsOnce(t *testing.T) {
assert.False(t, fabricatedB, "no fabricated per-operation ID for /b")
}

const paramProvenanceSpec = `openapi: 3.1.0
info: {title: T, version: "1"}
paths:
/pets/{petId}:
parameters:
- {name: petId, in: path, required: true, schema: {type: string}}
get:
operationId: getPet
parameters:
- {name: fields, in: query, schema: {type: string}}
- {$ref: '#/components/parameters/Page'}
responses: {"200": {description: ok}}
delete:
operationId: deletePet
responses: {"200": {description: ok}}
components:
parameters:
Page: {name: page, in: query, schema: {type: integer}}
`

// TestParams_ProvenanceIsTheDeclaringPosition pins where a parameter says it
// came from (GitHub #423). The three positions a parameter can be written at
// each answer differently, and the merge is why: an operation's own entry sits
// under that operation, a $ref'd one under the component it names, and a
// path-item one under the path item — the last shared by every operation on the
// path. Only the inline entries tell an inherited parameter from a declared
// one: a $ref'd entry lands on its component from either mount, and the mount
// site is not recorded.
func TestParams_ProvenanceIsTheDeclaringPosition(t *testing.T) {
t.Parallel()
doc, diags := parseFull(t, paramProvenanceSpec)
openapitest.RequireNoErrorDiags(t, diags)
getPet := openapitest.FindOp(t, doc, "getPet")
deletePet := openapitest.FindOp(t, doc, "deletePet")
byName := openapitest.IndexBy(getPet.Params, func(p ir.Parameter) string { return p.Name.Source })
require.Len(t, byName, 3, "two declared plus the inherited path-item one")

assert.Equal(t, "/paths/~1pets~1{petId}/get/parameters/0", byName["fields"].Provenance.Pointer,
"an operation's own entry is declared under that operation")
assert.Equal(t, "/components/parameters/Page", byName["page"].Provenance.Pointer,
"a $ref'd entry is declared at the component it names, not at the use site")

const pathItem = "/paths/~1pets~1{petId}/parameters/0"
assert.Equal(t, pathItem, byName["petId"].Provenance.Pointer,
"an inherited entry keeps the path item's pointer rather than the operation it merged into")
require.Len(t, deletePet.Params, 1)
assert.Equal(t, pathItem, deletePet.Params[0].Provenance.Pointer,
"and both operations on the path name the one declaration, not one pointer each")

for name, p := range byName {
assert.Equal(t, 0, p.Provenance.Source, "%s addresses the compiled source", name)
assert.Empty(t, p.Provenance.Inferred, "%s is declared, not inferred", name)
}
}

const componentContentParamRefSpec = `openapi: 3.1.0
info: {title: T, version: "1"}
paths:
Expand Down
4 changes: 2 additions & 2 deletions compilers/openapi/internal/schema/accumulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ func AppendExample(c lowering.Ctx, out []ir.Example, proto ir.Example, node *yam
return append(out, proto), nil
}

// Preserve records raw under key in *p with why it was kept and where it was
// preserve records raw under key in *p with why it was kept and where it was
// written, allocating the map on first write. An absent or unconvertible
// payload records nothing, so no caller needs a nil guard of its own.
func Preserve(c lowering.Ctx, p *ir.Unmodeled, key string, raw ir.RawValue,
func preserve(c lowering.Ctx, p *ir.Unmodeled, key string, raw ir.RawValue,
reason ir.UnmodeledReason, pointer string,
) {
annotation.PreserveInto(p, key, raw, reason, pointer, c.SrcIndex)
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func applyFalseBranches(c lowering.Ctx, m *ir.Model, s *oas3.Schema, pointer str
}
bptr := pointer + ids.Ptr("allOf", strconv.Itoa(i))
m.Additional = ir.AdditionalClosed
Preserve(c, &m.Unmodeled, "openapi:allOf/"+strconv.Itoa(i),
preserve(c, &m.Unmodeled, "openapi:allOf/"+strconv.Itoa(i),
ir.RawValue("false"), ir.ReasonDegradedLowering, bptr)

diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.FalseSchema, bptr,
Expand Down
4 changes: 2 additions & 2 deletions compilers/openapi/internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o
pointer, pointer+ids.Ptr(kw), kw)...)
continue
}
Preserve(c, &common.Unmodeled, "openapi:"+kw, raw, reason, pointer+ids.Ptr(kw))
preserve(c, &common.Unmodeled, "openapi:"+kw, raw, reason, pointer+ids.Ptr(kw))
kept = kept || len(raw) > 0
}
if reason == ir.ReasonValidationOnly || !kept {
Expand Down Expand Up @@ -494,7 +494,7 @@ func falseSchema(c lowering.Ctx, ts *compile.Types, pointer, hint string) (ir.Ty
// The key names the position rather than a keyword, because a boolean
// schema writes none. Nothing can collide with it: a schema that is a
// boolean has no other keywords to preserve.
Preserve(c, &common.Unmodeled, "openapi:schema",
preserve(c, &common.Unmodeled, "openapi:schema",
ir.RawValue("false"), ir.ReasonDegradedLowering, pointer)

diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.FalseSchema, pointer,
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/internal/schema/schema_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestPreserve_EmptyRawIsRejectedLikeNil(t *testing.T) {
t.Parallel()
l := &lowerer{}
var p ir.Unmodeled
Preserve(l.ctx, &p, "openapi:k", raw, ir.ReasonVendorExtension, "/p/k")
preserve(l.ctx, &p, "openapi:k", raw, ir.ReasonVendorExtension, "/p/k")
assert.Nil(t, p, "a payload with no bytes preserves no construct")

var q ir.Unmodeled
Expand Down
Loading
Loading