Summary
lower_object attaches field-owned keywords only when the property is not a bare $ref:
// crates/gnr8-core/src/lower/mod.rs
if prop.schema_ref.is_none() {
// description, example, apply_field_meta(...)
}
So a property whose type is a named schema drops all of its field-owned metadata —
constraints, description, example, default, format and vendor extensions. The two
write_schema implementations reinforce it: both return after emitting the $ref key alone
(lower/yaml.rs, lower/json.rs).
Why this is a bug rather than a limitation
The import side already reads that metadata beside a $ref. In openapi_source.rs,
type_from_schema resolves the $ref for the type, while description, example and
field_meta_from_schema(property_schema) are read off the same property object independently
(crates/gnr8-core/src/sdk/openapi_source.rs:2029-2037).
gnr8 can therefore read minItems next to a $ref but cannot write it. Importing a
document with that shape as a Source and re-emitting it silently loses the keyword, so
OpenApi → OpenApi is lossy for any metadata a named-type property carries.
This is also valid output to produce: gnr8 emits openapi: 3.1.0, and under JSON Schema
2020-12 $ref is an ordinary keyword whose siblings apply as an intersection. minItems: 1
beside $ref: '#/components/schemas/Tags' means exactly "matches Tags and has at least one
item". The sibling-keys-are-ignored rule the code comments cite is the OpenAPI 3.0 rule.
Second symptom: named types lose source-declared constraints
The same guard is why a Go named collection loses cardinality that its unnamed equivalent keeps:
Names []string `json:"names" validate:"min=1"` // minItems: 1
Names Tags `json:"names" validate:"min=1"` // nothing (type Tags []string)
Extraction is not the blocker here — fixing the extractor alone would be inert, because the
field meta is discarded during lowering. The same applies to named strings and named ints
(type Name string with min=2, type Rank int with min=1).
Scope
lower_object's schema_ref.is_none() guard (crates/gnr8-core/src/lower/mod.rs)
- the bare-
$ref early return in write_schema in both crates/gnr8-core/src/lower/yaml.rs
and crates/gnr8-core/src/lower/json.rs
write_schema_seq_item's compact $ref variant rendering in lower/yaml.rs
- the nullable-
$ref oneOf path already receives field meta, so it needs checking for
double-application
Blast radius
Output changes for every named-type property that carries any field metadata, in all three
source languages — not only the Go collection case. That includes description and default,
so generated SDK doc comments move too. Worth its own release note, framed as the round-trip
fix rather than a Go tag fix.
Suggested starting point
A failing OpenApi → OpenApi round-trip test over a property with $ref plus minItems and
description. That pins the behaviour independently of the Go extractor and demonstrates the
loss without needing a Go fixture.
Found while reviewing #92, which fixes Go collection cardinality for unnamed slices, arrays and
maps. That PR deliberately leaves this alone: it is a spec-writer change with a much wider blast
radius than the extraction work there.
Summary
lower_objectattaches field-owned keywords only when the property is not a bare$ref:So a property whose type is a named schema drops all of its field-owned metadata —
constraints,
description,example,default,formatand vendor extensions. The twowrite_schemaimplementations reinforce it: both return after emitting the$refkey alone(
lower/yaml.rs,lower/json.rs).Why this is a bug rather than a limitation
The import side already reads that metadata beside a
$ref. Inopenapi_source.rs,type_from_schemaresolves the$reffor the type, whiledescription,exampleandfield_meta_from_schema(property_schema)are read off the same property object independently(
crates/gnr8-core/src/sdk/openapi_source.rs:2029-2037).gnr8 can therefore read
minItemsnext to a$refbut cannot write it. Importing adocument with that shape as a
Sourceand re-emitting it silently loses the keyword, soOpenApi→OpenApiis lossy for any metadata a named-type property carries.This is also valid output to produce: gnr8 emits
openapi: 3.1.0, and under JSON Schema2020-12
$refis an ordinary keyword whose siblings apply as an intersection.minItems: 1beside
$ref: '#/components/schemas/Tags'means exactly "matchesTagsand has at least oneitem". The sibling-keys-are-ignored rule the code comments cite is the OpenAPI 3.0 rule.
Second symptom: named types lose source-declared constraints
The same guard is why a Go named collection loses cardinality that its unnamed equivalent keeps:
Extraction is not the blocker here — fixing the extractor alone would be inert, because the
field meta is discarded during lowering. The same applies to named strings and named ints
(
type Name stringwithmin=2,type Rank intwithmin=1).Scope
lower_object'sschema_ref.is_none()guard (crates/gnr8-core/src/lower/mod.rs)$refearly return inwrite_schemain bothcrates/gnr8-core/src/lower/yaml.rsand
crates/gnr8-core/src/lower/json.rswrite_schema_seq_item's compact$refvariant rendering inlower/yaml.rs$refoneOfpath already receives field meta, so it needs checking fordouble-application
Blast radius
Output changes for every named-type property that carries any field metadata, in all three
source languages — not only the Go collection case. That includes
descriptionanddefault,so generated SDK doc comments move too. Worth its own release note, framed as the round-trip
fix rather than a Go tag fix.
Suggested starting point
A failing
OpenApi→OpenApiround-trip test over a property with$refplusminItemsanddescription. That pins the behaviour independently of the Go extractor and demonstrates theloss without needing a Go fixture.
Found while reviewing #92, which fixes Go collection cardinality for unnamed slices, arrays and
maps. That PR deliberately leaves this alone: it is a spec-writer change with a much wider blast
radius than the extraction work there.