Skip to content

Commit 283b77e

Browse files
authored
fix(compilers/openapi): keep unquoted YAML dates as string values (#103)
PR: #103
1 parent f3cbde8 commit 283b77e

17 files changed

Lines changed: 781 additions & 56 deletions

compilers/openapi/compose.go

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -457,31 +457,25 @@ func (l *lowerer) enumAsUnion(s *oas3.Schema, common ir.TypeCommon, pointer, hin
457457
}
458458
}
459459

460-
// hoistLiteral hoists a single node as a Literal type at its own pointer. It is
460+
// hoistLiteral hoists a single node as a Literal type at its own pointer, or
461+
// falls back to the schemaless top type when the node is structurally
462+
// unconvertible — never a Literal whose Value lies about being null. It is
461463
// the single entry point for lowering both a bare `const` schema (pointer may
462464
// be a top-level component, so internNode's typeIDForPointer keeps that
463465
// component's stable named ID) and each individual member of a heterogeneous
464466
// enum (enumAsUnion, always an anonymous sub-pointer).
465467
func (l *lowerer) hoistLiteral(node values.Value, pointer, hint string) ir.TypeID {
466468
return l.internNode(pointer, hint, func(common ir.TypeCommon) ir.TypeDef {
467-
return &ir.Literal{
468-
TypeCommon: common,
469-
Value: l.valueOrNull(node, pointer),
469+
val, err := valueFromNode(node)
470+
if err != nil {
471+
l.diag(ir.SeverityWarning, codeDegradedConstruct, pointer,
472+
"unconvertible value lowered as the top type: %s", err.Error())
473+
return &ir.Any{TypeCommon: common}
470474
}
475+
return &ir.Literal{TypeCommon: common, Value: val}
471476
})
472477
}
473478

474-
// valueOrNull converts a node to an ir.Value, emitting a diagnostic and using
475-
// null when the node is structurally unconvertible.
476-
func (l *lowerer) valueOrNull(node values.Value, pointer string) ir.Value {
477-
val, err := valueFromNode(node)
478-
if err != nil {
479-
l.diag(ir.SeverityWarning, codeDegradedConstruct, pointer, "value: %s", err.Error())
480-
return ir.Value{Kind: ir.ValueNull}
481-
}
482-
return val
483-
}
484-
485479
// enumValueType picks an Enum's ValueType from the schema's declared scalar
486480
// type, falling back to the kind inferred from its members.
487481
func enumValueType(s *oas3.Schema, kind ir.ValueKind) ir.PrimKind {

compilers/openapi/compose_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,101 @@ func TestConst_BecomesLiteral(t *testing.T) {
715715
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "fixed"}, k.Value)
716716
}
717717

718+
func TestHoistLiteral_UnconvertibleConstBecomesAny(t *testing.T) {
719+
t.Parallel()
720+
// A custom tag is structurally unconvertible (no scalarValue case resolves
721+
// it), forcing hoistLiteral's fallback. Before the fix this silently
722+
// produced a Literal asserting the value is null, which the spec never said.
723+
spec := componentSpec(" K:\n const: !foo bar\n")
724+
doc, diags := lowerSpec(t, spec)
725+
k, ok := doc.Types[componentID("K")].(*ir.Any)
726+
require.True(t, ok, "an unconvertible const hoists the schemaless top type at its own pointer")
727+
assert.Equal(t, ir.KindAny, k.Kind())
728+
for _, td := range doc.Types {
729+
_, isLiteral := td.(*ir.Literal)
730+
assert.False(t, isLiteral, "no Literal is produced anywhere; nothing lies about the value being null")
731+
}
732+
require.Equal(t, 1, countDiagsAt(diags, codeDegradedConstruct, ir.SeverityWarning),
733+
"exactly one warning fires for the unconvertible value")
734+
d, ok := firstDegradedWarning(diags)
735+
require.True(t, ok)
736+
assert.Equal(t, "/components/schemas/K", d.Provenance.Pointer)
737+
}
738+
739+
func TestEnumAsUnion_UnconvertibleMemberBecomesAny(t *testing.T) {
740+
t.Parallel()
741+
// The convertible member ("ok") must still hoist a real Literal; only the
742+
// genuinely unconvertible member ("!foo bar") falls back to the top type.
743+
spec := componentSpec(" M:\n enum: [ok, !foo bar]\n")
744+
doc, diags := lowerSpec(t, spec)
745+
u, ok := doc.Types[componentID("M")].(*ir.Union)
746+
require.True(t, ok, "heterogeneous enum still lowers to a union of literals")
747+
require.Len(t, u.Variants, 2)
748+
749+
member0, ok := doc.Types[u.Variants[0].Type.Target].(*ir.Literal)
750+
require.True(t, ok, "the convertible member still hoists a Literal")
751+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "ok"}, member0.Value)
752+
753+
member1, ok := doc.Types[u.Variants[1].Type.Target].(*ir.Any)
754+
require.True(t, ok, "the unconvertible member hoists the schemaless top type, not a lying null Literal")
755+
assert.Equal(t, ir.KindAny, member1.Kind())
756+
757+
require.Equal(t, 1, countDiagsAt(diags, codeDegradedConstruct, ir.SeverityWarning),
758+
"exactly one warning for the unconvertible member, distinct from the heterogeneous-enum info diagnostic")
759+
d, ok := firstDegradedWarning(diags)
760+
require.True(t, ok)
761+
assert.Equal(t, "/components/schemas/M/enum/1", d.Provenance.Pointer)
762+
}
763+
764+
func TestEnum_UnquotedDatesStayClosedEnum(t *testing.T) {
765+
t.Parallel()
766+
// The exact issue repro: YAML 1.1 resolves an unquoted date to !!timestamp.
767+
// Before the fix, scalarValue had no case for it, so both enum members
768+
// failed to convert, enumMembers bailed to enumAsUnion, and valueOrNull
769+
// turned every member into a null literal — the actual dates never
770+
// survived. A component-level default (as in the repro) is never lowered
771+
// onto anything by itself (only properties/params read one), so it
772+
// contributes no diagnostic either way; see
773+
// TestProperty_UnquotedDateDefaultPreserved for the default path.
774+
spec := componentSpec(` D:
775+
type: string
776+
format: date
777+
default: 2021-01-01
778+
enum: [2021-01-01, 2022-02-02]
779+
`)
780+
doc, diags := lowerSpec(t, spec)
781+
assert.Empty(t, diags, "no diagnostics at all: the dates convert cleanly")
782+
e, ok := doc.Types[componentID("D")].(*ir.Enum)
783+
require.True(t, ok, "D stays a closed Enum, never degrades to a Union of literals")
784+
assert.True(t, e.Closed)
785+
assert.Equal(t, ir.PrimString, e.ValueType)
786+
require.Len(t, e.Members, 2)
787+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, e.Members[0].Value)
788+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2022-02-02"}, e.Members[1].Value)
789+
}
790+
791+
func TestProperty_UnquotedDateDefaultPreserved(t *testing.T) {
792+
t.Parallel()
793+
// The repro's default sits at the component level, which nothing lowers by
794+
// itself; this covers the path that actually surfaces the bug in practice —
795+
// a date default declared on an object property.
796+
spec := componentSpec(` S:
797+
type: object
798+
properties:
799+
d:
800+
type: string
801+
format: date
802+
default: 2021-01-01
803+
`)
804+
doc, diags := lowerSpec(t, spec)
805+
assert.Empty(t, diags, "no diagnostics at all: the date default converts cleanly")
806+
m, ok := doc.Types[componentID("S")].(*ir.Model)
807+
require.True(t, ok)
808+
require.Len(t, m.Properties, 1)
809+
require.NotNil(t, m.Properties[0].Default)
810+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *m.Properties[0].Default)
811+
}
812+
718813
func TestAllOf_DiscriminatorHierarchy(t *testing.T) {
719814
t.Parallel()
720815
spec := componentSpecVer("3.2.0", ` Pet:

compilers/openapi/conformance_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func TestConformance(t *testing.T) {
4949
{"nullable-30", assertNullable30},
5050
{"nullable-31-ref", assertNullable31Ref},
5151
{"defaults", assertDefaults},
52+
{"yaml-timestamp-scalars", assertYAMLTimestampScalars},
5253
{"constraints", assertConstraints},
5354
{"numeric-precision", assertNumericPrecision},
5455
{"readonly-writeonly", assertReadOnlyWriteOnly},
@@ -354,6 +355,45 @@ func assertDefaults(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
354355
assert.Equal(t, ir.BigVal("9007199254740993"), m.Properties[0].Default.Num)
355356
}
356357

358+
// assertYAMLTimestampScalars covers a YAML 1.1 quirk: an unquoted date like
359+
// 2021-01-01 resolves to tag !!timestamp, not !!str. It must survive as the
360+
// literal string everywhere OpenAPI's JSON data model can carry one — enum,
361+
// const, a property default, a schema-level example, and a media-type
362+
// example — with nothing dropped or degraded to null.
363+
func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
364+
assert.Empty(t, diags, "every unquoted date converts cleanly; nothing is dropped or degraded")
365+
366+
d, ok := doc.Types[namedID("D")].(*ir.Enum)
367+
require.True(t, ok, "D stays a closed Enum of the real dates, not a union of null literals")
368+
assert.Equal(t, ir.PrimString, d.ValueType)
369+
require.Len(t, d.Members, 2)
370+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, d.Members[0].Value)
371+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2022-02-02"}, d.Members[1].Value)
372+
373+
k, ok := doc.Types[namedID("K")].(*ir.Literal)
374+
require.True(t, ok, "K's const hoists a real Literal, not the schemaless top type")
375+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, k.Value)
376+
377+
s, ok := doc.Types[namedID("S")].(*ir.Model)
378+
require.True(t, ok)
379+
require.Len(t, s.Properties, 1)
380+
prop := s.Properties[0]
381+
require.NotNil(t, prop.Default, "the property default is preserved")
382+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *prop.Default)
383+
require.Len(t, prop.Examples, 1, "the schema-level example is preserved")
384+
require.NotNil(t, prop.Examples[0].Value)
385+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *prop.Examples[0].Value)
386+
387+
op, ok := opByName(doc, "getItem")
388+
require.True(t, ok)
389+
require.NotNil(t, op.Responses[0].Payload)
390+
require.Len(t, op.Responses[0].Payload.Contents, 1)
391+
mediaExamples := op.Responses[0].Payload.Contents[0].Examples
392+
require.Len(t, mediaExamples, 1, "the media-type example is preserved")
393+
require.NotNil(t, mediaExamples[0].Value)
394+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *mediaExamples[0].Value)
395+
}
396+
357397
func assertConstraints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
358398
m, ok := doc.Types[namedID("S")].(*ir.Model)
359399
require.True(t, ok)
@@ -597,6 +637,18 @@ func assertExamples(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
597637
require.True(t, ok)
598638
require.Len(t, m.Properties, 1)
599639
assert.Len(t, m.Properties[0].Examples, 2)
640+
641+
// The plural `examples` map, in both spellings: an inline entry and one
642+
// written as a $ref, which must resolve to the referenced component's value.
643+
op, ok := opByName(doc, "getItem")
644+
require.True(t, ok)
645+
require.Len(t, op.Responses[0].Payload.Contents, 1)
646+
ex := op.Responses[0].Payload.Contents[0].Examples
647+
require.Len(t, ex, 2, "both entries lower, in source order")
648+
require.NotNil(t, ex[0].Value)
649+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "hello"}, *ex[0].Value)
650+
require.NotNil(t, ex[1].Value)
651+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "world"}, *ex[1].Value)
600652
}
601653

602654
func assertDocsSummaryDesc(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {

compilers/openapi/content.go

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func (l *lowerer) lowerContent(mt string, media *soa.MediaType, pointer, hint st
4242
MediaType: mt,
4343
Type: l.schemaRef(media.GetSchema(), mediaPtr+ptr("schema"), hint),
4444
}
45-
if ex := l.mediaExamples(media); len(ex) > 0 {
45+
if ex := l.mediaExamples(media, mediaPtr); len(ex) > 0 {
4646
c.Examples = ex
4747
}
4848
switch {
@@ -155,38 +155,49 @@ func (l *lowerer) lowerHeaders(headers *sequencedmap.Map[string, *soa.Referenced
155155
}
156156

157157
// mediaExamples lowers a media type's single and plural example values.
158-
func (l *lowerer) mediaExamples(media *soa.MediaType) []ir.Example {
159-
return l.exampleList(media.GetExample(), media.GetExamples())
158+
func (l *lowerer) mediaExamples(media *soa.MediaType, pointer string) []ir.Example {
159+
return l.exampleList(media.GetExample(), media.GetExamples(), pointer)
160160
}
161161

162162
// exampleList lowers a single example node and a plural example map into value
163-
// examples, in source order; unconvertible nodes are skipped.
164-
func (l *lowerer) exampleList(single *yaml.Node, plural *sequencedmap.Map[string, *soa.ReferencedExample]) []ir.Example {
163+
// examples, in source order. An unconvertible node is skipped with a warning
164+
// diagnostic rather than silently; an entry carrying no value node at all — an
165+
// externalValue, or a summary-only stub — is still skipped without one.
166+
func (l *lowerer) exampleList(single *yaml.Node, plural *sequencedmap.Map[string, *soa.ReferencedExample], pointer string) []ir.Example {
165167
var out []ir.Example
166168
if single != nil {
167-
if v, err := valueFromNode(single); err == nil {
168-
out = append(out, ir.Example{Value: &v})
169-
}
169+
out = l.appendExample(out, single, pointer, "example")
170170
}
171171
if plural == nil {
172172
return out
173173
}
174-
for _, re := range plural.All() {
175-
ex := resolveRef[soa.Example](re)
176-
if ex == nil {
177-
continue
178-
}
179-
node := ex.GetValue()
180-
if node == nil {
181-
continue
182-
}
183-
if v, err := valueFromNode(node); err == nil {
184-
out = append(out, ir.Example{Value: &v})
185-
}
174+
for name, re := range plural.All() {
175+
out = l.appendPluralExample(out, re, pointer, name)
186176
}
187177
return out
188178
}
189179

180+
// appendPluralExample lowers one named entry of a plural `examples` map. An
181+
// entry written as a $ref holds no value of its own — the value lives in the
182+
// referenced component — so its diagnostic is stamped at the reference site
183+
// rather than at a `value` node this entry never had; an inline entry is
184+
// stamped at its own `value`. Only this hop is de-referenced: an enclosing
185+
// $ref'd response or parameter is already flattened into pointer.
186+
func (l *lowerer) appendPluralExample(out []ir.Example, re *soa.ReferencedExample, pointer, name string) []ir.Example {
187+
ex := resolveRef[soa.Example](re)
188+
if ex == nil {
189+
return out
190+
}
191+
node := ex.GetValue()
192+
if node == nil {
193+
return out
194+
}
195+
if re.IsReference() {
196+
return l.appendExample(out, node, pointer, "examples", name)
197+
}
198+
return l.appendExample(out, node, pointer, "examples", name, "value")
199+
}
200+
190201
// lowerRequestBody lowers an operation's request body onto op.Request and the
191202
// binding's RequestContentTypes. The IR expresses body optionality via presence,
192203
// so a non-required body stays present with its optionality preserved under

compilers/openapi/content_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,86 @@ func TestContent_ExampleWithoutValueSkipped(t *testing.T) {
443443
assert.Empty(t, c.Examples, "an example without a value is skipped")
444444
}
445445

446+
func TestContent_UnconvertibleExamplesDiagnosed(t *testing.T) {
447+
t.Parallel()
448+
// Covers both the single 3.0-style `example` and one entry of the plural
449+
// 3.1-style `examples` map — each carries a custom, structurally
450+
// unconvertible tag, and each conversion failure must be diagnosed rather
451+
// than discarded silently.
452+
spec := pathsSpec(` /items:
453+
get:
454+
operationId: getItem
455+
responses:
456+
"200":
457+
description: ok
458+
content:
459+
application/json:
460+
schema: {type: string}
461+
example: !foo bar
462+
examples:
463+
one: {value: !foo baz}
464+
`)
465+
_, svc, diags := lowerServiceSpec(t, spec)
466+
op := firstOp(t, svc)
467+
require.Len(t, op.Responses, 1)
468+
c := op.Responses[0].Payload.Contents[0]
469+
assert.Empty(t, c.Examples, "both unconvertible examples are skipped, not appended")
470+
471+
require.Equal(t, 2, countDiagsAt(diags, codeDegradedConstruct, ir.SeverityWarning))
472+
pointers := map[string]bool{}
473+
for _, d := range diags {
474+
if d.Code == codeDegradedConstruct && d.Severity == ir.SeverityWarning {
475+
pointers[d.Provenance.Pointer] = true
476+
assert.Contains(t, d.Message, "example:")
477+
}
478+
}
479+
const base = "/paths/~1items/get/responses/200/content/application~1json"
480+
assert.True(t, pointers[base+"/example"], "the single example's pointer")
481+
assert.True(t, pointers[base+"/examples/one/value"], "the plural example's pointer, keyed by its name")
482+
}
483+
484+
func TestContent_RefdExampleDiagnosedAtReferenceSite(t *testing.T) {
485+
t.Parallel()
486+
// An `examples` entry written as a $ref holds no value of its own — the
487+
// value lives in the referenced component — so `.../examples/<name>/value`
488+
// would name a location the source never had. The diagnostic belongs at the
489+
// reference site. The convertible sibling pins that a $ref'd example still
490+
// lowers normally.
491+
spec := `openapi: 3.1.0
492+
info: {title: T, version: "1.0.0"}
493+
paths:
494+
/items:
495+
get:
496+
operationId: getItem
497+
responses:
498+
"200":
499+
description: ok
500+
content:
501+
application/json:
502+
schema: {type: string}
503+
examples:
504+
good: {$ref: '#/components/examples/Good'}
505+
bad: {$ref: '#/components/examples/Bad'}
506+
components:
507+
examples:
508+
Good: {value: fine}
509+
Bad: {value: !foo baz}
510+
`
511+
_, svc, diags := lowerServiceSpec(t, spec)
512+
op := firstOp(t, svc)
513+
c := op.Responses[0].Payload.Contents[0]
514+
require.Len(t, c.Examples, 1, "the convertible $ref'd example still lowers")
515+
require.NotNil(t, c.Examples[0].Value)
516+
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "fine"}, *c.Examples[0].Value)
517+
518+
require.Equal(t, 1, countDiagsAt(diags, codeDegradedConstruct, ir.SeverityWarning))
519+
d, ok := firstDegradedWarning(diags)
520+
require.True(t, ok)
521+
assert.Equal(t, "/paths/~1items/get/responses/200/content/application~1json/examples/bad",
522+
d.Provenance.Pointer, "the reference site, not a /value the source never had")
523+
assert.Contains(t, d.Message, "example:")
524+
}
525+
446526
func TestContentTypeKeys_Nil(t *testing.T) {
447527
t.Parallel()
448528
assert.Nil(t, contentTypeKeys(nil))

compilers/openapi/diag.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ const (
4040
// value form is wrong for the document's dialect (a boolean under 2020-12, or
4141
// a number under 3.0) — see exclusiveFormDiag.
4242
codeExclusiveBoundForm = "openapi/invalid-exclusive-bound"
43-
// codeDegradedConstruct reports a construct preserved raw because the IR
44-
// has no structural home for it.
43+
// codeDegradedConstruct reports a construct the compiler could not carry
44+
// into the IR as written: preserved raw for want of a structural home,
45+
// lowered to a weaker shape (a heterogeneous enum as a union, an
46+
// unconvertible value as the top type), or — for an annotation like a
47+
// default or example — dropped. It marks the lossy lowerings the compiler
48+
// reports, not a guarantee that every lossy lowering is reported.
4549
codeDegradedConstruct = "openapi/degraded-construct"
4650
// codeConflictingRedecl reports that inline allOf branches redeclare one
4751
// field with values that disagree: an incompatible target type (string vs.

0 commit comments

Comments
 (0)