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
67 changes: 64 additions & 3 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -854,10 +854,22 @@ func assertNonScalarDefaults(t *testing.T, m *ir.Model) {
// assertYAMLTimestampScalars covers a YAML 1.1 quirk: an unquoted date like
// 2021-01-01 resolves to tag !!timestamp, not !!str. It must survive as the
// literal string everywhere OpenAPI's JSON data model can carry one — enum,
// const, a property default, a schema-level example, and a media-type
// example — with nothing dropped or degraded to null.
// const, a property default, a schema-level example, and a media-type example —
// with nothing dropped or degraded to null.
//
// "Everywhere" spans both channels a date can land in. It used to mean only the
// ir.Value one, and the raw-JSON one went on rewriting a date to RFC 3339 with
// this fixture green (GitHub #242); assertRawPreservedDates is the other half.
func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
assert.Empty(t, diags, "every unquoted date converts cleanly; nothing is dropped or degraded")
// R's `not` announces the §4.7 carve-out, and that notice is the only thing
// any site here is allowed to say: a date that was dropped or degraded
// reports itself as a warning, which is what this fixture exists to catch.
for _, d := range diags {
assert.Equal(t, ir.SeverityInfo, d.Severity,
"every unquoted date converts cleanly; nothing is dropped or degraded: %+v", d)
assert.Equal(t, "openapi/validation-only-keyword", d.Code,
"the §4.7 carve-out is the only notice this fixture expects: %+v", d)
}

d, ok := doc.Types[namedID("D")].(*ir.Enum)
require.True(t, ok, "D stays a closed Enum of the real dates, not a union of null literals")
Expand Down Expand Up @@ -888,6 +900,31 @@ func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagn
require.Len(t, mediaExamples, 1, "the media-type example is preserved")
require.NotNil(t, mediaExamples[0].Value)
assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *mediaExamples[0].Value)

assertRawPreservedDates(t, doc)
}

// assertRawPreservedDates pins the source spelling of a date kept as raw JSON,
// at both sites that channel has: a vendor extension and the §4.7
// validation-only carve-out. Each wants the text the source wrote — resolving
// the tag and re-rendering it hands the construct a padding, a time and a zone
// nobody asked for, and the resolved form cannot be read back to the spelling.
func assertRawPreservedDates(t *testing.T, doc *ir.Document) {
t.Helper()
r, ok := doc.Types[namedID("R")].(*ir.Model)
require.True(t, ok)
kept := r.Unmodeled

for _, tc := range []struct{ key, want string }{
{"openapi:x-effective", `"2021-1-1"`},
{"openapi:x-window", `{"from":"2021-1-1","to":"2022-2-2"}`},
{"openapi:not", `{"const":"2021-1-1"}`},
} {
entry, found := kept[tc.key]
require.True(t, found, "%s is preserved; got %v", tc.key, kept)
assert.Equal(t, tc.want, string(entry.Value),
"%s keeps the date as written, not as RFC 3339 spells it", tc.key)
}
}

func assertConstraints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
Expand Down Expand Up @@ -1672,6 +1709,30 @@ func assertExtensionsX(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
require.True(t, ok, "an operation extension lands on the operation; got %v", op.Unmodeled)
assert.Equal(t, ir.ReasonVendorExtension, entry.Reason)
assert.JSONEq(t, `true`, string(entry.Value))

assertRawPreservedBinary(t, m)
}

// assertRawPreservedBinary pins what a !!binary extension keeps: the base64 the
// source wrote. Decoding it and storing the bytes in a JSON string lost the
// spelling on every value and lost the data itself on any byte that is not
// valid UTF-8, since encoding/json rewrites those to U+FFFD (GitHub #242).
//
// The block-form row carries the line breaks with it, because they are part of
// what the source wrote; base64.StdEncoding skips them, so a consumer resolving
// the tag reads the same bytes the decode used to store.
func assertRawPreservedBinary(t *testing.T, m *ir.Model) {
t.Helper()
for _, tc := range []struct{ key, want string }{
{"openapi:x-blob", `"aGVsbG8="`},
{"openapi:x-raw", `"/w=="`},
{"openapi:x-wrapped", `"aGVs\nbG8=\n"`},
} {
entry, found := m.Unmodeled[tc.key]
require.True(t, found, "%s is preserved; got %v", tc.key, m.Unmodeled)
assert.Equal(t, tc.want, string(entry.Value),
"%s keeps its base64 text, not the bytes it names", tc.key)
}
}

// assertInlineAnnotations covers the positions with no ir.Property or
Expand Down
74 changes: 50 additions & 24 deletions compilers/openapi/internal/annotation/rawjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,10 @@ func (c *rawConv) node(n *yaml.Node, depth int) (json.RawMessage, error) {
// parser never hands one over — it resolves every scalar it emits, and writes
// the short form even for a spelled-out `!<tag:yaml.org,2002:int>` — but a
// caller assembling nodes can, and reading n.Tag would spell such a scalar as a
// string. A tag yaml.v3 assigns no type to keeps its text rather than being
// refused, which is also what that Decode did.
// string.
//
// What changed is the numeric arms, which now read the source text (GitHub #32).
// Every other arm still renders the way that Decode would have, so a timestamp
// still normalizes to RFC 3339 and a !!binary still carries its decoded bytes
// rather than its base64 spelling. Those two are lossy against the source and
// deliberately left that way: they are a different mechanism from the float64
// rounding, and moving them belongs with its own reasoning (GitHub #242).
// A tag yaml.v3 assigns no type to keeps its text rather than being refused,
// which is also what that Decode did.
func (c *rawConv) scalar(n *yaml.Node) (json.RawMessage, error) {
switch n.ShortTag() {
case "!!null":
Expand Down Expand Up @@ -153,31 +148,62 @@ func (c *rawConv) scalar(n *yaml.Node) (json.RawMessage, error) {
return json.RawMessage(num), nil
case "!!str":
return jsonString(n.Value), nil
case "!!timestamp", "!!binary":
// The two tags YAML gives a type and JSON does not. Both keep the text
// the source wrote, because the resolved form is derivable from the
// spelling and the spelling is not derivable from the resolved form
// (GitHub #242).
//
// Rendering the resolved form instead lost data both ways: a timestamp
// came back RFC 3339, so `2021-1-1` acquired a padding, a time and a
// zone the source never wrote, and a !!binary came back as its decoded
// bytes, so `/w==` — the byte 0xFF — reached the IR as the U+FFFD
// encoding/json substitutes for it, indistinguishable from a source
// that wrote U+FFFD itself.
//
// The decode stays, and stays the tag check it has always been: a
// scalar that does not satisfy the type it declares is refused exactly
// as before, so only the kept spelling moved. Whether such a scalar
// should instead survive as text — JSON can name it, and refusing costs
// the caller the whole construct — is a question about what this walk
// accepts rather than what it preserves, and is open as GitHub #245
// rather than settled as a side effect here.
return c.verbatimTagged(n)
default:
// A tag yaml.v3 cannot resolve carries no type it could decode to, so
// its scalar comes back as the text it was written with — the behaviour
// this walk replaced, and the lossless one: refusing would drop an
// `!acme/thing` extension value the source did write.
return jsonString(n.Value), nil
}
}

// verbatimTagged renders a scalar whose tag YAML resolves and JSON cannot name.
// Each arm only checks the tag, by the decode that used to supply the output,
// and the one return states the rule they share: what is kept is the source
// text. The two decode to different Go types and report differently — a
// timestamp names the offending text, a binary payload deliberately does not.
func (c *rawConv) verbatimTagged(n *yaml.Node) (json.RawMessage, error) {
switch tag := n.ShortTag(); tag {
case "!!timestamp":
var t time.Time
if err := n.Decode(&t); err != nil {
var when time.Time
if err := n.Decode(&when); err != nil {
return nil, fmt.Errorf("timestamp literal %q: %w", n.Value, err)
}
// The spelling time.Time's own MarshalJSON produces. It is reproduced
// rather than called because that method reports an error for a year
// outside [0,9999], which YAML's timestamp resolution cannot produce —
// an unreachable branch is worse than an explicit format.
return jsonString(t.Format(time.RFC3339Nano)), nil
case "!!binary":
// yaml.v3 base64-decodes a !!binary node into a string (it rejects a
// []byte target), so decode to string and carry the bytes from there.
var raw string
if err := n.Decode(&raw); err != nil {
// []byte target), so the check decodes to string.
var decoded string
if err := n.Decode(&decoded); err != nil {
return nil, fmt.Errorf("binary literal: %w", err)
}
return jsonString(raw), nil
default:
// A tag yaml.v3 cannot resolve carries no type it could decode to, so
// its scalar comes back as the text it was written with — the behaviour
// this walk replaced, and the lossless one: refusing would drop an
// `!acme/thing` extension value the source did write.
return jsonString(n.Value), nil
// Unreachable through scalar, which routes only the two tags above
// here. It stays so a third tag added to that arm is answered rather
// than silently read as base64.
return nil, fmt.Errorf("scalar tag %q is not kept verbatim", tag)
}
return jsonString(n.Value), nil
}

// sequence renders a YAML sequence as a JSON array, in source order.
Expand Down
Loading
Loading