Skip to content

openapiv3: string examples still lose quotes when YAML 1.2 and 1.1 resolvers disagree (follow-up to #244) #248

Description

@SebastienMelki

Follow-up to #244. That fix is correct as far as it goes, but a residual set of string examples still get retyped by consumers. The cause is not the tag — it's that the tag doesn't survive emission.

Summary

newExampleNodeForSchema sets Tag: "!!str" unconditionally for string schemas, which is right. But the node's Style is left at 0, so gopkg.in/yaml.v3 decides on its own whether the scalar needs quoting — and it decides using YAML 1.2 implicit resolution. Since the tag itself isn't written to the output either, any scalar that is a plain string under YAML 1.2 but a typed value under YAML 1.1 is emitted bare and gets retyped by 1.1 consumers (PyYAML, Ruby Psych, older SnakeYAML — i.e. most OpenAPI tooling outside Go).

So 50000.00 and 2024-01-01T00:00:00Z are fixed, because they're numbers/timestamps in 1.2 as well. 16:00 is not, because 1.2 removed sexagesimal.

Reproduction

No sebuf needed — this is newExampleNodeForSchema's exact output shape:

package main

import (
	"fmt"
	"gopkg.in/yaml.v3"
)

func main() {
	for _, v := range []string{
		"16:00", "09:30", "1:00", "yes",
		"0x1234567890abcdef1234567890abcdef12345678",
		"0x12ab", "50000.00", "2024-01-15",
	} {
		n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v}
		out, _ := yaml.Marshal(n)
		fmt.Printf("%-45q -> %s", v, string(out))
	}
}
"16:00"                                       -> 16:00        <-- lost quotes
"09:30"                                       -> 09:30        <-- lost quotes
"1:00"                                        -> 1:00         <-- lost quotes
"yes"                                         -> yes          <-- lost quotes
"0x1234567890abcdef1234567890abcdef12345678"  -> 0x1234...    <-- lost quotes
"0x12ab"                                      -> "0x12ab"     ok
"50000.00"                                    -> "50000.00"   ok (#244)
"2024-01-15"                                  -> "2024-01-15" ok (#244)

Note 0x12ab is quoted but the 40-char address is not: yaml.v3's int resolver overflows on the long form, concludes "not an int", and drops the quotes. PyYAML has arbitrary-precision ints and reads it back as 103929005307927756724354605802047639613112342136.

Round-tripping the unquoted output through a YAML 1.1 loader:

>>> import yaml
>>> yaml.safe_load('16:00')   # sexagesimal: 16*60
960
>>> yaml.safe_load('yes')
True
>>> yaml.safe_load('0x1234567890abcdef1234567890abcdef12345678')
103929005307927756724354605802047639613112342136

Real-world impact

Hit this upgrading alpaca-go from v0.18.0 to v0.23.2. After the bump, 14 fields across two specs still declare type: string beside an example that parses as an integer:

Spec Field Proto example Parsed as
TradingService CalendarDay/close "16:00" 960
TradingService CalendarDay/sessionClose "20:00" 1200
BrokerService MarketDay/close "16:00" 960
BrokerService MarketDay/sessionClose "20:00" 1200
TradingService CryptoWallet/address, CreateCryptoTransferRequest/address, WhitelistedAddress/address, CreateWhitelistedAddressRequest/address, GetCryptoTransferEstimateRequest/fromAddress, GetCryptoTransferEstimateRequest/toAddress "0x1234...5678" 48-digit int
BrokerService BrokerCryptoWallet/address, BrokerWhitelistedAddress/address, CreateBrokerCryptoTransferRequest/address, CreateBrokerWhitelistedAddressRequest/address "0x1234...5678" 48-digit int

The protos declare these as strings explicitly, e.g. alpaca/trading/v1/calendar.proto:

// Market close time (HH:MM).
string close = 3 [(sebuf.http.field_examples) = { values: ["16:00"] }];

A quirk worth noting: open/sessionOpen ("09:30", "04:00") are also emitted unquoted but happen to survive PyYAML, because its sexagesimal regex requires a leading [1-9]. They're latent rather than safe — a 1.1 parser with a looser resolver would retype them too. Same for any yes/no/on/off example, of which we currently have none.

Proposed fix

Set the style explicitly so quoting doesn't depend on the resolver's YAML version:

func newExampleNodeForSchema(schema *base.Schema, value string) *yaml.Node {
	tag := yamlTagForSchema(schema)
	node := &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value}
	if tag == "!!str" {
		node.Style = yaml.DoubleQuotedStyle
	}
	return node
}

Verified this quotes all of the cases above while leaving !!int / !!float / !!bool examples bare:

--- with Style: DoubleQuotedStyle ---
"16:00"        -> "16:00"
"09:30"        -> "09:30"
"yes"          -> "yes"
"0x1234...678" -> "0x1234...678"
--- numeric schema unaffected ---
!!int   "100"  -> 100
!!float "1.5"  -> 1.5
!!bool  "true" -> true

Tradeoff

This conflicts with one existing expectation in TestNewExampleNodeForSchemaTagsStringExamples:

{
	name:          "unambiguous string is not over-quoted",
	example:       "ACTIVE",
	wantMarshaled: "ACTIVE",
},

Unconditional quoting would render that "ACTIVE". My read is that this is the right trade and the assertion should be relaxed — quoting every string example is valid YAML, renders identically in every viewer, and is the only version-independent way to pin the type. The alternative is keeping the no-over-quoting aesthetic and adding an explicit YAML 1.1 danger check (sexagesimal, yes/no/on/off, arbitrary-width hex/octal, ~, .inf/.nan), but that's re-implementing a resolver you'd have to keep in sync with several downstream parsers — much more surface area for the same outcome.

Happy to send a PR for whichever direction you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions