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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func TestConformance(t *testing.T) {
{"nullable-30", assertNullable30},
{"defaults", assertDefaults},
{"constraints", assertConstraints},
{"numeric-precision", assertNumericPrecision},
{"readonly-writeonly", assertReadOnlyWriteOnly},
{"recursive", assertRecursive},
{"maps", assertMaps},
Expand Down Expand Up @@ -327,6 +328,64 @@ func assertConstraints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
assert.Equal(t, ir.BigVal("0.1"), *c.MultipleOf)
}

func assertNumericPrecision(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
m, ok := doc.Types[namedID("S")].(*ir.Model)
require.True(t, ok)

// Bounds beyond float64 range and a huge integer beyond int64, exact.
bounded, ok := propByWire(m, "bounded")
require.True(t, ok)
require.NotNil(t, bounded.Constraints)
require.NotNil(t, bounded.Constraints.Min)
require.NotNil(t, bounded.Constraints.Max)
require.NotNil(t, bounded.Constraints.MultipleOf)
assert.Equal(t, ir.BigVal("1.8e308"), *bounded.Constraints.Min)
assert.Equal(t, ir.BigVal("123456789012345678901234567890"), *bounded.Constraints.Max)
assert.Equal(t, ir.BigVal("1e-30"), *bounded.Constraints.MultipleOf)

// A leading-dot spelling is canonicalized to JSON form; a high-precision
// decimal is kept to the last digit.
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)

// A default beyond float64 range is captured as a number, not a string.
withDefault, ok := propByWire(m, "withDefault")
require.True(t, ok)
require.NotNil(t, withDefault.Default)
assert.Equal(t, ir.ValueNumber, withDefault.Default.Kind)
assert.Equal(t, ir.BigVal("1.8e308"), withDefault.Default.Num)

// A const beyond float64 range hoists a Literal over the exact number.
pinned, ok := propByWire(m, "pinned")
require.True(t, ok)
lit, ok := doc.Types[pinned.Type.Target].(*ir.Literal)
require.True(t, ok)
assert.Equal(t, ir.ValueNumber, lit.Value.Kind)
assert.Equal(t, ir.BigVal("1.8e308"), lit.Value.Num)

// Numeric enum members keep their exact value past int64 range.
choice, ok := propByWire(m, "choice")
require.True(t, ok)
enum, ok := doc.Types[choice.Type.Target].(*ir.Enum)
require.True(t, ok)
require.Len(t, enum.Members, 2)
assert.Equal(t, ir.BigVal("123456789012345678901234567890"), enum.Members[1].Value.Num)

// A leading-dot example is canonicalized losslessly.
sampled, ok := propByWire(m, "sampled")
require.True(t, ok)
require.Len(t, sampled.Examples, 1)
require.NotNil(t, sampled.Examples[0].Value)
assert.Equal(t, ir.BigVal("0.5"), sampled.Examples[0].Value.Num)
}

func assertReadOnlyWriteOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
m, ok := doc.Types[namedID("S")].(*ir.Model)
require.True(t, ok)
Expand Down
87 changes: 73 additions & 14 deletions compilers/openapi/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import (
// value constraints into an ir.Constraints. Numeric bounds are read from the raw
// YAML nodes, never the *float64 model fields, so full decimal precision is
// preserved (the no-float64 invariant). Collection bounds (minItems/maxItems/
// uniqueItems) are List-owned and read elsewhere. A malformed numeric literal
// yields a codeNumericPrecision diagnostic and is skipped; nil is returned when
// no constraint is present.
func constraintsFromSchema(s *oas3.Schema) (*ir.Constraints, []ir.Diagnostic) {
// uniqueItems) are List-owned and read elsewhere. A bound literal that is not a
// finite number yields an error-severity codeNumericPrecision diagnostic and is
// skipped; nil is returned when no constraint is present. exclusiveBoolean selects
// the exclusiveMinimum/exclusiveMaximum dialect (see applyExclusive).
func constraintsFromSchema(s *oas3.Schema, exclusiveBoolean bool) (*ir.Constraints, []ir.Diagnostic) {
if s == nil {
return nil, nil
}
c := &ir.Constraints{}
diags := numericBounds(c, s)
diags = append(diags, applyExclusive(c, s, true)...)
diags = append(diags, applyExclusive(c, s, false)...)
diags = append(diags, applyExclusive(c, s, true, exclusiveBoolean)...)
diags = append(diags, applyExclusive(c, s, false, exclusiveBoolean)...)
c.MinLength = s.MinLength
c.MaxLength = s.MaxLength
c.Pattern = s.GetPattern()
Expand Down Expand Up @@ -51,27 +52,46 @@ func numericBounds(c *ir.Constraints, s *oas3.Schema) []ir.Diagnostic {
}
v, err := ir.NewBigVal(node.Value)
if err != nil {
diags = append(diags, diagf(ir.SeverityWarning, codeNumericPrecision,
ir.Provenance{}, "%s literal %q: %s", b.prop, node.Value, err.Error()))
diags = append(diags, boundLiteralDiag(b.prop, node.Value, err))
continue
}
*b.dst = &v
}
return diags
}

// boundLiteralDiag reports a numeric bound whose literal is not a finite number.
// Morphic — not the library's float64 model — is authoritative for these
// keywords: load suppresses the library's redundant float64 type-mismatch
// finding on them (a valid magnitude beyond float64 range must not fail the
// spec), so this is the sole diagnostic for a genuinely bad bound and therefore
// carries error severity — a non-numeric bound is an invalid schema, not a
// lossy-but-tolerable value.
func boundLiteralDiag(prop, literal string, err error) ir.Diagnostic {
return diagf(ir.SeverityError, codeNumericPrecision, ir.Provenance{},
"%s literal %q: %s", prop, literal, err.Error())
}

// applyExclusive handles exclusiveMinimum/exclusiveMaximum in both dialects: the
// 3.0 boolean arm flags the corresponding Min/Max as exclusive, while the
// 2020-12 numeric arm carries the bound value itself (read from the raw node to
// avoid the float64 trap) and sets the exclusive flag.
func applyExclusive(c *ir.Constraints, s *oas3.Schema, isMin bool) []ir.Diagnostic {
// 3.0 boolean arm flags the corresponding Min/Max as exclusive, while the 2020-12
// numeric arm carries the bound value itself (read from the raw node to avoid the
// float64 trap) and sets the exclusive flag. exclusiveBoolean selects the dialect
// (true for 3.0, false for the 2020-12 dialect of 3.1/3.2). Because load
// suppresses the library's type-mismatch on these keywords, Morphic validates the
// value form here: a value written in the wrong form for the dialect (a boolean
// under 2020-12, or a number under 3.0) is reported and dropped rather than
// silently accepted.
func applyExclusive(c *ir.Constraints, s *oas3.Schema, isMin, exclusiveBoolean bool) []ir.Diagnostic {
ev, prop := s.GetExclusiveMaximum(), "exclusiveMaximum"
if isMin {
ev, prop = s.GetExclusiveMinimum(), "exclusiveMinimum"
}
if ev == nil {
return nil
}
if ev.IsLeft() != exclusiveBoolean {
return []ir.Diagnostic{exclusiveFormDiag(prop, exclusiveBoolean)}
}
if ev.IsLeft() {
if b := ev.GetLeft(); b != nil && *b {
setExclusiveFlag(c, isMin)
Expand All @@ -84,13 +104,26 @@ func applyExclusive(c *ir.Constraints, s *oas3.Schema, isMin bool) []ir.Diagnost
}
v, err := ir.NewBigVal(node.Value)
if err != nil {
return []ir.Diagnostic{diagf(ir.SeverityWarning, codeNumericPrecision,
ir.Provenance{}, "%s literal %q: %s", prop, node.Value, err.Error())}
return []ir.Diagnostic{boundLiteralDiag(prop, node.Value, err)}
}
setExclusiveBound(c, isMin, &v)
return nil
}

// exclusiveFormDiag reports an exclusiveMinimum/exclusiveMaximum whose value form
// is wrong for the document's dialect: 3.0 spells it as a boolean modifier of
// minimum/maximum, while the 2020-12 dialect (3.1, 3.2) spells it as a numeric
// bound. The mismatched value carries no usable bound, so it is dropped with this
// error rather than accepted as a degenerate constraint.
func exclusiveFormDiag(prop string, exclusiveBoolean bool) ir.Diagnostic {
want := "a number"
if exclusiveBoolean {
want = "a boolean"
}
return diagf(ir.SeverityError, codeExclusiveBoundForm, ir.Provenance{},
"%s must be %s in this OpenAPI dialect", prop, want)
}

// setExclusiveFlag marks the low or high bound exclusive.
func setExclusiveFlag(c *ir.Constraints, isMin bool) {
if isMin {
Expand Down Expand Up @@ -118,3 +151,29 @@ func emptyConstraints(c *ir.Constraints) bool {
c.MultipleOf == nil && c.MinLength == nil && c.MaxLength == nil &&
c.Pattern == "" && c.MinProps == nil && c.MaxProps == nil
}

// exclusiveBoundIsBoolean reports whether this document's dialect spells
// exclusiveMinimum/exclusiveMaximum as a boolean modifier (OpenAPI 3.0) rather
// than a numeric bound (the 2020-12 dialect of 3.1 and 3.2). An unrecognized
// version defaults to the 2020-12 numeric form.
func (l *lowerer) exclusiveBoundIsBoolean() bool {
minor, _ := supportedMinor(l.doc.OpenAPI)
return minor == "3.0"
}

// appendConstraintDiags stamps constraint diagnostics with pointer's provenance
// and records them at most once per pointer. A sub-schema reached from two
// positions — its owning property and a $ref that hoists it — reads its
// constraints twice, but a malformed bound must be reported only once, so a
// second visit to an already-diagnosed pointer is dropped. The constraint data
// itself still lands on both nodes; only the diagnostic is de-duplicated.
func (l *lowerer) appendConstraintDiags(diags []ir.Diagnostic, pointer string) {
if l.diagnosedConstraints[pointer] {
return
}
l.diagnosedConstraints[pointer] = true
for i := range diags {
diags[i].Provenance = ir.Provenance{Source: l.srcIndex, Pointer: pointer}
}
l.diags = append(l.diags, diags...)
}
132 changes: 127 additions & 5 deletions compilers/openapi/constraints_edgecases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ func TestConstraints_ExclusiveBoolean30(t *testing.T) {
maximum: 10
exclusiveMaximum: true
`)
// The library flags the 3.0 boolean exclusiveMinimum form with a type-mismatch
// validation diagnostic, but still parses it, so lowering sets the flag.
doc, _ := lowerSpec(t, spec)
// The library models exclusiveMinimum/Maximum as numbers and flags the valid
// 3.0 boolean form with a type-mismatch; because those keywords are Morphic's
// to own, load suppresses that false positive, so a valid 3.0 boolean exclusive
// bound lowers cleanly with the flag set and no error diagnostic.
doc, diags := lowerSpec(t, spec)
requireNoErrorDiags(t, diags)
c := propConstraints(t, doc, "S", "n")
assert.True(t, c.ExclusiveMin)
assert.True(t, c.ExclusiveMax)
Expand Down Expand Up @@ -66,10 +69,129 @@ func TestConstraints_MalformedNumericLiterals(t *testing.T) {
for _, d := range diags {
if d.Code == codeNumericPrecision {
count++
assert.Equal(t, ir.SeverityWarning, d.Severity)
assert.Equal(t, ir.SeverityError, d.Severity)
}
}
assert.GreaterOrEqual(t, count, 2, "both malformed literals error")
}

func TestConstraints_LosslessNumericLiterals(t *testing.T) {
t.Parallel()
cases := []struct {
name string
literal string
want ir.BigVal
}{
{"beyond float64 range", "1.8e308", "1.8e308"},
{"far beyond float64 range", "1e400", "1e400"},
{"leading dot spelling", ".5", "0.5"},
{"trailing dot spelling", "5.", "5"},
{"huge integer beyond int64", "123456789012345678901234567890", "123456789012345678901234567890"},
{"high-precision decimal", "0.12345678901234567890123456789", "0.12345678901234567890123456789"},
{"exponential notation", "6.022e23", "6.022e23"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
spec := componentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: " + tc.literal + "}\n")
doc, diags := lowerSpec(t, spec)
// A valid number, however spelled, is accepted with no error: the
// library's float64/JSON complaint is not surfaced.
requireNoErrorDiags(t, diags)
c := propConstraints(t, doc, "S", "n")
require.NotNil(t, c.Min)
assert.Equal(t, tc.want, *c.Min)
})
}
}

// countErrors returns the error-severity diagnostics carrying code.
func countErrors(diags []ir.Diagnostic, code string) int {
var n int
for _, d := range diags {
if d.Code == code && d.Severity == ir.SeverityError {
n++
}
}
return n
}

// TestConstraints_HoistedSubSchemaBadBoundSingleError pins that a malformed bound
// on a component-property sub-schema reached by a $ref is reported exactly once,
// even though the sub-schema's constraints are read from two positions (the owning
// property and the $ref hoist). Without per-pointer de-duplication both reads
// would emit the same error at the same pointer.
func TestConstraints_HoistedSubSchemaBadBoundSingleError(t *testing.T) {
t.Parallel()
spec := componentSpec(" Foo:\n type: object\n properties:\n bar: {type: number, minimum: hello}\n" +
" User:\n type: object\n properties:\n b: {$ref: '#/components/schemas/Foo/properties/bar'}\n")
_, diags := lowerSpec(t, spec)
assert.Equal(t, 1, countErrors(diags, codeNumericPrecision),
"one error for the shared bad bound, got: %+v", diags)
}

// TestConstraints_ExclusiveWrongDialectForm pins that an exclusiveMinimum/Maximum
// written in the wrong form for the document's dialect is reported as a single
// error, not silently accepted as a degenerate constraint: a boolean under the
// 2020-12 dialect (3.1/3.2) and a number under 3.0.
func TestConstraints_ExclusiveWrongDialectForm(t *testing.T) {
t.Parallel()
cases := []struct {
name, version, value string
}{
{"boolean under 3.1", "3.1.0", "true"},
{"boolean under 3.2", "3.2.0", "false"},
{"number under 3.0", "3.0.3", "5"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
spec := componentSpecVer(tc.version,
" S:\n type: object\n properties:\n n: {type: number, exclusiveMinimum: "+tc.value+"}\n")
doc, diags := lowerSpec(t, spec)
require.NotNil(t, doc)
assert.Equal(t, 1, countErrors(diags, codeExclusiveBoundForm),
"one dialect-form error, got: %+v", diags)
// The degenerate bound is dropped, not recorded.
m, ok := typeByName(doc, "S").(*ir.Model)
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")
}
}
})
}
}

func TestConstraints_TypeWrongBoundYieldsSingleError(t *testing.T) {
t.Parallel()
spec := componentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: hello}\n")
_, diags := lowerSpec(t, spec)
// Exactly one diagnostic: Morphic's error with the schema's own provenance.
// The library emits two redundant float64 type-mismatch findings on the same
// keyword; load suppresses both because Morphic owns numeric-bound keywords.
require.Len(t, diags, 1, "one diagnostic for a type-wrong bound, got: %+v", diags)
assert.Equal(t, codeNumericPrecision, diags[0].Code)
assert.Equal(t, ir.SeverityError, diags[0].Severity)
assert.NotEmpty(t, diags[0].Provenance.Pointer)
}

func TestConstraints_NonNumericMinimumErrors(t *testing.T) {
t.Parallel()
spec := componentSpec(" S:\n type: object\n properties:\n n: {type: number, minimum: hello}\n")
doc, diags := lowerSpec(t, spec)
require.NotNil(t, doc)
// A genuinely non-numeric bound is never dropped silently: Morphic owns the
// keyword and reports it as an error with the property's exact provenance.
var reported bool
for _, d := range diags {
if d.Code == codeNumericPrecision {
reported = true
assert.Equal(t, ir.SeverityError, d.Severity)
}
}
assert.GreaterOrEqual(t, count, 2, "both malformed literals warn")
assert.True(t, reported, "a non-numeric minimum yields an error diagnostic")
}

// propConstraints returns the constraints of a named model's property.
Expand Down
9 changes: 7 additions & 2 deletions compilers/openapi/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@ const (
codeValidationOnlyKeyword = "openapi/validation-only-keyword"
// codeFalseSchema reports a boolean `false` schema (matches nothing).
codeFalseSchema = "openapi/false-schema"
// codeNumericPrecision reports a numeric literal that could not be parsed
// as an exact decimal.
// codeNumericPrecision reports a numeric bound literal that is not a finite
// number (error severity: Morphic owns these keywords, so this is the sole
// diagnostic for the defect — see boundLiteralDiag).
codeNumericPrecision = "openapi/invalid-numeric-literal"
// codeExclusiveBoundForm reports an exclusiveMinimum/exclusiveMaximum whose
// value form is wrong for the document's dialect (a boolean under 2020-12, or
// a number under 3.0) — see exclusiveFormDiag.
codeExclusiveBoundForm = "openapi/invalid-exclusive-bound"
// codeDegradedConstruct reports a construct preserved raw because the IR
// has no structural home for it.
codeDegradedConstruct = "openapi/degraded-construct"
Expand Down
Loading
Loading