Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 51 additions & 36 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1510,62 +1510,63 @@ func assertConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
}

// assertCoDeclaredBounds pins the 2020-12 rule that a side declaring both of
// its keywords keeps the tighter of the two: the property bounded below keeps
// its minimum, the one bounded above keeps its exclusiveMaximum, and each side
// names the keyword that did not reach ir.Constraints (GitHub #33).
// its keywords carries both: minimum and exclusiveMinimum are independent and
// conjunctive, ir.Constraints has a field for each, and neither is chosen over
// the other (GitHub #33, #425).
//
// Both directions are here on purpose. A case where only the exclusive keyword
// survives passes just as well on the reader that always took it, so on its own
// it would say nothing about the fix.
// Both directions are here on purpose. The property bounded below has the
// inclusive keyword as its tighter bound and the one bounded above the
// exclusive one, so a reader that kept the tighter alone answers the two
// differently — and a reader that always kept the exclusive keyword passes the
// second on its own.
func assertCoDeclaredBounds(t *testing.T, m *ir.Model, diags []ir.Diagnostic) {
t.Helper()
low, ok := propByWire(m, "atLeastTen")
require.True(t, ok)
require.NotNil(t, low.Constraints)
require.NotNil(t, low.Constraints.Min)
assert.Equal(t, ir.BigVal("10"), *low.Constraints.Min, "minimum is the tighter bound")
assert.False(t, low.Constraints.ExclusiveMin, "and it is inclusive as written")
require.NotNil(t, low.Constraints.ExclusiveMin)
assert.Equal(t, ir.BigVal("10"), *low.Constraints.Min, "minimum as written")
assert.Equal(t, ir.BigVal("0"), *low.Constraints.ExclusiveMin,
"and the looser exclusiveMinimum beside it, not dropped for being implied")

high, ok := propByWire(m, "underTen")
require.True(t, ok)
require.NotNil(t, high.Constraints)
require.NotNil(t, high.Constraints.Max)
assert.Equal(t, ir.BigVal("10"), *high.Constraints.Max, "exclusiveMaximum is the tighter bound")
assert.True(t, high.Constraints.ExclusiveMax)
require.NotNil(t, high.Constraints.ExclusiveMax)
assert.Equal(t, ir.BigVal("100"), *high.Constraints.Max, "maximum as written")
assert.Equal(t, ir.BigVal("10"), *high.Constraints.ExclusiveMax, "and exclusiveMaximum beside it")

for _, want := range []string{"exclusiveMinimum, which it implies", "maximum, which it implies"} {
assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool {
return strings.Contains(d.Message, want)
}), "the keyword ir.Constraints has no room for is named, not dropped in silence: %q", want)
for _, d := range diags {
assert.NotContains(t, d.Message, "exclusiveMinimum",
"a pair that reaches two fields is not a degradation to report")
}
}

// assertCoDeclaredBoundKept is the losslessness half of the same rule
// (GitHub #286): a keyword named only in a diagnostic reaches no field of the
// document a downstream stage reads, so {minimum: 10, exclusiveMinimum: 0} and
// {minimum: 10} lowered identically. It is kept verbatim on whichever carrier
// read it — the property here, the alias node a component's body reduces to
// below — beside the constraints it did not reach.
// assertCoDeclaredBoundKept is the losslessness half of the same rule: with a
// field per keyword there is nothing left over, so neither carrier keeps a bound
// verbatim. Nothing is restated beside constraints that hold it all — an entry
// there would give one bound two homes, and {minimum: 10, exclusiveMinimum: 0}
// is told from {minimum: 10} by the fields themselves (GitHub #286).
func assertCoDeclaredBoundKept(t *testing.T, doc *ir.Document, m *ir.Model) {
t.Helper()
low, ok := propByWire(m, "atLeastTen")
require.True(t, ok)
entry := unmodeledEntry(t, low.Unmodeled, "openapi:exclusiveMinimum")
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
assert.JSONEq(t, "0", string(entry.Value))
assert.Equal(t, "/components/schemas/S/properties/atLeastTen/exclusiveMinimum",
entry.Provenance.Pointer)
assert.Empty(t, low.Unmodeled, "the property keeps nothing beside its constraints")

high, ok := propByWire(m, "underTen")
require.True(t, ok)
assert.JSONEq(t, "100", string(unmodeledEntry(t, high.Unmodeled, "openapi:maximum").Value),
"the inclusive keyword is the one kept where the exclusive bound is tighter")
assert.Empty(t, high.Unmodeled, "and neither does the side settled the other way")

alias, ok := doc.Types[namedID("Bounded")].(*ir.Scalar)
require.True(t, ok, "a component reducing to a shared primitive owns an alias node")
require.NotNil(t, alias.Constraints)
assert.JSONEq(t, "0", string(unmodeledEntry(t, alias.Unmodeled, "openapi:exclusiveMinimum").Value),
"a node carries what its constraints had no room for, exactly as a property does")
require.NotNil(t, alias.Constraints.Min)
require.NotNil(t, alias.Constraints.ExclusiveMin)
assert.Equal(t, ir.BigVal("0"), *alias.Constraints.ExclusiveMin,
"a node carries both bounds, exactly as a property does")
assert.Empty(t, alias.Unmodeled)
}

// assertLengthAndCollectionBounds pins the non-numeric bounds: a string length
Expand Down Expand Up @@ -1613,12 +1614,10 @@ func assertNumericPrecision(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
exclusive, ok := propByWire(m, "exclusive")
require.True(t, ok)
require.NotNil(t, exclusive.Constraints)
require.NotNil(t, exclusive.Constraints.Min)
require.NotNil(t, exclusive.Constraints.Max)
assert.True(t, exclusive.Constraints.ExclusiveMin)
assert.True(t, exclusive.Constraints.ExclusiveMax)
assert.Equal(t, ir.BigVal("0.5"), *exclusive.Constraints.Min)
assert.Equal(t, ir.BigVal("0.12345678901234567890123456789"), *exclusive.Constraints.Max)
require.NotNil(t, exclusive.Constraints.ExclusiveMin)
require.NotNil(t, exclusive.Constraints.ExclusiveMax)
assert.Equal(t, ir.BigVal("0.5"), *exclusive.Constraints.ExclusiveMin)
assert.Equal(t, ir.BigVal("0.12345678901234567890123456789"), *exclusive.Constraints.ExclusiveMax)

// A default beyond float64 range is captured as a number, not a string.
withDefault, ok := propByWire(m, "withDefault")
Expand Down Expand Up @@ -2150,6 +2149,12 @@ func assertParamQuerystring(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
// silent, and from the use site when it is not. Constraints inherit at neither
// carrier, so the identical property is asserted beside it — a parameter must not
// take more from a referent than a property does (GitHub #131).
//
// The bound the use site declares beside the $ref is asserted at both carriers
// too, against the referent's own: it is what makes the split observable rather
// than merely absent, and it is the case use-site precedence would get wrong,
// publishing 100 as the whole truth while the document enforces 64 (§12.2,
// GitHub #428).
func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
op, ok := opByName(doc, "listItems")
require.True(t, ok)
Expand All @@ -2169,6 +2174,10 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic
"...and a keyword the use site is silent about still inherits")
require.NotNil(t, override.Default)
assert.Equal(t, "9", override.Default.Str)
require.NotNil(t, override.Constraints, "a bound beside the $ref lands on the carrier")
require.NotNil(t, override.Constraints.MaxLength)
assert.Equal(t, int64(100), *override.Constraints.MaxLength,
"and it is the use site's own, not narrowed against the referent's here")

holder, ok := doc.Types[namedID("Holder")].(*ir.Model)
require.True(t, ok)
Expand All @@ -2180,12 +2189,18 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic
assert.Equal(t, *cursor.Default, *prop.Default)
assert.Nil(t, prop.Constraints, "neither carrier inherits the referent's constraints")

overrideProp, ok := propByWire(holder, "override")
require.True(t, ok)
assert.Equal(t, override.Constraints, overrideProp.Constraints,
"and a property keeps its own bound exactly as the parameter does")

decl, ok := doc.Types[namedID("Cursor")].(*ir.Scalar)
require.True(t, ok)
require.NotNil(t, decl.Constraints)
require.NotNil(t, decl.Constraints.MaxLength)
assert.Equal(t, int64(64), *decl.Constraints.MaxLength,
"a consumer that wants the bound reads it off the referent")
"a consumer that wants the bound reads it off the referent, and conjoins "+
"it with the use site's: both are in force, and 64 is the narrower")
}

// assertHeaderContentSchema pins that both spellings of a header's type lower
Expand Down
20 changes: 12 additions & 8 deletions compilers/openapi/conformance_unmodeled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,10 +392,11 @@ func assertDependentRequired(t *testing.T, doc *ir.Document, diags []ir.Diagnost
diagsAt(diags, "openapi/validation-only-keyword", "/components/schemas/Card"))
}

// assertContentVocabulary pins the 2020-12 content vocabulary: contentEncoding
// and contentMediaType are an encoding and lower into ir.Encoding, contentSchema
// is a schema and has no IR home anywhere, and a position with no Encoding field
// at all keeps both of the first two verbatim (GitHub #125).
// assertContentVocabulary pins the 2020-12 content vocabulary: all three
// keywords lower into ir.Encoding — contentEncoding and contentMediaType as
// names, contentSchema as a reference to the type it hoists — and a position
// with no Encoding field at all keeps all three verbatim (GitHub #125,
// GitHub #426).
func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
thumb, ok := doc.Types[namedID("Thumbnail")].(*ir.Scalar)
require.True(t, ok)
Expand All @@ -408,13 +409,16 @@ func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic)
require.True(t, ok)
require.NotNil(t, env.Encoding)
assert.Equal(t, "application/json", env.Encoding.MediaType)
entry := unmodeledEntry(t, env.Unmodeled, "openapi:contentSchema")
assert.Equal(t, ir.ReasonNoIRHome, entry.Reason)
assert.JSONEq(t, `{"type":"object","properties":{"id":{"type":"string"}}}`, string(entry.Value))
require.NotNil(t, env.Encoding.Schema, "contentSchema has a home on ir.Encoding")
assert.Empty(t, env.Unmodeled, "so it is lowered, never also kept raw")
decoded, ok := doc.Types[env.Encoding.Schema.Target].(*ir.Model)
require.True(t, ok, "and it reaches the registry as a type rather than a blob")
require.Len(t, decoded.Properties, 1)
assert.Equal(t, "id", decoded.Properties[0].WireName)

bag, ok := doc.Types[namedID("Bag")].(*ir.Model)
require.True(t, ok)
for _, key := range []string{"openapi:contentEncoding", "openapi:contentMediaType"} {
for _, key := range []string{"openapi:contentEncoding", "openapi:contentMediaType", "openapi:contentSchema"} {
assert.Equal(t, ir.ReasonNoIRHome, unmodeledEntry(t, bag.Unmodeled, key).Reason,
"an object has no Encoding field, so %s is kept", key)
}
Expand Down
27 changes: 16 additions & 11 deletions compilers/openapi/constraints_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ func TestConstraints_ExclusiveBoolean30(t *testing.T) {
doc, diags := lowerSpec(t, spec)
openapitest.RequireNoErrorDiags(t, diags)
c := propConstraints(t, doc, "S", "n")
assert.True(t, c.ExclusiveMin)
assert.True(t, c.ExclusiveMax)
require.NotNil(t, c.Min)
assert.Equal(t, ir.BigVal("5"), *c.Min)
// The modifier is a spelling of the exclusive bound, so the literal it
// modified lands in the exclusive field and the inclusive one is left empty
// — the same constraints the 2020-12 spelling of "x > 5, x < 10" produces.
assert.Nil(t, c.Min, "the modified minimum does not also stay inclusive")
assert.Nil(t, c.Max)
require.NotNil(t, c.ExclusiveMin)
require.NotNil(t, c.ExclusiveMax)
assert.Equal(t, ir.BigVal("5"), *c.ExclusiveMin)
assert.Equal(t, ir.BigVal("10"), *c.ExclusiveMax)
}

func TestConstraints_ExclusiveNumeric31(t *testing.T) {
Expand All @@ -49,12 +54,12 @@ func TestConstraints_ExclusiveNumeric31(t *testing.T) {
doc, diags := lowerSpec(t, spec)
openapitest.RequireNoErrorDiags(t, diags)
c := propConstraints(t, doc, "S", "n")
assert.True(t, c.ExclusiveMin)
assert.True(t, c.ExclusiveMax)
require.NotNil(t, c.Min)
require.NotNil(t, c.Max)
assert.Equal(t, ir.BigVal("1.5"), *c.Min)
assert.Equal(t, ir.BigVal("9.5"), *c.Max)
assert.Nil(t, c.Min)
assert.Nil(t, c.Max)
require.NotNil(t, c.ExclusiveMin)
require.NotNil(t, c.ExclusiveMax)
assert.Equal(t, ir.BigVal("1.5"), *c.ExclusiveMin)
assert.Equal(t, ir.BigVal("9.5"), *c.ExclusiveMax)
}

func TestConstraints_MalformedNumericLiterals(t *testing.T) {
Expand Down Expand Up @@ -174,7 +179,7 @@ func TestConstraints_ExclusiveWrongDialectForm(t *testing.T) {
require.True(t, ok)
for _, p := range m.Properties {
if p.WireName == "n" && p.Constraints != nil {
assert.False(t, p.Constraints.ExclusiveMin, "wrong-form exclusive bound is not set")
assert.Nil(t, p.Constraints.ExclusiveMin, "wrong-form exclusive bound is not set")
}
}
})
Expand Down
40 changes: 10 additions & 30 deletions compilers/openapi/internal/annotation/annotation.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,43 +639,23 @@ func subObjectKeys(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled,

// unmodeledAt collects every keyword a site declares that the IR keeps verbatim
// instead of modelling, each under the reason that says which of those it is
// (§12): validation logic the IR draws a boundary against (§4.7), data with no IR
// field yet, and JSON Schema resource/dialect metadata the IR excludes on
// purpose.
// (§12): validation logic the IR draws a boundary against (§4.7), and JSON
// Schema resource/dialect metadata the IR excludes on purpose.
//
// The content vocabulary is not read here even though it is data with an IR
// home: whether contentEncoding, contentMediaType and contentSchema reached
// ir.Encoding depends on what the position lowered to, which only the schema
// package can answer — schema.recordUnplacedContent asks the node that was
// built rather than the keyword that was written.
func unmodeledAt(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled, []ir.Diagnostic) {
vOnly, vDiags := validationOnlyAt(s, pointer, srcIndex)
noHome, nhDiags := noIRHomeAt(s, pointer, srcIndex)
dialect, dDiags := dialectAt(s, pointer, srcIndex)

diags := make([]ir.Diagnostic, 0, len(vDiags)+len(nhDiags)+len(dDiags))
diags := make([]ir.Diagnostic, 0, len(vDiags)+len(dDiags))
diags = append(diags, vDiags...)
diags = append(diags, nhDiags...)
diags = append(diags, dDiags...)

return MergeUnmodeled(MergeUnmodeled(vOnly, noHome), dialect), diags
}

// noIRHomeAt collects the keywords a schema declares that describe real data yet
// have no field at any IR position. Unlike the §4.7 family these are gaps
// expected to close rather than a boundary the IR draws, which is what
// ReasonNoIRHome says and ReasonValidationOnly would not (§12).
//
// Site-only: contentSchema describes the value at the position that wrote it.
func noIRHomeAt(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled, []ir.Diagnostic) {
if s.GetContentSchema() == nil {
return nil, nil
}
at := pointer + ids.Ptr("contentSchema")
var p ir.Unmodeled
kept, diags := PreserveNodeInto(&p, "openapi:contentSchema", RawPropertyNode(s, "contentSchema"),
ir.ReasonNoIRHome, at, srcIndex)
if !kept {
return nil, diags
}
return p, []ir.Diagnostic{diag.Newf(ir.SeverityInfo, diag.DegradedConstruct,
ir.Provenance{Source: srcIndex, Pointer: at},
"contentSchema is the shape of the decoded content and no IR position has a field "+
"for it; kept verbatim under Unmodeled")}
return MergeUnmodeled(vOnly, dialect), diags
}

// DialectKeywords are the JSON Schema resource and dialect keywords the IR
Expand Down
37 changes: 0 additions & 37 deletions compilers/openapi/internal/annotation/annotation_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,23 +148,6 @@ func TestDialectAt_KeepsEachKeywordOutOfScope(t *testing.T) {
"and the hoist gate agrees a node is needed to hold them")
}

// TestNoIRHomeAt_ContentSchemaIsKeptNotExcluded pins the reason split the other
// way: contentSchema is real data shape with no field yet, a gap expected to
// close, so it must not be filed as a deliberate exclusion.
func TestNoIRHomeAt_ContentSchemaIsKeptNotExcluded(t *testing.T) {
t.Parallel()
s := schemaFromYAML(t, "type: string\ncontentSchema: {type: object}\n")

got, diags := noIRHomeAt(s, "/components/schemas/S", 0)

entry, ok := got["openapi:contentSchema"]
require.True(t, ok)
assert.JSONEq(t, `{"type":"object"}`, string(entry.Value))
assert.Equal(t, ir.ReasonNoIRHome, entry.Reason)
require.Len(t, diags, 1)
assert.Equal(t, "/components/schemas/S/contentSchema", diags[0].Provenance.Pointer)
}

// schemaFromYAML unmarshals body as a bare schema through the same marshaller
// the compiler's loader parses documents with, so the raw nodes the verbatim
// readers read off are present. A schema built in Go carries none, which
Expand All @@ -180,26 +163,6 @@ func schemaFromYAML(t *testing.T, body string) *oas3.Schema {
return s
}

// TestNoIRHomeAt_ModelSetWithoutRawSourceRecordsNothing pins the guard between
// the model and the raw tree. contentSchema is kept verbatim, so it is read off
// the source node rather than the parsed model — and a schema built in memory,
// or one whose value cannot be converted to JSON, has a model field set with no
// bytes behind it. Recording an entry there would announce a preservation with
// nothing preserved, so the collector reports nothing instead.
func TestNoIRHomeAt_ModelSetWithoutRawSourceRecordsNothing(t *testing.T) {
t.Parallel()
inner := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](
&oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaTypeObject)})
s := &oas3.Schema{ContentSchema: inner}
require.NotNil(t, s.GetContentSchema(), "the model reports the keyword as set")
require.Nil(t, RawPropertyNode(s, "contentSchema"), "and no raw node backs it")

got, diags := noIRHomeAt(s, "/components/schemas/A", 0)

assert.Nil(t, got, "no entry is recorded when there are no bytes to record")
assert.Empty(t, diags, "and nothing is announced, so the two channels agree")
}

// TestKind_String covers both named values and the default case, so an
// assertion failure or test diff over a Kind prints a name instead of a bare
// int.
Expand Down
Loading
Loading