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.
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
newExampleNodeForSchemasetsTag: "!!str"unconditionally for string schemas, which is right. But the node'sStyleis left at0, sogopkg.in/yaml.v3decides 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.00and2024-01-01T00:00:00Zare fixed, because they're numbers/timestamps in 1.2 as well.16:00is not, because 1.2 removed sexagesimal.Reproduction
No sebuf needed — this is
newExampleNodeForSchema's exact output shape:Note
0x12abis 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 as103929005307927756724354605802047639613112342136.Round-tripping the unquoted output through a YAML 1.1 loader:
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: stringbeside an example that parses as an integer:CalendarDay/close"16:00"960CalendarDay/sessionClose"20:00"1200MarketDay/close"16:00"960MarketDay/sessionClose"20:00"1200CryptoWallet/address,CreateCryptoTransferRequest/address,WhitelistedAddress/address,CreateWhitelistedAddressRequest/address,GetCryptoTransferEstimateRequest/fromAddress,GetCryptoTransferEstimateRequest/toAddress"0x1234...5678"BrokerCryptoWallet/address,BrokerWhitelistedAddress/address,CreateBrokerCryptoTransferRequest/address,CreateBrokerWhitelistedAddressRequest/address"0x1234...5678"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 anyyes/no/on/offexample, of which we currently have none.Proposed fix
Set the style explicitly so quoting doesn't depend on the resolver's YAML version:
Verified this quotes all of the cases above while leaving
!!int/!!float/!!boolexamples bare: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.