diff --git a/README.md b/README.md index fa79a571..649ea081 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ See all the documentation at https://pb33f.io/libopenapi/ - [What Changed / Diff Engine](https://pb33f.io/libopenapi/what-changed/) - [Overlays](https://pb33f.io/libopenapi/overlays/) - [Arazzo](https://pb33f.io/libopenapi/arazzo/) +- [Generating Code](https://pb33f.io/libopenapi/generating-code/) +- [Parsing Code](https://pb33f.io/libopenapi/parsing-code/) - [FAQ](https://pb33f.io/libopenapi/faq/) - [About libopenapi](https://pb33f.io/libopenapi/about/) --- diff --git a/generator/golang/README.md b/generator/golang/README.md new file mode 100644 index 00000000..fdb3fab0 --- /dev/null +++ b/generator/golang/README.md @@ -0,0 +1,179 @@ +# generator/golang + +`generator/golang` is a library package for model-only generation: + +- OpenAPI schema/component models to Go model source. +- Go reflection types to OpenAPI schema/component models. +- No CLI, client generation, server generation, validation runtime, or generated runtime dependency. + +## OpenAPI To Go + +Use `RenderSchema` for one schema or `Generator.RenderSchemas` for an ordered component map. + +```go +source, err := golang.RenderSchema("Pet", schemaProxy) +if err != nil { + return err +} +fmt.Println(string(source)) +``` + +`RenderSchemas` returns a `*GeneratedFile` with: + +- `PackageName`: generated package name. +- `Source`: gofmt-formatted Go source. +- `SchemaMetadata`: optional `schema_metadata.go` sidecar source when metadata sidecar generation is enabled. +- `Types`: top-level generated type names and kinds. +- `Diagnostics`: notable generator decisions. + +## Go To OpenAPI + +Use `SchemaFromType` for one schema or `SchemasFromTypes` for a reusable component graph. + +```go +set, err := golang.SchemasFromTypes(reflect.TypeOf(Customer{})) +if err != nil { + return err +} +root := set.Root +components := set.Components +``` + +`SchemaSet.Root` is the first requested root. `SchemaSet.Roots` contains every requested root keyed by generated type name. Named structs, registered interface unions, and reusable model shapes are emitted into `SchemaSet.Components`; nested named model references are rendered as `#/components/schemas/...` refs. + +Nullable reflected values render with JSON Schema 2020-12 native nullability: `type: [T, "null"]` for direct schemas, or `anyOf` around `$ref` plus `{type: "null"}` for nullable component references. The generator does not emit OpenAPI 3.0 `nullable: true`. + +Package-level graph helpers that need options use slice-based variants: + +```go +set, err := golang.SchemasFromTypesWithOptions( + []reflect.Type{reflect.TypeOf(Customer{})}, + golang.WithOneOfTypes((*PaymentMethod)(nil), Card{}, Bank{}), +) +``` + +Custom scalar aliases can be mapped without adding methods to the type: + +```go +gen := golang.NewGenerator( + golang.WithTypeSchema(reflect.TypeOf(CustomerID("")), customerIDSchema), +) +``` + +## Metadata Hooks + +Reflection metadata is layered from lightweight to exact: + +- Field tags for simple metadata: `openapi:"format=uuid;nullable=false;readOnly;minLength=3;maxLength=4"`. +- External registry overrides: `WithTypeSchema`, `WithFieldSchema`, and `WithFieldSchemaByJSONName`. +- Type-level providers: `OpenAPISchema() *base.SchemaProxy`, dependency-free `OpenAPISchemaMetadata() any`, or legacy `OpenAPISchemaYAML() string`. + +Use `WithOpenAPITags(true)` when generating Go models to include compact `openapi` tags for metadata that Go reflection cannot infer from type shape alone. Tags support `format`, `title`, `description`, `nullable`, `readOnly`, `writeOnly`, `deprecated`, scalar/object/array constraints, `enum`, and `const`. + +Use `WithSchemaMetadataSidecar(true)` when generated models should carry exact source schemas for high-fidelity reflection. The generated sidecar is a separate `schema_metadata.go` source file containing typed Go data exposed through `OpenAPISchemaMetadata() any`, so model packages do not need to import `libopenapi` or carry escaped YAML strings just to preserve metadata. + +Leave the metadata sidecar disabled when generated model source should stay lean and the reverse path only needs canonical Go-shape output. In that mode `GeneratedFile.SchemaMetadata` is nil and no `schema_metadata.go` file should be written. This is explicitly lossy for OpenAPI -> Go -> OpenAPI reconstruction: validation-only keywords, exact source ordering, and other non-Go-shape schema details may not be recreated from reflection alone. + +For exact per-field shapes without modifying model source, use field schema overrides: + +```go +gen := golang.NewGenerator( + golang.WithFieldSchema(reflect.TypeOf(BookingPayment{}), "Source", sourceSchema), + golang.WithFieldSchemaByJSONName(reflect.TypeOf(BookingPayment{}), "status", statusSchema), +) +``` + +## Polymorphism + +OpenAPI `oneOf` renders as a typed union when: + +- The schema has an explicit discriminator. +- The variants share an inferable required `const` discriminator property. +- The variants share an optional `const` discriminator and `WithOptionalConstDiscriminatorUnions(true)` is enabled. + +Ambiguous `oneOf` and all `anyOf` unions render as `json.RawMessage` wrappers. This keeps generated models dependency-free and avoids embedding validation behavior. + +For Go reflection to OpenAPI, register interface variants: + +```go +gen := golang.NewGenerator( + golang.WithOneOfTypes((*PaymentMethod)(nil), Card{}, Bank{}), + golang.WithDiscriminatorMapping((*PaymentMethod)(nil), "object", map[string]string{ + "card": "#/components/schemas/Card", + "bank": "#/components/schemas/Bank", + }), +) +``` + +## additionalProperties + +Schema-valued `additionalProperties` renders as an `AdditionalProperties map[string]T` field with `json:"-"`. + +Generated objects with schema-valued `additionalProperties` also receive `MarshalJSON` and `UnmarshalJSON` methods. Known properties are encoded normally, and unknown properties round-trip through the additional-properties map. + +Use `WithAdditionalPropertiesMethods(false)` when callers only want the struct field and will provide JSON behavior themselves. + +Boolean `additionalProperties` is preserved when generating OpenAPI from Go/OpenAPI IR, but it does not create a Go field unless a schema value exists. + +## External References + +External `$ref` values render as Go type names and emit `DiagnosticExternalReference`. By default, the type name is derived from the reference tail. Use `WithExternalRefTypeResolver` when an external reference should map to a different local type name. + +## Diagnostics + +Diagnostics have a stable `Code`, plus `Path` and human-readable `Message`. Callers should branch on `Code`, not message text. + +Current diagnostic codes: + +- `DiagnosticComponentNameCollision` +- `DiagnosticAdditionalPropertiesFalse` +- `DiagnosticArrayContains` +- `DiagnosticBooleanItems` +- `DiagnosticConstKeyword` +- `DiagnosticContentSchema` +- `DiagnosticConditionalSchema` +- `DiagnosticDependentRequired` +- `DiagnosticDependentSchemas` +- `DiagnosticDynamicReference` +- `DiagnosticExternalReference` +- `DiagnosticFieldNameCollision` +- `DiagnosticImplicitType` +- `DiagnosticMixedEnum` +- `DiagnosticMultiTypeSchema` +- `DiagnosticNotSchema` +- `DiagnosticNullEnum` +- `DiagnosticOptionalConstDiscriminator` +- `DiagnosticPatternProperties` +- `DiagnosticPrefixItems` +- `DiagnosticPropertyNames` +- `DiagnosticRootNameCollision` +- `DiagnosticSchemaMetadata` +- `DiagnosticStringEncoded` +- `DiagnosticTypeNameCollision` +- `DiagnosticUnevaluatedItems` +- `DiagnosticUnevaluatedProperties` +- `DiagnosticValidationKeyword` + +Diagnostics are intentionally not validation errors. They report lossy model-shape choices, unsupported validation-only keywords, naming collisions, and external reference assumptions. + +## Naming + +The default naming path handles common Go initialisms such as `ID`, `URL`, `UUID`, `CVC`, `IBAN`, and `JWT`. + +Inline/nested schema type names use `_` as the default parent/child delimiter, for example `Order_PaymentSource`. Use `WithNestedTypeNameDelimiter` to change it; pass an empty string to produce compact names such as `OrderPaymentSource`. + +Component names are resolved through a collision registry before refs are rendered, so colliding OpenAPI component keys such as `user-id`, `user_id`, and `UserID` produce stable Go names like `UserID`, `UserID__2`, and `UserID__3`, and local `$ref` fields point at the resolved names. The double underscore is reserved for collision suffixes, not ordinary nesting. + +Use resolvers when project-specific naming is required: + +- `WithTypeNameResolver` +- `WithFieldNameResolver` +- `WithEnumValueNameResolver` +- `WithNameResolver` as a broad fallback + +## Current Limits + +- Validation behavior belongs in `libopenapi-validator`, not generated models. +- External `$ref` values render as Go type names and emit diagnostics; this package does not load or generate external dependency packages. +- Tuple-like `prefixItems` render as `[]any`. +- `patternProperties`, conditional schemas, `not`, `propertyNames`, and dependent schemas are reported as diagnostics because they do not map cleanly to plain Go model fields. diff --git a/generator/golang/benchmark_test.go b/generator/golang/benchmark_test.go new file mode 100644 index 00000000..a4ec5ec6 --- /dev/null +++ b/generator/golang/benchmark_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "os" + "reflect" + "testing" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func BenchmarkRenderTrainTravel(b *testing.B) { + schemas := benchmarkTrainTravelSchemas(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := NewGenerator().RenderSchemas(schemas); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkRenderTrainTravelTypedUnion(b *testing.B) { + schemas := benchmarkTrainTravelSchemas(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := NewGenerator(WithOptionalConstDiscriminatorUnions(true)).RenderSchemas(schemas); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSchemasFromTypesComponentGraph(b *testing.B) { + generator := NewGenerator( + WithOneOfTypes((*PhaseTwoPaymentMethod)(nil), PhaseTwoCard{}, PhaseTwoBank{}), + WithDiscriminatorMapping((*PhaseTwoPaymentMethod)(nil), "object", map[string]string{ + "bank": "#/components/schemas/PhaseTwoBank", + "card": "#/components/schemas/PhaseTwoCard", + }), + ) + target := reflect.TypeOf(PhaseTwoCustomer{}) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := generator.SchemasFromTypes(target); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkRenderSyntheticLargeSchema(b *testing.B) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + for i := 0; i < 75; i++ { + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + props.Set("name", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + props.Set("labels", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + AdditionalProperties: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}}), + }, + })) + schemas.Set("SyntheticModel"+intString(i), highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Required: []string{"id"}, + Properties: props, + })) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := NewGenerator().RenderSchemas(schemas); err != nil { + b.Fatal(err) + } + } +} + +func benchmarkTrainTravelSchemas(tb testing.TB) *orderedmap.Map[string, *highbase.SchemaProxy] { + tb.Helper() + spec, err := os.ReadFile("testdata/train-travel.yaml") + if err != nil { + tb.Fatal(err) + } + doc, err := libopenapi.NewDocument(spec) + if err != nil { + tb.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + tb.Fatal(err) + } + return model.Model.Components.Schemas +} diff --git a/generator/golang/conformance_test.go b/generator/golang/conformance_test.go new file mode 100644 index 00000000..0082a5ce --- /dev/null +++ b/generator/golang/conformance_test.go @@ -0,0 +1,367 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import "testing" + +func TestJSONSchema202012GeneratedConformanceDefault(t *testing.T) { + file := renderJSONSchema202012(t) + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestJSONSchema202012GeneratedModels(t *testing.T) { + payload, err := json.Marshal(map[string]any{ + "id": "018f70f9-506f-7c68-b9ff-4b0d80dc8c31", + "kind": "torture", + "multi_value": 42, + "nullable_status": "active", + "mixed_enum": true, + "string_enum": "draft", + "int_enum": 2, + "float_enum": 1.5, + "bool_enum": false, + "closed_config": map[string]any{ + "enabled": true, + "threshold": 12.5, + }, + "labels": map[string]any{ + "region": "west", + "tier": "gold", + }, + "tuple": []any{"seat", 3}, + "object_rules": map[string]any{ + "name": "sample", + "count": 2, + }, + "encoded_payload": "eyJwYXlsb2FkX2lkIjoiYSJ9", + "payment": map[string]any{ + "object": "card", + "number": "4242424242424242", + "cvc": "123", + }, + "loose_choice": 7, + "dynamic_node": map[string]any{ + "name": "root", + "children": []any{ + map[string]any{"name": "leaf"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + var doc TortureDocument + if err := json.Unmarshal(payload, &doc); err != nil { + t.Fatal(err) + } + if doc.ID != "018f70f9-506f-7c68-b9ff-4b0d80dc8c31" || doc.Kind != "torture" { + t.Fatalf("unexpected scalar fields: %#v", doc) + } + if doc.MultiValue == nil || string(doc.MultiValue.Bytes()) != "42" { + t.Fatalf("raw multi-type value did not decode: %#v", doc.MultiValue) + } + copied := doc.MultiValue.Bytes() + copied[0] = '9' + if string(doc.MultiValue.Bytes()) != "42" { + t.Fatal("raw multi-type Bytes should return a copy") + } + if doc.NullableStatus == nil || *doc.NullableStatus != NullableStatus("active") { + t.Fatalf("nullable enum did not decode: %#v", doc.NullableStatus) + } + if doc.MixedEnum == nil || any(*doc.MixedEnum) != true { + t.Fatalf("mixed enum did not decode: %#v", doc.MixedEnum) + } + if doc.StringEnum == nil || *doc.StringEnum != StringEnum("draft") { + t.Fatalf("string enum did not decode: %#v", doc.StringEnum) + } + if doc.IntEnum == nil || *doc.IntEnum != IntEnum(2) { + t.Fatalf("integer enum did not decode: %#v", doc.IntEnum) + } + if doc.FloatEnum == nil || *doc.FloatEnum != FloatEnum(1.5) { + t.Fatalf("number enum did not decode: %#v", doc.FloatEnum) + } + if doc.BoolEnum == nil || *doc.BoolEnum != BoolEnum(false) { + t.Fatalf("boolean enum did not decode: %#v", doc.BoolEnum) + } + if doc.ClosedConfig == nil || !doc.ClosedConfig.Enabled || doc.ClosedConfig.Threshold == nil || *doc.ClosedConfig.Threshold != 12.5 { + t.Fatalf("closed config did not decode: %#v", doc.ClosedConfig) + } + if doc.Labels == nil || doc.Labels.AdditionalProperties["region"] != "west" || doc.Labels.AdditionalProperties["tier"] != "gold" { + t.Fatalf("additional properties did not decode: %#v", doc.Labels) + } + if doc.Tuple == nil || len(*doc.Tuple) != 2 || (*doc.Tuple)[0] != "seat" || (*doc.Tuple)[1] != float64(3) { + t.Fatalf("tuple probe did not decode: %#v", doc.Tuple) + } + if doc.ObjectRules == nil || doc.ObjectRules.Name == nil || *doc.ObjectRules.Name != "sample" || doc.ObjectRules.Count == nil || *doc.ObjectRules.Count != 2 { + t.Fatalf("object rules did not decode: %#v", doc.ObjectRules) + } + if doc.EncodedPayload == nil || *doc.EncodedPayload != EncodedPayload("eyJwYXlsb2FkX2lkIjoiYSJ9") { + t.Fatalf("encoded payload did not decode: %#v", doc.EncodedPayload) + } + card, ok := doc.Payment.Value.(CardSource) + if !ok || card.Object != "card" || card.Number != "4242424242424242" || card.CVC != "123" { + t.Fatalf("payment union did not decode card: %#v", doc.Payment.Value) + } + if doc.LooseChoice == nil || string(doc.LooseChoice.Bytes()) != "7" { + t.Fatalf("anyOf raw union did not decode: %#v", doc.LooseChoice) + } + if doc.DynamicNode == nil || doc.DynamicNode.Name == nil || *doc.DynamicNode.Name != "root" || len(doc.DynamicNode.Children) != 1 || doc.DynamicNode.Children[0].Name == nil || *doc.DynamicNode.Children[0].Name != "leaf" { + t.Fatalf("dynamic recursive node did not decode: %#v", doc.DynamicNode) + } + + out, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "\"object\":\"card\"", + "\"region\":\"west\"", + "\"loose_choice\":7", + "\"multi_value\":42", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %s in marshal output: %s", want, out) + } + } + + var labels StringMap + if err := json.Unmarshal([]byte("{\"region\":\"west\",\"tier\":\"gold\"}"), &labels); err != nil { + t.Fatal(err) + } + if labels.AdditionalProperties["region"] != "west" || labels.AdditionalProperties["tier"] != "gold" { + t.Fatalf("additional property map did not decode: %#v", labels.AdditionalProperties) + } + out, err = json.Marshal(labels) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "\"region\":\"west\"") || !strings.Contains(string(out), "\"tier\":\"gold\"") { + t.Fatalf("additional property map did not marshal: %s", out) + } + + var payment PaymentSourceUnion + if err := json.Unmarshal([]byte("{\"object\":\"bank_account\",\"account_number\":\"abc\",\"bank_name\":\"Bank\"}"), &payment); err != nil { + t.Fatal(err) + } + bank, ok := payment.Value.(BankSource) + if !ok || bank.Object != "bank_account" || bank.AccountNumber != "abc" || bank.BankName == nil || *bank.BankName != "Bank" { + t.Fatalf("payment union did not decode bank source: %#v", payment.Value) + } + if err := json.Unmarshal([]byte("{\"object\":\"cash\"}"), &payment); err == nil || !strings.Contains(err.Error(), "unknown object discriminator") { + t.Fatalf("expected unknown discriminator error, got %v", err) + } + + var loose LooseChoiceUnion + if !loose.IsZero() { + t.Fatal("zero raw union should report IsZero") + } + out, err = json.Marshal(loose) + if err != nil { + t.Fatal(err) + } + if string(out) != "null" { + t.Fatalf("zero raw union should marshal null, got %s", out) + } + if err := json.Unmarshal([]byte("\"abc\""), &loose); err != nil { + t.Fatal(err) + } + if string(loose.Bytes()) != "\"abc\"" { + t.Fatalf("raw union did not retain bytes: %s", loose.Bytes()) + } +} +`) +} + +func TestJSONSchema202012GeneratedConformanceOptions(t *testing.T) { + file := renderJSONSchema202012(t, + WithAdditionalPropertiesMethods(false), + WithEnumConstants(true), + ) + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "testing" +) + +func TestJSONSchema202012GeneratedOptions(t *testing.T) { + if StringEnumDraft != StringEnum("draft") || StringEnumPublished != StringEnum("published") { + t.Fatalf("unexpected string enum constants: %q %q", StringEnumDraft, StringEnumPublished) + } + if IntEnumValue1 != IntEnum(1) || IntEnumValue2 != IntEnum(2) { + t.Fatalf("unexpected integer enum constants: %d %d", IntEnumValue1, IntEnumValue2) + } + if FloatEnumValue15 != FloatEnum(1.5) || FloatEnumValue2 != FloatEnum(2) { + t.Fatalf("unexpected number enum constants: %v %v", FloatEnumValue15, FloatEnumValue2) + } + if BoolEnumTrue != BoolEnum(true) || BoolEnumFalse != BoolEnum(false) { + t.Fatalf("unexpected boolean enum constants: %v %v", BoolEnumTrue, BoolEnumFalse) + } + if NullableStatusActive != NullableStatus("active") || NullableStatusInactive != NullableStatus("inactive") { + t.Fatalf("unexpected nullable enum constants: %q %q", NullableStatusActive, NullableStatusInactive) + } + + var doc TortureDocument + if err := json.Unmarshal([]byte("{\"id\":\"018f70f9-506f-7c68-b9ff-4b0d80dc8c31\",\"kind\":\"torture\",\"payment\":{\"object\":\"card\",\"number\":\"4242424242424242\",\"cvc\":\"123\"},\"string_enum\":\"published\"}"), &doc); err != nil { + t.Fatal(err) + } + card, ok := doc.Payment.Value.(CardSource) + if !ok || card.Object != "card" || card.Number != "4242424242424242" { + t.Fatalf("payment union did not decode with options: %#v", doc.Payment.Value) + } + if doc.StringEnum == nil || *doc.StringEnum != StringEnumPublished { + t.Fatalf("enum constant value did not decode: %#v", doc.StringEnum) + } + + var labels StringMap + if err := json.Unmarshal([]byte("{\"region\":\"west\"}"), &labels); err != nil { + t.Fatal(err) + } + if labels.AdditionalProperties != nil { + t.Fatalf("additional property methods should be disabled: %#v", labels.AdditionalProperties) + } + out, err := json.Marshal(StringMap{AdditionalProperties: map[string]string{"region": "west"}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "{}" { + t.Fatalf("additional properties should be ignored without generated methods, got %s", out) + } +} +`) +} + +func TestNameCollisionGeneratedConformanceDefault(t *testing.T) { + file := renderNameCollisions(t, WithEnumConstants(true)) + if !hasDiagnosticCode(file.Diagnostics, DiagnosticComponentNameCollision) { + t.Fatalf("expected component collision diagnostic: %#v", file.Diagnostics) + } + if !hasDiagnosticCode(file.Diagnostics, DiagnosticFieldNameCollision) { + t.Fatalf("expected field collision diagnostic: %#v", file.Diagnostics) + } + if !hasDiagnosticCode(file.Diagnostics, DiagnosticTypeNameCollision) { + t.Fatalf("expected nested type collision diagnostic: %#v", file.Diagnostics) + } + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestNameCollisionGeneratedModels(t *testing.T) { + var root CollisionRoot + if err := json.Unmarshal([]byte(`+"`"+`{ + "type": "root", + "user-id": {"id": "first"}, + "user_id": {"id": 2}, + "UserID": {"id": true}, + "map": {"func": "call", "type": "mapping", "interface": "shape"}, + "choice": {"type": "card_duplicate", "value": 9}, + "recursive": {"next": {"children": [{"next": {}}]}}, + "map_ref": {"region": "west"}, + "alias": "alias-1", + "enum": "1-5", + "additional_properties": "known", + "nested": { + "value-id": "nested-id", + "value_id": 5, + "dup-obj": {"name": "first"}, + "dup_obj": {"count": 2}, + "inline-item": {"func": "inner"} + }, + "x-extra": "extra" + }`+"`"+`), &root); err != nil { + t.Fatal(err) + } + if root.Type != "root" { + t.Fatalf("keyword property did not decode: %#v", root) + } + if root.UserID.ID != "first" || root.UserID__2.ID != 2 || !root.UserID__3.ID { + t.Fatalf("component refs did not resolve collision-safe names: %#v %#v %#v", root.UserID, root.UserID__2, root.UserID__3) + } + if root.Map.Func != "call" || root.Map.Type != "mapping" || root.Map.Interface != "shape" { + t.Fatalf("keyword-like fields did not decode: %#v", root.Map) + } + duplicate, ok := root.Choice.Value.(ChoiceCard__2) + if !ok || duplicate.Type != "card_duplicate" || duplicate.Value != 9 { + t.Fatalf("discriminator mapping did not resolve collided variant: %#v", root.Choice.Value) + } + if root.Recursive == nil || root.Recursive.Next == nil || len(root.Recursive.Next.Children) != 1 || root.Recursive.Next.Children[0].Next == nil { + t.Fatalf("recursive ref did not decode: %#v", root.Recursive) + } + if root.MapRef.AdditionalProperties["region"] != "west" { + t.Fatalf("map ref did not decode: %#v", root.MapRef) + } + if root.Alias != AliasValue("alias-1") || root.Enum != EnumCollisionValue15__3 { + t.Fatalf("alias or enum collision constants did not decode: %q %q", root.Alias, root.Enum) + } + if root.AdditionalProperties == nil || *root.AdditionalProperties != "known" { + t.Fatalf("known additional_properties field did not decode: %#v", root.AdditionalProperties) + } + if root.AdditionalProperties__2["x-extra"] != "extra" { + t.Fatalf("unknown additional property did not decode through collision-safe field: %#v", root.AdditionalProperties__2) + } + if root.Nested == nil || root.Nested.ValueID != "nested-id" || root.Nested.ValueID__2 != 5 || root.Nested.InlineItem == nil || root.Nested.InlineItem.Func == nil || *root.Nested.InlineItem.Func != "inner" { + t.Fatalf("nested collided fields did not decode: %#v", root.Nested) + } + if root.Nested.DupObj == nil || root.Nested.DupObj.Name == nil || *root.Nested.DupObj.Name != "first" || root.Nested.DupObj__2 == nil || root.Nested.DupObj__2.Count == nil || *root.Nested.DupObj__2.Count != 2 { + t.Fatalf("nested type-name collisions did not decode: %#v", root.Nested) + } + + out, err := json.Marshal(root) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "\"user-id\":{\"id\":\"first\"}", + "\"user_id\":{\"id\":2}", + "\"UserID\":{\"id\":true}", + "\"x-extra\":\"extra\"", + "\"type\":\"card_duplicate\"", + } { + if !strings.Contains(string(out), want) { + t.Fatalf("missing %s in output: %s", want, out) + } + } +} +`) +} + +func TestNameCollisionGeneratedConformanceCompactDelimiter(t *testing.T) { + file := renderNameCollisions(t, + WithNestedTypeNameDelimiter(""), + WithEnumConstants(true), + ) + assertParsesCompilesAndTests(t, file.Source, `package models + +import "testing" + +func TestCompactNestedDelimiterGeneratedModels(t *testing.T) { + nested := CollisionRootNested{ + ValueID: "nested-id", + ValueID__2: 5, + DupObj: &CollisionRootNestedDupObj{}, + DupObj__2: &CollisionRootNestedDupObj__2{}, + InlineItem: &CollisionRootNestedInlineItem{ + Func: stringPtr("inner"), + }, + } + if nested.InlineItem == nil || nested.InlineItem.Func == nil || *nested.InlineItem.Func != "inner" { + t.Fatalf("compact delimiter nested names did not compile: %#v", nested) + } +} + +func stringPtr(value string) *string { + return &value +} +`) +} diff --git a/generator/golang/coverage_test.go b/generator/golang/coverage_test.go new file mode 100644 index 00000000..a2ad90d8 --- /dev/null +++ b/generator/golang/coverage_test.go @@ -0,0 +1,625 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +func TestInternalBranchCoverage(t *testing.T) { + gen := NewGenerator( + WithNullableAsPointer(false), + WithEnumConstants(true), + WithNameResolver(func(name string) string { + if name == "Custom" { + return "Resolved" + } + return "" + }), + ) + if got := gen.publicName("Custom"); got != "Resolved" { + t.Fatalf("resolver not used: %s", got) + } + if got := toPublicName(""); got != "Value" { + t.Fatalf("empty public name: %s", got) + } + if got := toPublicName("type"); got != "Type" { + t.Fatalf("keyword public name: %s", got) + } + if got := gen.nestedTypeName("", "child value"); got != "ChildValue" { + t.Fatalf("empty parent nested name: %s", got) + } + if names := gen.resolveComponentTypeNames(nil); len(names) != 0 { + t.Fatalf("nil component name map should be empty: %#v", names) + } + componentNameProbe := orderedmap.New[string, *highbase.SchemaProxy]() + componentNameProbe.Set("component", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + if names := gen.resolveComponentTypeNames(componentNameProbe); names["component"] != "Component" { + t.Fatalf("component name map should resolve without an active registry: %#v", names) + } + if got := refName("Pet"); got != "Pet" { + t.Fatalf("plain ref: %s", got) + } + if got := refName(""); got != "" { + t.Fatalf("empty ref: %s", got) + } + if got := refName("#/"); got != "#/" { + t.Fatalf("trailing ref: %s", got) + } + if splitCamel("") != nil { + t.Fatal("empty camel split should be nil") + } + if got := uniqueName("", map[string]struct{}{}); got != "Value" { + t.Fatalf("empty unique name: %s", got) + } + if derefType(nil) != nil { + t.Fatal("nil deref should stay nil") + } + if got := typeName(nil); got != "" { + t.Fatalf("nil type name: %s", got) + } + if got := typeName(reflect.TypeOf([]string{})); got != "Slice" { + t.Fatalf("unnamed type name: %s", got) + } + if interfaceKey(nil) != nil || interfaceKey(struct{}{}) != nil { + t.Fatal("bad interface keys should be nil") + } + var iface any + if interfaceKey(&iface) == nil { + t.Fatal("interface pointer key should resolve") + } + if got := derefType(reflect.TypeOf((**string)(nil))); got.Kind() != reflect.String { + t.Fatalf("pointer deref failed: %v", got) + } + if isRequired(nil, "x") { + t.Fatal("nil required should be false") + } + + var bare Generator + WithFormatMapping("date", "civil.Date", "civil")(&bare) + if bare.formatMappings["date"].goType != "civil.Date" { + t.Fatal("format mapping should initialize nil map") + } + WithAdditionalPropertiesMethods(false)(&bare) + if bare.additionalPropertiesMethods { + t.Fatal("additional properties methods option not applied") + } + WithExternalRefTypeResolver(func(ref string) string { return "ResolvedExternal" })(&bare) + if bare.refTypeName("../common.yaml#/components/schemas/Pet") != "ResolvedExternal" { + t.Fatal("external ref resolver not applied") + } + if bare.refTypeName("") != "" { + t.Fatal("empty ref type should stay empty") + } + bare.externalRefResolver = nil + if bare.refTypeName("AlreadyNamed") != "AlreadyNamed" { + t.Fatal("plain ref type should stay unchanged") + } + WithTypeSchema(nil, highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}}))(&bare) + WithTypeSchema(reflect.TypeOf(""), nil)(&bare) + WithTypeSchema(reflect.TypeOf(""), highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}}))(&bare) + if bare.typeSchemas[reflect.TypeOf("")] == nil { + t.Fatal("type schema option should initialize nil map") + } + WithOneOfTypes(struct{}{}, nil)(&bare) + WithDiscriminatorMapping(struct{}{}, "kind", map[string]string{"x": "Y"})(&bare) + + tag := parseJSONTag(reflect.StructField{Name: "Value", Tag: `json:",omitempty"`}) + if tag.name != "Value" || !tag.omitempty { + t.Fatalf("unexpected empty-name tag: %#v", tag) + } + tag = parseJSONTag(reflect.StructField{Name: "Value", Tag: `json:"-"`}) + if !tag.skip { + t.Fatal("skip tag not parsed") + } + if tagLiteral("x", false, false, false, false, "") != "" { + t.Fatal("expected empty literal") + } + if _, err := NewGenerator().RenderSchemas(nil); err != nil { + t.Fatal(err) + } + if _, err := (&Generator{packageName: "bad-name"}).renderFile(nil); err == nil { + t.Fatal("expected direct renderFile package error") + } + if _, err := NewGenerator().renderFile([]*SchemaIR{nil}); err != nil { + t.Fatal(err) + } + if _, err := NewGenerator().SchemaFromValue(nil); err == nil { + t.Fatal("expected generator nil value error") + } + if _, err := NewGenerator().SchemaFromValue("hello"); err != nil { + t.Fatal(err) + } + if _, err := NewGenerator().RenderSchema("Empty", &highbase.SchemaProxy{}); err == nil { + t.Fatal("expected render schema openapi error") + } + badNameGen := NewGenerator(WithNameResolver(func(string) string { return "bad-name" })) + if _, err := badNameGen.RenderSchema("Bad", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}})); err == nil { + t.Fatal("expected formatting error from invalid resolved name") + } + nilSchemas := orderedmap.New[string, *highbase.SchemaProxy]() + nilSchemas.Set("Broken", nil) + if _, err := NewGenerator().RenderSchemas(nilSchemas); err == nil { + t.Fatal("expected render schemas error") + } + + gen.renderDecl(nil) + gen.renderDecl(&SchemaIR{Kind: KindRef, Name: "Ref", Ref: "#/components/schemas/Ref"}) + if !gen.rememberDecl("Once") || gen.rememberDecl("Once") || gen.rememberDecl("") { + t.Fatal("rememberDecl branch failed") + } + gen.renderAliasDecl(&SchemaIR{Name: "Alias", Kind: KindString}) + gen.renderAliasDecl(&SchemaIR{Name: "Alias", Kind: KindString}) + gen.renderObjectDecl(&SchemaIR{ + Name: "MapOnly", + Kind: KindObject, + AdditionalProperties: &SchemaIR{Kind: KindString}, + }) + gen.renderObjectDecl(&SchemaIR{Name: "MapOnly", Kind: KindObject}) + gen.renderObjectDecl(&SchemaIR{ + Name: "Embedded", + Kind: KindObject, + Properties: orderedmap.New[string, *SchemaIR](), + AllOf: []*SchemaIR{{Kind: KindRef, Ref: "#/components/schemas/Base", Name: "Base"}}, + }) + gen.renderChildren(&SchemaIR{ + Items: &SchemaIR{Name: "ChildItem", Kind: KindString}, + AdditionalProperties: &SchemaIR{Name: "ChildAdditional", Kind: KindString}, + AllOf: []*SchemaIR{{Name: "ChildAllOf", Kind: KindString}}, + }) + gen.renderNested(nil) + gen.renderNested(&SchemaIR{Kind: KindArray, Items: &SchemaIR{Name: "NestedAlias", Kind: KindInteger}}) + gen.renderNested(&SchemaIR{Kind: KindMap, AdditionalProperties: &SchemaIR{Name: "NestedMapAlias", Kind: KindBoolean}}) + gen.renderUnionDecl(&SchemaIR{Name: "BrokenUnion", Kind: KindUnion}) + gen.renderDiscriminatedUnion(&SchemaIR{Name: "BrokenDisc", Kind: KindUnion, Union: &UnionIR{}}) + gen.renderRawUnion(&SchemaIR{Name: "BrokenDisc"}) + gen.renderDiscriminatedUnion(&SchemaIR{ + Name: "DiscWithNilVariant", + Kind: KindUnion, + Union: &UnionIR{ + Discriminator: &Discriminator{PropertyName: "kind", Mapping: map[string]string{"x": "X"}}, + Variants: []*SchemaIR{nil, {Name: "", Kind: KindObject}}, + }, + }) + gen.renderDiscriminatedUnion(&SchemaIR{ + Name: "DiscWithNilVariant", + Kind: KindUnion, + Union: &UnionIR{ + Discriminator: &Discriminator{PropertyName: "kind", Mapping: map[string]string{"x": "X"}}, + }, + }) + + enum := &SchemaIR{ + Name: "IntEnum", + Kind: KindEnum, + Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}}, + } + gen.renderEnumDecl(enum) + gen.renderEnumDecl(enum) + gen.renderEnumDecl(&SchemaIR{ + Name: "StringEnum", + Kind: KindEnum, + Enum: []*yaml.Node{stringNode("hello-world")}, + }) + if got := gen.goType(nil, true, false); got != "any" { + t.Fatalf("nil type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindRef, Ref: "#/components/schemas/Pet"}, true, false); got != "Pet" { + t.Fatalf("ref type: %s", got) + } + gen.componentKinds = map[string]Kind{"Choice": KindUnion} + if got := gen.goType(&SchemaIR{Kind: KindRef, Name: "Choice", Ref: "#/components/schemas/Choice"}, true, false); got != "ChoiceUnion" { + t.Fatalf("union ref type: %s", got) + } + gen.componentKinds = nil + if got := gen.goType(&SchemaIR{Kind: KindObject, Name: "Obj", Properties: orderedmap.New[string, *SchemaIR]()}, true, false); got != "map[string]any" { + t.Fatalf("empty object type: %s", got) + } + closed := false + if got := gen.goType(&SchemaIR{Kind: KindObject, Name: "ClosedObj", AdditionalAllowed: &closed}, true, false); got != "ClosedObj" { + t.Fatalf("closed object type: %s", got) + } + props := orderedmap.New[string, *SchemaIR]() + props.Set("id", &SchemaIR{Kind: KindString}) + if got := gen.goType(&SchemaIR{Kind: KindObject, Name: "Obj", Properties: props}, true, false); got != "Obj" { + t.Fatalf("object type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindObject, AdditionalProperties: &SchemaIR{Kind: KindInteger}}, true, false); got != "map[string]int" { + t.Fatalf("additional object type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindArray, Items: &SchemaIR{Kind: KindString}}, true, false); got != "[]string" { + t.Fatalf("array type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindMap, AdditionalProperties: &SchemaIR{Kind: KindString}}, true, false); got != "map[string]string" { + t.Fatalf("map type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindInteger, Format: "int32"}, true, false); got != "int32" { + t.Fatalf("int32 type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindInteger, Format: "int64"}, true, false); got != "int64" { + t.Fatalf("int64 type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindNumber, Format: "float"}, true, false); got != "float32" { + t.Fatalf("float type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindNumber}, true, false); got != "float64" { + t.Fatalf("number type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindEnum}, true, false); got != "string" { + t.Fatalf("unnamed enum type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindUnion, Name: "Choice"}, true, false); got != "ChoiceUnion" { + t.Fatalf("union type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindUnknown}, true, false); got != "any" { + t.Fatalf("unknown type: %s", got) + } + if got := gen.goType(&SchemaIR{Kind: KindBoolean}, true, true); got != "bool" { + t.Fatalf("nullable disabled pointer type: %s", got) + } + gen.formatMappings["date-time"] = formatMapping{goType: "time.Time", importPath: "time"} + if got := gen.formatType("date-time", "string"); got != "time.Time" { + t.Fatalf("mapped format: %s", got) + } + if got := gen.formatType("unknown", "string"); got != "string" { + t.Fatalf("fallback format: %s", got) + } + if shouldPointer("[]string", nil, false, true, true) { + t.Fatal("slices should not be pointered") + } + if !shouldPointer("string", &SchemaIR{Nullable: true}, true, true, true) { + t.Fatal("nullable should pointer") + } + var comment strings.Builder + writeComment(&comment, "Thing", "") + writeComment(&comment, "Thing", "\n") + writeComment(&comment, "Thing", "already.") + writeComment(&comment, "Thing", "missing") + if !strings.Contains(comment.String(), "already.") || !strings.Contains(comment.String(), "missing.") { + t.Fatal("comment not written") + } + if gen.stringEncodedIR(nil, "nil") != nil { + t.Fatal("nil string encoded IR should stay nil") + } + unsupportedStringEncoded := &SchemaIR{Kind: KindArray} + if got := gen.stringEncodedIR(unsupportedStringEncoded, "array"); got != unsupportedStringEncoded { + t.Fatal("unsupported string encoded IR should return original") + } + + if shape := enumShapeFor(nil); shape.goType != "any" || shape.constants { + t.Fatalf("nil enum shape should be any without constants: %#v", shape) + } + if shape := enumShapeFor([]*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}, {Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.5"}}); shape.goType != "float64" || !shape.constants { + t.Fatalf("numeric enum shape should widen to float64: %#v", shape) + } + if shape := enumShapeFor([]*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!binary", Value: "abc"}}); shape.goType != "string" || !shape.constants { + t.Fatalf("unknown scalar enum shape should fall back to string: %#v", shape) + } + if enumLiteral(nil, "string") != "" || enumLiteral(stringNode("x"), "any") != "" { + t.Fatal("enum literal should skip nil and unsupported bases") + } + for typ, kind := range map[string]Kind{ + "string": KindString, + "integer": KindInteger, + "number": KindNumber, + "boolean": KindBoolean, + "array": KindArray, + "object": KindObject, + "unknown": KindAny, + } { + if got := kindForJSONType(typ); got != kind { + t.Fatalf("kind for %s: %v", typ, got) + } + } + if schemaDeclaresType(nil) { + t.Fatal("nil schema should not declare a type") + } +} + +func TestChildIRPreservesFieldsOnBuildError(t *testing.T) { + gen := NewGenerator() + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("broken", nil) + ir, err := gen.irFromOpenAPI("Holder", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: props, + }), "Holder") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + prop, ok := ir.Properties.Get("broken") + if !ok || prop == nil || prop.Kind != KindAny { + t.Fatalf("expected broken property preserved as any, got %#v", prop) + } + if !hasDiagnosticCode(gen.diagnostics, DiagnosticChildSchema) { + t.Fatalf("expected child schema diagnostic, got %#v", gen.diagnostics) + } +} + +func TestOpenAPIBranchCoverage(t *testing.T) { + gen := NewGenerator() + ref := highbase.CreateSchemaProxyRef("#/components/schemas/Pet") + if ir, err := gen.irFromOpenAPI("Pet", ref, "Pet"); err != nil || ir.Kind != KindRef { + t.Fatalf("ref ir failed: %#v %v", ir, err) + } + if ir, err := gen.irFromOpenAPI("Pet", ref, "Pet"); err != nil || ir.Kind != KindRef { + t.Fatalf("cached ref ir failed: %#v %v", ir, err) + } + if _, err := gen.irFromOpenAPI("Nil", nil, "Nil"); err == nil { + t.Fatal("expected nil openapi schema error") + } + if _, err := gen.irFromOpenAPI("Empty", &highbase.SchemaProxy{}, "Empty"); err == nil { + t.Fatal("expected empty proxy schema error") + } + nullable := true + if ir, err := gen.irFromOpenAPI("Nullable", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Nullable: &nullable, + }), "Nullable"); err != nil || !ir.Nullable { + t.Fatalf("nullable schema failed: %#v %v", ir, err) + } + + schemas := map[string]string{ + "String": "type: string\n", + "Integer": "type: integer\n", + "Number": "type: number\n", + "Boolean": "type: boolean\n", + "Array": "type: array\nitems: true\n", + "ArraySchema": "type: array\nitems:\n type: string\n", + "Free": "type: object\nadditionalProperties: true\n", + "Closed": "type: object\nadditionalProperties: false\n", + "MapSchema": "type: object\nadditionalProperties:\n type: string\n", + "InferObject": "properties:\n id:\n type: string\n", + "InferMap": "additionalProperties: true\n", + } + for name, yml := range schemas { + if _, err := gen.irFromOpenAPI(name, schemaProxyFromYAML(t, yml), name); err != nil { + t.Fatalf("%s failed: %v", name, err) + } + } + unknownIR := &SchemaIR{Name: "Unknown", Required: make(map[string]struct{})} + gen.populateSchemaShape(unknownIR, &highbase.Schema{Type: []string{"unknown"}}, "unknown") + if unknownIR.Kind != KindAny { + t.Fatalf("unknown explicit type should render as any: %#v", unknownIR) + } + unknownObjectIR := &SchemaIR{Name: "UnknownObject", Required: make(map[string]struct{})} + unknownObjectProps := orderedmap.New[string, *highbase.SchemaProxy]() + unknownObjectProps.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + gen.populateSchemaShape(unknownObjectIR, &highbase.Schema{Type: []string{"unknown"}, Properties: unknownObjectProps}, "unknownObject") + if unknownObjectIR.Kind != KindObject { + t.Fatalf("unknown explicit type with properties should render as object: %#v", unknownObjectIR) + } + unknownMapIR := &SchemaIR{Name: "UnknownMap", Required: make(map[string]struct{})} + gen.populateSchemaShape(unknownMapIR, &highbase.Schema{ + Type: []string{"unknown"}, + AdditionalProperties: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + N: 1, + B: true, + }, + }, "unknownMap") + if unknownMapIR.Kind != KindObject { + t.Fatalf("unknown explicit type with additionalProperties should render as object: %#v", unknownMapIR) + } + + merged := &SchemaIR{ + Name: "Merged", + Required: map[string]struct{}{"wrapper": {}}, + AllOf: []*SchemaIR{ + nil, + {Kind: KindRef, Ref: "#/components/schemas/Base", Name: "Base"}, + {Kind: KindString, Name: "Ignored"}, + {Kind: KindObject, Properties: orderedmap.New[string, *SchemaIR](), Required: map[string]struct{}{"id": {}}}, + }, + } + merged.AllOf[3].Properties.Set("id", &SchemaIR{Kind: KindString}) + gen.mergeAllOf(merged) + if merged.Properties.Len() != 1 || len(merged.AllOf) != 2 || !isRequired(merged, "wrapper") { + t.Fatalf("bad allOf merge: %#v", merged) + } + + nullableAny := true + if len(nonNullVariants([]*SchemaIR{ + nil, + {Kind: KindAny, Nullable: true, SourceSchema: &highbase.Schema{Nullable: &nullableAny}}, + {Kind: KindAny, Nullable: true, SourceSchema: &highbase.Schema{Type: []string{"null"}}}, + {Kind: KindAny, Const: nullNode()}, + {Kind: KindEnum, Enum: []*yaml.Node{nullNode()}}, + {Kind: KindString}, + })) != 2 { + t.Fatal("nonNullVariants should only remove null-only variants") + } + if isNullOnlyIR(nil) || schemaOnlyAllowsNull(nil) { + t.Fatal("nil null-only checks should be false") + } + if isNullOnlyIR(&SchemaIR{Const: stringNode("x")}) { + t.Fatal("non-null const should not be null-only") + } + if schemaOnlyAllowsNull(&highbase.Schema{Enum: []*yaml.Node{stringNode("x")}}) { + t.Fatal("non-null enum should not be null-only") + } + if schemaNeedsNullAlternative(nil) || schemaNeedsNullAlternative(&highbase.Schema{Type: []string{"string"}}) { + t.Fatal("plain schemas should not need a null alternative wrapper") + } + if inferConstDiscriminator(nil) != nil { + t.Fatal("nil variants should not infer discriminator") + } + if inferConstDiscriminator([]*SchemaIR{{Kind: KindObject}}) != nil { + t.Fatal("missing properties should not infer") + } + props := orderedmap.New[string, *SchemaIR]() + props.Set("kind", &SchemaIR{Const: stringNode("same")}) + if inferConstDiscriminator([]*SchemaIR{ + {Kind: KindObject, Name: "A", Properties: props}, + {Kind: KindObject, Name: "B", Properties: props}, + }) != nil { + t.Fatal("duplicate discriminator values should not infer") + } + propsA := orderedmap.New[string, *SchemaIR]() + propsA.Set("kind", &SchemaIR{Const: &yaml.Node{Kind: yaml.SequenceNode}}) + if inferConstDiscriminator([]*SchemaIR{{Kind: KindObject, Name: "A", Properties: propsA}}) != nil { + t.Fatal("non-scalar const should not infer") + } + propsB := orderedmap.New[string, *SchemaIR]() + propsB.Set("other", &SchemaIR{Const: stringNode("b")}) + if inferConstDiscriminator([]*SchemaIR{ + {Kind: KindObject, Name: "A", Properties: props}, + {Kind: KindObject, Name: "B", Properties: propsB}, + }) != nil { + t.Fatal("missing discriminator on later variant should not infer") + } + discSchema := &highbase.Schema{Discriminator: &highbase.Discriminator{PropertyName: "kind"}} + if disc := discriminatorFromSchema(discSchema, []*SchemaIR{{Name: "RefVariant", Ref: "#/components/schemas/ref-variant"}}); disc.Mapping["ref-variant"] != "#/components/schemas/ref-variant" { + t.Fatalf("ref discriminator mapping not inferred: %#v", disc) + } + if disc := discriminatorFromSchema(discSchema, []*SchemaIR{{Name: "InlineVariant"}}); len(disc.Mapping) != 0 { + t.Fatalf("inline variant should not infer discriminator mapping: %#v", disc) + } + if disc := discriminatorFromSchema(discSchema, []*SchemaIR{ + {Name: "RefVariant", Ref: "#/components/schemas/ref-variant"}, + {Name: "InlineVariant"}, + }); len(disc.Mapping) != 0 { + t.Fatalf("mixed ref and inline variants should not infer partial discriminator mapping: %#v", disc) + } + if disc := discriminatorFromSchema(discSchema, []*SchemaIR{nil}); len(disc.Mapping) != 0 { + t.Fatalf("nil variant should not infer discriminator mapping: %#v", disc) + } +} + +func TestReflectBranchCoverage(t *testing.T) { + gen := NewGenerator() + if _, err := gen.irFromReflect(nil, "", "nil"); err == nil { + t.Fatal("expected nil reflect type error") + } + type Recursive struct { + Next *Recursive `json:"next,omitempty"` + } + if _, err := gen.irFromReflect(reflect.TypeOf(Recursive{}), "Recursive", "Recursive"); err != nil { + t.Fatal(err) + } + type Numbers struct { + Uint uint `json:"uint"` + Uint8 uint8 `json:"uint8"` + Bool bool `json:"bool"` + Int32 int32 `json:"int32"` + Int64 int64 `json:"int64"` + Float32 float32 `json:"float32"` + Float64 float64 `json:"float64"` + Bytes []byte `json:"bytes"` + } + if _, err := gen.irFromReflect(reflect.TypeOf(Numbers{}), "Numbers", "Numbers"); err != nil { + t.Fatal(err) + } + if _, err := gen.irFromReflect(reflect.TypeOf(&Numbers{}), "Numbers", "Numbers"); err != nil { + t.Fatal(err) + } + providerIR, err := gen.irFromReflect(reflect.TypeOf(&Provider{}), "Provider", "Provider") + if err != nil { + t.Fatal(err) + } + if !providerIR.Nullable { + t.Fatal("pointer schema provider should preserve nullable") + } + type WithPrivate struct { + name string + ID string `json:"id"` + } + if _, err := gen.irFromReflect(reflect.TypeOf(WithPrivate{}), "WithPrivate", "WithPrivate"); err != nil { + t.Fatal(err) + } + type BrokenInterface interface{ broken() } + brokenGen := NewGenerator(WithOneOfTypes((*BrokenInterface)(nil), make(chan string))) + if _, err := brokenGen.irFromReflect(reflect.TypeOf((*BrokenInterface)(nil)).Elem(), "BrokenInterface", "BrokenInterface"); err == nil { + t.Fatal("expected broken interface variant error") + } + type ProviderLikeInterface interface { + OpenAPISchema() *highbase.SchemaProxy + } + if _, err := gen.irFromReflect(reflect.TypeOf((*ProviderLikeInterface)(nil)).Elem(), "ProviderLikeInterface", "ProviderLikeInterface"); err == nil { + t.Fatal("provider-like interfaces should fail cleanly unless registered as oneOf") + } + type BadSlice []chan string + if _, err := gen.irFromReflect(reflect.TypeOf(BadSlice{}), "BadSlice", "BadSlice"); err == nil { + t.Fatal("expected bad slice error") + } + type BadMap map[string]chan string + if _, err := gen.irFromReflect(reflect.TypeOf(BadMap{}), "BadMap", "BadMap"); err == nil { + t.Fatal("expected bad map value error") + } + if _, err := gen.irFromReflect(reflect.TypeOf(make(chan string)), "Chan", "Chan"); err == nil { + t.Fatal("expected unsupported chan") + } +} + +func TestToOpenAPIBranchCoverage(t *testing.T) { + gen := NewGenerator() + applySchemaFidelity(nil, nil) + fidelitySchema := &highbase.Schema{} + applySchemaFidelity(fidelitySchema, &SchemaIR{SourceSchema: &highbase.Schema{DynamicRef: "#dynamic"}}) + if fidelitySchema.DynamicRef != "#dynamic" { + t.Fatalf("dynamic ref fidelity not applied: %#v", fidelitySchema) + } + gen.populateOpenAPIUnion(&highbase.Schema{}, &SchemaIR{}) + falseValue := false + obj := &SchemaIR{ + Kind: KindObject, + AdditionalAllowed: &falseValue, + } + if out := gen.openapiFromIR(obj); out == nil { + t.Fatal("expected object schema") + } + union := &SchemaIR{ + Kind: KindUnion, + Union: &UnionIR{ + Kind: UnionAnyOf, + Variants: []*SchemaIR{ + {Kind: KindString}, + {Kind: KindInteger}, + }, + }, + } + if out := gen.openapiFromIR(union); out == nil { + t.Fatal("expected union schema") + } + emptyUnion := &SchemaIR{Kind: KindUnion, Union: &UnionIR{}} + if out := gen.openapiFromIR(emptyUnion); out == nil { + t.Fatal("expected empty union schema") + } + nullableDynamic := gen.openapiFromIR(&SchemaIR{ + Kind: KindRef, + Ref: "#dynamic", + DynamicRef: true, + Nullable: true, + SourceSchema: &highbase.Schema{ + DynamicRef: "#dynamic", + Comment: "dynamic nullable ref", + }, + }).Schema() + if nullableDynamic == nil || len(nullableDynamic.AnyOf) != 2 || nullableDynamic.AnyOf[0].Schema().DynamicRef != "#dynamic" { + t.Fatalf("expected nullable dynamic ref anyOf, got %#v", nullableDynamic) + } + nullableEnum := gen.openapiFromIR(&SchemaIR{ + Kind: KindEnum, + Nullable: true, + Enum: []*yaml.Node{stringNode("active")}, + }).Schema() + if nullableEnum == nil || !schemaTypeContains(nullableEnum.Type, "null") || !enumHasNull(nullableEnum.Enum) { + t.Fatalf("expected nullable enum to include null type and enum value, got %#v", nullableEnum) + } + for _, enumIR := range []*SchemaIR{ + {Kind: KindEnum, Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}}}, + {Kind: KindEnum, Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.5"}}}, + {Kind: KindEnum, Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}}}, + {Kind: KindEnum, Enum: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!str", Value: "x"}, {Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}}}, + } { + if out := gen.openapiFromIR(enumIR); out == nil { + t.Fatalf("expected enum schema for %#v", enumIR) + } + } +} diff --git a/generator/golang/doc.go b/generator/golang/doc.go new file mode 100644 index 00000000..8b468f9e --- /dev/null +++ b/generator/golang/doc.go @@ -0,0 +1,56 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package golang generates Go model types from OpenAPI schemas and generates +// OpenAPI schemas from Go runtime types. +// +// The package is intentionally library-only. It does not provide a CLI, +// generated client or server code, a validation runtime, or runtime helper +// package. Callers provide libopenapi schema models or Go reflection types and +// receive generated Go source or OpenAPI schema proxies. +// +// OpenAPI to Go model generation starts with RenderSchema for a single schema +// or Generator.RenderSchemas for component maps. The generated source is +// gofmt-formatted and diagnostics report schema shapes that do not map directly +// to plain Go model fields. +// +// Go to OpenAPI generation starts with SchemaFromType for a single schema or +// Generator.SchemasFromTypes for a reusable component graph. Package-level +// graph helpers also have WithOptions variants for callers that do not need to +// keep a Generator instance. Named reflected structs, enums, and registered +// interface unions are emitted as components, nested named model references are +// rendered as component $refs, and SchemaSet.Roots exposes every requested +// root. WithTypeSchema maps reflected project scalar aliases to explicit +// OpenAPI schema models without adding methods to the scalar type, and +// WithFieldSchema/WithFieldSchemaByJSONName map individual struct fields to +// exact schema models while keeping the surrounding type reflected normally. +// Reflected nullable values use JSON Schema 2020-12 native nullability rather +// than OpenAPI 3.0 nullable: direct schemas use type arrays that include +// "null", and nullable component references use anyOf wrappers. +// +// Reflection metadata is layered. Field-level openapi struct tags handle +// compact scalar metadata such as format, constraints, enum, const, +// readOnly/writeOnly/deprecated, and nullable overrides. SchemaProvider, +// SchemaMetadataProvider, and SchemaYAMLProvider methods handle exact +// type-level schemas. OpenAPI-to-Go generation can opt into WithOpenAPITags and +// WithSchemaMetadataSidecar to emit those hooks into a separate +// schema_metadata.go source file for higher-fidelity Go-to-OpenAPI round trips. +// Disabling the metadata sidecar leaves GeneratedFile.SchemaMetadata nil and +// keeps generated code leaner, but recreating the original OpenAPI input from +// reflected Go types becomes intentionally lossy. +// +// Polymorphic oneOf schemas with an explicit discriminator, or an inferable +// required const discriminator, render as typed union wrappers. Ambiguous oneOf +// and anyOf schemas render as json.RawMessage wrappers so the generated model +// remains dependency-free and does not embed validation behavior. +// +// Schema-valued additionalProperties can round-trip unknown JSON object fields +// through generated marshal/unmarshal methods. WithAdditionalPropertiesMethods +// disables those methods when callers want to provide JSON behavior themselves. +// +// Inline schema type names use "_" as the default parent/child delimiter. +// WithNestedTypeNameDelimiter changes that delimiter, including to an empty +// string for compact names. Name collisions use "__" before the numeric suffix. +// Component names are collision-resolved before local refs are rendered, so +// generated fields point at the final Go type names. +package golang diff --git a/generator/golang/enum.go b/generator/golang/enum.go new file mode 100644 index 00000000..7e71e733 --- /dev/null +++ b/generator/golang/enum.go @@ -0,0 +1,101 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strconv" + + "go.yaml.in/yaml/v4" +) + +type enumShape struct { + goType string + constants bool + mixed bool + nullable bool + nonNullValues int +} + +func enumShapeFor(nodes []*yaml.Node) enumShape { + var shape enumShape + var family string + for _, node := range nodes { + if node == nil || nodeIsNull(node) { + shape.nullable = true + continue + } + shape.nonNullValues++ + next := enumFamily(node) + if family == "" { + family = next + continue + } + if family == "number" && next == "integer" { + continue + } + if family == "integer" && next == "number" { + family = "number" + continue + } + if family != next { + shape.mixed = true + } + } + switch { + case shape.nonNullValues == 0: + shape.goType = "any" + case shape.mixed: + shape.goType = "any" + case family == "integer": + shape.goType = "int" + shape.constants = true + case family == "number": + shape.goType = "float64" + shape.constants = true + case family == "boolean": + shape.goType = "bool" + shape.constants = true + default: + shape.goType = "string" + shape.constants = true + } + return shape +} + +func enumFamily(node *yaml.Node) string { + switch node.Tag { + case "!!int": + return "integer" + case "!!float": + return "number" + case "!!bool": + return "boolean" + case "!!str": + return "string" + default: + return "unknown" + } +} + +func enumHasNull(nodes []*yaml.Node) bool { + return enumShapeFor(nodes).nullable +} + +func enumLiteral(node *yaml.Node, goType string) string { + if node == nil || nodeIsNull(node) { + return "" + } + switch goType { + case "string": + return strconv.Quote(node.Value) + case "int", "float64", "bool": + return node.Value + default: + return "" + } +} + +func nodeIsNull(node *yaml.Node) bool { + return node != nil && node.Tag == "!!null" +} diff --git a/generator/golang/errors.go b/generator/golang/errors.go new file mode 100644 index 00000000..98d0d781 --- /dev/null +++ b/generator/golang/errors.go @@ -0,0 +1,24 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "errors" + "fmt" +) + +var ( + ErrNilSchema = errors.New("nil schema") + ErrNilType = errors.New("nil type") + ErrUnsupportedType = errors.New("unsupported type") + ErrUnsupportedMapKey = errors.New("unsupported map key") + ErrInvalidPackageName = errors.New("invalid package name") +) + +func wrapPath(err error, path string) error { + if path == "" { + return fmt.Errorf("generator/golang: %w", err) + } + return fmt.Errorf("generator/golang: %w at %s", err, path) +} diff --git a/generator/golang/example_test.go b/generator/golang/example_test.go new file mode 100644 index 00000000..d4410fae --- /dev/null +++ b/generator/golang/example_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "fmt" + "reflect" + "strings" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func ExampleRenderSchema() { + properties := orderedmap.New[string, *highbase.SchemaProxy]() + properties.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + + schema := highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Required: []string{"id"}, + Properties: properties, + }) + source, err := RenderSchema("Pet", schema, WithOptionalFieldsAsPointers(false)) + if err != nil { + panic(err) + } + + fmt.Println(strings.Contains(string(source), "type Pet struct")) + fmt.Println(strings.Contains(string(source), "ID string `json:\"id\"`")) + + // Output: + // true + // true +} + +type ExampleBillingAddress struct { + Line1 string `json:"line1"` +} + +type ExampleCustomer struct { + ID string `json:"id"` + Address ExampleBillingAddress `json:"address"` +} + +func ExampleGenerator_SchemasFromTypes() { + generator := NewGenerator() + set, err := generator.SchemasFromTypes(reflect.TypeOf(ExampleCustomer{})) + if err != nil { + panic(err) + } + + _, hasCustomer := set.Components.Get("ExampleCustomer") + _, hasAddress := set.Components.Get("ExampleBillingAddress") + + fmt.Println(set.Root.GetReference()) + fmt.Println(hasCustomer, hasAddress) + + // Output: + // #/components/schemas/ExampleCustomer + // true true +} diff --git a/generator/golang/fidelity_test.go b/generator/golang/fidelity_test.go new file mode 100644 index 00000000..3aa299d2 --- /dev/null +++ b/generator/golang/fidelity_test.go @@ -0,0 +1,477 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func TestAPIPolishSchemaSetRootsAndOptionVariants(t *testing.T) { + set, err := SchemasFromTypesWithOptions([]reflect.Type{ + reflect.TypeOf(PhaseTwoCustomer{}), + reflect.TypeOf(PhaseTwoAddress{}), + }, + WithOneOfTypes((*PhaseTwoPaymentMethod)(nil), PhaseTwoCard{}, PhaseTwoBank{}), + WithDiscriminatorMapping((*PhaseTwoPaymentMethod)(nil), "object", map[string]string{ + "bank": "#/components/schemas/PhaseTwoBank", + "card": "#/components/schemas/PhaseTwoCard", + }), + ) + if err != nil { + t.Fatal(err) + } + if set.Roots.Len() != 2 { + t.Fatalf("expected two roots, got %d", set.Roots.Len()) + } + if root, ok := set.Roots.Get("PhaseTwoCustomer"); !ok || !root.IsReference() { + t.Fatalf("customer root should be a component reference: %#v", root) + } + if root, ok := set.Roots.Get("PhaseTwoAddress"); !ok || !root.IsReference() { + t.Fatalf("address root should be a component reference: %#v", root) + } + + values, err := SchemasFromValuesWithOptions([]any{PhaseTwoCustomer{}}, + WithOneOfTypes((*PhaseTwoPaymentMethod)(nil), PhaseTwoCard{}, PhaseTwoBank{}), + ) + if err != nil { + t.Fatal(err) + } + if values.Roots.Len() != 1 { + t.Fatalf("expected one value root, got %d", values.Roots.Len()) + } + + primitive, err := SchemasFromTypes(reflect.TypeOf("")) + if err != nil { + t.Fatal(err) + } + if primitive.Root == nil || primitive.Root.IsReference() { + t.Fatalf("primitive root should render inline, got %#v", primitive.Root) + } + if primitive.Components.Len() != 0 { + t.Fatalf("primitive roots should not create components: %d", primitive.Components.Len()) + } +} + +type GraphReviewPaymentMethod interface { + graphReviewPaymentMethod() +} + +type GraphReviewCard struct { + Object string `json:"object"` + CVC string `json:"cvc"` +} + +func (GraphReviewCard) graphReviewPaymentMethod() {} + +type GraphReviewBank struct { + Object string `json:"object"` + IBAN string `json:"iban"` +} + +func (GraphReviewBank) graphReviewPaymentMethod() {} + +type GraphReviewNode struct { + ID string `json:"id"` + Parent *GraphReviewNode `json:"parent,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Payment GraphReviewPaymentMethod `json:"payment,omitempty"` + History []GraphReviewPaymentMethod `json:"history,omitempty"` +} + +type CustomSchemaScalar string + +type CustomSchemaModel struct { + ID CustomSchemaScalar `json:"id"` + ParentID *CustomSchemaScalar `json:"parent_id,omitempty"` +} + +func TestPreMergeReflectedComponentGraphReview(t *testing.T) { + set, err := SchemasFromTypesWithOptions([]reflect.Type{reflect.TypeOf(GraphReviewNode{})}, + WithOneOfTypes((*GraphReviewPaymentMethod)(nil), GraphReviewCard{}, GraphReviewBank{}), + WithDiscriminatorMapping((*GraphReviewPaymentMethod)(nil), "object", map[string]string{ + "bank": "#/components/schemas/GraphReviewBank", + "card": "#/components/schemas/GraphReviewCard", + }), + ) + if err != nil { + t.Fatal(err) + } + if set.Root.GetReference() != "#/components/schemas/GraphReviewNode" { + t.Fatalf("unexpected root: %q", set.Root.GetReference()) + } + for _, name := range []string{"GraphReviewBank", "GraphReviewCard", "GraphReviewNode", "GraphReviewNode_Labels", "GraphReviewNode_Payment"} { + if _, ok := set.Components.Get(name); !ok { + t.Fatalf("missing component %s", name) + } + } + node := componentSchema(t, set, "GraphReviewNode") + parent, ok := node.Properties.Get("parent") + if !ok { + t.Fatal("missing parent property") + } + assertNullableRef(t, parent, "#/components/schemas/GraphReviewNode") + if schemaTypeContains(node.Type, "null") || node.Nullable != nil { + t.Fatalf("node component should not be nullable from recursive pointer usage, got %#v", node) + } + labels, ok := node.Properties.Get("labels") + if !ok || !labels.IsReference() || labels.GetReference() != "#/components/schemas/GraphReviewNode_Labels" { + t.Fatalf("labels should be map component ref, got %#v", labels) + } + labelSchema := componentSchema(t, set, "GraphReviewNode_Labels") + if labelSchema.AdditionalProperties == nil || !labelSchema.AdditionalProperties.IsA() { + t.Fatalf("labels should be schema-valued additionalProperties, got %#v", labelSchema) + } + payment, ok := node.Properties.Get("payment") + if !ok || !payment.IsReference() || payment.GetReference() != "#/components/schemas/GraphReviewNode_Payment" { + t.Fatalf("payment should be union component ref, got %#v", payment) + } + paymentSchema := componentSchema(t, set, "GraphReviewNode_Payment") + if len(paymentSchema.OneOf) != 2 || paymentSchema.Discriminator == nil { + t.Fatalf("payment should be discriminated oneOf, got %#v", paymentSchema) + } + history, ok := node.Properties.Get("history") + if !ok { + t.Fatal("missing history property") + } + historySchema := history.Schema() + if historySchema == nil || historySchema.Items == nil || !historySchema.Items.IsA() || !historySchema.Items.A.IsReference() { + t.Fatalf("history should be an array of union refs, got %#v", historySchema) + } + if historySchema.Items.A.GetReference() != "#/components/schemas/GraphReviewNode_Payment" { + t.Fatalf("history item should reuse payment union component, got %q", historySchema.Items.A.GetReference()) + } +} + +func TestReflectionFidelityTypeSchemaOverride(t *testing.T) { + customSchema := highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Format: "custom-id", + }) + set, err := SchemasFromTypesWithOptions( + []reflect.Type{reflect.TypeOf(CustomSchemaModel{})}, + WithTypeSchema(reflect.TypeOf(CustomSchemaScalar("")), customSchema), + ) + if err != nil { + t.Fatal(err) + } + model := componentSchema(t, set, "CustomSchemaModel") + id, ok := model.Properties.Get("id") + if !ok { + t.Fatal("missing id property") + } + idSchema := id.Schema() + if idSchema == nil || idSchema.Format != "custom-id" { + t.Fatalf("id should use custom schema format, got %#v", idSchema) + } + parent, ok := model.Properties.Get("parent_id") + if !ok { + t.Fatal("missing parent_id property") + } + parentSchema := parent.Schema() + if parentSchema == nil || parentSchema.Format != "custom-id" || !schemaTypeContains(parentSchema.Type, "null") || parentSchema.Nullable != nil { + t.Fatalf("parent_id should use nullable custom schema format, got %#v", parentSchema) + } + + badGen := NewGenerator(WithTypeSchema(reflect.TypeOf(CustomSchemaScalar("")), &highbase.SchemaProxy{})) + if _, err := badGen.SchemaFromType(reflect.TypeOf(CustomSchemaScalar(""))); err == nil { + t.Fatal("expected bad custom schema error") + } +} + +func TestModelFidelityEnumConstantsAndResolvers(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("status", schemaProxyFromYAML(t, ` +type: string +enum: + - "" + - in-progress + - in progress + - "200" +`)) + file, err := NewGenerator( + WithEnumConstants(true), + WithTypeNameResolver(func(name string) string { + if name == "status" { + return "PaymentStatus" + } + return "" + }), + WithEnumValueNameResolver(func(name string) string { + if name == "200" { + return "OK" + } + return "" + }), + ).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + compact := strings.Join(strings.Fields(src), " ") + assertContains(t, src, "type PaymentStatus string") + assertContains(t, compact, "PaymentStatusEmpty PaymentStatus = \"\"") + assertContains(t, compact, "PaymentStatusInProgress PaymentStatus = \"in-progress\"") + assertContains(t, compact, "PaymentStatusInProgress__2 PaymentStatus = \"in progress\"") + assertContains(t, compact, "PaymentStatusOK PaymentStatus = \"200\"") + assertParsesAndCompiles(t, file.Source) + + broadSource, err := RenderSchema("broad enum", schemaProxyFromYAML(t, ` +type: string +enum: + - custom-value +`), WithEnumConstants(true), WithNameResolver(func(name string) string { + if name == "custom-value" { + return "CustomBroad" + } + return "" + })) + if err != nil { + t.Fatal(err) + } + assertContains(t, string(broadSource), "BroadEnumCustomBroad BroadEnum = \"custom-value\"") +} + +func TestModelFidelityAdditionalPropertiesRoundTrip(t *testing.T) { + schema := schemaProxyFromYAML(t, ` +type: object +required: [id] +properties: + id: + type: string +additionalProperties: + type: integer +`) + source, err := RenderSchema("extra model", schema) + if err != nil { + t.Fatal(err) + } + src := string(source) + assertContains(t, src, "func (m *ExtraModel) UnmarshalJSON") + assertContains(t, src, "func (m ExtraModel) MarshalJSON") + assertParsesCompilesAndTests(t, source, "package models\n\n"+ + "import (\n"+ + "\t\"encoding/json\"\n"+ + "\t\"strings\"\n"+ + "\t\"testing\"\n"+ + ")\n\n"+ + "func TestAdditionalPropertiesRoundTrip(t *testing.T) {\n"+ + "\tvar model ExtraModel\n"+ + "\tif err := json.Unmarshal([]byte(`{\"id\":\"abc\",\"x\":7}`), &model); err != nil {\n"+ + "\t\tt.Fatal(err)\n"+ + "\t}\n"+ + "\tif model.ID != \"abc\" || model.AdditionalProperties[\"x\"] != 7 {\n"+ + "\t\tt.Fatalf(\"unexpected model: %#v\", model)\n"+ + "\t}\n"+ + "\tout, err := json.Marshal(ExtraModel{ID: \"def\", AdditionalProperties: map[string]int{\"x\": 9}})\n"+ + "\tif err != nil {\n"+ + "\t\tt.Fatal(err)\n"+ + "\t}\n"+ + "\ttext := string(out)\n"+ + "\tif !strings.Contains(text, \"\\\"id\\\":\\\"def\\\"\") || !strings.Contains(text, \"\\\"x\\\":9\") {\n"+ + "\t\tt.Fatalf(\"missing encoded fields: %s\", text)\n"+ + "\t}\n"+ + "}\n") + + collisionSource, err := RenderSchema("extra collision", schemaProxyFromYAML(t, ` +type: object +properties: + additional_properties: + type: string +additionalProperties: + type: string +`)) + if err != nil { + t.Fatal(err) + } + collisionText := strings.Join(strings.Fields(string(collisionSource)), " ") + assertContains(t, collisionText, "AdditionalProperties *string `json:\"additional_properties,omitempty\"`") + assertContains(t, collisionText, "AdditionalProperties__2 map[string]string `json:\"-\"`") +} + +func TestGeneratedCodeQualityAdditionalPropertiesMethodOption(t *testing.T) { + schema := schemaProxyFromYAML(t, ` +type: object +properties: + id: + type: string +additionalProperties: + type: string +`) + source, err := RenderSchema("extra model", schema, WithAdditionalPropertiesMethods(false)) + if err != nil { + t.Fatal(err) + } + src := string(source) + assertContains(t, src, "AdditionalProperties map[string]string `json:\"-\"`") + assertNotContains(t, src, "func (m *ExtraModel) UnmarshalJSON") + assertNotContains(t, src, "func (m ExtraModel) MarshalJSON") + assertNotContains(t, src, "encoding/json") + assertParsesAndCompiles(t, source) +} + +func TestModelFidelityRecursiveAndExternalReferences(t *testing.T) { + nodeProps := orderedmap.New[string, *highbase.SchemaProxy]() + nodeProps.Set("value", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + nodeProps.Set("next", highbase.CreateSchemaProxyRef("#/components/schemas/Node")) + ownerProps := orderedmap.New[string, *highbase.SchemaProxy]() + ownerProps.Set("pet", highbase.CreateSchemaProxyRef("../common.yaml#/components/schemas/Pet")) + ownerProps.Set("bare", highbase.CreateSchemaProxyRef("pet.yaml")) + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("Node", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: nodeProps, + })) + schemas.Set("ExternalOwner", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: ownerProps, + })) + file, err := NewGenerator().RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + compact := strings.Join(strings.Fields(src), " ") + assertContains(t, src, "type Node struct") + assertContains(t, compact, "Next *Node `json:\"next,omitempty\"`") + assertContains(t, compact, "Pet *Pet `json:\"pet,omitempty\"`") + assertContains(t, compact, "Bare *PetYaml `json:\"bare,omitempty\"`") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticExternalReference) { + t.Fatalf("expected external ref diagnostic, got %#v", file.Diagnostics) + } + + nodeOnly := orderedmap.New[string, *highbase.SchemaProxy]() + node, ok := schemas.Get("Node") + if !ok { + t.Fatal("missing Node") + } + nodeOnly.Set("Node", node) + compiled, err := NewGenerator().RenderSchemas(nodeOnly) + if err != nil { + t.Fatal(err) + } + assertParsesAndCompiles(t, compiled.Source) +} + +func TestModelFidelityExternalReferenceResolver(t *testing.T) { + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("pet", highbase.CreateSchemaProxyRef("../common.yaml#/components/schemas/Pet")) + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("ExternalOwner", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: props, + })) + + file, err := NewGenerator(WithExternalRefTypeResolver(func(ref string) string { + if ref == "../common.yaml#/components/schemas/Pet" { + return "SharedPet" + } + return "" + })).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(file.Source)), " ") + assertContains(t, src, "Pet *SharedPet `json:\"pet,omitempty\"`") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticExternalReference) { + t.Fatalf("expected external ref diagnostic, got %#v", file.Diagnostics) + } + assertContains(t, file.Diagnostics[0].Message, "SharedPet") +} + +func TestModelFidelityNullableOptionalMatrix(t *testing.T) { + schema := schemaProxyFromYAML(t, ` +type: object +required: [required_plain, required_nullable] +properties: + required_plain: + type: string + required_nullable: + type: [string, "null"] + optional_plain: + type: string + optional_nullable: + type: [string, "null"] +`) + source, err := RenderSchema("nullability", schema, WithOptionalFieldsAsPointers(false)) + if err != nil { + t.Fatal(err) + } + src := string(source) + compact := strings.Join(strings.Fields(src), " ") + assertContains(t, compact, "RequiredPlain string `json:\"required_plain\"`") + assertContains(t, compact, "RequiredNullable *string `json:\"required_nullable\"`") + assertContains(t, compact, "OptionalPlain string `json:\"optional_plain,omitempty\"`") + assertContains(t, compact, "OptionalNullable *string `json:\"optional_nullable,omitempty\"`") + assertParsesAndCompiles(t, source) +} + +func TestGeneratedCodeQualityFieldResolverAndAcronyms(t *testing.T) { + schema := schemaProxyFromYAML(t, ` +type: object +properties: + cvc: + type: string + callback_url: + type: string + account_id: + type: string + custom: + type: string +`) + source, err := RenderSchema("naming", schema, WithFieldNameResolver(func(name string) string { + if name == "custom" { + return "Special" + } + return "" + })) + if err != nil { + t.Fatal(err) + } + src := string(source) + compact := strings.Join(strings.Fields(src), " ") + assertContains(t, compact, "CVC *string `json:\"cvc,omitempty\"`") + assertContains(t, compact, "CallbackURL *string `json:\"callback_url,omitempty\"`") + assertContains(t, compact, "AccountID *string `json:\"account_id,omitempty\"`") + assertContains(t, compact, "Special *string `json:\"custom,omitempty\"`") + assertParsesAndCompiles(t, source) + + broadSource, err := RenderSchema("broad field", schemaProxyFromYAML(t, ` +type: object +properties: + broad: + type: string +`), WithNameResolver(func(name string) string { + if name == "broad" { + return "BroadField" + } + return "" + })) + if err != nil { + t.Fatal(err) + } + assertContains(t, string(broadSource), "BroadField *string") +} + +func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Code == code { + return true + } + } + return false +} + +func hasDiagnosticCodeOrMessage(diagnostics []Diagnostic, code, substr string) bool { + for _, diagnostic := range diagnostics { + if diagnostic.Code == code || strings.Contains(diagnostic.Message, substr) || strings.Contains(diagnostic.Path, substr) { + return true + } + } + return false +} diff --git a/generator/golang/from_openapi.go b/generator/golang/from_openapi.go new file mode 100644 index 00000000..b1d499a5 --- /dev/null +++ b/generator/golang/from_openapi.go @@ -0,0 +1,668 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strings" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func (g *Generator) irFromOpenAPI(name string, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { + return g.irFromOpenAPIName(name, false, proxy, path) +} + +func (g *Generator) irFromOpenAPIName(name string, nameResolved bool, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { + if proxy == nil { + return nil, wrapPath(ErrNilSchema, path) + } + if cached := g.openapiCache[proxy]; cached != nil { + return cached, nil + } + if proxy.IsReference() { + ref := proxy.GetReference() + typeName := g.refTypeName(ref) + if !strings.HasPrefix(ref, "#/") { + g.addDiagnostic(DiagnosticExternalReference, path, "external reference rendered as Go type "+typeName) + } + ir := &SchemaIR{ + Name: typeName, + Ref: ref, + Kind: KindRef, + } + g.openapiCache[proxy] = ir + return ir, nil + } + schema := proxy.Schema() + if schema == nil { + return nil, wrapPath(ErrNilSchema, path) + } + ir := g.irFromSchema(name, nameResolved, schema, path) + g.openapiCache[proxy] = ir + return ir, nil +} + +// childIR builds a nested schema. If the nested schema cannot be built it +// records a diagnostic and falls back to an any-typed shape so the surrounding +// field, item, or variant is preserved rather than silently dropped. +func (g *Generator) childIR(name string, proxy *highbase.SchemaProxy, path string) *SchemaIR { + ir, err := g.irFromOpenAPIName(name, true, proxy, path) + if err != nil { + g.addDiagnostic(DiagnosticChildSchema, path, "nested schema could not be built and was rendered as any: "+err.Error()) + return &SchemaIR{Name: name, Kind: KindAny} + } + return ir +} + +func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbase.Schema, path string) *SchemaIR { + g.collectShapeDiagnostics(path, schema) + if schema.DynamicRef != "" && schemaHasOnlyDynamicRefShape(schema) { + nullable := schema.Nullable != nil && *schema.Nullable + return &SchemaIR{ + Name: g.refTypeName(schema.DynamicRef), + Ref: schema.DynamicRef, + Kind: KindRef, + DynamicRef: true, + Nullable: nullable, + Format: schema.Format, + Description: schema.Description, + Title: schema.Title, + Extensions: schema.Extensions, + SourceSchema: schema, + } + } + typeName := g.openapiSchemaTypeName(name, nameResolved, schema, path) + ir := &SchemaIR{ + Name: typeName, + Format: schema.Format, + Description: schema.Description, + Title: schema.Title, + Required: make(map[string]struct{}), + Properties: nil, + Enum: schema.Enum, + Const: schema.Const, + Extensions: schema.Extensions, + SourceSchema: schema, + } + if schema.Nullable != nil && *schema.Nullable { + ir.Nullable = true + } + if schema.ReadOnly != nil && *schema.ReadOnly { + ir.ReadOnly = true + ir.Comments = append(ir.Comments, "readOnly") + } + if schema.WriteOnly != nil && *schema.WriteOnly { + ir.WriteOnly = true + ir.Comments = append(ir.Comments, "writeOnly") + } + if schema.Deprecated != nil && *schema.Deprecated { + ir.Deprecated = true + ir.Comments = append(ir.Comments, "Deprecated.") + } + if schema.Default != nil { + ir.Comments = append(ir.Comments, "default value is defined in the OpenAPI schema") + } + if schema.Example != nil || len(schema.Examples) > 0 { + ir.Comments = append(ir.Comments, "example value is defined in the OpenAPI schema") + } + for _, t := range schema.Type { + if t == "null" { + ir.Nullable = true + } + } + if schema.Const != nil && nodeIsNull(schema.Const) { + ir.Nullable = true + } + for _, required := range schema.Required { + ir.Required[required] = struct{}{} + } + + if len(schema.AllOf) > 0 { + ir.Kind = KindAllOf + for i, child := range schema.AllOf { + ir.AllOf = append(ir.AllOf, g.childIR(g.nestedTypeName(ir.Name, "AllOf"+intString(i+1)), child, path+".allOf")) + } + g.mergeAllOf(ir) + return ir + } + + if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + g.populateUnion(ir, schema, path) + return ir + } + + if len(schema.Enum) > 0 { + shape := enumShapeFor(schema.Enum) + if shape.nullable { + ir.Nullable = true + g.addDiagnostic(DiagnosticNullEnum, path, "enum contains null; generated model uses nullable Go shape for non-null enum values") + } + if shape.mixed { + g.addDiagnostic(DiagnosticMixedEnum, path, "mixed-type enum rendered as any because Go constants require one scalar base type") + } + ir.Kind = KindEnum + return ir + } + + if nonNull := nonNullTypes(schema.Type); len(nonNull) > 1 { + g.populateMultiTypeUnion(ir, nonNull, path) + return ir + } + + g.populateSchemaShape(ir, schema, path) + return ir +} + +func (g *Generator) openapiSchemaTypeName(name string, nameResolved bool, schema *highbase.Schema, path string) string { + if !nameResolved && g.componentTypeNames != nil { + return g.componentTypeName(name) + } + candidate := name + if !nameResolved { + candidate = g.publicName(name) + } + if !nameResolved || schemaDeclaresType(schema) { + return g.resolveTypeName(path, candidate, path) + } + return candidate +} + +func schemaDeclaresType(schema *highbase.Schema) bool { + if schema == nil { + return false + } + if len(schema.AllOf) > 0 || len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 || len(schema.Enum) > 0 { + return true + } + if len(nonNullTypes(schema.Type)) > 1 { + return true + } + typ, _, _ := primaryTypeForSchema(schema) + if typ != "object" { + return false + } + return (schema.Properties != nil && schema.Properties.Len() > 0) || + schema.AdditionalProperties != nil || + (schema.PatternProperties != nil && schema.PatternProperties.Len() > 0) +} + +func (g *Generator) populateSchemaShape(ir *SchemaIR, schema *highbase.Schema, path string) { + typ, implicit, ambiguous := primaryTypeForSchema(schema) + if implicit { + g.addDiagnostic(DiagnosticImplicitType, path, "schema type inferred from JSON Schema keywords") + } + if ambiguous { + g.addDiagnostic(DiagnosticImplicitType, path, "schema has validation keywords for multiple JSON types; generated model uses any") + } + switch typ { + case "string": + ir.Kind = KindString + case "integer": + ir.Kind = KindInteger + case "number": + ir.Kind = KindNumber + case "boolean": + ir.Kind = KindBoolean + case "array": + ir.Kind = KindArray + if schema.Items != nil && schema.Items.IsA() { + ir.Items = g.childIR(g.nestedTypeName(ir.Name, "Item"), schema.Items.A, path+".items") + } else if schema.Items != nil && schema.Items.IsB() && !schema.Items.B { + g.addDiagnostic(DiagnosticBooleanItems, path, "items: false constrains array length but generated Go model uses []any") + } + for i, prefixItem := range schema.PrefixItems { + ir.PrefixItems = append(ir.PrefixItems, g.childIR(g.nestedTypeName(ir.Name, "Tuple"+intString(i+1)), prefixItem, path+".prefixItems")) + } + if len(ir.PrefixItems) > 0 { + g.addDiagnostic(DiagnosticPrefixItems, path, "prefixItems tuple shape rendered as []any") + } + case "object": + g.populateObject(ir, schema, path) + default: + if schema.Properties != nil && schema.Properties.Len() > 0 { + g.populateObject(ir, schema, path) + return + } + if schema.AdditionalProperties != nil { + g.populateObject(ir, schema, path) + return + } + ir.Kind = KindAny + } +} + +func (g *Generator) populateMultiTypeUnion(ir *SchemaIR, types []string, path string) { + g.addDiagnostic(DiagnosticMultiTypeSchema, path, "multi-type JSON Schema rendered as json.RawMessage union") + ir.Kind = KindUnion + ir.Union = &UnionIR{Kind: UnionAnyOf, Strategy: UnionRawMessage, FromMultiType: true} + for _, typ := range types { + ir.Union.Variants = append(ir.Union.Variants, &SchemaIR{ + Name: g.nestedTypeName(ir.Name, typ), + Kind: kindForJSONType(typ), + }) + } +} + +func (g *Generator) populateObject(ir *SchemaIR, schema *highbase.Schema, path string) { + ir.Kind = KindObject + ir.Properties = orderedProperties() + if schema.Properties != nil { + for propName, propSchema := range schema.Properties.FromOldest() { + ir.Properties.Set(propName, g.childIR(g.nestedTypeName(ir.Name, propName), propSchema, path+"."+propName)) + } + } + if schema.PatternProperties != nil && schema.PatternProperties.Len() > 0 { + ir.PatternProperties = orderedProperties() + for pattern, propSchema := range schema.PatternProperties.FromOldest() { + ir.PatternProperties.Set(pattern, g.childIR(g.nestedTypeName(ir.Name, "PatternProperty"), propSchema, path+".patternProperties")) + } + g.addDiagnostic(DiagnosticPatternProperties, path, "patternProperties cannot be represented directly as Go struct fields") + } + if schema.AdditionalProperties != nil { + switch { + case schema.AdditionalProperties.IsA(): + ir.AdditionalProperties = g.childIR(g.nestedTypeName(ir.Name, "AdditionalProperty"), schema.AdditionalProperties.A, path+".additionalProperties") + case schema.AdditionalProperties.IsB(): + allowed := schema.AdditionalProperties.B + ir.AdditionalAllowed = &allowed + if !allowed { + g.addDiagnostic(DiagnosticAdditionalPropertiesFalse, path, "additionalProperties: false prevents extra JSON fields but generated Go models do not reject unknown fields") + } + } + } +} + +func (g *Generator) collectShapeDiagnostics(path string, schema *highbase.Schema) { + if schema == nil { + return + } + if schema.PropertyNames != nil { + g.addDiagnostic(DiagnosticPropertyNames, path, "propertyNames is validation-only and was not rendered into Go model shape") + } + if hasSchemaMetadata(schema) { + g.addDiagnostic(DiagnosticSchemaMetadata, path, "JSON Schema metadata keywords are preserved in the source schema but do not change generated Go model shape") + } + if schema.DynamicRef != "" { + g.addDiagnostic(DiagnosticDynamicReference, path, "$dynamicRef rendered as a Go reference name without dynamic resolution behavior") + } + if schema.ContentSchema != nil { + g.addDiagnostic(DiagnosticContentSchema, path, "contentSchema describes decoded string content and was not rendered into Go model shape") + } + if schema.Contains != nil || schema.MinContains != nil || schema.MaxContains != nil { + g.addDiagnostic(DiagnosticArrayContains, path, "contains/minContains/maxContains are validation-only and were not rendered into Go model shape") + } + if schema.UnevaluatedItems != nil { + g.addDiagnostic(DiagnosticUnevaluatedItems, path, "unevaluatedItems is validation-only and was not rendered into Go model shape") + } + if schema.DependentSchemas != nil && schema.DependentSchemas.Len() > 0 { + g.addDiagnostic(DiagnosticDependentSchemas, path, "dependentSchemas is validation-only and was not rendered into Go model shape") + } + if schema.DependentRequired != nil && schema.DependentRequired.Len() > 0 { + g.addDiagnostic(DiagnosticDependentRequired, path, "dependentRequired is validation-only and was not rendered into Go model shape") + } + if schema.If != nil || schema.Then != nil || schema.Else != nil { + g.addDiagnostic(DiagnosticConditionalSchema, path, "if/then/else is validation-only and was not rendered into Go model shape") + } + if schema.Not != nil { + g.addDiagnostic(DiagnosticNotSchema, path, "not is validation-only and was not rendered into Go model shape") + } + if schema.Const != nil { + g.addDiagnostic(DiagnosticConstKeyword, path, "const is validation-only and was not enforced by the generated Go model") + } + if hasValidationKeyword(schema) { + g.addDiagnostic(DiagnosticValidationKeyword, path, "JSON Schema validation keywords are not enforced by generated Go models") + } + if schema.UnevaluatedProperties != nil { + g.addDiagnostic(DiagnosticUnevaluatedProperties, path, "unevaluatedProperties is validation-only and was not rendered into Go model shape") + } +} + +func (g *Generator) populateUnion(ir *SchemaIR, schema *highbase.Schema, path string) { + kind := UnionOneOf + children := schema.OneOf + if len(children) == 0 { + kind = UnionAnyOf + children = schema.AnyOf + } + variants := make([]*SchemaIR, 0, len(children)) + for i, child := range children { + variantName := g.nestedTypeName(ir.Name, "Variant"+intString(i+1)) + if built, err := child.BuildSchema(); err == nil && built != nil && built.Title != "" { + variantName = g.nestedTypeName(ir.Name, built.Title) + } + variants = append(variants, g.childIR(variantName, child, path+".union")) + } + nonNull := nonNullVariants(variants) + if len(nonNull) == 1 && len(nonNull) != len(variants) { + *ir = *nonNull[0] + ir.Nullable = true + return + } + ir.Kind = KindUnion + ir.Union = &UnionIR{Kind: kind, Variants: variants, Strategy: UnionRawMessage} + if kind != UnionOneOf { + return + } + if schema.Discriminator != nil && schema.Discriminator.PropertyName != "" { + if disc := discriminatorFromSchema(schema, variants); len(disc.Mapping) > 0 { + ir.Union.Discriminator = disc + ir.Union.Strategy = UnionDiscriminator + return + } + } + if disc := inferConstDiscriminator(variants); disc != nil { + ir.Union.Discriminator = disc + if !disc.Optional || g.optionalConstDiscriminatorUnions { + ir.Union.Strategy = UnionDiscriminator + return + } + g.addDiagnostic(DiagnosticOptionalConstDiscriminator, path, "oneOf has a shared const discriminator property, but it is optional; using json.RawMessage") + } +} + +func (g *Generator) mergeAllOf(ir *SchemaIR) { + merged := newObjectIR(ir.Name) + merged.Format = ir.Format + merged.Description = ir.Description + merged.Title = ir.Title + merged.Nullable = ir.Nullable + merged.Enum = ir.Enum + merged.Const = ir.Const + merged.Extensions = ir.Extensions + merged.Source = ir.Source + merged.ReadOnly = ir.ReadOnly + merged.WriteOnly = ir.WriteOnly + merged.Deprecated = ir.Deprecated + merged.FieldMetadata = ir.FieldMetadata + merged.ExactSource = ir.ExactSource + merged.Comments = append([]string(nil), ir.Comments...) + merged.SourceSchema = ir.SourceSchema + for req := range ir.Required { + merged.Required[req] = struct{}{} + } + for _, child := range ir.AllOf { + if child == nil { + continue + } + if child.Kind == KindRef { + merged.AllOf = append(merged.AllOf, child) + continue + } + if child.Kind == KindObject && child.Properties != nil { + for name, prop := range child.Properties.FromOldest() { + merged.Properties.Set(name, prop) + } + for req := range child.Required { + merged.Required[req] = struct{}{} + } + continue + } + merged.AllOf = append(merged.AllOf, child) + } + *ir = *merged +} + +func orderedProperties() *orderedmap.Map[string, *SchemaIR] { + return orderedmap.New[string, *SchemaIR]() +} + +func nonNullTypes(types []string) []string { + out := make([]string, 0, len(types)) + for _, t := range types { + if t != "null" { + out = append(out, t) + } + } + return out +} + +func primaryTypeForSchema(schema *highbase.Schema) (string, bool, bool) { + types := nonNullTypes(schema.Type) + if len(types) > 0 { + return types[0], false, false + } + var inferred []string + if hasStringKeyword(schema) { + inferred = append(inferred, "string") + } + if hasNumberKeyword(schema) { + inferred = append(inferred, "number") + } + if hasArrayKeyword(schema) { + inferred = append(inferred, "array") + } + if hasObjectKeyword(schema) { + inferred = append(inferred, "object") + } + if len(inferred) == 1 { + return inferred[0], true, false + } + if len(inferred) > 1 { + return "", false, true + } + return "", false, false +} + +func kindForJSONType(typ string) Kind { + switch typ { + case "string": + return KindString + case "integer": + return KindInteger + case "number": + return KindNumber + case "boolean": + return KindBoolean + case "array": + return KindArray + case "object": + return KindObject + default: + return KindAny + } +} + +func schemaHasOnlyDynamicRefShape(schema *highbase.Schema) bool { + return schema.DynamicRef != "" && + len(schema.Type) == 0 && + len(schema.AllOf) == 0 && + len(schema.OneOf) == 0 && + len(schema.AnyOf) == 0 && + len(schema.Enum) == 0 && + schema.Const == nil && + schema.Not == nil && + schema.Properties == nil && + schema.Items == nil && + len(schema.PrefixItems) == 0 && + schema.AdditionalProperties == nil +} + +func hasSchemaMetadata(schema *highbase.Schema) bool { + return schema.SchemaTypeRef != "" || + schema.Id != "" || + schema.Anchor != "" || + schema.DynamicAnchor != "" || + schema.Comment != "" || + (schema.Vocabulary != nil && schema.Vocabulary.Len() > 0) +} + +func hasValidationKeyword(schema *highbase.Schema) bool { + return schema.MultipleOf != nil || + schema.Maximum != nil || + schema.Minimum != nil || + schema.ExclusiveMaximum != nil || + schema.ExclusiveMinimum != nil || + schema.MaxLength != nil || + schema.MinLength != nil || + schema.Pattern != "" || + schema.MaxItems != nil || + schema.MinItems != nil || + schema.UniqueItems != nil || + schema.MaxProperties != nil || + schema.MinProperties != nil || + schema.ContentEncoding != "" || + schema.ContentMediaType != "" +} + +func hasStringKeyword(schema *highbase.Schema) bool { + return schema.MaxLength != nil || + schema.MinLength != nil || + schema.Pattern != "" || + schema.ContentEncoding != "" || + schema.ContentMediaType != "" || + schema.ContentSchema != nil +} + +func hasNumberKeyword(schema *highbase.Schema) bool { + return schema.MultipleOf != nil || + schema.Maximum != nil || + schema.Minimum != nil || + schema.ExclusiveMaximum != nil || + schema.ExclusiveMinimum != nil +} + +func hasArrayKeyword(schema *highbase.Schema) bool { + return schema.Items != nil || + len(schema.PrefixItems) > 0 || + schema.Contains != nil || + schema.MinContains != nil || + schema.MaxContains != nil || + schema.MaxItems != nil || + schema.MinItems != nil || + schema.UniqueItems != nil || + schema.UnevaluatedItems != nil +} + +func hasObjectKeyword(schema *highbase.Schema) bool { + return (schema.Properties != nil && schema.Properties.Len() > 0) || + schema.AdditionalProperties != nil || + (schema.PatternProperties != nil && schema.PatternProperties.Len() > 0) || + schema.PropertyNames != nil || + schema.MaxProperties != nil || + schema.MinProperties != nil || + len(schema.Required) > 0 || + (schema.DependentSchemas != nil && schema.DependentSchemas.Len() > 0) || + (schema.DependentRequired != nil && schema.DependentRequired.Len() > 0) || + schema.UnevaluatedProperties != nil +} + +func nonNullVariants(variants []*SchemaIR) []*SchemaIR { + var out []*SchemaIR + for _, variant := range variants { + if variant == nil { + continue + } + if isNullOnlyIR(variant) { + continue + } + out = append(out, variant) + } + return out +} + +func isNullOnlyIR(ir *SchemaIR) bool { + if ir == nil { + return false + } + if ir.SourceSchema != nil { + return schemaOnlyAllowsNull(ir.SourceSchema) + } + if ir.Const != nil && nodeIsNull(ir.Const) { + return true + } + if len(ir.Enum) > 0 { + shape := enumShapeFor(ir.Enum) + return shape.nullable && shape.nonNullValues == 0 + } + return false +} + +func schemaOnlyAllowsNull(schema *highbase.Schema) bool { + if schema == nil { + return false + } + if schema.Const != nil && nodeIsNull(schema.Const) { + return true + } + if len(schema.Enum) > 0 { + shape := enumShapeFor(schema.Enum) + return shape.nullable && shape.nonNullValues == 0 + } + return len(schema.Type) > 0 && len(nonNullTypes(schema.Type)) == 0 +} + +func discriminatorFromSchema(schema *highbase.Schema, variants []*SchemaIR) *Discriminator { + disc := &Discriminator{ + PropertyName: schema.Discriminator.PropertyName, + Mapping: make(map[string]string), + } + if schema.Discriminator.Mapping != nil { + for k, v := range schema.Discriminator.Mapping.FromOldest() { + disc.Mapping[k] = v + } + } + if len(disc.Mapping) > 0 { + return disc + } + implicit := make(map[string]string, len(variants)) + for _, variant := range variants { + if variant == nil || variant.Name == "" || variant.Ref == "" { + return disc + } + implicit[refName(variant.Ref)] = variant.Ref + } + disc.Mapping = implicit + return disc +} + +func inferConstDiscriminator(variants []*SchemaIR) *Discriminator { + if len(variants) == 0 { + return nil + } + type candidate struct { + values map[string]string + optional bool + } + candidates := make(map[string]*candidate) + for i, variant := range variants { + if variant == nil || variant.Properties == nil { + return nil + } + seen := make(map[string]struct{}) + for propName, prop := range variant.Properties.FromOldest() { + if prop == nil || prop.Const == nil { + continue + } + var value string + if err := prop.Const.Decode(&value); err != nil || value == "" { + continue + } + seen[propName] = struct{}{} + c := candidates[propName] + if c == nil { + c = &candidate{values: make(map[string]string)} + candidates[propName] = c + } + if _, exists := c.values[value]; exists { + return nil + } + c.values[value] = variant.Name + if !isRequired(variant, propName) { + c.optional = true + } + } + for propName := range candidates { + if _, ok := seen[propName]; !ok && i > 0 { + delete(candidates, propName) + } + } + } + for propName, c := range candidates { + if len(c.values) == len(variants) { + return &Discriminator{PropertyName: propName, Mapping: c.values, Optional: c.optional} + } + } + return nil +} diff --git a/generator/golang/from_reflect.go b/generator/golang/from_reflect.go new file mode 100644 index 00000000..682d0bac --- /dev/null +++ b/generator/golang/from_reflect.go @@ -0,0 +1,415 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "encoding/json" + "reflect" + "strings" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" +) + +type SchemaProvider interface { + OpenAPISchema() *highbase.SchemaProxy +} + +type SchemaYAMLProvider interface { + OpenAPISchemaYAML() string +} + +var schemaProviderType = reflect.TypeOf((*SchemaProvider)(nil)).Elem() +var schemaMetadataProviderType = reflect.TypeOf((*SchemaMetadataProvider)(nil)).Elem() +var schemaYAMLProviderType = reflect.TypeOf((*SchemaYAMLProvider)(nil)).Elem() +var rawMessageType = reflect.TypeOf(json.RawMessage{}) + +func (g *Generator) irFromReflect(t reflect.Type, name, path string) (*SchemaIR, error) { + return g.irFromReflectName(t, name, false, path) +} + +func (g *Generator) irFromReflectName(t reflect.Type, name string, nameResolved bool, path string) (*SchemaIR, error) { + if t == nil { + return nil, wrapPath(ErrNilType, path) + } + resolvedName := name + if !nameResolved { + resolvedName = g.publicName(name) + } + nullable := false + for t.Kind() == reflect.Pointer { + nullable = true + t = t.Elem() + } + if schema := g.typeSchemas[t]; schema != nil { + return g.irFromTypeSchema(t, resolvedName, path, schema, nullable) + } + if t == rawMessageType { + return &SchemaIR{Name: resolvedName, Kind: KindAny, Nullable: nullable}, nil + } + if g.reflectStack[t] { + return &SchemaIR{ + Name: g.publicName(typeName(t)), + Ref: "#/components/schemas/" + g.publicName(typeName(t)), + Kind: KindRef, + Nullable: nullable, + }, nil + } + if cached := g.reflectCache[t]; cached != nil { + cp := *cached + cp.Nullable = cp.Nullable || nullable + return &cp, nil + } + if t.Kind() != reflect.Interface && implementsOrPointerImplements(t, schemaMetadataProviderType) { + return g.irFromSchemaMetadataProvider(t, resolvedName, path, nullable) + } + if t.Kind() != reflect.Interface && implementsOrPointerImplements(t, schemaProviderType) { + return g.irFromSchemaProvider(t, resolvedName, path, nullable) + } + if t.Kind() != reflect.Interface && implementsOrPointerImplements(t, schemaYAMLProviderType) { + return g.irFromSchemaYAMLProvider(t, resolvedName, path, nullable) + } + + var ir *SchemaIR + var err error + switch t.Kind() { + case reflect.String: + ir = &SchemaIR{Name: resolvedName, Kind: KindString} + case reflect.Bool: + ir = &SchemaIR{Name: resolvedName, Kind: KindBoolean} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + ir = &SchemaIR{Name: resolvedName, Kind: KindInteger} + if t.Kind() == reflect.Int32 { + ir.Format = "int32" + } + if t.Kind() == reflect.Int64 { + ir.Format = "int64" + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + ir = &SchemaIR{Name: resolvedName, Kind: KindInteger} + case reflect.Float32: + ir = &SchemaIR{Name: resolvedName, Kind: KindNumber, Format: "float"} + case reflect.Float64: + ir = &SchemaIR{Name: resolvedName, Kind: KindNumber, Format: "double"} + case reflect.Slice, reflect.Array: + ir, err = g.irFromReflectArray(t, resolvedName, path, false) + case reflect.Map: + ir, err = g.irFromReflectMap(t, resolvedName, path, false) + case reflect.Struct: + ir, err = g.irFromReflectStruct(t, resolvedName, path, false) + case reflect.Interface: + ir, err = g.irFromReflectInterface(t, resolvedName, path, false) + default: + err = wrapPath(ErrUnsupportedType, path) + } + if err != nil { + return nil, err + } + g.reflectCache[t] = ir + if nullable { + cp := *ir + cp.Nullable = true + return &cp, nil + } + return ir, nil +} + +func (g *Generator) irFromTypeSchema(t reflect.Type, name, path string, schema *highbase.SchemaProxy, nullable bool) (*SchemaIR, error) { + schemaName := name + if t.Name() != "" { + schemaName = typeName(t) + } + ir, err := g.irFromOpenAPI(schemaName, schema, path) + if err != nil { + return nil, err + } + base := *ir + base.ExactSource = true + g.reflectCache[t] = &base + if nullable { + cp := base + cp.Nullable = true + return &cp, nil + } + return &base, nil +} + +func (g *Generator) irFromSchemaProvider(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + provider := providerValue(t).(SchemaProvider) + schemaName := name + if t.Name() != "" { + schemaName = g.publicName(typeName(t)) + } + ir, err := g.irFromOpenAPI(schemaName, provider.OpenAPISchema(), path) + if err != nil { + return nil, err + } + base := *ir + base.ExactSource = true + g.reflectCache[t] = &base + if nullable { + cp := base + cp.Nullable = true + return &cp, nil + } + return &base, nil +} + +func (g *Generator) irFromSchemaMetadataProvider(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + provider := providerValue(t).(SchemaMetadataProvider) + proxy, err := schemaProxyFromProviderMetadata(provider.OpenAPISchemaMetadata()) + if err != nil { + return nil, wrapPath(err, path) + } + schemaName := name + if t.Name() != "" { + schemaName = g.publicName(typeName(t)) + } + ir, _ := g.irFromOpenAPI(schemaName, proxy, path) + base := *ir + base.ExactSource = true + g.reflectCache[t] = &base + if nullable { + cp := base + cp.Nullable = true + return &cp, nil + } + return &base, nil +} + +func (g *Generator) irFromSchemaYAMLProvider(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + provider := providerValue(t).(SchemaYAMLProvider) + schemaName := name + if t.Name() != "" { + schemaName = g.publicName(typeName(t)) + } + proxy, err := schemaProxyFromProviderYAML(schemaName, provider.OpenAPISchemaYAML()) + if err != nil { + return nil, wrapPath(err, path) + } + ir, _ := g.irFromOpenAPI(schemaName, proxy, path) + base := *ir + base.ExactSource = true + g.reflectCache[t] = &base + if nullable { + cp := base + cp.Nullable = true + return &cp, nil + } + return &base, nil +} + +func (g *Generator) irFromReflectArray(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + return &SchemaIR{Name: name, Kind: KindString, Format: "byte", Nullable: nullable}, nil + } + item, err := g.irFromReflectName(t.Elem(), g.nestedTypeName(name, "Item"), true, path+"[]") + if err != nil { + return nil, err + } + return &SchemaIR{Name: name, Kind: KindArray, Items: item, Nullable: nullable}, nil +} + +func (g *Generator) irFromReflectMap(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + if t.Key().Kind() != reflect.String { + return nil, wrapPath(ErrUnsupportedMapKey, path) + } + value, err := g.irFromReflectName(t.Elem(), g.nestedTypeName(name, "Value"), true, path+"{}") + if err != nil { + return nil, err + } + return &SchemaIR{Name: name, Kind: KindObject, AdditionalProperties: value, Nullable: nullable}, nil +} + +func (g *Generator) irFromReflectStruct(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + if t.PkgPath() == "time" && t.Name() == "Time" { + return &SchemaIR{Name: name, Kind: KindString, Format: "date-time", Nullable: nullable}, nil + } + structName := name + if t.Name() != "" { + structName = g.publicName(typeName(t)) + } + ir := newObjectIR(structName) + ir.Nullable = nullable + g.reflectCache[t] = ir + g.reflectStack[t] = true + defer delete(g.reflectStack, t) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" && !field.Anonymous { + continue + } + tag := parseJSONTag(field) + if tag.skip { + continue + } + child, err := g.irFromReflectField(t, field, tag, g.nestedTypeName(ir.Name, tag.name), path+"."+field.Name) + if err != nil { + return nil, err + } + if tag.stringEncoded { + child = g.stringEncodedIR(child, path+"."+field.Name) + } + if tag.openapi.Present { + child = cloneIR(child) + } + g.applyOpenAPIMetadata(child, tag.openapi) + if field.Anonymous && !tag.hasName && child.Kind == KindObject && child.Properties != nil { + for propName, prop := range child.Properties.FromOldest() { + ir.Properties.Set(propName, prop) + } + if !tag.omitempty && field.Type.Kind() != reflect.Pointer { + for req := range child.Required { + ir.Required[req] = struct{}{} + } + } + continue + } + ir.Properties.Set(tag.name, child) + if !tag.omitempty { + ir.Required[tag.name] = struct{}{} + } + } + return ir, nil +} + +func cloneIR(ir *SchemaIR) *SchemaIR { + if ir == nil { + return nil + } + cp := *ir + if ir.SourceSchema != nil { + schema := *ir.SourceSchema + cp.SourceSchema = &schema + } + return &cp +} + +func (g *Generator) irFromReflectField(owner reflect.Type, field reflect.StructField, tag fieldTag, name, path string) (*SchemaIR, error) { + if schema := g.fieldSchema(owner, field, tag.name); schema != nil { + return g.irFromFieldSchema(field.Type, name, path, schema) + } + return g.irFromReflectName(field.Type, name, true, path) +} + +func (g *Generator) fieldSchema(owner reflect.Type, field reflect.StructField, jsonName string) *highbase.SchemaProxy { + owner = derefType(owner) + if g.fieldSchemas != nil { + if schema := g.fieldSchemas[fieldSchemaKey{owner: owner, name: field.Name}]; schema != nil { + return schema + } + } + if g.jsonSchemas != nil { + return g.jsonSchemas[fieldSchemaKey{owner: owner, name: jsonName}] + } + return nil +} + +func (g *Generator) irFromFieldSchema(fieldType reflect.Type, name, path string, schema *highbase.SchemaProxy) (*SchemaIR, error) { + nullable := false + for fieldType.Kind() == reflect.Pointer { + nullable = true + fieldType = fieldType.Elem() + } + ir, err := g.irFromOpenAPIName(name, true, schema, path) + if err != nil { + return nil, err + } + cp := *ir + cp.Nullable = cp.Nullable || nullable + return &cp, nil +} + +func (g *Generator) stringEncodedIR(ir *SchemaIR, path string) *SchemaIR { + if ir == nil { + return nil + } + if ir.Kind != KindString && ir.Kind != KindInteger && ir.Kind != KindNumber && ir.Kind != KindBoolean { + g.addDiagnostic(DiagnosticStringEncoded, path, "json string option is only modeled for scalar fields") + return ir + } + cp := *ir + cp.Kind = KindString + cp.Format = "" + cp.Comments = append(cp.Comments, "encoded as a JSON string") + g.addDiagnostic(DiagnosticStringEncoded, path, "json string option rendered as OpenAPI string schema") + return &cp +} + +func (g *Generator) irFromReflectInterface(t reflect.Type, name, path string, nullable bool) (*SchemaIR, error) { + variants, ok := g.oneOfRegistrations[t] + if !ok { + return nil, wrapPath(ErrUnsupportedType, path) + } + ir := &SchemaIR{ + Name: name, + Kind: KindUnion, + Nullable: nullable, + Union: &UnionIR{Kind: UnionOneOf, Strategy: UnionRawMessage}, + } + for _, variantType := range variants { + variantIR, err := g.irFromReflect(variantType, typeName(variantType), path+"."+typeName(variantType)) + if err != nil { + return nil, err + } + ir.Union.Variants = append(ir.Union.Variants, &SchemaIR{ + Name: variantIR.Name, + Ref: "#/components/schemas/" + variantIR.Name, + Kind: KindRef, + }) + } + if reg, ok := g.discriminatorRegistrations[t]; ok { + ir.Union.Strategy = UnionDiscriminator + ir.Union.Discriminator = &Discriminator{ + PropertyName: reg.property, + Mapping: reg.mapping, + } + } + return ir, nil +} + +func implementsOrPointerImplements(t reflect.Type, iface reflect.Type) bool { + return t.Implements(iface) || reflect.PointerTo(t).Implements(iface) +} + +func providerValue(t reflect.Type) any { + return reflect.New(t).Interface() +} + +func schemaProxyFromProviderYAML(name, schemaYAML string) (*highbase.SchemaProxy, error) { + if name == "" { + name = "Schema" + } + var b strings.Builder + b.WriteString("openapi: 3.1.0\n") + b.WriteString("info:\n") + b.WriteString(" title: Generated Schema Provider\n") + b.WriteString(" version: 1.0.0\n") + b.WriteString("paths: {}\n") + b.WriteString("components:\n") + b.WriteString(" schemas:\n") + b.WriteString(" ") + b.WriteString(name) + b.WriteString(":\n") + b.WriteString(indentSchemaYAML(schemaYAML, " ")) + doc, err := libopenapi.NewDocument([]byte(b.String())) + if err != nil { + return nil, err + } + model, _ := doc.BuildV3Model() + schema, _ := model.Model.Components.Schemas.Get(name) + return schema, nil +} + +func indentSchemaYAML(in, prefix string) string { + lines := strings.Split(strings.TrimSuffix(in, "\n"), "\n") + var b strings.Builder + for _, line := range lines { + b.WriteString(prefix) + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} diff --git a/generator/golang/fullcircle_test.go b/generator/golang/fullcircle_test.go new file mode 100644 index 00000000..92c72cef --- /dev/null +++ b/generator/golang/fullcircle_test.go @@ -0,0 +1,235 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestTrainTravelFullCircleCanonicalRoundTrip(t *testing.T) { + file := renderTrainTravel(t, trainTravelFullCircleOptions()...) + + repoRoot := repoRootDir(t) + dir := t.TempDir() + writeTempModule(t, dir, repoRoot) + writeTempFile(t, filepath.Join(dir, "internal", "trainmodels", "models_gen.go"), file.Source) + if file.SchemaMetadata == nil { + t.Fatal("expected schema metadata sidecar") + } + writeTempFile(t, filepath.Join(dir, "internal", "trainmodels", file.SchemaMetadata.Name), file.SchemaMetadata.Source) + writeTempFile(t, filepath.Join(dir, "cmd", "roundtrip", "main.go"), []byte(trainTravelFullCircleProgram)) + + cmd := exec.Command("go", "run", "./cmd/roundtrip") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GOWORK=off", + "GOFLAGS=-mod=mod", + "TRAIN_TRAVEL_SPEC="+filepath.Join(repoRoot, "generator", "golang", "testdata", "train-travel.yaml"), + "GENERATED_MODELS="+filepath.Join(dir, "internal", "trainmodels", "models_gen.go"), + "GENERATED_METADATA="+filepath.Join(dir, "internal", "trainmodels", file.SchemaMetadata.Name), + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("full-circle command failed: %v\n%s", err, out) + } + assertContains(t, string(out), "canonical schema equal: true") + assertContains(t, string(out), "model source equal: true") + assertContains(t, string(out), "metadata source equal: true") +} + +func trainTravelFullCircleOptions() []Option { + return []Option{ + WithPackageName("trainmodels"), + WithGeneratedComment(true), + WithOptionalConstDiscriminatorUnions(true), + WithOpenAPITags(true), + WithSchemaMetadataSidecar(true), + } +} + +func repoRootDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root, err := filepath.Abs(filepath.Join(wd, "..", "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +func writeTempModule(t *testing.T, dir, repoRoot string) { + t.Helper() + writeTempFile(t, filepath.Join(dir, "go.mod"), []byte("module trainfullcircle\n\ngo 1.25.0\n\nrequire github.com/pb33f/libopenapi v0.0.0\n\nreplace github.com/pb33f/libopenapi => "+repoRoot+"\n")) + sum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum")) + if err != nil { + t.Fatal(err) + } + writeTempFile(t, filepath.Join(dir, "go.sum"), sum) +} + +func writeTempFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +const trainTravelFullCircleProgram = `package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "reflect" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel/high/base" + gogenerator "github.com/pb33f/libopenapi/generator/golang" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" + + "trainfullcircle/internal/trainmodels" +) + +var order = []string{"Station", "Trip", "Booking", "BookingPayment"} + +func main() { + set, err := gogenerator.SchemasFromTypes( + reflect.TypeOf(trainmodels.Station{}), + reflect.TypeOf(trainmodels.Trip{}), + reflect.TypeOf(trainmodels.Booking{}), + reflect.TypeOf(trainmodels.BookingPayment{}), + ) + if err != nil { + panic(err) + } + originalCanonical, err := canonicalOriginal(os.Getenv("TRAIN_TRAVEL_SPEC")) + if err != nil { + panic(err) + } + reflectedCanonical, err := canonicalReflected(set) + if err != nil { + panic(err) + } + canonicalEqual := bytes.Equal(originalCanonical, reflectedCanonical) + fmt.Printf("canonical schema equal: %v\n", canonicalEqual) + if !canonicalEqual { + fmt.Printf("--- original\n%s\n--- reflected\n%s\n", originalCanonical, reflectedCanonical) + } + + schemas, err := schemasInOrder(set) + if err != nil { + panic(err) + } + regenerated, err := gogenerator.NewGenerator(trainTravelOptions()...).RenderSchemas(schemas) + if err != nil { + panic(err) + } + originalModels, err := os.ReadFile(os.Getenv("GENERATED_MODELS")) + if err != nil { + panic(err) + } + fmt.Printf("model source equal: %v\n", bytes.Equal(originalModels, regenerated.Source)) + originalMetadata, err := os.ReadFile(os.Getenv("GENERATED_METADATA")) + if err != nil { + panic(err) + } + if regenerated.SchemaMetadata == nil { + panic("missing regenerated schema metadata") + } + fmt.Printf("metadata source equal: %v\n", bytes.Equal(originalMetadata, regenerated.SchemaMetadata.Source)) +} + +func trainTravelOptions() []gogenerator.Option { + return []gogenerator.Option{ + gogenerator.WithPackageName("trainmodels"), + gogenerator.WithGeneratedComment(true), + gogenerator.WithOptionalConstDiscriminatorUnions(true), + gogenerator.WithOpenAPITags(true), + gogenerator.WithSchemaMetadataSidecar(true), + } +} + +func schemasInOrder(set *gogenerator.SchemaSet) (*orderedmap.Map[string, *base.SchemaProxy], error) { + schemas := orderedmap.New[string, *base.SchemaProxy]() + for _, name := range order { + schema, ok := set.Components.Get(name) + if !ok { + return nil, fmt.Errorf("missing reflected component %q", name) + } + schemas.Set(name, schema) + } + return schemas, nil +} + +func canonicalOriginal(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + doc, err := libopenapi.NewDocument(data) + if err != nil { + return nil, err + } + model, err := doc.BuildV3Model() + if err != nil { + return nil, err + } + schemas := make(map[string]any, len(order)) + for _, name := range order { + schema, ok := model.Model.Components.Schemas.Get(name) + if !ok { + return nil, fmt.Errorf("missing original component %q", name) + } + rendered, err := schema.Render() + if err != nil { + return nil, err + } + decoded, err := canonicalSchemaValue(rendered) + if err != nil { + return nil, err + } + schemas[name] = decoded + } + return json.Marshal(schemas) +} + +func canonicalReflected(set *gogenerator.SchemaSet) ([]byte, error) { + schemas := make(map[string]any, len(order)) + for _, name := range order { + schema, ok := set.Components.Get(name) + if !ok { + return nil, fmt.Errorf("missing reflected component %q", name) + } + rendered, err := schema.Render() + if err != nil { + return nil, err + } + decoded, err := canonicalSchemaValue(rendered) + if err != nil { + return nil, err + } + schemas[name] = decoded + } + return json.Marshal(schemas) +} + +func canonicalSchemaValue(rendered []byte) (any, error) { + var value any + if err := yaml.Unmarshal(rendered, &value); err != nil { + return nil, err + } + return value, nil +} +` diff --git a/generator/golang/generator.go b/generator/golang/generator.go new file mode 100644 index 00000000..3732acc7 --- /dev/null +++ b/generator/golang/generator.go @@ -0,0 +1,393 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + "sort" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +// Generator holds immutable configuration for code generation. Each public +// entry point runs against a fresh copy of this configuration (see run), so a +// configured Generator carries no per-invocation state and is safe to reuse for +// many documents and to share across goroutines. +type Generator struct { + packageName string + + optionalFieldsAsPointers bool + omitEmpty bool + nullableAsPointer bool + jsonTags bool + yamlTags bool + enumConstants bool + optionalConstDiscriminatorUnions bool + additionalPropertiesMethods bool + generatedComment bool + openapiTags bool + schemaMetadataSidecar bool + nestedTypeNameDelimiter string + + nameResolver NameResolver + typeNameResolver NameResolver + fieldNameResolver NameResolver + enumValueNameResolver NameResolver + externalRefResolver ExternalRefResolver + headerComment string + packageComment string + + formatMappings map[string]formatMapping + typeSchemas map[reflect.Type]*highbase.SchemaProxy + fieldSchemas map[fieldSchemaKey]*highbase.SchemaProxy + jsonSchemas map[fieldSchemaKey]*highbase.SchemaProxy + + diagnostics []Diagnostic + imports map[string]struct{} + decls []string + seenDecls map[string]struct{} + metadataSchemas map[string]*highbase.Schema + metadataOrder []string + + openapiCache map[*highbase.SchemaProxy]*SchemaIR + reflectCache map[reflect.Type]*SchemaIR + reflectStack map[reflect.Type]bool + typeNames *nameRegistry + + componentNames map[string]struct{} + componentTypeNames map[string]string + componentKinds map[string]Kind + currentComponent string + + oneOfRegistrations map[reflect.Type][]reflect.Type + discriminatorRegistrations map[reflect.Type]discriminatorRegistration +} + +// SchemaSet contains OpenAPI schemas generated from one or more Go types. +type SchemaSet struct { + // Root is the first generated root schema, kept as a convenience for + // single-root callers. + Root *highbase.SchemaProxy + // Roots contains every requested root schema keyed by generated type name. + Roots *orderedmap.Map[string, *highbase.SchemaProxy] + // Components contains reusable schemas discovered while walking the root + // graph. + Components *orderedmap.Map[string, *highbase.SchemaProxy] + // Diagnostics reports schema features that required a lossy or notable + // model-generation decision. + Diagnostics []Diagnostic +} + +const SchemaMetadataFileName = "schema_metadata.go" + +// GeneratedFile contains Go source generated from OpenAPI schemas. +type GeneratedFile struct { + PackageName string + Source []byte + SchemaMetadata *GeneratedSourceFile + Types []*GeneratedType + Diagnostics []Diagnostic +} + +// GeneratedSourceFile contains a named generated source file. +type GeneratedSourceFile struct { + Name string + Source []byte +} + +// GeneratedType describes one top-level generated Go type. +type GeneratedType struct { + Name string + Kind Kind +} + +// NewGenerator creates a Go model generator. +func NewGenerator(opts ...Option) *Generator { + g := &Generator{ + packageName: "models", + optionalFieldsAsPointers: true, + omitEmpty: true, + nullableAsPointer: true, + additionalPropertiesMethods: true, + nestedTypeNameDelimiter: "_", + jsonTags: true, + formatMappings: make(map[string]formatMapping), + typeSchemas: make(map[reflect.Type]*highbase.SchemaProxy), + fieldSchemas: make(map[fieldSchemaKey]*highbase.SchemaProxy), + jsonSchemas: make(map[fieldSchemaKey]*highbase.SchemaProxy), + imports: make(map[string]struct{}), + seenDecls: make(map[string]struct{}), + metadataSchemas: make(map[string]*highbase.Schema), + openapiCache: make(map[*highbase.SchemaProxy]*SchemaIR), + reflectCache: make(map[reflect.Type]*SchemaIR), + reflectStack: make(map[reflect.Type]bool), + oneOfRegistrations: make(map[reflect.Type][]reflect.Type), + discriminatorRegistrations: make(map[reflect.Type]discriminatorRegistration), + } + for _, opt := range opts { + if opt != nil { + opt(g) + } + } + return g +} + +// run returns a generator carrying fresh per-invocation state. Configuration is +// shared with the receiver and treated as read-only during generation, so a +// configured Generator is safe to reuse across calls and across goroutines. +// renderFile owns the rendering output buffers (imports, decls, metadata), so +// they are reset there rather than duplicated here. +func (g *Generator) run() *Generator { + r := *g + r.diagnostics = nil + r.openapiCache = make(map[*highbase.SchemaProxy]*SchemaIR) + r.reflectCache = make(map[reflect.Type]*SchemaIR) + r.reflectStack = make(map[reflect.Type]bool) + r.typeNames = nil + r.componentNames = nil + r.componentTypeNames = nil + r.componentKinds = nil + r.currentComponent = "" + return &r +} + +// RenderSchema renders a single OpenAPI schema as Go source. +func RenderSchema(name string, schema *highbase.SchemaProxy, opts ...Option) ([]byte, error) { + return NewGenerator(opts...).RenderSchema(name, schema) +} + +// SchemaFromValue generates an OpenAPI schema for the runtime type of value. +func SchemaFromValue(value any, opts ...Option) (*highbase.SchemaProxy, error) { + return NewGenerator(opts...).SchemaFromValue(value) +} + +// SchemaFromType generates an OpenAPI schema for a Go reflection type. +func SchemaFromType(t reflect.Type, opts ...Option) (*highbase.SchemaProxy, error) { + return NewGenerator(opts...).SchemaFromType(t) +} + +// SchemasFromValues generates an OpenAPI component graph for runtime values. +func SchemasFromValues(values ...any) (*SchemaSet, error) { + return NewGenerator().SchemasFromValues(values...) +} + +// SchemasFromValuesWithOptions generates an OpenAPI component graph for runtime +// values using generator options. +func SchemasFromValuesWithOptions(values []any, opts ...Option) (*SchemaSet, error) { + return NewGenerator(opts...).SchemasFromValues(values...) +} + +// SchemasFromTypes generates an OpenAPI component graph for Go reflection +// types. +func SchemasFromTypes(types ...reflect.Type) (*SchemaSet, error) { + return NewGenerator().SchemasFromTypes(types...) +} + +// SchemasFromTypesWithOptions generates an OpenAPI component graph for Go +// reflection types using generator options. +func SchemasFromTypesWithOptions(types []reflect.Type, opts ...Option) (*SchemaSet, error) { + return NewGenerator(opts...).SchemasFromTypes(types...) +} + +// RenderSchema renders a single OpenAPI schema as Go source using this +// generator. +func (g *Generator) RenderSchema(name string, schema *highbase.SchemaProxy) ([]byte, error) { + if schema == nil { + return nil, wrapPath(ErrNilSchema, name) + } + r := g.run() + r.typeNames = newNameRegistry() + ir, err := r.irFromOpenAPI(name, schema, name) + if err != nil { + return nil, err + } + file, err := r.renderFile([]*SchemaIR{ir}) + if err != nil { + return nil, err + } + return file.Source, nil +} + +// RenderSchemas renders an ordered map of OpenAPI schemas as one Go source +// file. +func (g *Generator) RenderSchemas(schemas *orderedmap.Map[string, *highbase.SchemaProxy]) (*GeneratedFile, error) { + if err := validatePackageName(g.packageName); err != nil { + return nil, err + } + r := g.run() + if schemas == nil { + return r.renderFile(nil) + } + r.typeNames = newNameRegistry() + r.componentTypeNames = r.resolveComponentTypeNames(schemas) + irs := make([]*SchemaIR, 0, schemas.Len()) + for name, schema := range schemas.FromOldest() { + ir, err := r.irFromOpenAPI(name, schema, name) + if err != nil { + return nil, err + } + irs = append(irs, ir) + } + r.componentKinds = make(map[string]Kind, len(irs)) + for _, ir := range irs { + if ir != nil && ir.Name != "" { + r.componentKinds[ir.Name] = ir.Kind + } + } + return r.renderFile(irs) +} + +func (g *Generator) resolveComponentTypeNames(schemas *orderedmap.Map[string, *highbase.SchemaProxy]) map[string]string { + names := make(map[string]string) + if schemas == nil { + return names + } + registry := g.typeNames + if registry == nil { + registry = newNameRegistry() + } + for name := range schemas.FromOldest() { + resolved, collision := registry.resolve(name, g.publicName(name)) + names[name] = resolved + if collision { + g.addDiagnostic(DiagnosticComponentNameCollision, name, "component name collision resolved as "+resolved) + } + } + return names +} + +func (g *Generator) resolveTypeName(original, candidate, path string) string { + if g.typeNames == nil { + return candidate + } + resolved, collision := g.typeNames.resolve(original, candidate) + if collision { + g.addDiagnostic(DiagnosticTypeNameCollision, path, "type name collision resolved as "+resolved) + } + return resolved +} + +// SchemaFromValue generates an OpenAPI schema for the runtime type of value +// using this generator. +func (g *Generator) SchemaFromValue(value any) (*highbase.SchemaProxy, error) { + if value == nil { + return nil, wrapPath(ErrNilType, "") + } + return g.SchemaFromType(reflect.TypeOf(value)) +} + +// SchemaFromType generates an OpenAPI schema for a Go reflection type using +// this generator. +func (g *Generator) SchemaFromType(t reflect.Type) (*highbase.SchemaProxy, error) { + if t == nil { + return nil, wrapPath(ErrNilType, "") + } + r := g.run() + nameType := derefType(t) + ir, err := r.irFromReflect(t, typeName(nameType), typeName(nameType)) + if err != nil { + return nil, err + } + return r.openapiFromIR(ir), nil +} + +// SchemasFromValues generates an OpenAPI component graph for runtime values +// using this generator. +func (g *Generator) SchemasFromValues(values ...any) (*SchemaSet, error) { + types := make([]reflect.Type, 0, len(values)) + for _, value := range values { + if value == nil { + return nil, wrapPath(ErrNilType, "") + } + types = append(types, reflect.TypeOf(value)) + } + return g.SchemasFromTypes(types...) +} + +// SchemasFromTypes generates an OpenAPI component graph for Go reflection types +// using this generator. +func (g *Generator) SchemasFromTypes(types ...reflect.Type) (*SchemaSet, error) { + r := g.run() + roots := orderedmap.New[string, *highbase.SchemaProxy]() + components := orderedmap.New[string, *highbase.SchemaProxy]() + var root *highbase.SchemaProxy + for i, t := range types { + if t == nil { + return nil, wrapPath(ErrNilType, "") + } + nameType := derefType(t) + ir, err := r.irFromReflect(t, typeName(nameType), typeName(nameType)) + if err != nil { + return nil, err + } + rootName := ir.Name + rootProxy := r.rootProxy(ir) + if i == 0 { + root = rootProxy + } + if _, exists := roots.Get(rootName); exists { + r.addDiagnostic(DiagnosticRootNameCollision, rootName, "root name collision resolved by keeping first schema") + continue + } + roots.Set(rootName, rootProxy) + } + irs := make([]*SchemaIR, 0, len(r.reflectCache)) + for _, ir := range r.reflectCache { + if ir != nil && ir.Name != "" && isComponentKind(ir.Kind) { + irs = append(irs, ir) + } + } + sortIRsByName(irs) + componentNames := make(map[string]struct{}, len(irs)) + for _, ir := range irs { + componentNames[ir.Name] = struct{}{} + } + r.componentNames = componentNames + for _, ir := range irs { + if _, exists := components.Get(ir.Name); exists { + r.addDiagnostic(DiagnosticComponentNameCollision, ir.Name, "component name collision resolved by keeping first schema") + continue + } + r.currentComponent = ir.Name + components.Set(ir.Name, r.openapiFromIR(ir)) + } + return &SchemaSet{ + Root: root, + Roots: roots, + Components: components, + Diagnostics: append([]Diagnostic(nil), r.diagnostics...), + }, nil +} + +func (g *Generator) rootProxy(ir *SchemaIR) *highbase.SchemaProxy { + if ir != nil && ir.Name != "" && isComponentKind(ir.Kind) { + ref := "#/components/schemas/" + ir.Name + if ir.Nullable { + return nullableReferenceProxy(ref, false, ir) + } + return highbase.CreateSchemaProxyRef(ref) + } + return g.openapiFromIR(ir) +} + +func (g *Generator) addDiagnostic(code, path, message string) { + g.diagnostics = append(g.diagnostics, Diagnostic{Code: code, Path: path, Message: message}) +} + +func (g *Generator) addImport(path string) { + if path != "" { + g.imports[path] = struct{}{} + } +} + +func isComponentKind(kind Kind) bool { + return kind == KindObject || kind == KindAllOf || kind == KindEnum || kind == KindUnion +} + +func sortIRsByName(irs []*SchemaIR) { + sort.SliceStable(irs, func(i, j int) bool { + return irs[i].Name < irs[j].Name + }) +} diff --git a/generator/golang/generator_test.go b/generator/golang/generator_test.go new file mode 100644 index 00000000..20558705 --- /dev/null +++ b/generator/golang/generator_test.go @@ -0,0 +1,671 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "bytes" + "go/format" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +func TestTrainTravelDefaultRawUnion(t *testing.T) { + file := renderTrainTravel(t) + src := string(file.Source) + + assertContains(t, src, "type Station struct") + assertContains(t, src, "type Trip struct") + assertContains(t, src, "type Booking struct") + assertContains(t, src, "type BookingPayment struct") + assertContains(t, src, "Source") + assertContains(t, src, "*BookingPayment_SourceUnion") + assertContains(t, src, "`json:\"source,omitempty\"`") + assertContains(t, src, "type BookingPayment_SourceUnion struct") + assertContains(t, src, "Raw json.RawMessage") + assertNotContains(t, src, "type BookingPayment_Source interface") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticOptionalConstDiscriminator) { + t.Fatalf("expected optional discriminator diagnostic, got %#v", file.Diagnostics) + } + if !hasDiagnosticCode(file.Diagnostics, DiagnosticValidationKeyword) { + t.Fatalf("expected validation keyword diagnostic, got %#v", file.Diagnostics) + } + assertParsesAndCompiles(t, file.Source) +} + +func TestTrainTravelOptionalConstDiscriminatorTypedUnion(t *testing.T) { + file := renderTrainTravel(t, WithOptionalConstDiscriminatorUnions(true)) + src := string(file.Source) + + assertContains(t, src, "type BookingPayment_Source interface") + assertContains(t, src, "type BookingPayment_SourceUnion struct") + assertContains(t, src, "Value BookingPayment_Source") + assertContains(t, src, "case \"bank_account\":") + assertContains(t, src, "case \"card\":") + assertContains(t, src, "func (BookingPayment_Source_Card) isBookingPayment_Source() {}") + assertContains(t, src, "func (BookingPayment_Source_BankAccount) isBookingPayment_Source() {}") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticUnevaluatedProperties) { + t.Fatalf("expected unevaluatedProperties diagnostic, got %#v", file.Diagnostics) + } + assertParsesAndCompiles(t, file.Source) +} + +func TestRenderSchemaConvenienceAndOptions(t *testing.T) { + schema := schemaProxyFromYAML(t, ` +type: object +required: [id] +properties: + id: + type: string + enabled: + type: boolean +`) + src, err := RenderSchema("option probe", schema, + WithPackageName("custommodels"), + WithOptionalFieldsAsPointers(false), + WithOmitEmpty(false), + WithGenerateYAMLTags(true), + WithGenerateJSONTags(false), + ) + if err != nil { + t.Fatal(err) + } + assertContains(t, string(src), "package custommodels") + assertContains(t, string(src), "Enabled bool") + assertContains(t, string(src), "`yaml:\"enabled\"`") + assertNotContains(t, string(src), "omitempty") +} + +func TestOpenAPICompositionAndUnionPolicies(t *testing.T) { + tests := map[string]string{ + "raw oneOf": ` +oneOf: + - type: object + properties: + a: { type: string } + - type: object + properties: + b: { type: string } +`, + "raw anyOf": ` +anyOf: + - type: string + - type: integer +`, + "nullable union": ` +oneOf: + - type: string + - type: 'null' +`, + "allOf": ` +allOf: + - type: object + required: [id] + properties: + id: { type: string } + - type: object + properties: + name: { type: string } +`, + } + for name, yml := range tests { + t.Run(name, func(t *testing.T) { + src, err := RenderSchema("Sample", schemaProxyFromYAML(t, yml)) + if err != nil { + t.Fatal(err) + } + assertParsesAndCompiles(t, src) + }) + } +} + +func TestTopLevelArrayInlineItemDeclarations(t *testing.T) { + source, err := RenderSchema("pets", schemaProxyFromYAML(t, ` +type: array +items: + type: object + required: [name] + properties: + name: + type: string +`)) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source)), " ") + assertContains(t, src, "type Pets []Pets_Item") + assertContains(t, src, "type Pets_Item struct") + assertContains(t, src, "Name string `json:\"name\"`") + assertParsesAndCompiles(t, source) +} + +func TestTopLevelFreeFormObjectRendersMapAlias(t *testing.T) { + for name, yml := range map[string]string{ + "payload": "type: object\n", + "free bool": "type: object\nadditionalProperties: true\n", + } { + t.Run(name, func(t *testing.T) { + source, err := RenderSchema(name, schemaProxyFromYAML(t, yml)) + if err != nil { + t.Fatal(err) + } + typeName := toPublicName(name) + assertContains(t, strings.Join(strings.Fields(string(source)), " "), "type "+typeName+" map[string]any") + assertParsesCompilesAndTests(t, source, "package models\n\n"+ + "import (\n"+ + "\t\"encoding/json\"\n"+ + "\t\"testing\"\n"+ + ")\n\n"+ + "func TestFreeFormObjectRoundTrip(t *testing.T) {\n"+ + "\tvar model "+typeName+"\n"+ + "\tif err := json.Unmarshal([]byte(`{\"x\":7}`), &model); err != nil {\n"+ + "\t\tt.Fatal(err)\n"+ + "\t}\n"+ + "\tif model[\"x\"].(float64) != 7 {\n"+ + "\t\tt.Fatalf(\"unexpected model: %#v\", model)\n"+ + "\t}\n"+ + "\tout, err := json.Marshal(model)\n"+ + "\tif err != nil {\n"+ + "\t\tt.Fatal(err)\n"+ + "\t}\n"+ + "\tif string(out) != `{\"x\":7}` {\n"+ + "\t\tt.Fatalf(\"unexpected output: %s\", out)\n"+ + "\t}\n"+ + "}\n") + }) + } +} + +func TestNullableAllOfPreservesWrapperMetadata(t *testing.T) { + source, err := RenderSchema("holder", schemaProxyFromYAML(t, ` +type: object +required: [value] +properties: + value: + title: Nullable Value + nullable: true + allOf: + - type: object + required: [id] + properties: + id: + type: string +`)) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source)), " ") + assertContains(t, src, "Value *Holder_Value `json:\"value\"`") + assertContains(t, string(source), "// Holder_Value Nullable Value.") + assertParsesAndCompiles(t, source) +} + +func TestSchemaFromPointerRootPreservesNullability(t *testing.T) { + for name, proxy := range map[string]*highbase.SchemaProxy{ + "type": mustSchemaFromType(t, reflect.TypeOf((*string)(nil))), + "value": mustSchemaFromValue(t, (*string)(nil)), + } { + schema := proxy.Schema() + if schema == nil || !schemaTypeContains(schema.Type, "string") || !schemaTypeContains(schema.Type, "null") { + t.Fatalf("%s pointer root should render as nullable string, got %#v", name, schema) + } + } + + for name, set := range map[string]*SchemaSet{ + "types": mustSchemasFromTypes(t, reflect.TypeOf((*string)(nil))), + "values": mustSchemasFromValues(t, (*string)(nil)), + } { + assertNullableStringSchema(t, name+" root", set.Root) + assertNullableStringSchema(t, name+" roots entry", set.Roots.GetOrZero("String")) + } + + type PointerRootModel struct { + ID string `json:"id"` + } + for name, set := range map[string]*SchemaSet{ + "types": mustSchemasFromTypes(t, reflect.TypeOf((*PointerRootModel)(nil))), + "values": mustSchemasFromValues(t, (*PointerRootModel)(nil)), + } { + assertNullableRef(t, set.Root, "#/components/schemas/PointerRootModel") + assertNullableRef(t, set.Roots.GetOrZero("PointerRootModel"), "#/components/schemas/PointerRootModel") + component := componentSchema(t, set, "PointerRootModel") + if schemaTypeContains(component.Type, "null") || component.Nullable != nil { + t.Fatalf("%s component should stay non-nullable, got %#v", name, component) + } + } +} + +func TestSchemaFromTypeReflection(t *testing.T) { + type Embedded struct { + TraceID string `json:"trace_id"` + } + type Meta map[string]string + type Pet interface{ pet() } + type Cat struct { + Object string `json:"object"` + Name string `json:"name"` + } + type Sample struct { + Embedded + ID string `json:"id"` + Name *string `json:"name,omitempty"` + CreatedAt time.Time `json:"created_at"` + Labels []string `json:"labels,omitempty"` + Meta Meta `json:"meta,omitempty"` + Ignored string `json:"-"` + Choice Pet `json:"choice,omitempty"` + } + + gen := NewGenerator( + WithOneOfTypes((*Pet)(nil), Cat{}), + WithDiscriminatorMapping((*Pet)(nil), "object", map[string]string{ + "cat": "#/components/schemas/Cat", + }), + ) + proxy, err := gen.SchemaFromType(reflect.TypeOf(Sample{})) + if err != nil { + t.Fatal(err) + } + rendered, err := proxy.Render() + if err != nil { + t.Fatal(err) + } + text := string(rendered) + assertContains(t, text, "trace_id:") + assertContains(t, text, "created_at:") + assertContains(t, text, "format: date-time") + assertContains(t, text, "oneOf:") + assertContains(t, text, "discriminator:") + assertNotContains(t, text, "Ignored") +} + +func TestSchemaFromTypeErrorsAndProvider(t *testing.T) { + if _, err := SchemaFromValue(nil); err == nil { + t.Fatal("expected nil value error") + } + if _, err := SchemaFromType(nil); err == nil { + t.Fatal("expected nil type error") + } + type BadMap struct { + Values map[int]string `json:"values"` + } + if _, err := SchemaFromType(reflect.TypeOf(BadMap{})); !strings.Contains(err.Error(), ErrUnsupportedMapKey.Error()) { + t.Fatalf("expected map key error, got %v", err) + } + type NeedsRegistration interface{ marker() } + type HasInterface struct { + Value NeedsRegistration `json:"value"` + } + if _, err := SchemaFromType(reflect.TypeOf(HasInterface{})); !strings.Contains(err.Error(), ErrUnsupportedType.Error()) { + t.Fatalf("expected unsupported interface error, got %v", err) + } + proxy, err := SchemaFromType(reflect.TypeOf(Provider{})) + if err != nil { + t.Fatal(err) + } + if proxy == nil { + t.Fatal("expected proxy") + } + if _, err := SchemaFromType(reflect.TypeOf(BadProvider{})); err == nil { + t.Fatal("expected bad provider schema error") + } +} + +func (Provider) OpenAPISchema() *highbase.SchemaProxy { + return highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}}) +} + +type Provider struct{} + +func (BadProvider) OpenAPISchema() *highbase.SchemaProxy { + return &highbase.SchemaProxy{} +} + +type BadProvider struct{} + +func TestHelpersAndErrors(t *testing.T) { + if got := toPublicName("links-self"); got != "LinksSelf" { + t.Fatalf("unexpected public name %q", got) + } + if got := toPublicName("trip_id"); got != "TripID" { + t.Fatalf("unexpected initialism %q", got) + } + if got := toPublicName("123-value"); got != "Value123Value" { + t.Fatalf("unexpected digit prefix %q", got) + } + if got := toPrivateName("HTTPServer"); got != "httpServer" { + t.Fatalf("unexpected private name %q", got) + } + if got := refName("#/components/schemas/Pet"); got != "Pet" { + t.Fatalf("unexpected ref name %q", got) + } + if got := NewGenerator().refTypeName("pet.yaml"); got != "PetYaml" { + t.Fatalf("unexpected bare external ref name %q", got) + } + used := map[string]struct{}{} + if uniqueName("Pet", used) != "Pet" || uniqueName("Pet", used) != "Pet__2" { + t.Fatal("uniqueName did not allocate suffix") + } + if intString(0) != "0" || intString(42) != "42" { + t.Fatal("intString failed") + } + if err := validatePackageName("type"); err == nil { + t.Fatal("expected invalid package error") + } + if _, err := RenderSchema("Bad", nil); err == nil { + t.Fatal("expected nil schema error") + } + if _, err := NewGenerator(WithPackageName("bad-name")).RenderSchemas(nil); err == nil { + t.Fatal("expected invalid package error") + } +} + +func TestToOpenAPIPrimitiveAndRefPaths(t *testing.T) { + gen := NewGenerator() + values := []*SchemaIR{ + {Kind: KindRef, Ref: "#/components/schemas/Pet"}, + {Kind: KindString, Format: "uuid", Nullable: true}, + {Kind: KindInteger, Format: "int32"}, + {Kind: KindNumber, Format: "float"}, + {Kind: KindBoolean}, + {Kind: KindArray, Items: &SchemaIR{Kind: KindString}}, + {Kind: KindEnum, Enum: []*yaml.Node{stringNode("a")}}, + {Kind: KindUnknown}, + nil, + } + for _, value := range values { + proxy := gen.openapiFromIR(value) + if proxy == nil { + t.Fatal("expected proxy") + } + if _, err := proxy.Render(); err != nil { + t.Fatal(err) + } + } +} + +func TestExplicitDiscriminatorSchema(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + Cat: + type: object + properties: + kind: + type: string + Pet: + discriminator: + propertyName: kind + mapping: + cat: '#/components/schemas/Cat' + oneOf: + - $ref: '#/components/schemas/Cat' +`) + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + schema, ok := model.Model.Components.Schemas.Get("Pet") + if !ok { + t.Fatal("missing pet schema") + } + src, err := RenderSchema("Pet", schema) + if err != nil { + t.Fatal(err) + } + assertContains(t, string(src), "type Pet interface") + assertContains(t, string(src), "case \"cat\":") +} + +func TestImplicitDiscriminatorSchema(t *testing.T) { + spec := []byte(`openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +paths: {} +components: + schemas: + cat: + type: object + required: [kind, name] + properties: + kind: + type: string + name: + type: string + dog: + type: object + required: [kind, bark] + properties: + kind: + type: string + bark: + type: string + Pet: + discriminator: + propertyName: kind + oneOf: + - $ref: '#/components/schemas/cat' + - $ref: '#/components/schemas/dog' +`) + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + file, err := NewGenerator().RenderSchemas(model.Model.Components.Schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + assertContains(t, src, "case \"cat\":") + assertContains(t, src, "case \"dog\":") + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "testing" +) + +func TestImplicitDiscriminatorJSON(t *testing.T) { + var pet PetUnion + if err := json.Unmarshal([]byte("{\"kind\":\"cat\",\"name\":\"milo\"}"), &pet); err != nil { + t.Fatal(err) + } + cat, ok := pet.Value.(Cat) + if !ok || cat.Name != "milo" { + t.Fatalf("unexpected pet value: %#v", pet.Value) + } +} +`) +} + +func renderTrainTravel(t *testing.T, opts ...Option) *GeneratedFile { + t.Helper() + spec, err := os.ReadFile("testdata/train-travel.yaml") + if err != nil { + t.Fatal(err) + } + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + file, err := NewGenerator(opts...).RenderSchemas(model.Model.Components.Schemas) + if err != nil { + t.Fatal(err) + } + return file +} + +func schemaProxyFromYAML(t *testing.T, yml string) *highbase.SchemaProxy { + t.Helper() + spec := []byte("openapi: 3.1.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n Sample:\n" + indent(yml, " ")) + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + schema, ok := model.Model.Components.Schemas.Get("Sample") + if !ok { + t.Fatal("missing sample schema") + } + return schema +} + +func indent(in, prefix string) string { + lines := strings.Split(strings.TrimPrefix(in, "\n"), "\n") + var b strings.Builder + for _, line := range lines { + if strings.TrimSpace(line) == "" { + b.WriteByte('\n') + continue + } + b.WriteString(prefix) + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} + +func assertParsesAndCompiles(t *testing.T, src []byte) { + t.Helper() + assertParsesCompilesAndTests(t, src, "package models\n\nimport \"testing\"\n\nfunc TestGeneratedPackage(t *testing.T) {}\n") +} + +func assertParsesCompilesAndTests(t *testing.T, src []byte, testSource string) { + t.Helper() + assertParsesCompilesAndTestsWithFiles(t, map[string][]byte{"models.go": src}, testSource) +} + +func assertParsesCompilesAndTestsWithFiles(t *testing.T, files map[string][]byte, testSource string) { + t.Helper() + dir := t.TempDir() + for name, src := range files { + if !bytes.Equal(bytes.TrimSpace(src), bytes.TrimSpace(mustFormat(t, src))) { + t.Fatalf("%s is not gofmt formatted", name) + } + if _, err := parser.ParseFile(token.NewFileSet(), name, src, parser.AllErrors); err != nil { + t.Fatalf("generated source %s does not parse: %v\n%s", name, err, src) + } + if err := os.WriteFile(filepath.Join(dir, name), src, 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "models_test.go"), []byte(testSource), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command("go", "test") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GO111MODULE=off") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("generated source does not compile: %v\n%s", err, out) + } +} + +func mustFormat(t *testing.T, src []byte) []byte { + t.Helper() + out, err := format.Source(src) + if err != nil { + t.Fatal(err) + } + return out +} + +func mustSchemaFromType(t *testing.T, typ reflect.Type) *highbase.SchemaProxy { + t.Helper() + proxy, err := SchemaFromType(typ) + if err != nil { + t.Fatal(err) + } + return proxy +} + +func mustSchemaFromValue(t *testing.T, value any) *highbase.SchemaProxy { + t.Helper() + proxy, err := SchemaFromValue(value) + if err != nil { + t.Fatal(err) + } + return proxy +} + +func mustSchemasFromTypes(t *testing.T, types ...reflect.Type) *SchemaSet { + t.Helper() + set, err := SchemasFromTypes(types...) + if err != nil { + t.Fatal(err) + } + return set +} + +func mustSchemasFromValues(t *testing.T, values ...any) *SchemaSet { + t.Helper() + set, err := SchemasFromValues(values...) + if err != nil { + t.Fatal(err) + } + return set +} + +func assertNullableStringSchema(t *testing.T, name string, proxy *highbase.SchemaProxy) { + t.Helper() + schema := proxy.Schema() + if schema == nil || !schemaTypeContains(schema.Type, "string") || !schemaTypeContains(schema.Type, "null") { + t.Fatalf("%s should render as nullable string, got %#v", name, schema) + } +} + +func assertContains(t *testing.T, s, substr string) { + t.Helper() + if !strings.Contains(s, substr) { + t.Fatalf("expected %q in:\n%s", substr, s) + } +} + +func assertNotContains(t *testing.T, s, substr string) { + t.Helper() + if strings.Contains(s, substr) { + t.Fatalf("did not expect %q in:\n%s", substr, s) + } +} + +func TestManualRenderSchemasNilAndFormatMapping(t *testing.T) { + file, err := NewGenerator(WithFormatMapping("date-time", "time.Time", "time")).RenderSchemas(orderedmap.New[string, *highbase.SchemaProxy]()) + if err != nil { + t.Fatal(err) + } + if string(file.Source) != "package models\n" { + t.Fatalf("unexpected empty file %q", file.Source) + } +} diff --git a/generator/golang/golden_test.go b/generator/golang/golden_test.go new file mode 100644 index 00000000..d32baf64 --- /dev/null +++ b/generator/golang/golden_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "os" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestTrainTravelGoldenDefaultRawUnion(t *testing.T) { + assertGolden(t, "testdata/train_travel_default.golden.go", renderTrainTravel(t).Source) +} + +func TestTrainTravelGoldenOptionalConstDiscriminatorTypedUnion(t *testing.T) { + assertGolden(t, "testdata/train_travel_typed_union.golden.go", renderTrainTravel(t, WithOptionalConstDiscriminatorUnions(true)).Source) +} + +func TestJSONSchema202012GoldenDefault(t *testing.T) { + assertGolden(t, "testdata/jsonschema_2020_12_default.golden.go", renderJSONSchema202012(t).Source) +} + +func TestJSONSchema202012GoldenOptions(t *testing.T) { + assertGolden(t, "testdata/jsonschema_2020_12_options.golden.go", renderJSONSchema202012(t, + WithAdditionalPropertiesMethods(false), + WithEnumConstants(true), + ).Source) +} + +func TestNameCollisionsGoldenDefault(t *testing.T) { + assertGolden(t, "testdata/name_collisions_default.golden.go", renderNameCollisions(t).Source) +} + +func TestNameCollisionsGoldenCompactDelimiter(t *testing.T) { + assertGolden(t, "testdata/name_collisions_compact_delimiter.golden.go", renderNameCollisions(t, + WithNestedTypeNameDelimiter(""), + WithEnumConstants(true), + ).Source) +} + +func assertGolden(t *testing.T, path string, got []byte) { + t.Helper() + if os.Getenv("LIBOPENAPI_GENERATOR_UPDATE_GOLDENS") == "true" { + if err := os.WriteFile(path, got, 0o600); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + wantText := normalizeGoldenLineEndings(want) + gotText := normalizeGoldenLineEndings(got) + if gotText != wantText { + t.Fatalf("golden mismatch for %s at %s", path, firstDiff(wantText, gotText)) + } +} + +func TestNormalizeGoldenLineEndings(t *testing.T) { + got := normalizeGoldenLineEndings([]byte("package models\r\n\r\nimport \"encoding/json\"\r\n")) + want := "package models\n\nimport \"encoding/json\"\n" + if got != want { + t.Fatalf("unexpected normalized text: %q", got) + } +} + +func normalizeGoldenLineEndings(input []byte) string { + return strings.ReplaceAll(string(input), "\r\n", "\n") +} + +func firstDiff(want, got string) string { + max := len(want) + if len(got) < max { + max = len(got) + } + for i := 0; i < max; i++ { + if want[i] != got[i] { + return diffLocation(want, i) + } + } + if len(want) != len(got) { + return diffLocation(want, max) + } + return "no difference" +} + +func renderJSONSchema202012(t *testing.T, opts ...Option) *GeneratedFile { + t.Helper() + spec, err := os.ReadFile("testdata/jsonschema-2020-12.yaml") + if err != nil { + t.Fatal(err) + } + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + file, err := NewGenerator(opts...).RenderSchemas(model.Model.Components.Schemas) + if err != nil { + t.Fatal(err) + } + return file +} + +func renderNameCollisions(t *testing.T, opts ...Option) *GeneratedFile { + t.Helper() + spec, err := os.ReadFile("testdata/name-collisions.yaml") + if err != nil { + t.Fatal(err) + } + doc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + file, err := NewGenerator(opts...).RenderSchemas(model.Model.Components.Schemas) + if err != nil { + t.Fatal(err) + } + return file +} + +func diffLocation(text string, offset int) string { + line := 1 + strings.Count(text[:offset], "\n") + column := offset - strings.LastIndex(text[:offset], "\n") + return "line " + intString(line) + ", column " + intString(column) +} diff --git a/generator/golang/ir.go b/generator/golang/ir.go new file mode 100644 index 00000000..9982bfe0 --- /dev/null +++ b/generator/golang/ir.go @@ -0,0 +1,120 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +type Kind int + +const ( + KindUnknown Kind = iota + KindAny + KindObject + KindArray + KindMap + KindString + KindInteger + KindNumber + KindBoolean + KindRef + KindEnum + KindAllOf + KindUnion +) + +type UnionKind int + +const ( + UnionNone UnionKind = iota + UnionOneOf + UnionAnyOf +) + +type UnionStrategy int + +const ( + UnionRawMessage UnionStrategy = iota + UnionDiscriminator +) + +type Discriminator struct { + PropertyName string + Mapping map[string]string + Optional bool +} + +type Source struct { + Line int + Column int + Ref string +} + +// SchemaIR is the neutral hub both generation directions converge on. It +// carries two distinct channels. The shaping fields (Kind, Properties, Items, +// AdditionalProperties, Union, AllOf, Required, Nullable, Format, …) determine +// the generated Go type. SourceSchema carries the full original schema for the +// fidelity the IR does not model (validation keywords, conditionals, content, +// vocabulary, and so on); when ExactSource is set, openapiFromIR emits +// SourceSchema verbatim and ignores the shaping fields. FieldMetadata marks an +// IR whose SourceSchema should be rendered as $ref sibling metadata rather than +// inlined. +type SchemaIR struct { + Name string + Ref string + Kind Kind + DynamicRef bool + Format string + Description string + Title string + Nullable bool + Required map[string]struct{} + Properties *orderedmap.Map[string, *SchemaIR] + PatternProperties *orderedmap.Map[string, *SchemaIR] + Items *SchemaIR + PrefixItems []*SchemaIR + AdditionalProperties *SchemaIR + AdditionalAllowed *bool + Enum []*yaml.Node + Const *yaml.Node + AllOf []*SchemaIR + Union *UnionIR + Extensions *orderedmap.Map[string, *yaml.Node] + Source *Source + ReadOnly bool + WriteOnly bool + Deprecated bool + FieldMetadata bool + ExactSource bool + Comments []string + SourceSchema *highbase.Schema +} + +type UnionIR struct { + Kind UnionKind + Variants []*SchemaIR + Discriminator *Discriminator + Strategy UnionStrategy + FromMultiType bool +} + +func newObjectIR(name string) *SchemaIR { + return &SchemaIR{ + Name: name, + Kind: KindObject, + Required: make(map[string]struct{}), + Properties: orderedmap.New[string, *SchemaIR](), + } +} + +func isRequired(ir *SchemaIR, name string) bool { + if ir == nil || ir.Required == nil { + return false + } + _, ok := ir.Required[name] + return ok +} diff --git a/generator/golang/jsonschema_fidelity_test.go b/generator/golang/jsonschema_fidelity_test.go new file mode 100644 index 00000000..e87509b8 --- /dev/null +++ b/generator/golang/jsonschema_fidelity_test.go @@ -0,0 +1,409 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func TestJSONSchema202012KeywordDiagnostics(t *testing.T) { + source, err := RenderSchema("full fidelity", schemaProxyFromYAML(t, ` +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/full +$anchor: full +$dynamicAnchor: fullNode +$comment: generator diagnostics should report metadata +$vocabulary: + https://json-schema.org/draft/2020-12/vocab/core: true +type: object +minProperties: 1 +maxProperties: 8 +required: [id] +if: + properties: + kind: + const: business +then: + required: [tax_id] +else: + required: [ssn] +not: + required: [forbidden] +properties: + id: + type: string + const: fixed + payload: + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-z]+$" + contentEncoding: base64 + contentMediaType: application/json + contentSchema: + type: object + amount: + type: number + multipleOf: 0.01 + minimum: 0 + exclusiveMaximum: 100 + tuple: + type: array + minItems: 1 + maxItems: 3 + uniqueItems: true + prefixItems: + - type: string + - type: integer + items: false + contains: + type: string + minContains: 1 + maxContains: 2 + unevaluatedItems: + type: boolean + object_rules: + type: object + minProperties: 1 + maxProperties: 4 + propertyNames: + pattern: "^[a-z_]+$" + dependentSchemas: + card: + type: object + dependentRequired: + card: [billing] + patternProperties: + "^x-": + type: string + additionalProperties: false + unevaluatedProperties: false + dynamic: + $dynamicRef: '#/components/schemas/Meta' +`)) + if err != nil { + t.Fatal(err) + } + if len(source) == 0 { + t.Fatal("expected generated source") + } + + file, err := NewGenerator().RenderSchemas(singleSchemaMap(t, "full fidelity", schemaProxyFromYAML(t, ` +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/full +$anchor: full +$dynamicAnchor: fullNode +$comment: generator diagnostics should report metadata +$vocabulary: + https://json-schema.org/draft/2020-12/vocab/core: true +type: object +minProperties: 1 +maxProperties: 8 +required: [id] +if: + properties: + kind: + const: business +then: + required: [tax_id] +else: + required: [ssn] +not: + required: [forbidden] +properties: + id: + type: string + const: fixed + payload: + type: string + minLength: 1 + maxLength: 100 + pattern: "^[a-z]+$" + contentEncoding: base64 + contentMediaType: application/json + contentSchema: + type: object + amount: + type: number + multipleOf: 0.01 + minimum: 0 + exclusiveMaximum: 100 + tuple: + type: array + minItems: 1 + maxItems: 3 + uniqueItems: true + prefixItems: + - type: string + - type: integer + items: false + contains: + type: string + minContains: 1 + maxContains: 2 + unevaluatedItems: + type: boolean + object_rules: + type: object + minProperties: 1 + maxProperties: 4 + propertyNames: + pattern: "^[a-z_]+$" + dependentSchemas: + card: + type: object + dependentRequired: + card: [billing] + patternProperties: + "^x-": + type: string + additionalProperties: false + unevaluatedProperties: false + dynamic: + $dynamicRef: '#/components/schemas/Meta' +`))) + if err != nil { + t.Fatal(err) + } + for _, code := range []string{ + DiagnosticAdditionalPropertiesFalse, + DiagnosticArrayContains, + DiagnosticBooleanItems, + DiagnosticConditionalSchema, + DiagnosticConstKeyword, + DiagnosticContentSchema, + DiagnosticDependentRequired, + DiagnosticDependentSchemas, + DiagnosticDynamicReference, + DiagnosticNotSchema, + DiagnosticPatternProperties, + DiagnosticPrefixItems, + DiagnosticPropertyNames, + DiagnosticSchemaMetadata, + DiagnosticUnevaluatedItems, + DiagnosticUnevaluatedProperties, + DiagnosticValidationKeyword, + } { + if !hasDiagnosticCode(file.Diagnostics, code) { + t.Fatalf("missing diagnostic code %s: %#v", code, file.Diagnostics) + } + } +} + +func TestJSONSchema202012MultiTypeRawUnion(t *testing.T) { + source, err := RenderSchema("multi type", schemaProxyFromYAML(t, ` +type: object +properties: + value: + type: [string, integer, "null"] +`)) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source)), " ") + assertContains(t, src, "Value *MultiType_ValueUnion `json:\"value,omitempty\"`") + assertContains(t, string(source), "type MultiType_ValueUnion struct") + assertContains(t, string(source), "Raw json.RawMessage") + assertParsesAndCompiles(t, source) +} + +func TestJSONSchema202012NullOnlyUnionVariantsCollapse(t *testing.T) { + gen := NewGenerator() + ir, err := gen.irFromOpenAPI("null union", schemaProxyFromYAML(t, ` +type: object +properties: + const_null: + anyOf: + - const: null + - type: string + enum_null: + anyOf: + - enum: [null] + - type: integer + nullable_any: + anyOf: + - nullable: true + - type: string +`), "null union") + if err != nil { + t.Fatal(err) + } + constNull := ir.Properties.GetOrZero("const_null") + if constNull == nil || constNull.Kind != KindString || !constNull.Nullable { + t.Fatalf("const:null anyOf variant should collapse to nullable string, got %#v", constNull) + } + enumNull := ir.Properties.GetOrZero("enum_null") + if enumNull == nil || enumNull.Kind != KindInteger || !enumNull.Nullable { + t.Fatalf("enum:[null] anyOf variant should collapse to nullable integer, got %#v", enumNull) + } + nullableAny := ir.Properties.GetOrZero("nullable_any") + if nullableAny == nil || nullableAny.Kind != KindUnion { + t.Fatalf("nullable unconstrained schema is not null-only and should remain a union, got %#v", nullableAny) + } + + source, err := gen.renderFile([]*SchemaIR{ir}) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source.Source)), " ") + assertContains(t, src, "ConstNull *string `json:\"const_null,omitempty\"`") + assertContains(t, src, "EnumNull *int `json:\"enum_null,omitempty\"`") + assertContains(t, src, "NullableAny *NullUnion_NullableAnyUnion `json:\"nullable_any,omitempty\"`") + assertParsesAndCompiles(t, source.Source) +} + +func TestJSONSchema202012EnumScalarVariants(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("string enum", schemaProxyFromYAML(t, ` +type: string +enum: [open, closed] +`)) + schemas.Set("int enum", schemaProxyFromYAML(t, ` +type: integer +enum: [1, 2] +`)) + schemas.Set("float enum", schemaProxyFromYAML(t, ` +type: number +enum: [1.5, 2] +`)) + schemas.Set("bool enum", schemaProxyFromYAML(t, ` +type: boolean +enum: [true, false] +`)) + schemas.Set("nullable enum", schemaProxyFromYAML(t, ` +enum: + - null + - active +`)) + schemas.Set("mixed enum", schemaProxyFromYAML(t, ` +enum: + - active + - 2 + - true +`)) + + file, err := NewGenerator(WithEnumConstants(true)).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(file.Source)), " ") + assertContains(t, src, "type StringEnum string") + assertContains(t, src, "StringEnumOpen StringEnum = \"open\"") + assertContains(t, src, "type IntEnum int") + assertContains(t, src, "IntEnumValue1 IntEnum = 1") + assertContains(t, src, "type FloatEnum float64") + assertContains(t, src, "FloatEnumValue15 FloatEnum = 1.5") + assertContains(t, src, "type BoolEnum bool") + assertContains(t, src, "BoolEnumTrue BoolEnum = true") + assertContains(t, src, "type NullableEnum string") + assertContains(t, src, "NullableEnumActive NullableEnum = \"active\"") + assertContains(t, src, "type MixedEnum any") + assertNotContains(t, src, "MixedEnumActive") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticNullEnum) { + t.Fatalf("expected nullable enum diagnostic: %#v", file.Diagnostics) + } + if !hasDiagnosticCode(file.Diagnostics, DiagnosticMixedEnum) { + t.Fatalf("expected mixed enum diagnostic: %#v", file.Diagnostics) + } + assertParsesAndCompiles(t, file.Source) +} + +func TestJSONSchema202012ClosedNestedObjectUsesStruct(t *testing.T) { + source, err := RenderSchema("closed parent", schemaProxyFromYAML(t, ` +type: object +properties: + config: + type: object + additionalProperties: false +`)) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source)), " ") + assertContains(t, src, "type ClosedParent_Config struct { }") + assertContains(t, src, "Config *ClosedParent_Config `json:\"config,omitempty\"`") + assertParsesAndCompiles(t, source) +} + +func TestJSONSchema202012ImplicitTypeInference(t *testing.T) { + source, err := RenderSchema("implicit", schemaProxyFromYAML(t, ` +type: object +properties: + name: + minLength: 1 + tags: + items: + type: string + loose_object: + minProperties: 1 + ambiguous: + minLength: 1 + minimum: 0 +`)) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(source)), " ") + assertContains(t, src, "Name *string `json:\"name,omitempty\"`") + assertContains(t, src, "Tags []string `json:\"tags,omitempty\"`") + assertContains(t, src, "LooseObject map[string]any `json:\"loose_object,omitempty\"`") + assertContains(t, src, "Ambiguous any `json:\"ambiguous,omitempty\"`") + assertParsesAndCompiles(t, source) +} + +func TestJSONSchema202012DynamicRefRendersNamedReference(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("Meta", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + schemas.Set("Holder", schemaProxyFromYAML(t, ` +type: object +properties: + meta: + $dynamicRef: '#/components/schemas/Meta' + nullable_meta: + description: Nullable dynamic metadata. + $dynamicRef: '#/components/schemas/Meta' + nullable: true +`)) + file, err := NewGenerator().RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := strings.Join(strings.Fields(string(file.Source)), " ") + assertContains(t, src, "Meta *Meta `json:\"meta,omitempty\"`") + if !hasDiagnosticCode(file.Diagnostics, DiagnosticDynamicReference) { + t.Fatalf("expected dynamic reference diagnostic: %#v", file.Diagnostics) + } + assertParsesAndCompiles(t, file.Source) + + gen := NewGenerator() + ir, err := gen.irFromOpenAPI("Holder", schemas.GetOrZero("Holder"), "Holder") + if err != nil { + t.Fatal(err) + } + nullableMeta := ir.Properties.GetOrZero("nullable_meta") + if nullableMeta == nil || !nullableMeta.DynamicRef || !nullableMeta.Nullable { + t.Fatalf("nullable dynamic ref should preserve both dynamic ref and nullability, got %#v", nullableMeta) + } + if nullableMeta.Description != "Nullable dynamic metadata." { + t.Fatalf("nullable dynamic ref should preserve description, got %#v", nullableMeta) + } + rendered := gen.openapiFromIR(nullableMeta).Schema() + if rendered == nil || len(rendered.AnyOf) != 2 || rendered.AnyOf[0].Schema().DynamicRef != "#/components/schemas/Meta" { + t.Fatalf("nullable dynamic ref should render as anyOf dynamicRef/null, got %#v", rendered) + } + if rendered.AnyOf[0].Schema().Description != "Nullable dynamic metadata." { + t.Fatalf("nullable dynamic ref should render description on dynamicRef variant, got %#v", rendered.AnyOf[0].Schema()) + } +} + +func singleSchemaMap(t *testing.T, name string, schema *highbase.SchemaProxy) *orderedmap.Map[string, *highbase.SchemaProxy] { + t.Helper() + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set(name, schema) + return schemas +} diff --git a/generator/golang/metadata.go b/generator/golang/metadata.go new file mode 100644 index 00000000..3c8274bc --- /dev/null +++ b/generator/golang/metadata.go @@ -0,0 +1,478 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strconv" + "strings" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "go.yaml.in/yaml/v4" +) + +type openAPIMetadata struct { + Present bool + + FormatSet bool + Format string + TitleSet bool + Title string + DescriptionSet bool + Description string + NullableSet bool + Nullable bool + ReadOnlySet bool + ReadOnly bool + WriteOnlySet bool + WriteOnly bool + DeprecatedSet bool + Deprecated bool + + MinimumSet bool + Minimum float64 + MaximumSet bool + Maximum float64 + ExclusiveMinimumSet bool + ExclusiveMinimum float64 + ExclusiveMaximumSet bool + ExclusiveMaximum float64 + MultipleOfSet bool + MultipleOf float64 + MinLengthSet bool + MinLength int64 + MaxLengthSet bool + MaxLength int64 + PatternSet bool + Pattern string + MinItemsSet bool + MinItems int64 + MaxItemsSet bool + MaxItems int64 + UniqueItemsSet bool + UniqueItems bool + MinPropertiesSet bool + MinProperties int64 + MaxPropertiesSet bool + MaxProperties int64 + + Enum []*yaml.Node + Const *yaml.Node +} + +func parseOpenAPITag(raw string) openAPIMetadata { + var meta openAPIMetadata + if raw == "" { + return meta + } + meta.Present = true + for _, part := range splitEscaped(raw, ';') { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, value, hasValue := cutEscaped(part, '=') + key = strings.TrimSpace(key) + rawValue := strings.TrimSpace(value) + value = unescapeOpenAPITagValue(rawValue) + switch key { + case "format": + meta.FormatSet = hasValue + meta.Format = value + case "title": + meta.TitleSet = hasValue + meta.Title = value + case "description", "desc": + meta.DescriptionSet = hasValue + meta.Description = value + case "nullable": + if parsed, ok := parseTagBool(value, hasValue); ok { + meta.NullableSet = true + meta.Nullable = parsed + } + case "readOnly": + if parsed, ok := parseTagBool(value, hasValue); ok { + meta.ReadOnlySet = true + meta.ReadOnly = parsed + } + case "writeOnly": + if parsed, ok := parseTagBool(value, hasValue); ok { + meta.WriteOnlySet = true + meta.WriteOnly = parsed + } + case "deprecated": + if parsed, ok := parseTagBool(value, hasValue); ok { + meta.DeprecatedSet = true + meta.Deprecated = parsed + } + case "minimum": + meta.Minimum, meta.MinimumSet = parseTagFloat(value, hasValue) + case "maximum": + meta.Maximum, meta.MaximumSet = parseTagFloat(value, hasValue) + case "exclusiveMinimum": + meta.ExclusiveMinimum, meta.ExclusiveMinimumSet = parseTagFloat(value, hasValue) + case "exclusiveMaximum": + meta.ExclusiveMaximum, meta.ExclusiveMaximumSet = parseTagFloat(value, hasValue) + case "multipleOf": + meta.MultipleOf, meta.MultipleOfSet = parseTagFloat(value, hasValue) + case "minLength": + meta.MinLength, meta.MinLengthSet = parseTagInt(value, hasValue) + case "maxLength": + meta.MaxLength, meta.MaxLengthSet = parseTagInt(value, hasValue) + case "pattern": + meta.PatternSet = hasValue + meta.Pattern = value + case "minItems": + meta.MinItems, meta.MinItemsSet = parseTagInt(value, hasValue) + case "maxItems": + meta.MaxItems, meta.MaxItemsSet = parseTagInt(value, hasValue) + case "uniqueItems": + if parsed, ok := parseTagBool(value, hasValue); ok { + meta.UniqueItemsSet = true + meta.UniqueItems = parsed + } + case "minProperties": + meta.MinProperties, meta.MinPropertiesSet = parseTagInt(value, hasValue) + case "maxProperties": + meta.MaxProperties, meta.MaxPropertiesSet = parseTagInt(value, hasValue) + case "enum": + if hasValue { + meta.Enum = parseTagNodes(rawValue) + } + case "const": + if hasValue { + meta.Const = parseTagNode(rawValue) + } + } + } + return meta +} + +func (g *Generator) applyOpenAPIMetadata(ir *SchemaIR, meta openAPIMetadata) { + if ir == nil || !meta.Present { + return + } + ir.FieldMetadata = true + schema := ir.SourceSchema + if schema == nil { + schema = &highbase.Schema{} + ir.SourceSchema = schema + } + if meta.FormatSet { + ir.Format = meta.Format + schema.Format = meta.Format + } + if meta.TitleSet { + ir.Title = meta.Title + schema.Title = meta.Title + } + if meta.DescriptionSet { + ir.Description = meta.Description + schema.Description = meta.Description + } + if meta.NullableSet { + ir.Nullable = meta.Nullable + schema.Nullable = nil + } + if meta.ReadOnlySet { + ir.ReadOnly = meta.ReadOnly + schema.ReadOnly = boolPtr(meta.ReadOnly) + } + if meta.WriteOnlySet { + ir.WriteOnly = meta.WriteOnly + schema.WriteOnly = boolPtr(meta.WriteOnly) + } + if meta.DeprecatedSet { + ir.Deprecated = meta.Deprecated + schema.Deprecated = boolPtr(meta.Deprecated) + } + if meta.MinimumSet { + schema.Minimum = &meta.Minimum + } + if meta.MaximumSet { + schema.Maximum = &meta.Maximum + } + if meta.ExclusiveMinimumSet { + schema.ExclusiveMinimum = &highbase.DynamicValue[bool, float64]{N: 1, B: meta.ExclusiveMinimum} + } + if meta.ExclusiveMaximumSet { + schema.ExclusiveMaximum = &highbase.DynamicValue[bool, float64]{N: 1, B: meta.ExclusiveMaximum} + } + if meta.MultipleOfSet { + schema.MultipleOf = &meta.MultipleOf + } + if meta.MinLengthSet { + schema.MinLength = &meta.MinLength + } + if meta.MaxLengthSet { + schema.MaxLength = &meta.MaxLength + } + if meta.PatternSet { + schema.Pattern = meta.Pattern + } + if meta.MinItemsSet { + schema.MinItems = &meta.MinItems + } + if meta.MaxItemsSet { + schema.MaxItems = &meta.MaxItems + } + if meta.UniqueItemsSet { + schema.UniqueItems = &meta.UniqueItems + } + if meta.MinPropertiesSet { + schema.MinProperties = &meta.MinProperties + } + if meta.MaxPropertiesSet { + schema.MaxProperties = &meta.MaxProperties + } + if len(meta.Enum) > 0 { + ir.Enum = meta.Enum + schema.Enum = meta.Enum + } + if meta.Const != nil { + ir.Const = meta.Const + schema.Const = meta.Const + } +} + +func (g *Generator) openAPITagLiteral(ir *SchemaIR, fieldType string) string { + if !g.openapiTags || ir == nil { + return "" + } + var parts []string + add := func(key, value string) { + if value == "" || strings.Contains(value, "`") { + return + } + parts = append(parts, key+"="+escapeOpenAPITagValue(value)) + } + addBool := func(key string) { + parts = append(parts, key) + } + addInt := func(key string, value *int64) { + if value != nil { + parts = append(parts, key+"="+strconv.FormatInt(*value, 10)) + } + } + addFloat := func(key string, value *float64) { + if value != nil { + parts = append(parts, key+"="+strconv.FormatFloat(*value, 'g', -1, 64)) + } + } + if strings.HasPrefix(fieldType, "*") || ir.Nullable { + parts = append(parts, "nullable="+strconv.FormatBool(ir.Nullable)) + } + add("format", ir.Format) + add("title", ir.Title) + add("description", ir.Description) + if ir.ReadOnly { + addBool("readOnly") + } + if ir.WriteOnly { + addBool("writeOnly") + } + if ir.Deprecated { + addBool("deprecated") + } + if len(ir.Enum) > 0 { + encoded := encodeTagNodes(ir.Enum) + if encoded != "" { + parts = append(parts, "enum="+encoded) + } + } + if ir.Const != nil { + if encoded := encodeTagNode(ir.Const); encoded != "" { + parts = append(parts, "const="+encoded) + } + } + if schema := ir.SourceSchema; schema != nil { + addFloat("minimum", schema.Minimum) + addFloat("maximum", schema.Maximum) + if schema.ExclusiveMinimum != nil && schema.ExclusiveMinimum.IsB() { + value := schema.ExclusiveMinimum.B + addFloat("exclusiveMinimum", &value) + } + if schema.ExclusiveMaximum != nil && schema.ExclusiveMaximum.IsB() { + value := schema.ExclusiveMaximum.B + addFloat("exclusiveMaximum", &value) + } + addFloat("multipleOf", schema.MultipleOf) + addInt("minLength", schema.MinLength) + addInt("maxLength", schema.MaxLength) + add("pattern", schema.Pattern) + addInt("minItems", schema.MinItems) + addInt("maxItems", schema.MaxItems) + if schema.UniqueItems != nil { + parts = append(parts, "uniqueItems="+strconv.FormatBool(*schema.UniqueItems)) + } + addInt("minProperties", schema.MinProperties) + addInt("maxProperties", schema.MaxProperties) + } + return strings.Join(parts, ";") +} + +func parseTagBool(value string, hasValue bool) (bool, bool) { + if !hasValue { + return true, true + } + parsed, err := strconv.ParseBool(value) + return parsed, err == nil +} + +func parseTagFloat(value string, hasValue bool) (float64, bool) { + if !hasValue { + return 0, false + } + parsed, err := strconv.ParseFloat(value, 64) + return parsed, err == nil +} + +func parseTagInt(value string, hasValue bool) (int64, bool) { + if !hasValue { + return 0, false + } + parsed, err := strconv.ParseInt(value, 10, 64) + return parsed, err == nil +} + +func parseTagNodes(value string) []*yaml.Node { + tokens := splitEscaped(value, '|') + nodes := make([]*yaml.Node, 0, len(tokens)) + for _, token := range tokens { + if node := parseTagNode(token); node != nil { + nodes = append(nodes, node) + } + } + return nodes +} + +func parseTagNode(value string) *yaml.Node { + kind, raw, ok := strings.Cut(value, ":") + if !ok { + return stringNode(unescapeOpenAPITagValue(value)) + } + raw = unescapeOpenAPITagValue(raw) + switch kind { + case "str": + return stringNode(raw) + case "int": + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: raw} + case "float": + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: raw} + case "bool": + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: raw} + case "null": + return nullNode() + default: + return stringNode(unescapeOpenAPITagValue(value)) + } +} + +func encodeTagNodes(nodes []*yaml.Node) string { + values := make([]string, 0, len(nodes)) + for _, node := range nodes { + if encoded := encodeTagNode(node); encoded != "" { + values = append(values, encoded) + } + } + return strings.Join(values, "|") +} + +func encodeTagNode(node *yaml.Node) string { + if node == nil { + return "" + } + if nodeIsNull(node) { + return "null:" + } + if strings.Contains(node.Value, "`") { + return "" + } + prefix := "str:" + switch node.Tag { + case "!!int": + prefix = "int:" + case "!!float": + prefix = "float:" + case "!!bool": + prefix = "bool:" + } + return prefix + escapeOpenAPITagValue(node.Value) +} + +func splitEscaped(value string, sep rune) []string { + var out []string + var b strings.Builder + escaped := false + for _, r := range value { + if escaped { + b.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + b.WriteRune(r) + continue + } + if r == sep { + out = append(out, b.String()) + b.Reset() + continue + } + b.WriteRune(r) + } + out = append(out, b.String()) + return out +} + +func cutEscaped(value string, sep rune) (string, string, bool) { + escaped := false + for i, r := range value { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + if r == sep { + return value[:i], value[i+len(string(r)):], true + } + } + return value, "", false +} + +func escapeOpenAPITagValue(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `;`, `\;`) + value = strings.ReplaceAll(value, `=`, `\=`) + value = strings.ReplaceAll(value, `|`, `\|`) + return value +} + +func unescapeOpenAPITagValue(value string) string { + var b strings.Builder + escaped := false + for _, r := range value { + if escaped { + b.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + b.WriteRune(r) + } + if escaped { + b.WriteByte('\\') + } + return b.String() +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/generator/golang/metadata_test.go b/generator/golang/metadata_test.go new file mode 100644 index 00000000..c17fc11b --- /dev/null +++ b/generator/golang/metadata_test.go @@ -0,0 +1,1028 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "errors" + "go/parser" + "go/token" + "reflect" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +type MetadataTaggedModel struct { + ID string `json:"id" openapi:"format=uuid;description=identifier;readOnly"` + Secret string `json:"secret" openapi:"writeOnly;deprecated=false"` + Old string `json:"old" openapi:"deprecated"` + Optional *string `json:"optional,omitempty" openapi:"nullable=false;minLength=3;maxLength=4;pattern=^[a-z]+$"` + Amount *float64 `json:"amount,omitempty" openapi:"nullable=false;minimum=1;maximum=10;exclusiveMinimum=0;exclusiveMaximum=11;multipleOf=0.5"` + Tags []string `json:"tags,omitempty" openapi:"minItems=1;maxItems=3;uniqueItems=true"` + Extras map[string]string `json:"extras,omitempty" openapi:"minProperties=1;maxProperties=2"` + Status string `json:"status" openapi:"enum=str:pending|str:done|int:7|float:1.5|bool:true|null:"` + Kind *string `json:"kind,omitempty" openapi:"const=str:card;nullable=false"` +} + +type MetadataQuotedTagModel struct { + Value string `json:"value" openapi:"description=quote \"inside\" metadata;pattern=^\"[a-z]+\"$;enum=str:\"red\"|str:blue;const=str:\"red\""` +} + +type MetadataFieldOverrideUnion struct { + Value any `json:"value"` +} + +type MetadataFieldOverride struct { + Source MetadataFieldOverrideUnion `json:"source"` + Alt string `json:"alt"` +} + +type MetadataNullableFieldOverride struct { + Source *MetadataFieldOverrideUnion `json:"source,omitempty"` +} + +type MetadataSchemaProvider struct{} + +func (*MetadataSchemaProvider) OpenAPISchema() *highbase.SchemaProxy { + return highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: orderedmap.ToOrderedMap(map[string]*highbase.SchemaProxy{ + "code": highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Format: "uuid", + }), + }), + Required: []string{"code"}, + }) +} + +type MetadataSchemaProviderHolder struct { + Provider *MetadataSchemaProvider `json:"provider,omitempty"` +} + +var metadataCountingSchemaProviderCalls int + +type MetadataCountingSchemaProvider struct{} + +func (*MetadataCountingSchemaProvider) OpenAPISchema() *highbase.SchemaProxy { + metadataCountingSchemaProviderCalls++ + return highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}}) +} + +type MetadataCountingSchemaProviderHolder struct { + Provider *MetadataCountingSchemaProvider `json:"provider,omitempty"` +} + +type MetadataYAMLProvider struct{} + +func (*MetadataYAMLProvider) OpenAPISchemaYAML() string { + return "type: object\nproperties:\n code:\n type: string\n format: uuid\nrequired:\n - code\n" +} + +type MetadataBadYAMLProvider struct{} + +func (*MetadataBadYAMLProvider) OpenAPISchemaYAML() string { + return "type: [" +} + +type MetadataTypedProvider struct{} + +func (*MetadataTypedProvider) OpenAPISchemaMetadata() any { + return &providerSchemaMetadata{ + Type: []string{"object"}, + Required: []string{"code"}, + Properties: []providerNamedSchemaMetadata{{ + Name: "code", + Schema: &providerSchemaMetadata{ + Type: []string{"string"}, + Format: "uuid", + }, + }}, + AdditionalProperties: &providerDynamicSchemaBool{Bool: &providerBool{Value: false}}, + } +} + +type MetadataBadTypedProvider struct{} + +func (*MetadataBadTypedProvider) OpenAPISchemaMetadata() any { + return func() {} +} + +type MetadataInvalidTypedProvider struct{} + +func (*MetadataInvalidTypedProvider) OpenAPISchemaMetadata() any { + return map[string]any{"Type": 7} +} + +type MetadataProviderHolder struct { + Provider *MetadataYAMLProvider `json:"provider,omitempty"` +} + +type MetadataTypedProviderHolder struct { + Provider *MetadataTypedProvider `json:"provider,omitempty"` +} + +func TestOpenAPITagMetadataReflectsIntoSchema(t *testing.T) { + set, err := SchemasFromTypes(reflect.TypeOf(MetadataTaggedModel{})) + if err != nil { + t.Fatal(err) + } + root := componentSchema(t, set, "MetadataTaggedModel") + id := root.Properties.GetOrZero("id").Schema() + if id.Format != "uuid" || id.Description != "identifier" || id.ReadOnly == nil || !*id.ReadOnly { + t.Fatalf("id metadata not reflected: %#v", id) + } + secret := root.Properties.GetOrZero("secret").Schema() + if secret.WriteOnly == nil || !*secret.WriteOnly || secret.Deprecated == nil || *secret.Deprecated { + t.Fatalf("secret metadata not reflected: %#v", secret) + } + old := root.Properties.GetOrZero("old").Schema() + if old.Deprecated == nil || !*old.Deprecated { + t.Fatalf("deprecated metadata not reflected: %#v", old) + } + optional := root.Properties.GetOrZero("optional").Schema() + if schemaTypeContains(optional.Type, "null") || optional.MinLength == nil || *optional.MinLength != 3 || optional.MaxLength == nil || *optional.MaxLength != 4 || optional.Pattern != "^[a-z]+$" { + t.Fatalf("optional metadata not reflected: %#v", optional) + } + amount := root.Properties.GetOrZero("amount").Schema() + if schemaTypeContains(amount.Type, "null") || amount.Minimum == nil || *amount.Minimum != 1 || amount.Maximum == nil || *amount.Maximum != 10 { + t.Fatalf("amount range metadata not reflected: %#v", amount) + } + if amount.ExclusiveMinimum == nil || !amount.ExclusiveMinimum.IsB() || amount.ExclusiveMinimum.B != 0 { + t.Fatalf("exclusive minimum not reflected: %#v", amount.ExclusiveMinimum) + } + if amount.ExclusiveMaximum == nil || !amount.ExclusiveMaximum.IsB() || amount.ExclusiveMaximum.B != 11 { + t.Fatalf("exclusive maximum not reflected: %#v", amount.ExclusiveMaximum) + } + if amount.MultipleOf == nil || *amount.MultipleOf != 0.5 { + t.Fatalf("multipleOf not reflected: %#v", amount.MultipleOf) + } + tags := root.Properties.GetOrZero("tags").Schema() + if tags.MinItems == nil || *tags.MinItems != 1 || tags.MaxItems == nil || *tags.MaxItems != 3 || tags.UniqueItems == nil || !*tags.UniqueItems { + t.Fatalf("array metadata not reflected: %#v", tags) + } + extras := root.Properties.GetOrZero("extras").Schema() + if extras.MinProperties == nil || *extras.MinProperties != 1 || extras.MaxProperties == nil || *extras.MaxProperties != 2 { + t.Fatalf("object metadata not reflected: %#v", extras) + } + status := root.Properties.GetOrZero("status").Schema() + if len(status.Enum) != 6 || status.Enum[2].Tag != "!!int" || status.Enum[5].Tag != "!!null" { + t.Fatalf("enum metadata not reflected: %#v", status.Enum) + } + kind := root.Properties.GetOrZero("kind").Schema() + if schemaTypeContains(kind.Type, "null") || kind.Const == nil || kind.Const.Value != "card" { + t.Fatalf("const metadata not reflected: %#v", kind) + } +} + +func TestOpenAPITagQuotedMetadataRoundTrips(t *testing.T) { + schema := highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Description: "quoted tag test", + Properties: orderedmap.ToOrderedMap(map[string]*highbase.SchemaProxy{ + "value": highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Description: `quote "inside" metadata`, + Pattern: `^"[a-z]+"$`, + Enum: []*yaml.Node{stringNode(`"red"`), stringNode("blue")}, + Const: stringNode(`"red"`), + }), + }), + }) + source, err := RenderSchema("Quoted", schema, WithOpenAPITags(true)) + if err != nil { + t.Fatal(err) + } + src := string(source) + assertContains(t, src, `description=quote \"inside\" metadata`) + assertContains(t, src, `pattern=^\"[a-z]+\"$`) + assertContains(t, src, `enum=str:\"red\"|str:blue`) + assertContains(t, src, `const=str:\"red\"`) + if _, err := parser.ParseFile(token.NewFileSet(), "quoted.go", source, parser.AllErrors); err != nil { + t.Fatalf("quoted tag source should parse: %v\n%s", err, source) + } + assertParsesCompilesAndTests(t, source, `package models + +import ( + "reflect" + "strings" + "testing" +) + +func TestGeneratedQuotedOpenAPITag(t *testing.T) { + field, ok := reflect.TypeOf(Quoted{}).FieldByName("Value") + if !ok { + t.Fatal("missing generated field") + } + tag := field.Tag.Get("openapi") + for _, want := range []string{ + "description=quote \"inside\" metadata", + "pattern=^\"[a-z]+\"$", + "enum=str:\"red\"|str:blue", + "const=str:\"red\"", + } { + if !strings.Contains(tag, want) { + t.Fatalf("generated tag missing %q in %q", want, tag) + } + } +} +`) + + field, ok := reflect.TypeOf(MetadataQuotedTagModel{}).FieldByName("Value") + if !ok { + t.Fatal("missing quoted tag field") + } + rawTag := field.Tag.Get("openapi") + for _, want := range []string{ + `description=quote "inside" metadata`, + `pattern=^"[a-z]+"$`, + `enum=str:"red"|str:blue`, + `const=str:"red"`, + } { + if !strings.Contains(rawTag, want) { + t.Fatalf("reflect.StructTag.Get truncated or corrupted %q in %q", want, rawTag) + } + } + + set, err := SchemasFromTypes(reflect.TypeOf(MetadataQuotedTagModel{})) + if err != nil { + t.Fatal(err) + } + prop := componentSchema(t, set, "MetadataQuotedTagModel").Properties.GetOrZero("value").Schema() + if prop.Description != `quote "inside" metadata` || prop.Pattern != `^"[a-z]+"$` { + t.Fatalf("quoted metadata did not reflect: %#v", prop) + } + if len(prop.Enum) != 2 || prop.Enum[0].Value != `"red"` || prop.Const == nil || prop.Const.Value != `"red"` { + t.Fatalf("quoted enum/const metadata did not reflect: %#v", prop) + } +} + +func TestFieldSchemaOverridesAndSchemaYAMLProvider(t *testing.T) { + sourceSchema := schemaProxyFromYAML(t, ` +oneOf: + - type: object + properties: + object: + type: string + const: card + required: + - object +`) + altSchema := highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}, Format: "uuid"}) + set, err := NewGenerator( + WithFieldSchema(reflect.TypeOf(MetadataFieldOverride{}), "Source", sourceSchema), + WithFieldSchemaByJSONName(reflect.TypeOf(MetadataFieldOverride{}), "alt", altSchema), + ).SchemasFromTypes(reflect.TypeOf(MetadataFieldOverride{})) + if err != nil { + t.Fatal(err) + } + root := componentSchema(t, set, "MetadataFieldOverride") + source := root.Properties.GetOrZero("source").Schema() + if len(source.OneOf) != 1 { + t.Fatalf("field schema override did not preserve oneOf: %#v", source) + } + alt := root.Properties.GetOrZero("alt").Schema() + if alt.Format != "uuid" { + t.Fatalf("json-name field schema override did not apply: %#v", alt) + } + nullableSet, err := NewGenerator( + WithFieldSchema(reflect.TypeOf(MetadataNullableFieldOverride{}), "Source", sourceSchema), + ).SchemasFromTypes(reflect.TypeOf(MetadataNullableFieldOverride{})) + if err != nil { + t.Fatal(err) + } + nullableRoot := componentSchema(t, nullableSet, "MetadataNullableFieldOverride") + nullableSource := nullableRoot.Properties.GetOrZero("source").Schema() + if nullableSource == nil || len(nullableSource.AnyOf) != 2 { + t.Fatalf("nullable composed field should render as anyOf original/null, got %#v", nullableSource) + } + if original := nullableSource.AnyOf[0].Schema(); original == nil || len(original.OneOf) != 1 { + t.Fatalf("nullable composed field should preserve original oneOf branch, got %#v", original) + } + if nullSchema := nullableSource.AnyOf[1].Schema(); nullSchema == nil || !schemaTypeContains(nullSchema.Type, "null") { + t.Fatalf("nullable composed field should include native null branch, got %#v", nullSchema) + } + + providerSet, err := SchemasFromTypes(reflect.TypeOf(MetadataYAMLProvider{})) + if err != nil { + t.Fatal(err) + } + provider := componentSchema(t, providerSet, "MetadataYamlProvider") + code := provider.Properties.GetOrZero("code").Schema() + if code == nil || code.Format != "uuid" || !containsString(provider.Required, "code") { + t.Fatalf("schema YAML provider did not reflect: %#v", provider) + } + if proxy, err := schemaProxyFromProviderYAML("", "type: string\n"); err != nil || proxy.Schema().Type[0] != "string" { + t.Fatalf("provider yaml helper failed: %#v %v", proxy, err) + } + metadataSet, err := SchemasFromTypes(reflect.TypeOf(MetadataTypedProvider{})) + if err != nil { + t.Fatal(err) + } + metadataProvider := componentSchema(t, metadataSet, "MetadataTypedProvider") + metadataCode := metadataProvider.Properties.GetOrZero("code").Schema() + if metadataCode == nil || metadataCode.Format != "uuid" || !containsString(metadataProvider.Required, "code") { + t.Fatalf("schema metadata provider did not reflect: %#v", metadataProvider) + } + metadataHolderSet, err := SchemasFromTypes(reflect.TypeOf(MetadataTypedProviderHolder{})) + if err != nil { + t.Fatal(err) + } + metadataHolder := componentSchema(t, metadataHolderSet, "MetadataTypedProviderHolder") + if prop := metadataHolder.Properties.GetOrZero("provider").Schema(); prop == nil || len(prop.AnyOf) != 2 { + t.Fatalf("nullable schema metadata provider should render as anyOf ref/null: %#v", metadataHolder.Properties.GetOrZero("provider")) + } + holderSet, err := SchemasFromTypes(reflect.TypeOf(MetadataProviderHolder{})) + if err != nil { + t.Fatal(err) + } + holder := componentSchema(t, holderSet, "MetadataProviderHolder") + if prop := holder.Properties.GetOrZero("provider").Schema(); prop == nil || len(prop.AnyOf) != 2 { + t.Fatalf("nullable schema yaml provider should render as anyOf ref/null: %#v", holder.Properties.GetOrZero("provider")) + } + if _, err := SchemasFromTypes(reflect.TypeOf(MetadataBadYAMLProvider{})); err == nil { + t.Fatal("invalid schema yaml provider should return an error") + } + if _, err := schemaProxyFromProviderYAML("Broken", "type: ["); err == nil { + t.Fatal("invalid provider yaml helper should fail") + } + if _, err := SchemasFromTypes(reflect.TypeOf(MetadataBadTypedProvider{})); err == nil { + t.Fatal("bad schema metadata provider should return an error") + } + if _, err := SchemasFromTypes(reflect.TypeOf(MetadataInvalidTypedProvider{})); err == nil { + t.Fatal("invalid schema metadata provider should return an error") + } +} + +func TestProviderSchemasReuseCanonicalNamesAcrossRootsAndFields(t *testing.T) { + cases := []struct { + holderName string + holderType reflect.Type + providerName string + providerType reflect.Type + }{ + { + holderName: "MetadataSchemaProviderHolder", + holderType: reflect.TypeOf(MetadataSchemaProviderHolder{}), + providerName: "MetadataSchemaProvider", + providerType: reflect.TypeOf(MetadataSchemaProvider{}), + }, + { + holderName: "MetadataProviderHolder", + holderType: reflect.TypeOf(MetadataProviderHolder{}), + providerName: "MetadataYamlProvider", + providerType: reflect.TypeOf(MetadataYAMLProvider{}), + }, + { + holderName: "MetadataTypedProviderHolder", + holderType: reflect.TypeOf(MetadataTypedProviderHolder{}), + providerName: "MetadataTypedProvider", + providerType: reflect.TypeOf(MetadataTypedProvider{}), + }, + } + for _, tc := range cases { + t.Run(tc.providerName, func(t *testing.T) { + set, err := SchemasFromTypes(tc.holderType, tc.providerType) + if err != nil { + t.Fatal(err) + } + if root, ok := set.Roots.Get(tc.providerName); !ok || !root.IsReference() || root.GetReference() != "#/components/schemas/"+tc.providerName { + t.Fatalf("provider root should use canonical provider component name, got %#v", root) + } + if _, ok := set.Components.Get(tc.providerName); !ok { + t.Fatalf("provider component %q missing from %#v", tc.providerName, set.Components) + } + fieldDerivedName := NewGenerator().nestedTypeName(tc.holderName, "provider") + if _, ok := set.Components.Get(fieldDerivedName); ok { + t.Fatalf("field-derived provider component %q should not be emitted", fieldDerivedName) + } + holder := componentSchema(t, set, tc.holderName) + assertNullableRef(t, holder.Properties.GetOrZero("provider"), "#/components/schemas/"+tc.providerName) + }) + } + + metadataCountingSchemaProviderCalls = 0 + if _, err := SchemasFromTypes(reflect.TypeOf(MetadataCountingSchemaProvider{}), reflect.TypeOf(MetadataCountingSchemaProviderHolder{})); err != nil { + t.Fatal(err) + } + if metadataCountingSchemaProviderCalls != 1 { + t.Fatalf("cached provider schema should be reused for repeated provider type, got %d calls", metadataCountingSchemaProviderCalls) + } +} + +func TestGeneratedOpenAPITagsAndProviderMethods(t *testing.T) { + file := renderTrainTravel(t, + WithOpenAPITags(true), + WithSchemaMetadataSidecar(true), + WithOptionalConstDiscriminatorUnions(true), + ) + source := string(file.Source) + assertContains(t, source, `openapi:"format=uuid"`) + assertContains(t, source, `openapi:"writeOnly;minLength=3;maxLength=4"`) + assertNotContains(t, source, "var openAPISchemas = map[string]*openAPISchemaMetadata") + assertNotContains(t, source, "OpenAPISchemaMetadata") + if file.SchemaMetadata == nil { + t.Fatal("expected schema metadata sidecar") + } + if file.SchemaMetadata.Name != SchemaMetadataFileName { + t.Fatalf("unexpected schema metadata file name %q", file.SchemaMetadata.Name) + } + metadataSource := string(file.SchemaMetadata.Source) + assertContains(t, metadataSource, "var openAPISchemas = map[string]*openAPISchemaMetadata") + assertContains(t, metadataSource, "func (Station) OpenAPISchemaMetadata() any") + assertContains(t, metadataSource, "func (BookingPayment_SourceUnion) OpenAPISchemaMetadata() any") + if strings.Contains(metadataSource, "OpenAPISchemaYAML") { + t.Fatal("generated sidecar should not emit yaml provider methods") + } + assertParsesCompilesAndTestsWithFiles(t, map[string][]byte{ + "models.go": file.Source, + file.SchemaMetadata.Name: file.SchemaMetadata.Source, + }, "package models\n\nimport \"testing\"\n\nfunc TestGeneratedPackage(t *testing.T) {}\n") + + withoutSidecar := renderTrainTravel(t, WithOpenAPITags(true)) + if withoutSidecar.SchemaMetadata != nil { + t.Fatal("schema metadata sidecar should be disabled unless requested") + } + if strings.Contains(string(withoutSidecar.Source), "OpenAPISchemaMetadata") || strings.Contains(string(withoutSidecar.Source), "openAPISchemas") { + t.Fatal("schema metadata sidecar should be disabled unless requested") + } +} + +func TestSchemaMetadataSidecarFileHeaderAndRenderError(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("Sample", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + file, err := NewGenerator( + WithHeaderComment("Schema metadata header."), + WithSchemaMetadataSidecar(true), + ).RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + if file.SchemaMetadata == nil { + t.Fatal("expected schema metadata sidecar") + } + assertContains(t, string(file.SchemaMetadata.Source), "// Schema metadata header.") + + _, err = NewGenerator( + WithSchemaMetadataSidecar(true), + WithTypeNameResolver(func(string) string { return "Bad Type" }), + ).RenderSchemas(schemas) + if err == nil { + t.Fatal("expected invalid sidecar source to return an error") + } + + oldFormatSource := formatSource + formatSource = func(src []byte) ([]byte, error) { + if strings.Contains(string(src), "openAPISchemas") { + return nil, errors.New("sidecar format failed") + } + return oldFormatSource(src) + } + defer func() { + formatSource = oldFormatSource + }() + _, err = NewGenerator(WithSchemaMetadataSidecar(true)).RenderSchemas(schemas) + if err == nil { + t.Fatal("expected sidecar formatting error") + } +} + +func TestSchemaMetadataSidecarTypedDataCoversJSONSchemaKeywords(t *testing.T) { + zeroFloat := float64(0) + oneFloat := float64(1) + tenFloat := float64(10) + zeroInt := int64(0) + oneInt := int64(1) + twoInt := int64(2) + trueValue := true + falseValue := false + discriminatorMapping := orderedmap.New[string, string]() + discriminatorMapping.Set("card", "#/components/schemas/Card") + vocabulary := orderedmap.New[string, bool]() + vocabulary.Set("https://json-schema.org/draft/2020-12/vocab/core", true) + properties := orderedmap.New[string, *highbase.SchemaProxy]() + properties.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}, Format: "uuid"})) + patternProperties := orderedmap.New[string, *highbase.SchemaProxy]() + patternProperties.Set("^x-", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + dependentSchemas := orderedmap.New[string, *highbase.SchemaProxy]() + dependentSchemas.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Required: []string{"kind"}})) + dependentRequired := orderedmap.New[string, []string]() + dependentRequired.Set("id", []string{"kind"}) + extensions := orderedmap.New[string, *yaml.Node]() + extensions.Set("x-test", stringNode("extension")) + defaultNode := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + stringNode("enabled"), + {Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}, + }, + } + aliasTarget := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "alias-target", Anchor: "target"} + schema := &highbase.Schema{ + SchemaTypeRef: "https://json-schema.org/draft/2020-12/schema", + ExclusiveMaximum: &highbase.DynamicValue[bool, float64]{A: true}, + ExclusiveMinimum: &highbase.DynamicValue[bool, float64]{N: 1, B: zeroFloat}, + Type: []string{"object"}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxyRef("#/components/schemas/Base")}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"null"}})}, + Discriminator: &highbase.Discriminator{ + PropertyName: "kind", + Mapping: discriminatorMapping, + DefaultMapping: "#/components/schemas/Fallback", + }, + Examples: []*yaml.Node{stringNode("example")}, + PrefixItems: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"integer"}})}, + Contains: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}}), + MinContains: &zeroInt, + MaxContains: &twoInt, + If: highbase.CreateSchemaProxy(&highbase.Schema{Required: []string{"kind"}}), + Else: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}}), + Then: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}}), + DependentSchemas: dependentSchemas, + DependentRequired: dependentRequired, + PatternProperties: patternProperties, + PropertyNames: highbase.CreateSchemaProxy(&highbase.Schema{Pattern: "^[a-z]+$"}), + UnevaluatedItems: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"boolean"}}), + UnevaluatedProperties: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{N: 1, B: false}, + Items: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{A: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})}, + Id: "https://example.com/schema", + Anchor: "root", + DynamicAnchor: "node", + DynamicRef: "#node", + Comment: "comment", + ContentSchema: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}}), + Vocabulary: vocabulary, + Not: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"null"}}), + Properties: properties, + Title: "Typed Metadata", + MultipleOf: &oneFloat, + Maximum: &tenFloat, + Minimum: &zeroFloat, + MaxLength: &twoInt, + MinLength: &zeroInt, + Pattern: "^[a-z]+$", + Format: "uuid", + MaxItems: &twoInt, + MinItems: &zeroInt, + UniqueItems: &trueValue, + MaxProperties: &twoInt, + MinProperties: &oneInt, + Required: []string{"id"}, + Enum: []*yaml.Node{stringNode("a"), nullNode()}, + AdditionalProperties: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{A: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"number"}})}, + Description: "description", + ContentEncoding: "base64", + ContentMediaType: "application/json", + Default: defaultNode, + Const: stringNode("constant"), + Nullable: &falseValue, + ReadOnly: &trueValue, + WriteOnly: &falseValue, + Example: &yaml.Node{Kind: yaml.AliasNode, Alias: aliasTarget}, + Deprecated: &trueValue, + Extensions: extensions, + } + + gen := NewGenerator(WithSchemaMetadataSidecar(true)) + gen.recordSchemaMetadata("Sample", schema) + sidecar := gen.renderSchemaMetadataSidecarDecl() + if sidecar == "" { + t.Fatal("expected metadata sidecar declaration") + } + for _, want := range []string{"SchemaTypeRef", "ExclusiveMaximum", "DependentRequired", "UnevaluatedProperties", "ContentMediaType", "Extensions"} { + assertContains(t, sidecar, want) + } + + proxy, err := schemaProxyFromProviderMetadata(&providerSchemaMetadata{ + SchemaTypeRef: "https://json-schema.org/draft/2020-12/schema", + Type: []string{"object"}, + AllOf: []*providerSchemaMetadata{{Ref: "#/components/schemas/Base"}}, + OneOf: []*providerSchemaMetadata{{Type: []string{"string"}}}, + AnyOf: []*providerSchemaMetadata{{Type: []string{"null"}}}, + Discriminator: &providerDiscriminatorMetadata{PropertyName: "kind", Mapping: []providerStringString{{Name: "card", Value: "#/components/schemas/Card"}}, DefaultMapping: "#/components/schemas/Fallback"}, + Examples: []*providerYAMLNode{{Kind: "sequence", Tag: "!!seq", Content: []*providerYAMLNode{{Kind: "scalar", Tag: "!!str", Value: "example"}}}}, + PrefixItems: []*providerSchemaMetadata{{Type: []string{"integer"}}}, + Contains: &providerSchemaMetadata{Type: []string{"string"}}, + MinContains: &providerInt{Value: 0}, + MaxContains: &providerInt{Value: 1}, + If: &providerSchemaMetadata{Required: []string{"kind"}}, + Else: &providerSchemaMetadata{Type: []string{"object"}}, + Then: &providerSchemaMetadata{Type: []string{"object"}}, + DependentSchemas: []providerNamedSchemaMetadata{{Name: "id", Schema: &providerSchemaMetadata{Required: []string{"kind"}}}}, + DependentRequired: []providerStringList{{ + Name: "id", + Values: []string{"kind"}, + }}, + PatternProperties: []providerNamedSchemaMetadata{{Name: "^x-", Schema: &providerSchemaMetadata{Type: []string{"string"}}}}, + PropertyNames: &providerSchemaMetadata{Pattern: "^[a-z]+$"}, + UnevaluatedItems: &providerSchemaMetadata{Type: []string{"boolean"}}, + Properties: []providerNamedSchemaMetadata{{ + Name: "id", + Schema: &providerSchemaMetadata{ + Type: []string{"string"}, + Format: "uuid", + }, + }}, + Required: []string{"id"}, + AdditionalProperties: &providerDynamicSchemaBool{Bool: &providerBool{Value: false}}, + UnevaluatedProperties: &providerDynamicSchemaBool{Schema: &providerSchemaMetadata{Type: []string{"string"}}}, + ExclusiveMaximum: &providerDynamicBoolNumber{Bool: &providerBool{Value: true}}, + ExclusiveMinimum: &providerDynamicBoolNumber{Number: &providerFloat{Value: 0}}, + ID: "https://example.com/schema", + Anchor: "root", + DynamicAnchor: "node", + DynamicRef: "#node", + Comment: "comment", + ContentSchema: &providerSchemaMetadata{Type: []string{"object"}}, + Vocabulary: []providerStringBool{{Name: "https://json-schema.org/draft/2020-12/vocab/core", Value: true}}, + Not: &providerSchemaMetadata{Type: []string{"null"}}, + Title: "Typed Metadata", + MultipleOf: &providerFloat{Value: 1}, + Maximum: &providerFloat{Value: 10}, + Minimum: &providerFloat{Value: 0}, + MinLength: &providerInt{Value: 0}, + MaxLength: &providerInt{Value: 2}, + Pattern: "^[a-z]+$", + MaxItems: &providerInt{Value: 2}, + MinItems: &providerInt{Value: 0}, + UniqueItems: &providerBool{Value: true}, + MaxProperties: &providerInt{Value: 2}, + MinProperties: &providerInt{Value: 1}, + Enum: []*providerYAMLNode{{Kind: "scalar", Tag: "!!str", Value: "a"}}, + Description: "description", + ContentEncoding: "base64", + ContentMediaType: "application/json", + Default: &providerYAMLNode{ + Kind: "mapping", + Tag: "!!map", + Value: "", + Content: []*providerYAMLNode{ + {Kind: "scalar", Tag: "!!str", Value: "enabled"}, + {Kind: "scalar", Tag: "!!bool", Value: "true"}, + }, + }, + Const: &providerYAMLNode{Kind: "document", Content: []*providerYAMLNode{{Kind: "scalar", Tag: "!!str", Value: "constant"}}}, + Nullable: &providerBool{Value: false}, + ReadOnly: &providerBool{Value: true}, + WriteOnly: &providerBool{Value: false}, + Example: &providerYAMLNode{Kind: "alias", Alias: &providerYAMLNode{Kind: "scalar", Tag: "!!str", Value: "alias-target", Anchor: "target"}}, + Deprecated: &providerBool{Value: true}, + Extensions: []providerNamedYAMLNode{{ + Name: "x-test", + Value: &providerYAMLNode{Kind: "scalar", Tag: "!!str", Value: "extension"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + roundTrip := proxy.Schema() + if roundTrip.Properties.GetOrZero("id").Schema().Format != "uuid" || roundTrip.AdditionalProperties == nil || !roundTrip.AdditionalProperties.IsB() || roundTrip.MinLength == nil || *roundTrip.MinLength != 0 { + t.Fatalf("typed provider metadata did not convert: %#v", roundTrip) + } + refProxy := schemaProxyFromMetadata(&providerSchemaMetadata{ + Ref: "#/components/schemas/Target", + Description: "sibling", + }) + if !refProxy.IsReference() || refProxy.Schema().Description != "sibling" { + t.Fatalf("ref sibling metadata did not convert: %#v", refProxy) + } +} + +func TestMetadataHelpersCoverage(t *testing.T) { + meta := parseOpenAPITag(`;format=uuid;title=Example;description=a\;b\=c\|d;nullable=maybe;minimum;maximum=bad;minLength;maxLength=bad;uniqueItems=maybe;readOnly=false;unknown=value;`) + if !meta.Present || meta.NullableSet || meta.MinimumSet || meta.MaximumSet || meta.MinLengthSet || meta.MaxLengthSet || meta.UniqueItemsSet { + t.Fatalf("invalid tag values should be ignored: %#v", meta) + } + enumPipe := parseOpenAPITag(`enum=str:a\|b|str:c`) + if len(enumPipe.Enum) != 2 || enumPipe.Enum[0].Value != "a|b" || enumPipe.Enum[1].Value != "c" { + t.Fatalf("escaped enum separator should survive splitting: %#v", enumPipe.Enum) + } + if got := unescapeOpenAPITagValue(`trailing\`); got != `trailing\` { + t.Fatalf("trailing escape not preserved: %q", got) + } + gen := NewGenerator() + if tag := gen.openAPITagLiteral(nil, "string"); tag != "" { + t.Fatalf("nil ir should not render openapi tag: %q", tag) + } + if tag := gen.openAPITagLiteral(&SchemaIR{Kind: KindString}, "string"); tag != "" { + t.Fatalf("disabled openapi tags should not render: %q", tag) + } + gen = NewGenerator(WithOpenAPITags(true)) + tag := gen.openAPITagLiteral(&SchemaIR{ + Kind: KindString, + Title: "bad`title", + Description: "", + }, "string") + if strings.Contains(tag, "bad`title") { + t.Fatalf("unsafe backtick tag value should be skipped: %q", tag) + } + min := float64(1) + max := float64(10) + multiple := float64(0.5) + minLen := int64(2) + maxLen := int64(8) + minItems := int64(1) + maxItems := int64(3) + unique := true + minProps := int64(1) + maxProps := int64(4) + tag = gen.openAPITagLiteral(&SchemaIR{ + Kind: KindString, + Nullable: true, + Format: "uuid", + Title: "Title", + Description: "Description", + ReadOnly: true, + WriteOnly: true, + Deprecated: true, + Enum: []*yaml.Node{stringNode("a")}, + Const: stringNode("a"), + SourceSchema: &highbase.Schema{ + Minimum: &min, + Maximum: &max, + MultipleOf: &multiple, + MinLength: &minLen, + MaxLength: &maxLen, + Pattern: "^[a]$", + MinItems: &minItems, + MaxItems: &maxItems, + UniqueItems: &unique, + MinProperties: &minProps, + MaxProperties: &maxProps, + ExclusiveMinimum: &highbase.DynamicValue[bool, float64]{ + N: 1, + B: min, + }, + ExclusiveMaximum: &highbase.DynamicValue[bool, float64]{ + N: 1, + B: max, + }, + }, + }, "string") + for _, want := range []string{"nullable=true", "title=Title", "description=Description", "readOnly", "writeOnly", "deprecated", "enum=str:a", "const=str:a", "minimum=1", "exclusiveMaximum=10", "maxProperties=4"} { + if !strings.Contains(tag, want) { + t.Fatalf("tag %q missing %q", tag, want) + } + } + tag = gen.openAPITagLiteral(&SchemaIR{ + Kind: KindString, + Enum: []*yaml.Node{stringNode("a|b"), stringNode("safe"), stringNode("bad`tick")}, + Const: stringNode("bad`tick"), + }, "string") + if !strings.Contains(tag, `enum=str:a\|b|str:safe`) || strings.Contains(tag, "bad`tick") || strings.Contains(tag, "const=") { + t.Fatalf("tag should keep escaped safe enum values and skip unsafe enum/const values: %q", tag) + } + parsedSafe := parseOpenAPITag(tag) + if len(parsedSafe.Enum) != 2 || parsedSafe.Enum[0].Value != "a|b" || parsedSafe.Enum[1].Value != "safe" || parsedSafe.Const != nil { + t.Fatalf("safe enum tag should parse after unsafe values are skipped: %#v", parsedSafe) + } + if node := parseTagNode("bare"); node == nil || node.Value != "bare" { + t.Fatalf("bare tag node not parsed: %#v", node) + } + if node := parseTagNode("weird:value"); node == nil || node.Value != "weird:value" { + t.Fatalf("unknown tag node kind should round-trip as string: %#v", node) + } + if key, value, ok := cutEscaped(`novalue`, '='); ok || key != "novalue" || value != "" { + t.Fatalf("cut without separator failed: %q %q %v", key, value, ok) + } + if encoded := encodeTagNodes([]*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}, + {Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.5"}, + {Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}, + {Kind: yaml.ScalarNode, Tag: "!!unknown", Value: "fallback"}, + nullNode(), + }); encoded != "int:1|float:1.5|bool:true|str:fallback|null:" { + t.Fatalf("typed tag nodes not encoded: %q", encoded) + } + if encoded := encodeTagNode(nil); encoded != "" { + t.Fatalf("nil tag node should not encode: %q", encoded) + } + if encoded := encodeTagNode(stringNode("bad`tick")); encoded != "" { + t.Fatalf("unsafe tag node should not encode: %q", encoded) + } + if key, value, ok := cutEscaped(`a\=b=c`, '='); !ok || key != `a\=b` || value != "c" { + t.Fatalf("escaped cut failed: %q %q %v", key, value, ok) + } + if key, value, ok := cutEscaped(`a=b`, '='); !ok || key != "a" || value != "b" { + t.Fatalf("plain cut failed: %q %q %v", key, value, ok) + } + var schema highbase.Schema + applyIRBooleans(nil, nil) + applyIRBooleans(&schema, &SchemaIR{ReadOnly: true, WriteOnly: true, Deprecated: true}) + if schema.ReadOnly == nil || schema.WriteOnly == nil || schema.Deprecated == nil { + t.Fatalf("ir booleans not applied: %#v", schema) + } + NewGenerator().applyOpenAPIMetadata(nil, openAPIMetadata{Present: true}) + NewGenerator().applyOpenAPIMetadata(&SchemaIR{}, openAPIMetadata{}) + var tagged SchemaIR + NewGenerator().applyOpenAPIMetadata(&tagged, openAPIMetadata{ + Present: true, + FormatSet: true, + Format: "uuid", + TitleSet: true, + Title: "Title", + DescriptionSet: true, + Description: "Description", + NullableSet: true, + Nullable: true, + ReadOnlySet: true, + WriteOnlySet: true, + DeprecatedSet: true, + MinimumSet: true, + Minimum: 1, + MaximumSet: true, + Maximum: 10, + ExclusiveMinimumSet: true, + ExclusiveMinimum: 1, + ExclusiveMaximumSet: true, + ExclusiveMaximum: 10, + MultipleOfSet: true, + MultipleOf: 0.5, + MinLengthSet: true, + MinLength: 2, + MaxLengthSet: true, + MaxLength: 8, + PatternSet: true, + Pattern: "^[a]$", + MinItemsSet: true, + MinItems: 1, + MaxItemsSet: true, + MaxItems: 3, + UniqueItemsSet: true, + UniqueItems: true, + MinPropertiesSet: true, + MinProperties: 1, + MaxPropertiesSet: true, + MaxProperties: 4, + Enum: []*yaml.Node{stringNode("a")}, + Const: stringNode("a"), + }) + if !tagged.Nullable || tagged.Format != "uuid" || tagged.SourceSchema == nil || tagged.SourceSchema.Minimum == nil { + t.Fatalf("full metadata was not applied: %#v", tagged) + } + var falseTagged SchemaIR + NewGenerator().applyOpenAPIMetadata(&falseTagged, openAPIMetadata{Present: true, ReadOnlySet: true, WriteOnlySet: true, DeprecatedSet: true}) + if falseTagged.ReadOnly || falseTagged.WriteOnly || falseTagged.Deprecated { + t.Fatalf("false boolean metadata should stay false: %#v", tagged) + } + if cloneIR(nil) != nil { + t.Fatal("nil clone should stay nil") + } + cloned := cloneIR(&SchemaIR{SourceSchema: &highbase.Schema{Format: "uuid"}}) + if cloned.SourceSchema == nil || cloned.SourceSchema.Format != "uuid" { + t.Fatalf("source schema clone failed: %#v", cloned) + } + var emptySchemaGen Generator + if schema := emptySchemaGen.fieldSchema(reflect.TypeOf(MetadataFieldOverride{}), reflect.StructField{Name: "Missing"}, "missing"); schema != nil { + t.Fatalf("empty field schema registry should miss: %#v", schema) + } + emptySchemaGen.fieldSchemas = map[fieldSchemaKey]*highbase.SchemaProxy{} + if schema := emptySchemaGen.fieldSchema(reflect.TypeOf(MetadataFieldOverride{}), reflect.StructField{Name: "Missing"}, "missing"); schema != nil { + t.Fatalf("field schema registry without json registry should miss: %#v", schema) + } + if _, err := NewGenerator().irFromFieldSchema(reflect.TypeOf((*string)(nil)), "Broken", "Broken", nil); err == nil { + t.Fatal("nil field schema should fail") + } + if ir, err := NewGenerator().irFromFieldSchema(reflect.TypeOf((*string)(nil)), "String", "String", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})); err != nil || !ir.Nullable { + t.Fatalf("pointer field schema should be nullable: %#v %v", ir, err) + } + var bare Generator + WithOpenAPITags(true)(&bare) + WithSchemaMetadataSidecar(true)(&bare) + WithFieldSchema(nil, "Field", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + WithFieldSchema(reflect.TypeOf(MetadataFieldOverride{}), "", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + WithFieldSchema(reflect.TypeOf(MetadataFieldOverride{}), "Field", nil)(&bare) + WithFieldSchema(reflect.TypeOf(MetadataFieldOverride{}), "Field", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + WithFieldSchemaByJSONName(nil, "field", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + WithFieldSchemaByJSONName(reflect.TypeOf(MetadataFieldOverride{}), "", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + WithFieldSchemaByJSONName(reflect.TypeOf(MetadataFieldOverride{}), "field", nil)(&bare) + WithFieldSchemaByJSONName(reflect.TypeOf(MetadataFieldOverride{}), "field", highbase.CreateSchemaProxy(&highbase.Schema{}))(&bare) + if !bare.openapiTags || !bare.schemaMetadataSidecar || bare.fieldSchemas == nil || bare.jsonSchemas == nil { + t.Fatalf("metadata options did not initialize bare generator: %#v", bare) + } + providerGen := NewGenerator() + providerGen.recordSchemaMetadata("", &highbase.Schema{Type: []string{"string"}}) + providerGen.schemaMetadataSidecar = true + providerGen.recordSchemaMetadata("NoSchema", nil) + providerGen.recordSchemaMetadata("Sample", &highbase.Schema{Type: []string{"string"}}) + providerGen.recordSchemaMetadata("Sample", &highbase.Schema{Type: []string{"string"}}) + sidecarDecl := providerGen.renderSchemaMetadataSidecarDecl() + if sidecarDecl == "" { + t.Fatal("expected metadata sidecar declaration") + } + if !strings.Contains(sidecarDecl, "OpenAPISchemaMetadata") { + t.Fatalf("metadata sidecar was not rendered: %s", sidecarDecl) + } + emptySidecar := NewGenerator(WithSchemaMetadataSidecar(true)) + if emptySidecar.renderSchemaMetadataSidecarDecl() != "" { + t.Fatal("empty metadata sidecar should not render") + } + if _, err := schemaProxyFromProviderMetadata(nil); err == nil { + t.Fatal("nil schema metadata should fail") + } + if schemaProxyFromMetadata(nil) != nil || schemaFromMetadata(nil) != nil { + t.Fatal("nil schema metadata should stay nil") + } + if schemaMetadataHasSiblings(nil) || !schemaMetadataEmpty(nil) { + t.Fatal("nil schema metadata helper mismatch") + } + if pureRef := schemaProxyFromMetadata(&providerSchemaMetadata{Ref: "#/components/schemas/Target"}); pureRef == nil || !pureRef.IsReference() || pureRef.Schema() != nil { + t.Fatalf("pure ref metadata should not create siblings: %#v", pureRef) + } + if dynamicBoolNumberFromMetadata(&providerDynamicBoolNumber{}) != nil { + t.Fatal("empty dynamic bool/number should stay nil") + } + if dynamicSchemaBoolFromMetadata(nil) != nil { + t.Fatal("nil dynamic schema/bool should stay nil") + } + if yamlKindFromMetadata("unknown") != yaml.ScalarNode || metadataYAMLKind(yaml.Kind(99)) != "scalar" { + t.Fatal("unknown yaml kinds should default to scalar") + } + for _, kind := range []yaml.Kind{yaml.DocumentNode, yaml.SequenceNode, yaml.MappingNode, yaml.AliasNode} { + if metadataYAMLKind(kind) == "scalar" { + t.Fatalf("yaml kind %v should not render as scalar", kind) + } + } + if metadataIndent(0) != "" { + t.Fatal("zero metadata indent should be empty") + } + writerGen := NewGenerator() + if got := writerGen.schemaMetadataLiteral(nil, 0); got != "nil" { + t.Fatalf("nil schema literal mismatch: %q", got) + } + if got := writerGen.schemaProxyMetadataLiteral(nil, 0); got != "nil" { + t.Fatalf("nil schema proxy literal mismatch: %q", got) + } + refWithSibling := highbase.CreateSchemaProxyRefWithSchema("#/components/schemas/Target", &highbase.Schema{ + Description: "sibling", + ReadOnly: boolPtr(true), + }) + refSiblingLiteral := writerGen.schemaProxyMetadataLiteral(refWithSibling, 0) + for _, want := range []string{`Ref: "#/components/schemas/Target"`, `Description: "sibling"`, `ReadOnly: &openAPIBool{Value: true}`} { + if !strings.Contains(refSiblingLiteral, want) { + t.Fatalf("ref sibling metadata literal missing %q in %s", want, refSiblingLiteral) + } + } + if schema := referenceSiblingMetadataSchema(highbase.CreateSchemaProxyRef("#/components/schemas/Target")); schema != nil { + t.Fatalf("pure programmatic ref should not expose sibling metadata: %#v", schema) + } + if referenceSiblingMetadataSchema(nil) != nil || referenceSiblingMetadataSchema(highbase.CreateSchemaProxy(&highbase.Schema{})) != nil { + t.Fatal("nil and non-reference proxies should not expose sibling metadata") + } + if schemaFromReferenceSiblingNode(nil) != nil { + t.Fatal("nil reference sibling node should not render metadata") + } + lowRefWithSibling := schemaProxyFromRefDocumentYAML(t, "$ref: '#/components/schemas/Target'\ndescription: low sibling\n") + if schema := referenceSiblingMetadataSchema(lowRefWithSibling); schema == nil || schema.Description != "low sibling" { + t.Fatalf("low-level ref sibling metadata should be detected: %#v", schema) + } + plainLowRef := schemaProxyFromRefDocumentYAML(t, "$ref: '#/components/schemas/Target'\n") + if schema := referenceSiblingMetadataSchema(plainLowRef); schema != nil { + t.Fatalf("plain low-level ref should not expose sibling metadata: %#v", schema) + } + if got := writerGen.schemaSliceMetadataLiteral([]*highbase.SchemaProxy{nil}, 0); !strings.Contains(got, "nil") { + t.Fatalf("nil schema slice literal mismatch: %q", got) + } + nilMap := orderedmap.New[string, *highbase.SchemaProxy]() + nilMap.Set("nil", nil) + if got := writerGen.schemaMapMetadataLiteral(nilMap, 0); !strings.Contains(got, "nil") { + t.Fatalf("nil schema map literal mismatch: %q", got) + } + if metadataStringStringMapLiteral(nil, 0) != "" || metadataPlainIntLiteral(1) != "1" { + t.Fatal("metadata literal helper fallback mismatch") + } + if stringStringMapFromMetadata(nil) != nil { + t.Fatal("nil string-string metadata map should stay nil") + } + if got := metadataYAMLNodeLiteral(nil, 0); got != "nil" { + t.Fatalf("nil yaml node literal mismatch: %q", got) + } +} + +func schemaProxyFromRefDocumentYAML(t *testing.T, sampleYAML string) *highbase.SchemaProxy { + t.Helper() + spec := []byte("openapi: 3.1.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n Target:\n type: string\n Sample:\n" + indent(sampleYAML, " ")) + config := datamodel.NewDocumentConfiguration() + config.TransformSiblingRefs = false + doc, err := libopenapi.NewDocumentWithConfiguration(spec, config) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + schema, ok := model.Model.Components.Schemas.Get("Sample") + if !ok { + t.Fatal("missing sample schema") + } + return schema +} diff --git a/generator/golang/name_registry.go b/generator/golang/name_registry.go new file mode 100644 index 00000000..7d6b1bea --- /dev/null +++ b/generator/golang/name_registry.go @@ -0,0 +1,33 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +const conflictNameDelimiter = "__" + +type nameRegistry struct { + used map[string]string +} + +func newNameRegistry() *nameRegistry { + return &nameRegistry{used: make(map[string]string)} +} + +func (r *nameRegistry) resolve(original, candidate string) (string, bool) { + if candidate == "" { + candidate = "Value" + } + if existing, ok := r.used[candidate]; !ok { + r.used[candidate] = original + return candidate, false + } else if existing == original { + return candidate, false + } + for i := 2; ; i++ { + next := candidate + conflictNameDelimiter + intString(i) + if _, ok := r.used[next]; !ok { + r.used[next] = original + return next, true + } + } +} diff --git a/generator/golang/names.go b/generator/golang/names.go new file mode 100644 index 00000000..876041db --- /dev/null +++ b/generator/golang/names.go @@ -0,0 +1,262 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "go/token" + "reflect" + "strings" + "unicode" +) + +var initialisms = map[string]string{ + "API": "API", "ASCII": "ASCII", "CPU": "CPU", "CSS": "CSS", "DNS": "DNS", "EOF": "EOF", + "BIC": "BIC", "CVC": "CVC", "CVV": "CVV", "GUID": "GUID", "HTML": "HTML", "HTTP": "HTTP", + "HTTPS": "HTTPS", "IBAN": "IBAN", "ID": "ID", "IP": "IP", "JSON": "JSON", "JWT": "JWT", + "QPS": "QPS", "RAM": "RAM", "RPC": "RPC", "SLA": "SLA", "SMTP": "SMTP", + "SQL": "SQL", "SSH": "SSH", "TCP": "TCP", "TLS": "TLS", "TTL": "TTL", "UDP": "UDP", + "UI": "UI", "UID": "UID", "URI": "URI", "URL": "URL", "UTF8": "UTF8", "UUID": "UUID", + "VM": "VM", "XML": "XML", "XMPP": "XMPP", "XSRF": "XSRF", "XSS": "XSS", +} + +func (g *Generator) publicName(name string) string { + if g.typeNameResolver != nil { + if resolved := g.typeNameResolver(name); resolved != "" { + return resolved + } + } + if g.nameResolver != nil { + if resolved := g.nameResolver(name); resolved != "" { + return resolved + } + } + return toPublicName(name) +} + +func (g *Generator) fieldName(name string) string { + if g.fieldNameResolver != nil { + if resolved := g.fieldNameResolver(name); resolved != "" { + return resolved + } + } + if g.nameResolver != nil { + if resolved := g.nameResolver(name); resolved != "" { + return resolved + } + } + return toPublicName(name) +} + +func (g *Generator) enumValueName(name string) string { + if g.enumValueNameResolver != nil { + if resolved := g.enumValueNameResolver(name); resolved != "" { + return resolved + } + } + if g.nameResolver != nil { + if resolved := g.nameResolver(name); resolved != "" { + return resolved + } + } + return toPublicName(enumNameSeed(name)) +} + +func (g *Generator) componentTypeName(name string) string { + if g.componentTypeNames != nil { + if resolved := g.componentTypeNames[name]; resolved != "" { + return resolved + } + } + return g.publicName(name) +} + +func (g *Generator) nestedTypeName(parent, child string) string { + childName := g.publicName(child) + if parent == "" { + return childName + } + return parent + g.nestedTypeNameDelimiter + childName +} + +func enumNameSeed(value string) string { + value = strings.Trim(strings.ReplaceAll(strings.ReplaceAll(value, "-", "_"), " ", "_"), "_") + if value == "" { + return "empty" + } + return value +} + +func toPublicName(name string) string { + parts := splitIdentifier(name) + if len(parts) == 0 { + return "Value" + } + var b strings.Builder + for _, p := range parts { + upper := strings.ToUpper(p) + if v, ok := initialisms[upper]; ok { + b.WriteString(v) + continue + } + rs := []rune(strings.ToLower(p)) + rs[0] = unicode.ToUpper(rs[0]) + b.WriteString(string(rs)) + } + out := b.String() + first := []rune(out)[0] + if unicode.IsDigit(first) { + return "Value" + out + } + return out +} + +func toPrivateName(name string) string { + pub := toPublicName(name) + parts := splitCamel(pub) + parts[0] = strings.ToLower(parts[0]) + return strings.Join(parts, "") +} + +func splitIdentifier(name string) []string { + var raw []string + var b strings.Builder + flush := func() { + if b.Len() > 0 { + raw = append(raw, b.String()) + b.Reset() + } + } + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + flush() + } + } + flush() + var parts []string + for _, part := range raw { + parts = append(parts, splitCamel(part)...) + } + return parts +} + +func splitCamel(value string) []string { + rs := []rune(value) + if len(rs) == 0 { + return nil + } + var parts []string + start := 0 + for i := 1; i < len(rs); i++ { + prev := rs[i-1] + cur := rs[i] + var next rune + if i+1 < len(rs) { + next = rs[i+1] + } + lowerToUpper := unicode.IsLower(prev) && unicode.IsUpper(cur) + acronymToWord := unicode.IsUpper(prev) && unicode.IsUpper(cur) && next != 0 && unicode.IsLower(next) + if lowerToUpper || acronymToWord { + parts = append(parts, string(rs[start:i])) + start = i + } + } + parts = append(parts, string(rs[start:])) + return parts +} + +func refName(ref string) string { + if ref == "" { + return "" + } + i := strings.LastIndex(ref, "/") + if i < 0 || i == len(ref)-1 { + return ref + } + return ref[i+1:] +} + +func (g *Generator) refTypeName(ref string) string { + if ref == "" { + return "" + } + if strings.HasPrefix(ref, "#/") { + return g.componentTypeName(refName(ref)) + } + if g.externalRefResolver != nil { + if resolved := g.externalRefResolver(ref); resolved != "" { + return resolved + } + } + return g.publicName(refName(ref)) +} + +func uniqueName(base string, used map[string]struct{}) string { + if base == "" { + base = "Value" + } + if _, ok := used[base]; !ok { + used[base] = struct{}{} + return base + } + for i := 2; ; i++ { + name := base + conflictNameDelimiter + intString(i) + if _, ok := used[name]; !ok { + used[name] = struct{}{} + return name + } + } +} + +func intString(v int) string { + const digits = "0123456789" + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = digits[v%10] + v /= 10 + } + return string(buf[i:]) +} + +func validatePackageName(name string) error { + if name == "" || !token.IsIdentifier(name) || token.Lookup(name).IsKeyword() { + return wrapPath(ErrInvalidPackageName, name) + } + return nil +} + +func derefType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func typeName(t reflect.Type) string { + if t == nil { + return "" + } + if name := t.Name(); name != "" { + return name + } + return toPublicName(t.Kind().String()) +} + +func interfaceKey(target any) reflect.Type { + if target == nil { + return nil + } + t := reflect.TypeOf(target) + if t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Interface { + return t.Elem() + } + return nil +} diff --git a/generator/golang/options.go b/generator/golang/options.go new file mode 100644 index 00000000..d2b19264 --- /dev/null +++ b/generator/golang/options.go @@ -0,0 +1,322 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// Option configures a Generator. +type Option func(*Generator) + +// NameResolver maps OpenAPI names to Go identifiers. Returning an empty string +// falls back to the generator's default naming. +type NameResolver func(string) string + +// ExternalRefResolver maps an external OpenAPI $ref to a Go type name. +// Returning an empty string falls back to deriving the type name from the +// reference tail. +type ExternalRefResolver func(ref string) string + +// Diagnostic describes a notable generator decision. +type Diagnostic struct { + Code string + Path string + Message string +} + +const ( + DiagnosticComponentNameCollision = "componentNameCollision" + DiagnosticChildSchema = "childSchema" + DiagnosticAdditionalPropertiesFalse = "additionalPropertiesFalse" + DiagnosticArrayContains = "arrayContains" + DiagnosticBooleanItems = "booleanItems" + DiagnosticConstKeyword = "constKeyword" + DiagnosticContentSchema = "contentSchema" + DiagnosticDependentRequired = "dependentRequired" + DiagnosticDependentSchemas = "dependentSchemas" + DiagnosticDynamicReference = "dynamicReference" + DiagnosticExternalReference = "externalReference" + DiagnosticFieldNameCollision = "fieldNameCollision" + DiagnosticConditionalSchema = "conditionalSchema" + DiagnosticImplicitType = "implicitType" + DiagnosticMixedEnum = "mixedEnum" + DiagnosticMultiTypeSchema = "multiTypeSchema" + DiagnosticNotSchema = "notSchema" + DiagnosticNullEnum = "nullEnum" + DiagnosticOptionalConstDiscriminator = "optionalConstDiscriminator" + DiagnosticPatternProperties = "patternProperties" + DiagnosticPrefixItems = "prefixItems" + DiagnosticPropertyNames = "propertyNames" + DiagnosticSchemaMetadata = "schemaMetadata" + DiagnosticStringEncoded = "stringEncoded" + DiagnosticTypeNameCollision = "typeNameCollision" + DiagnosticUnevaluatedItems = "unevaluatedItems" + DiagnosticRootNameCollision = "rootNameCollision" + DiagnosticUnevaluatedProperties = "unevaluatedProperties" + DiagnosticValidationKeyword = "validationKeyword" +) + +type formatMapping struct { + goType string + importPath string +} + +type discriminatorRegistration struct { + property string + mapping map[string]string +} + +type fieldSchemaKey struct { + owner reflect.Type + name string +} + +// WithPackageName sets the generated Go package name. +func WithPackageName(name string) Option { + return func(g *Generator) { + g.packageName = name + } +} + +// WithOptionalFieldsAsPointers controls whether optional scalar fields render +// as pointers. +func WithOptionalFieldsAsPointers(enabled bool) Option { + return func(g *Generator) { + g.optionalFieldsAsPointers = enabled + } +} + +// WithOmitEmpty controls omitempty on optional generated tags. +func WithOmitEmpty(enabled bool) Option { + return func(g *Generator) { + g.omitEmpty = enabled + } +} + +// WithNullableAsPointer controls whether nullable scalar fields render as +// pointers. +func WithNullableAsPointer(enabled bool) Option { + return func(g *Generator) { + g.nullableAsPointer = enabled + } +} + +// WithGenerateJSONTags controls generated json tags. +func WithGenerateJSONTags(enabled bool) Option { + return func(g *Generator) { + g.jsonTags = enabled + } +} + +// WithGenerateYAMLTags controls generated yaml tags. +func WithGenerateYAMLTags(enabled bool) Option { + return func(g *Generator) { + g.yamlTags = enabled + } +} + +// WithEnumConstants controls whether enum values generate Go constants. +func WithEnumConstants(enabled bool) Option { + return func(g *Generator) { + g.enumConstants = enabled + } +} + +// WithHeaderComment writes a file header comment before the package clause. +func WithHeaderComment(text string) Option { + return func(g *Generator) { + g.headerComment = text + } +} + +// WithPackageComment writes a package doc comment before the package clause. +func WithPackageComment(text string) Option { + return func(g *Generator) { + g.packageComment = text + } +} + +// WithGeneratedComment writes a standard generated-code comment. +func WithGeneratedComment(enabled bool) Option { + return func(g *Generator) { + g.generatedComment = enabled + } +} + +// WithOpenAPITags controls whether generated struct fields include compact +// openapi tags for metadata that cannot be recovered from Go reflection alone. +func WithOpenAPITags(enabled bool) Option { + return func(g *Generator) { + g.openapiTags = enabled + } +} + +// WithSchemaMetadataSidecar controls whether generated named types include a +// typed OpenAPISchemaMetadata sidecar. Enabling the sidecar preserves original +// OpenAPI schema fidelity for Go reflection round trips. Disabling it keeps the +// generated model code leaner, but OpenAPI -> Go -> OpenAPI reconstruction is +// intentionally lossy and falls back to Go type shape plus tags. +func WithSchemaMetadataSidecar(enabled bool) Option { + return func(g *Generator) { + g.schemaMetadataSidecar = enabled + } +} + +// WithFormatMapping maps an OpenAPI string format to a Go type and optional +// import path. +func WithFormatMapping(format, goType, importPath string) Option { + return func(g *Generator) { + if g.formatMappings == nil { + g.formatMappings = make(map[string]formatMapping) + } + g.formatMappings[format] = formatMapping{goType: goType, importPath: importPath} + } +} + +// WithNameResolver sets a broad fallback resolver for generated Go names. +func WithNameResolver(resolver NameResolver) Option { + return func(g *Generator) { + g.nameResolver = resolver + } +} + +// WithTypeNameResolver sets a resolver for generated Go type names. +func WithTypeNameResolver(resolver NameResolver) Option { + return func(g *Generator) { + g.typeNameResolver = resolver + } +} + +// WithFieldNameResolver sets a resolver for generated Go struct field names. +func WithFieldNameResolver(resolver NameResolver) Option { + return func(g *Generator) { + g.fieldNameResolver = resolver + } +} + +// WithEnumValueNameResolver sets a resolver for generated enum constant suffixes. +func WithEnumValueNameResolver(resolver NameResolver) Option { + return func(g *Generator) { + g.enumValueNameResolver = resolver + } +} + +// WithOptionalConstDiscriminatorUnions allows optional shared const +// discriminator properties to produce typed oneOf unions. +func WithOptionalConstDiscriminatorUnions(enabled bool) Option { + return func(g *Generator) { + g.optionalConstDiscriminatorUnions = enabled + } +} + +// WithAdditionalPropertiesMethods controls whether schema-valued +// additionalProperties generates JSON marshal/unmarshal methods that round-trip +// unknown fields through the AdditionalProperties map. +func WithAdditionalPropertiesMethods(enabled bool) Option { + return func(g *Generator) { + g.additionalPropertiesMethods = enabled + } +} + +// WithNestedTypeNameDelimiter sets the separator inserted between generated +// parent and child type names for inline schemas. The default is "_"; passing +// an empty delimiter restores compact names like ParentChild. +func WithNestedTypeNameDelimiter(delimiter string) Option { + return func(g *Generator) { + g.nestedTypeNameDelimiter = delimiter + } +} + +// WithExternalRefTypeResolver sets a resolver for external OpenAPI $ref values +// when rendering Go type names. The resolver is not used for local component +// references. +func WithExternalRefTypeResolver(resolver ExternalRefResolver) Option { + return func(g *Generator) { + g.externalRefResolver = resolver + } +} + +// WithTypeSchema overrides reflected schema generation for a specific Go type. +// This is useful for project scalar aliases that need a custom OpenAPI format, +// enum, or extension without implementing SchemaProvider on the type. +func WithTypeSchema(t reflect.Type, schema *highbase.SchemaProxy) Option { + return func(g *Generator) { + if t == nil || schema == nil { + return + } + if g.typeSchemas == nil { + g.typeSchemas = make(map[reflect.Type]*highbase.SchemaProxy) + } + g.typeSchemas[derefType(t)] = schema + } +} + +// WithFieldSchema overrides reflected schema generation for a specific Go +// struct field name while keeping the surrounding model reflected normally. +func WithFieldSchema(t reflect.Type, fieldName string, schema *highbase.SchemaProxy) Option { + return func(g *Generator) { + if t == nil || fieldName == "" || schema == nil { + return + } + if g.fieldSchemas == nil { + g.fieldSchemas = make(map[fieldSchemaKey]*highbase.SchemaProxy) + } + g.fieldSchemas[fieldSchemaKey{owner: derefType(t), name: fieldName}] = schema + } +} + +// WithFieldSchemaByJSONName overrides reflected schema generation for a +// specific JSON field name while keeping the surrounding model reflected +// normally. +func WithFieldSchemaByJSONName(t reflect.Type, jsonName string, schema *highbase.SchemaProxy) Option { + return func(g *Generator) { + if t == nil || jsonName == "" || schema == nil { + return + } + if g.jsonSchemas == nil { + g.jsonSchemas = make(map[fieldSchemaKey]*highbase.SchemaProxy) + } + g.jsonSchemas[fieldSchemaKey{owner: derefType(t), name: jsonName}] = schema + } +} + +// WithOneOfTypes registers concrete variants for a Go interface when producing +// OpenAPI oneOf schemas from reflection. +func WithOneOfTypes(target any, variants ...any) Option { + return func(g *Generator) { + key := interfaceKey(target) + if key == nil { + return + } + types := make([]reflect.Type, 0, len(variants)) + for _, variant := range variants { + if t := reflect.TypeOf(variant); t != nil { + types = append(types, derefType(t)) + } + } + g.oneOfRegistrations[key] = types + } +} + +// WithDiscriminatorMapping registers discriminator metadata for a reflected +// interface union. +func WithDiscriminatorMapping(target any, property string, mapping map[string]string) Option { + return func(g *Generator) { + key := interfaceKey(target) + if key == nil { + return + } + cp := make(map[string]string, len(mapping)) + for k, v := range mapping { + cp[k] = v + } + g.discriminatorRegistrations[key] = discriminatorRegistration{ + property: property, + mapping: cp, + } + } +} diff --git a/generator/golang/parity_test.go b/generator/golang/parity_test.go new file mode 100644 index 00000000..60bc39ee --- /dev/null +++ b/generator/golang/parity_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "go/ast" + "go/parser" + "go/token" + "reflect" + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" +) + +func stringSet(names ...string) map[string]struct{} { + set := make(map[string]struct{}, len(names)) + for _, name := range names { + set[name] = struct{}{} + } + return set +} + +// fillSentinel sets a non-zero value on every kind of field present on +// highbase.Schema so a round trip through applySchemaFidelity reveals which +// fields are propagated. +func fillSentinel(v reflect.Value) { + switch v.Kind() { + case reflect.String: + v.SetString("sentinel") + case reflect.Pointer: + v.Set(reflect.New(v.Type().Elem())) + case reflect.Slice: + v.Set(reflect.MakeSlice(v.Type(), 1, 1)) + } +} + +// TestApplySchemaFidelityCoversSchemaFields is a tripwire for the Schema hash +// contract applied to fidelity: every exported highbase.Schema field must +// either be propagated verbatim by applySchemaFidelity or be consciously +// listed as shape-derived (set from IR structure in openapiFromIR) or as a +// non-content navigation field. When libopenapi adds a Schema field this test +// fails until the new field is handled, instead of silently losing fidelity. +func TestApplySchemaFidelityCoversSchemaFields(t *testing.T) { + shapeOrIgnored := stringSet( + // Shape-derived: openapiFromIR sets these from the IR structure. + "Type", "AllOf", "AnyOf", "OneOf", "Discriminator", "Properties", + "PatternProperties", "PrefixItems", "AdditionalProperties", "Required", + "Enum", "Const", "Nullable", "Items", + // Navigation only, no schema content. + "ParentProxy", + ) + + src := &highbase.Schema{} + sv := reflect.ValueOf(src).Elem() + for i := 0; i < sv.NumField(); i++ { + if sv.Type().Field(i).PkgPath == "" { + fillSentinel(sv.Field(i)) + } + } + + target := &highbase.Schema{} + applySchemaFidelity(target, &SchemaIR{SourceSchema: src}) + + tv := reflect.ValueOf(target).Elem() + for i := 0; i < tv.NumField(); i++ { + field := tv.Type().Field(i) + if field.PkgPath != "" { + continue + } + _, exempt := shapeOrIgnored[field.Name] + copied := !tv.Field(i).IsZero() + switch { + case !copied && !exempt: + t.Fatalf("applySchemaFidelity does not propagate schema field %q; copy it in applySchemaFidelity or record it as shape-derived", field.Name) + case copied && exempt: + t.Fatalf("schema field %q is propagated by applySchemaFidelity but listed as shape-derived; update the allowlist", field.Name) + } + } +} + +// emittedStructFields parses the sidecar struct definitions and returns each +// emitted struct's field-name to field-type mapping. +func emittedStructFields(t *testing.T) map[string]map[string]string { + t.Helper() + var b strings.Builder + writeSchemaMetadataTypes(&b) + file, err := parser.ParseFile(token.NewFileSet(), "", "package golang\n"+b.String(), 0) + if err != nil { + t.Fatalf("emitted metadata struct definitions do not parse: %v", err) + } + out := make(map[string]map[string]string) + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts := spec.(*ast.TypeSpec) + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + fields := make(map[string]string) + for _, field := range st.Fields.List { + for _, name := range field.Names { + fields[name.Name] = exprString(field.Type) + } + } + out[ts.Name.Name] = fields + } + } + return out +} + +func exprString(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.StarExpr: + return "*" + exprString(e.X) + case *ast.ArrayType: + return "[]" + exprString(e.Elt) + default: + return "?" + } +} + +// providerTypeToEmitted maps a reflect type string for a read-side metadata +// type onto the name the sidecar emits for it (provider -> openAPI prefix, no +// package qualifier). +func providerTypeToEmitted(reflectType string) string { + return strings.ReplaceAll(reflectType, "golang.provider", "openAPI") +} + +func collectProviderTypes(t reflect.Type, seen map[string]reflect.Type) { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { + t = t.Elem() + } + if t.Kind() != reflect.Struct || !strings.HasPrefix(t.Name(), "provider") { + return + } + if _, ok := seen[t.Name()]; ok { + return + } + seen[t.Name()] = t + for i := 0; i < t.NumField(); i++ { + collectProviderTypes(t.Field(i).Type, seen) + } +} + +// TestSchemaMetadataMirrorParity guards the two hand-maintained metadata +// hierarchies against drift: the read-side providerSchemaMetadata structs +// (schema_metadata.go) and the openAPISchemaMetadata structs emitted into the +// generated sidecar (provider_methods.go) must define identical type and field +// sets, or a generated round trip silently loses metadata. +func TestSchemaMetadataMirrorParity(t *testing.T) { + emitted := emittedStructFields(t) + + providerTypes := make(map[string]reflect.Type) + collectProviderTypes(reflect.TypeOf(providerSchemaMetadata{}), providerTypes) + + if len(providerTypes) != len(emitted) { + t.Fatalf("metadata type count mismatch: read=%d emitted=%d", len(providerTypes), len(emitted)) + } + + for name, rt := range providerTypes { + emittedName := "openAPI" + strings.TrimPrefix(name, "provider") + emittedFields, ok := emitted[emittedName] + if !ok { + t.Fatalf("emitted sidecar is missing type %q for read type %q", emittedName, name) + } + if rt.NumField() != len(emittedFields) { + t.Fatalf("type %q field count mismatch: read=%d emitted=%d", name, rt.NumField(), len(emittedFields)) + } + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + emittedType, ok := emittedFields[field.Name] + if !ok { + t.Fatalf("emitted type %q is missing field %q", emittedName, field.Name) + } + if want := providerTypeToEmitted(field.Type.String()); want != emittedType { + t.Fatalf("type %q field %q type mismatch: read=%s emitted=%s", name, field.Name, want, emittedType) + } + } + } +} diff --git a/generator/golang/phase_two_test.go b/generator/golang/phase_two_test.go new file mode 100644 index 00000000..db2aa9f8 --- /dev/null +++ b/generator/golang/phase_two_test.go @@ -0,0 +1,344 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "archive/zip" + "mime/multipart" + "reflect" + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +type PhaseTwoAddress struct { + Street string `json:"street"` + City string `json:"city"` +} + +type PhaseTwoPaymentMethod interface { + phaseTwoPaymentMethod() +} + +type PhaseTwoCard struct { + Object string `json:"object"` + Last4 string `json:"last4"` +} + +func (PhaseTwoCard) phaseTwoPaymentMethod() {} + +type PhaseTwoBank struct { + Object string `json:"object"` + Routing string `json:"routing"` +} + +func (PhaseTwoBank) phaseTwoPaymentMethod() {} + +type PhaseTwoCustomer struct { + ID string `json:"id"` + Address PhaseTwoAddress `json:"address"` + Labels map[string]string `json:"labels,omitempty"` + Payment PhaseTwoPaymentMethod `json:"payment,omitempty"` + History []PhaseTwoPaymentMethod `json:"history,omitempty"` +} + +func TestPhaseTwoSchemaSetComponents(t *testing.T) { + if set, err := SchemasFromTypes(reflect.TypeOf(PhaseTwoAddress{})); err != nil || set.Root == nil { + t.Fatalf("package SchemasFromTypes failed: %#v %v", set, err) + } + if set, err := SchemasFromValues(PhaseTwoAddress{}); err != nil || set.Root == nil { + t.Fatalf("package SchemasFromValues failed: %#v %v", set, err) + } + if _, err := SchemasFromValues(nil); err == nil { + t.Fatal("expected nil value error") + } + if _, err := SchemasFromTypes(nil); err == nil { + t.Fatal("expected nil type error") + } + if _, err := NewGenerator().SchemasFromTypes(reflect.TypeOf(make(chan string))); err == nil { + t.Fatal("expected unsupported type error") + } + + gen := NewGenerator( + WithOneOfTypes((*PhaseTwoPaymentMethod)(nil), PhaseTwoCard{}, PhaseTwoBank{}), + WithDiscriminatorMapping((*PhaseTwoPaymentMethod)(nil), "object", map[string]string{ + "bank": "#/components/schemas/PhaseTwoBank", + "card": "#/components/schemas/PhaseTwoCard", + }), + ) + set, err := gen.SchemasFromValues(PhaseTwoCustomer{}) + if err != nil { + t.Fatal(err) + } + if !set.Root.IsReference() || set.Root.GetReference() != "#/components/schemas/PhaseTwoCustomer" { + t.Fatalf("unexpected root reference: %q", set.Root.GetReference()) + } + assertComponentKeysSorted(t, set.Components) + for _, name := range []string{"PhaseTwoAddress", "PhaseTwoBank", "PhaseTwoCard", "PhaseTwoCustomer", "PhaseTwoCustomer_Payment"} { + if _, ok := set.Components.Get(name); !ok { + t.Fatalf("missing component %s", name) + } + } + + customer := componentSchema(t, set, "PhaseTwoCustomer") + address, ok := customer.Properties.Get("address") + if !ok { + t.Fatal("missing address property") + } + if !address.IsReference() || address.GetReference() != "#/components/schemas/PhaseTwoAddress" { + t.Fatalf("address should be a component reference, got %q", address.GetReference()) + } + payment, ok := customer.Properties.Get("payment") + if !ok { + t.Fatal("missing payment property") + } + if !payment.IsReference() || payment.GetReference() != "#/components/schemas/PhaseTwoCustomer_Payment" { + t.Fatalf("payment should be a component reference, got %q", payment.GetReference()) + } + paymentSchema := componentSchema(t, set, "PhaseTwoCustomer_Payment") + if len(paymentSchema.OneOf) != 2 || paymentSchema.Discriminator == nil { + t.Fatalf("payment component should be discriminated oneOf: %#v", paymentSchema) + } + + collisionSet, err := NewGenerator().SchemasFromTypes(reflect.TypeOf(zip.FileHeader{}), reflect.TypeOf(multipart.FileHeader{})) + if err != nil { + t.Fatal(err) + } + if !hasDiagnostic(collisionSet.Diagnostics, "component name collision") { + t.Fatalf("expected component collision diagnostic, got %#v", collisionSet.Diagnostics) + } +} + +func TestPhaseTwoOpenAPIShapeRendering(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("shape probe", schemaProxyFromYAML(t, ` +title: Shape Probe +type: object +required: [id] +propertyNames: + pattern: "^[a-z_]+$" +dependentSchemas: + card: + type: object +dependentRequired: + card: [billing] +if: + properties: + kind: + const: business +then: + required: [tax_id] +else: + required: [ssn] +not: + required: [forbidden] +unevaluatedProperties: false +patternProperties: + "^x-": + type: string +additionalProperties: + type: integer +properties: + id: + type: string + readOnly: true + default: id-1 + example: id-2 + secret: + type: string + writeOnly: true + deprecated: true + examples: + - secret + titled: + title: Display title + type: string + tuple: + type: array + prefixItems: + - type: string + - type: integer +`)) + gen := NewGenerator( + WithGeneratedComment(true), + WithHeaderComment("internal models\nschema generated"), + WithPackageComment("contains generated models"), + ) + file, err := gen.RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + assertContains(t, src, "// Code generated by libopenapi generator/golang. DO NOT EDIT.") + assertContains(t, src, "// internal models.") + assertContains(t, src, "// schema generated.") + assertContains(t, src, "// Package models contains generated models.") + assertContains(t, src, "// ShapeProbe Shape Probe.") + assertContains(t, src, "// ID readOnly.") + assertContains(t, src, "// ID default value is defined in the OpenAPI schema.") + assertContains(t, src, "// ID example value is defined in the OpenAPI schema.") + assertContains(t, src, "// Secret writeOnly.") + assertContains(t, src, "// Secret Deprecated.") + assertContains(t, src, "// Titled Display title.") + assertContains(t, src, "Tuple") + assertContains(t, src, "[]any") + assertContains(t, src, "`json:\"tuple,omitempty\"`") + assertContains(t, src, "AdditionalProperties") + assertContains(t, src, "map[string]int") + assertParsesAndCompiles(t, file.Source) + + for _, expected := range []string{ + "propertyNames", + "dependentSchemas", + "dependentRequired", + "if/then/else", + "not", + "unevaluatedProperties", + "patternProperties", + "prefixItems", + } { + if !hasDiagnostic(file.Diagnostics, expected) { + t.Fatalf("missing diagnostic containing %q: %#v", expected, file.Diagnostics) + } + } +} + +func TestPhaseTwoNameCollisionsAndOpenAPIShapeExport(t *testing.T) { + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("collision probe", schemaProxyFromYAML(t, ` +type: object +properties: + id: + type: string + ID: + type: string +additionalProperties: + type: string +`)) + gen := NewGenerator() + file, err := gen.RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + src := string(file.Source) + assertContains(t, src, "ID") + assertContains(t, src, "ID__2") + assertContains(t, src, "`json:\"id,omitempty\"`") + assertContains(t, src, "`json:\"ID,omitempty\"`") + assertContains(t, src, "AdditionalProperties") + assertContains(t, src, "map[string]string") + if !hasDiagnostic(file.Diagnostics, "field name collision") { + t.Fatalf("expected field collision diagnostic, got %#v", file.Diagnostics) + } + + registry := newNameRegistry() + if name, collision := registry.resolve("blank", ""); name != "Value" || collision { + t.Fatalf("unexpected blank resolution: %s %v", name, collision) + } + if name, collision := registry.resolve("blank", "Value"); name != "Value" || collision { + t.Fatalf("same original should not collide: %s %v", name, collision) + } + registry = newNameRegistry() + registry.resolve("one", "Value") + registry.resolve("two", "Value__2") + if name, collision := registry.resolve("three", "Value"); name != "Value__3" || !collision { + t.Fatalf("expected suffixed collision resolution, got %s %v", name, collision) + } + + props := orderedmap.New[string, *SchemaIR]() + props.Set("id", &SchemaIR{Kind: KindString}) + patternProps := orderedmap.New[string, *SchemaIR]() + patternProps.Set("^x-", &SchemaIR{Kind: KindInteger}) + proxy := NewGenerator().openapiFromIR(&SchemaIR{ + Kind: KindObject, + Properties: props, + PatternProperties: patternProps, + Required: map[string]struct{}{"id": {}}, + AdditionalProperties: &SchemaIR{Kind: KindString}, + }) + rendered, err := proxy.Render() + if err != nil { + t.Fatal(err) + } + text := string(rendered) + assertContains(t, text, "properties:") + assertContains(t, text, "patternProperties:") + assertContains(t, text, "additionalProperties:") + + arrayProxy := NewGenerator().openapiFromIR(&SchemaIR{ + Kind: KindArray, + PrefixItems: []*SchemaIR{ + {Kind: KindString}, + {Kind: KindInteger}, + }, + }) + rendered, err = arrayProxy.Render() + if err != nil { + t.Fatal(err) + } + assertContains(t, string(rendered), "prefixItems:") +} + +func TestPhaseTwoCommentAndShapeHelpers(t *testing.T) { + gen := NewGenerator() + gen.collectShapeDiagnostics("nil", nil) + gen.renderChildren(&SchemaIR{ + PatternProperties: orderedmap.New[string, *SchemaIR](), + PrefixItems: []*SchemaIR{{Name: "PrefixAlias", Kind: KindString}}, + }) + gen.renderChildren(&SchemaIR{ + PatternProperties: func() *orderedmap.Map[string, *SchemaIR] { + props := orderedmap.New[string, *SchemaIR]() + props.Set("^x-", &SchemaIR{Name: "PatternAlias", Kind: KindString}) + return props + }(), + }) + if got := gen.goType(&SchemaIR{Kind: KindArray, PrefixItems: []*SchemaIR{{Kind: KindString}}}, true, false); got != "[]any" { + t.Fatalf("prefixItems should render as []any, got %s", got) + } + + var b strings.Builder + writeIRComments(&b, nil) + writeFieldComments(&b, "Field", nil) + writeLineComment(&b, "") + writeLineCommentBlock(&b, "first\nsecond") + if got := b.String(); !strings.Contains(got, "// first.") || !strings.Contains(got, "// second.") { + t.Fatalf("comment block not written: %q", got) + } +} + +func componentSchema(t *testing.T, set *SchemaSet, name string) *highbase.Schema { + t.Helper() + proxy, ok := set.Components.Get(name) + if !ok { + t.Fatalf("missing component %s", name) + } + schema := proxy.Schema() + if schema == nil { + t.Fatalf("component %s has nil schema", name) + } + return schema +} + +func assertComponentKeysSorted(t *testing.T, components *orderedmap.Map[string, *highbase.SchemaProxy]) { + t.Helper() + previous := "" + for name := range components.FromOldest() { + if previous != "" && name < previous { + t.Fatalf("components not sorted: %s before %s", name, previous) + } + previous = name + } +} + +func hasDiagnostic(diagnostics []Diagnostic, substr string) bool { + for _, diagnostic := range diagnostics { + if strings.Contains(diagnostic.Code, substr) || strings.Contains(diagnostic.Message, substr) || strings.Contains(diagnostic.Path, substr) { + return true + } + } + return false +} diff --git a/generator/golang/provider_methods.go b/generator/golang/provider_methods.go new file mode 100644 index 00000000..21648eeb --- /dev/null +++ b/generator/golang/provider_methods.go @@ -0,0 +1,610 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strconv" + "strings" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +func (g *Generator) recordSchemaMetadata(typeName string, schema *highbase.Schema) { + if !g.schemaMetadataSidecar || typeName == "" || schema == nil { + return + } + if _, ok := g.metadataSchemas[typeName]; ok { + return + } + g.metadataSchemas[typeName] = schema + g.metadataOrder = append(g.metadataOrder, typeName) +} + +func (g *Generator) renderSchemaMetadataSidecarDecl() string { + if !g.schemaMetadataSidecar || len(g.metadataOrder) == 0 { + return "" + } + var b strings.Builder + writeSchemaMetadataTypes(&b) + b.WriteString("\nvar openAPISchemas = map[string]*openAPISchemaMetadata{\n") + for _, typeName := range g.metadataOrder { + b.WriteByte('\t') + b.WriteString(strconv.Quote(typeName)) + b.WriteString(": ") + b.WriteString(g.schemaMetadataLiteral(g.metadataSchemas[typeName], 1)) + b.WriteString(",\n") + } + b.WriteString("}\n") + for _, typeName := range g.metadataOrder { + b.WriteString("\nfunc (") + b.WriteString(typeName) + b.WriteString(") OpenAPISchemaMetadata() any {\n\treturn openAPISchemas[") + b.WriteString(strconv.Quote(typeName)) + b.WriteString("]\n}\n") + } + return b.String() +} + +func writeSchemaMetadataTypes(b *strings.Builder) { + b.WriteString(`type openAPISchemaMetadata struct { + Ref string + SchemaTypeRef string + ExclusiveMaximum *openAPIDynamicBoolNumber + ExclusiveMinimum *openAPIDynamicBoolNumber + Type []string + AllOf []*openAPISchemaMetadata + OneOf []*openAPISchemaMetadata + AnyOf []*openAPISchemaMetadata + Discriminator *openAPIDiscriminatorMetadata + Examples []*openAPIYAMLNode + PrefixItems []*openAPISchemaMetadata + Contains *openAPISchemaMetadata + MinContains *openAPIInt + MaxContains *openAPIInt + If *openAPISchemaMetadata + Else *openAPISchemaMetadata + Then *openAPISchemaMetadata + DependentSchemas []openAPINamedSchemaMetadata + DependentRequired []openAPIStringList + PatternProperties []openAPINamedSchemaMetadata + PropertyNames *openAPISchemaMetadata + UnevaluatedItems *openAPISchemaMetadata + UnevaluatedProperties *openAPIDynamicSchemaBool + Items *openAPIDynamicSchemaBool + ID string + Anchor string + DynamicAnchor string + DynamicRef string + Comment string + ContentSchema *openAPISchemaMetadata + Vocabulary []openAPIStringBool + Not *openAPISchemaMetadata + Properties []openAPINamedSchemaMetadata + Title string + MultipleOf *openAPIFloat + Maximum *openAPIFloat + Minimum *openAPIFloat + MaxLength *openAPIInt + MinLength *openAPIInt + Pattern string + Format string + MaxItems *openAPIInt + MinItems *openAPIInt + UniqueItems *openAPIBool + MaxProperties *openAPIInt + MinProperties *openAPIInt + Required []string + Enum []*openAPIYAMLNode + AdditionalProperties *openAPIDynamicSchemaBool + Description string + ContentEncoding string + ContentMediaType string + Default *openAPIYAMLNode + Const *openAPIYAMLNode + Nullable *openAPIBool + ReadOnly *openAPIBool + WriteOnly *openAPIBool + Example *openAPIYAMLNode + Deprecated *openAPIBool + Extensions []openAPINamedYAMLNode +} + +type openAPIDynamicBoolNumber struct { + Bool *openAPIBool + Number *openAPIFloat +} + +type openAPIDynamicSchemaBool struct { + Schema *openAPISchemaMetadata + Bool *openAPIBool +} + +type openAPIDiscriminatorMetadata struct { + PropertyName string + Mapping []openAPIStringString + DefaultMapping string +} + +type openAPINamedSchemaMetadata struct { + Name string + Schema *openAPISchemaMetadata +} + +type openAPINamedYAMLNode struct { + Name string + Value *openAPIYAMLNode +} + +type openAPIStringBool struct { + Name string + Value bool +} + +type openAPIStringString struct { + Name string + Value string +} + +type openAPIStringList struct { + Name string + Values []string +} + +type openAPIYAMLNode struct { + Kind string + Style int + Tag string + Value string + Anchor string + Content []*openAPIYAMLNode + Alias *openAPIYAMLNode +} + +type openAPIFloat struct { + Value float64 +} + +type openAPIInt struct { + Value int64 +} + +type openAPIBool struct { + Value bool +} +`) +} + +func (g *Generator) schemaMetadataLiteral(schema *highbase.Schema, depth int) string { + return g.schemaMetadataLiteralWithRef("", schema, depth) +} + +func (g *Generator) schemaMetadataLiteralWithRef(ref string, schema *highbase.Schema, depth int) string { + if schema == nil { + return "nil" + } + var b strings.Builder + b.WriteString("&openAPISchemaMetadata{\n") + writeMetadataField(&b, depth+1, "Ref", metadataStringLiteral(ref)) + writeMetadataField(&b, depth+1, "SchemaTypeRef", metadataStringLiteral(schema.SchemaTypeRef)) + writeMetadataField(&b, depth+1, "ExclusiveMaximum", metadataDynamicBoolNumberLiteral(schema.ExclusiveMaximum)) + writeMetadataField(&b, depth+1, "ExclusiveMinimum", metadataDynamicBoolNumberLiteral(schema.ExclusiveMinimum)) + writeMetadataField(&b, depth+1, "Type", metadataStringSliceLiteral(schema.Type, depth+1)) + writeMetadataField(&b, depth+1, "AllOf", g.schemaSliceMetadataLiteral(schema.AllOf, depth+1)) + writeMetadataField(&b, depth+1, "OneOf", g.schemaSliceMetadataLiteral(schema.OneOf, depth+1)) + writeMetadataField(&b, depth+1, "AnyOf", g.schemaSliceMetadataLiteral(schema.AnyOf, depth+1)) + writeMetadataField(&b, depth+1, "Discriminator", metadataDiscriminatorLiteral(schema.Discriminator, depth+1)) + writeMetadataField(&b, depth+1, "Examples", metadataYAMLNodeSliceLiteral(schema.Examples, depth+1)) + writeMetadataField(&b, depth+1, "PrefixItems", g.schemaSliceMetadataLiteral(schema.PrefixItems, depth+1)) + writeMetadataField(&b, depth+1, "Contains", g.optionalSchemaProxyMetadataLiteral(schema.Contains, depth+1)) + writeMetadataField(&b, depth+1, "MinContains", metadataIntLiteral(schema.MinContains)) + writeMetadataField(&b, depth+1, "MaxContains", metadataIntLiteral(schema.MaxContains)) + writeMetadataField(&b, depth+1, "If", g.optionalSchemaProxyMetadataLiteral(schema.If, depth+1)) + writeMetadataField(&b, depth+1, "Else", g.optionalSchemaProxyMetadataLiteral(schema.Else, depth+1)) + writeMetadataField(&b, depth+1, "Then", g.optionalSchemaProxyMetadataLiteral(schema.Then, depth+1)) + writeMetadataField(&b, depth+1, "DependentSchemas", g.schemaMapMetadataLiteral(schema.DependentSchemas, depth+1)) + writeMetadataField(&b, depth+1, "DependentRequired", metadataStringListMapLiteral(schema.DependentRequired, depth+1)) + writeMetadataField(&b, depth+1, "PatternProperties", g.schemaMapMetadataLiteral(schema.PatternProperties, depth+1)) + writeMetadataField(&b, depth+1, "PropertyNames", g.optionalSchemaProxyMetadataLiteral(schema.PropertyNames, depth+1)) + writeMetadataField(&b, depth+1, "UnevaluatedItems", g.optionalSchemaProxyMetadataLiteral(schema.UnevaluatedItems, depth+1)) + writeMetadataField(&b, depth+1, "UnevaluatedProperties", g.metadataDynamicSchemaBoolLiteral(schema.UnevaluatedProperties, depth+1)) + writeMetadataField(&b, depth+1, "Items", g.metadataDynamicSchemaBoolLiteral(schema.Items, depth+1)) + writeMetadataField(&b, depth+1, "ID", metadataStringLiteral(schema.Id)) + writeMetadataField(&b, depth+1, "Anchor", metadataStringLiteral(schema.Anchor)) + writeMetadataField(&b, depth+1, "DynamicAnchor", metadataStringLiteral(schema.DynamicAnchor)) + writeMetadataField(&b, depth+1, "DynamicRef", metadataStringLiteral(schema.DynamicRef)) + writeMetadataField(&b, depth+1, "Comment", metadataStringLiteral(schema.Comment)) + writeMetadataField(&b, depth+1, "ContentSchema", g.optionalSchemaProxyMetadataLiteral(schema.ContentSchema, depth+1)) + writeMetadataField(&b, depth+1, "Vocabulary", metadataStringBoolMapLiteral(schema.Vocabulary, depth+1)) + writeMetadataField(&b, depth+1, "Not", g.optionalSchemaProxyMetadataLiteral(schema.Not, depth+1)) + writeMetadataField(&b, depth+1, "Properties", g.schemaMapMetadataLiteral(schema.Properties, depth+1)) + writeMetadataField(&b, depth+1, "Title", metadataStringLiteral(schema.Title)) + writeMetadataField(&b, depth+1, "MultipleOf", metadataFloatLiteral(schema.MultipleOf)) + writeMetadataField(&b, depth+1, "Maximum", metadataFloatLiteral(schema.Maximum)) + writeMetadataField(&b, depth+1, "Minimum", metadataFloatLiteral(schema.Minimum)) + writeMetadataField(&b, depth+1, "MaxLength", metadataIntLiteral(schema.MaxLength)) + writeMetadataField(&b, depth+1, "MinLength", metadataIntLiteral(schema.MinLength)) + writeMetadataField(&b, depth+1, "Pattern", metadataStringLiteral(schema.Pattern)) + writeMetadataField(&b, depth+1, "Format", metadataStringLiteral(schema.Format)) + writeMetadataField(&b, depth+1, "MaxItems", metadataIntLiteral(schema.MaxItems)) + writeMetadataField(&b, depth+1, "MinItems", metadataIntLiteral(schema.MinItems)) + writeMetadataField(&b, depth+1, "UniqueItems", metadataBoolLiteral(schema.UniqueItems)) + writeMetadataField(&b, depth+1, "MaxProperties", metadataIntLiteral(schema.MaxProperties)) + writeMetadataField(&b, depth+1, "MinProperties", metadataIntLiteral(schema.MinProperties)) + writeMetadataField(&b, depth+1, "Required", metadataStringSliceLiteral(schema.Required, depth+1)) + writeMetadataField(&b, depth+1, "Enum", metadataYAMLNodeSliceLiteral(schema.Enum, depth+1)) + writeMetadataField(&b, depth+1, "AdditionalProperties", g.metadataDynamicSchemaBoolLiteral(schema.AdditionalProperties, depth+1)) + writeMetadataField(&b, depth+1, "Description", metadataStringLiteral(schema.Description)) + writeMetadataField(&b, depth+1, "ContentEncoding", metadataStringLiteral(schema.ContentEncoding)) + writeMetadataField(&b, depth+1, "ContentMediaType", metadataStringLiteral(schema.ContentMediaType)) + writeMetadataField(&b, depth+1, "Default", optionalMetadataYAMLNodeLiteral(schema.Default, depth+1)) + writeMetadataField(&b, depth+1, "Const", optionalMetadataYAMLNodeLiteral(schema.Const, depth+1)) + writeMetadataField(&b, depth+1, "Nullable", metadataBoolLiteral(schema.Nullable)) + writeMetadataField(&b, depth+1, "ReadOnly", metadataBoolLiteral(schema.ReadOnly)) + writeMetadataField(&b, depth+1, "WriteOnly", metadataBoolLiteral(schema.WriteOnly)) + writeMetadataField(&b, depth+1, "Example", optionalMetadataYAMLNodeLiteral(schema.Example, depth+1)) + writeMetadataField(&b, depth+1, "Deprecated", metadataBoolLiteral(schema.Deprecated)) + writeMetadataField(&b, depth+1, "Extensions", metadataExtensionsLiteral(schema.Extensions, depth+1)) + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func (g *Generator) schemaProxyMetadataLiteral(proxy *highbase.SchemaProxy, depth int) string { + if proxy == nil { + return "nil" + } + if proxy.IsReference() { + if schema := referenceSiblingMetadataSchema(proxy); schema != nil { + return g.schemaMetadataLiteralWithRef(proxy.GetReference(), schema, depth) + } + return "&openAPISchemaMetadata{Ref: " + strconv.Quote(proxy.GetReference()) + "}" + } + schema, _ := proxy.BuildSchema() + return g.schemaMetadataLiteral(schema, depth) +} + +func referenceSiblingMetadataSchema(proxy *highbase.SchemaProxy) *highbase.Schema { + if proxy == nil || !proxy.IsReference() { + return nil + } + if proxy.GoLow() == nil { + return proxy.Schema() + } + if refNode := proxy.GetReferenceNode(); refNode != nil && len(refNode.Content) > 2 { + return schemaFromReferenceSiblingNode(refNode) + } + return nil +} + +func schemaFromReferenceSiblingNode(refNode *yaml.Node) *highbase.Schema { + siblingNode := &yaml.Node{Kind: yaml.MappingNode} + if refNode != nil { + for i := 0; i < len(refNode.Content)-1; i += 2 { + if refNode.Content[i].Value != "$ref" { + siblingNode.Content = append(siblingNode.Content, refNode.Content[i], refNode.Content[i+1]) + } + } + } + if len(siblingNode.Content) == 0 { + return nil + } + raw, _ := yaml.Marshal(siblingNode) + proxy, _ := schemaProxyFromProviderYAML("RefSibling", string(raw)) + return proxy.Schema() +} + +func (g *Generator) optionalSchemaProxyMetadataLiteral(proxy *highbase.SchemaProxy, depth int) string { + if proxy == nil { + return "" + } + return g.schemaProxyMetadataLiteral(proxy, depth) +} + +func (g *Generator) schemaSliceMetadataLiteral(schemas []*highbase.SchemaProxy, depth int) string { + if len(schemas) == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]*openAPISchemaMetadata{\n") + for _, schema := range schemas { + b.WriteString(metadataIndent(depth + 1)) + if schema == nil { + b.WriteString("nil") + } else { + b.WriteString(g.schemaProxyMetadataLiteral(schema, depth+1)) + } + b.WriteString(",\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func (g *Generator) schemaMapMetadataLiteral(schemas *orderedmap.Map[string, *highbase.SchemaProxy], depth int) string { + if schemas == nil || schemas.Len() == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]openAPINamedSchemaMetadata{\n") + for name, schema := range schemas.FromOldest() { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString("{Name: ") + b.WriteString(strconv.Quote(name)) + b.WriteString(", Schema: ") + if schema == nil { + b.WriteString("nil") + } else { + b.WriteString(g.schemaProxyMetadataLiteral(schema, depth+1)) + } + b.WriteString("},\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func (g *Generator) metadataDynamicSchemaBoolLiteral(value *highbase.DynamicValue[*highbase.SchemaProxy, bool], depth int) string { + if value == nil { + return "" + } + if value.IsB() { + return "&openAPIDynamicSchemaBool{Bool: " + metadataBoolValueLiteral(value.B) + "}" + } + return "&openAPIDynamicSchemaBool{Schema: " + g.schemaProxyMetadataLiteral(value.A, depth) + "}" +} + +func metadataDynamicBoolNumberLiteral(value *highbase.DynamicValue[bool, float64]) string { + if value == nil { + return "" + } + if value.IsB() { + return "&openAPIDynamicBoolNumber{Number: " + metadataFloatValueLiteral(value.B) + "}" + } + return "&openAPIDynamicBoolNumber{Bool: " + metadataBoolValueLiteral(value.A) + "}" +} + +func metadataDiscriminatorLiteral(discriminator *highbase.Discriminator, depth int) string { + if discriminator == nil { + return "" + } + var b strings.Builder + b.WriteString("&openAPIDiscriminatorMetadata{\n") + writeMetadataField(&b, depth+1, "PropertyName", metadataStringLiteral(discriminator.PropertyName)) + writeMetadataField(&b, depth+1, "Mapping", metadataStringStringMapLiteral(discriminator.Mapping, depth+1)) + writeMetadataField(&b, depth+1, "DefaultMapping", metadataStringLiteral(discriminator.DefaultMapping)) + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataStringStringMapLiteral(values *orderedmap.Map[string, string], depth int) string { + if values == nil || values.Len() == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]openAPIStringString{\n") + for name, value := range values.FromOldest() { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString("{Name: ") + b.WriteString(strconv.Quote(name)) + b.WriteString(", Value: ") + b.WriteString(strconv.Quote(value)) + b.WriteString("},\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataStringBoolMapLiteral(values *orderedmap.Map[string, bool], depth int) string { + if values == nil || values.Len() == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]openAPIStringBool{\n") + for name, value := range values.FromOldest() { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString("{Name: ") + b.WriteString(strconv.Quote(name)) + b.WriteString(", Value: ") + b.WriteString(strconv.FormatBool(value)) + b.WriteString("},\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataStringListMapLiteral(values *orderedmap.Map[string, []string], depth int) string { + if values == nil || values.Len() == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]openAPIStringList{\n") + for name, list := range values.FromOldest() { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString("{Name: ") + b.WriteString(strconv.Quote(name)) + b.WriteString(", Values: ") + b.WriteString(metadataStringSliceLiteral(list, depth+1)) + b.WriteString("},\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataExtensionsLiteral(values *orderedmap.Map[string, *yaml.Node], depth int) string { + if values == nil || values.Len() == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]openAPINamedYAMLNode{\n") + for name, value := range values.FromOldest() { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString("{Name: ") + b.WriteString(strconv.Quote(name)) + b.WriteString(", Value: ") + b.WriteString(metadataYAMLNodeLiteral(value, depth+1)) + b.WriteString("},\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataYAMLNodeSliceLiteral(nodes []*yaml.Node, depth int) string { + if len(nodes) == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]*openAPIYAMLNode{\n") + for _, node := range nodes { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString(metadataYAMLNodeLiteral(node, depth+1)) + b.WriteString(",\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataYAMLNodeLiteral(node *yaml.Node, depth int) string { + if node == nil { + return "nil" + } + var b strings.Builder + b.WriteString("&openAPIYAMLNode{\n") + writeMetadataField(&b, depth+1, "Kind", strconv.Quote(metadataYAMLKind(node.Kind))) + writeMetadataField(&b, depth+1, "Style", metadataPlainIntLiteral(int64(node.Style))) + writeMetadataField(&b, depth+1, "Tag", metadataStringLiteral(node.Tag)) + writeMetadataField(&b, depth+1, "Value", metadataStringLiteral(node.Value)) + writeMetadataField(&b, depth+1, "Anchor", metadataStringLiteral(node.Anchor)) + writeMetadataField(&b, depth+1, "Content", metadataYAMLNodeContentLiteral(node.Content, depth+1)) + writeMetadataField(&b, depth+1, "Alias", optionalMetadataYAMLNodeLiteral(node.Alias, depth+1)) + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func optionalMetadataYAMLNodeLiteral(node *yaml.Node, depth int) string { + if node == nil { + return "" + } + return metadataYAMLNodeLiteral(node, depth) +} + +func metadataYAMLNodeContentLiteral(nodes []*yaml.Node, depth int) string { + if len(nodes) == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]*openAPIYAMLNode{\n") + for _, node := range nodes { + b.WriteString(metadataIndent(depth + 1)) + b.WriteString(metadataYAMLNodeLiteral(node, depth+1)) + b.WriteString(",\n") + } + b.WriteString(metadataIndent(depth)) + b.WriteByte('}') + return b.String() +} + +func metadataYAMLKind(kind yaml.Kind) string { + switch kind { + case yaml.DocumentNode: + return "document" + case yaml.SequenceNode: + return "sequence" + case yaml.MappingNode: + return "mapping" + case yaml.AliasNode: + return "alias" + default: + return "scalar" + } +} + +func metadataStringSliceLiteral(values []string, depth int) string { + if len(values) == 0 { + return "" + } + var b strings.Builder + b.WriteString("[]string{") + for i, value := range values { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(value)) + } + b.WriteByte('}') + return b.String() +} + +func metadataStringLiteral(value string) string { + if value == "" { + return "" + } + return strconv.Quote(value) +} + +func metadataFloatLiteral(value *float64) string { + if value == nil { + return "" + } + return metadataFloatValueLiteral(*value) +} + +func metadataFloatValueLiteral(value float64) string { + return "&openAPIFloat{Value: " + strconv.FormatFloat(value, 'g', -1, 64) + "}" +} + +func metadataIntLiteral(value *int64) string { + if value == nil { + return "" + } + return metadataIntValueLiteral(*value) +} + +func metadataIntValueLiteral(value int64) string { + return "&openAPIInt{Value: " + strconv.FormatInt(value, 10) + "}" +} + +func metadataPlainIntLiteral(value int64) string { + if value == 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + +func metadataBoolLiteral(value *bool) string { + if value == nil { + return "" + } + return metadataBoolValueLiteral(*value) +} + +func metadataBoolValueLiteral(value bool) string { + return "&openAPIBool{Value: " + strconv.FormatBool(value) + "}" +} + +func writeMetadataField(b *strings.Builder, depth int, name, value string) { + if value == "" { + return + } + b.WriteString(metadataIndent(depth)) + b.WriteString(name) + b.WriteString(": ") + b.WriteString(value) + b.WriteString(",\n") +} + +func metadataIndent(depth int) string { + if depth <= 0 { + return "" + } + return strings.Repeat("\t", depth) +} diff --git a/generator/golang/reflection_conformance_test.go b/generator/golang/reflection_conformance_test.go new file mode 100644 index 00000000..065ddb40 --- /dev/null +++ b/generator/golang/reflection_conformance_test.go @@ -0,0 +1,300 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "go.yaml.in/yaml/v4" +) + +type ReflectConformanceID string + +type ReflectConformanceStatus string + +type ReflectConformanceEmbedded struct { + TraceID string `json:"trace_id"` +} + +type ReflectConformancePaymentMethod interface { + reflectConformancePaymentMethod() +} + +type ReflectConformanceCard struct { + Object string `json:"object"` + Number string `json:"number"` + CVC string `json:"cvc,omitempty"` +} + +func (ReflectConformanceCard) reflectConformancePaymentMethod() {} + +type ReflectConformanceBank struct { + Object string `json:"object"` + AccountNumber string `json:"account_number"` + BankName *string `json:"bank_name,omitempty"` +} + +func (ReflectConformanceBank) reflectConformancePaymentMethod() {} + +type ReflectConformanceAddress struct { + Line1 string `json:"line1"` + Line2 *string `json:"line2,omitempty"` + City string `json:"city"` +} + +type ReflectConformanceAttribute struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` +} + +type ReflectConformanceRoot struct { + *ReflectConformanceEmbedded `json:",omitempty"` + ID ReflectConformanceID `json:"id"` + Status ReflectConformanceStatus `json:"status"` + Active bool `json:"active"` + Count int `json:"count,string,omitempty"` + Limit int `json:"limit,omitzero"` + Nickname *string `json:"nickname,omitempty"` + Data []byte `json:"data,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` + Tags []string `json:"tags,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Attributes map[string]ReflectConformanceAttribute `json:"attributes,omitempty"` + Address *ReflectConformanceAddress `json:"address,omitempty"` + Payment ReflectConformancePaymentMethod `json:"payment,omitempty"` + History []ReflectConformancePaymentMethod `json:"history,omitempty"` + Self *ReflectConformanceRoot `json:"self,omitempty"` + Skipped string `json:"-"` +} + +func TestReflectOpenAPIConformanceSchemaSet(t *testing.T) { + set := renderReflectConformanceSet(t) + if set.Root == nil || !set.Root.IsReference() || set.Root.GetReference() != "#/components/schemas/ReflectConformanceRoot" { + t.Fatalf("unexpected root ref: %#v", set.Root) + } + if root, ok := set.Roots.Get("ReflectConformanceRoot"); !ok || !root.IsReference() { + t.Fatalf("missing root entry: %#v", set.Roots) + } + assertComponentKeysSorted(t, set.Components) + for _, name := range []string{ + "ReflectConformanceAddress", + "ReflectConformanceAttribute", + "ReflectConformanceBank", + "ReflectConformanceCard", + "ReflectConformanceEmbedded", + "ReflectConformanceRoot", + "ReflectConformanceRoot_Attributes", + "ReflectConformanceRoot_Labels", + "ReflectConformanceRoot_Payment", + "ReflectConformanceStatus", + } { + if _, ok := set.Components.Get(name); !ok { + t.Fatalf("missing component %s", name) + } + } + root := componentSchema(t, set, "ReflectConformanceRoot") + for _, required := range []string{"active", "id", "status"} { + if !containsString(root.Required, required) { + t.Fatalf("missing required field %q in %#v", required, root.Required) + } + } + for _, optional := range []string{"trace_id", "count", "limit", "nickname", "payment"} { + if containsString(root.Required, optional) { + t.Fatalf("field %q should be optional in %#v", optional, root.Required) + } + } + if _, ok := root.Properties.Get("Skipped"); ok { + t.Fatal("json:- field should not be exported") + } + + id := root.Properties.GetOrZero("id").Schema() + if id == nil || id.Type[0] != "string" || id.Format != "uuid" { + t.Fatalf("id should use custom uuid schema, got %#v", id) + } + status := root.Properties.GetOrZero("status") + if !status.IsReference() || status.GetReference() != "#/components/schemas/ReflectConformanceStatus" { + t.Fatalf("status should reference enum component, got %#v", status) + } + statusSchema := componentSchema(t, set, "ReflectConformanceStatus") + if statusSchema.Type[0] != "string" || len(statusSchema.Enum) != 2 { + t.Fatalf("status enum schema was not preserved: %#v", statusSchema) + } + count := root.Properties.GetOrZero("count").Schema() + if count == nil || count.Type[0] != "string" { + t.Fatalf("json,string count should render as string, got %#v", count) + } + nickname := root.Properties.GetOrZero("nickname").Schema() + if nickname == nil || !schemaTypeContains(nickname.Type, "null") || nickname.Nullable != nil { + t.Fatalf("pointer scalar should be nullable, got %#v", nickname) + } + data := root.Properties.GetOrZero("data").Schema() + if data == nil || data.Type[0] != "string" || data.Format != "byte" { + t.Fatalf("[]byte should render as string byte, got %#v", data) + } + raw := root.Properties.GetOrZero("raw").Schema() + if raw == nil || len(raw.Type) != 0 { + t.Fatalf("json.RawMessage should be unconstrained, got %#v", raw) + } + self := root.Properties.GetOrZero("self") + assertNullableRef(t, self, "#/components/schemas/ReflectConformanceRoot") + address := root.Properties.GetOrZero("address") + assertNullableRef(t, address, "#/components/schemas/ReflectConformanceAddress") + addressSchema := componentSchema(t, set, "ReflectConformanceAddress") + if schemaTypeContains(addressSchema.Type, "null") || addressSchema.Nullable != nil { + t.Fatalf("address component should stay non-null; nullable belongs at ref usage, got %#v", addressSchema) + } + if line2 := addressSchema.Properties.GetOrZero("line2").Schema(); line2 == nil || !schemaTypeContains(line2.Type, "null") || line2.Nullable != nil { + t.Fatalf("pointer scalar in address should be nullable, got %#v", line2) + } + embedded := componentSchema(t, set, "ReflectConformanceEmbedded") + if schemaTypeContains(embedded.Type, "null") || embedded.Nullable != nil { + t.Fatalf("embedded component should stay non-null; nullable belongs at usage, got %#v", embedded) + } + labels := root.Properties.GetOrZero("labels") + if !labels.IsReference() || labels.GetReference() != "#/components/schemas/ReflectConformanceRoot_Labels" { + t.Fatalf("labels should reference map component, got %#v", labels) + } + labelSchema := componentSchema(t, set, "ReflectConformanceRoot_Labels") + labelValue := labelSchema.AdditionalProperties.A.Schema() + if labelValue == nil || labelValue.Type[0] != "string" { + t.Fatalf("labels additionalProperties should be string, got %#v", labelSchema.AdditionalProperties) + } + attributes := componentSchema(t, set, "ReflectConformanceRoot_Attributes") + if !attributes.AdditionalProperties.A.IsReference() || attributes.AdditionalProperties.A.GetReference() != "#/components/schemas/ReflectConformanceAttribute" { + t.Fatalf("attributes map should reference attribute component, got %#v", attributes.AdditionalProperties) + } + payment := root.Properties.GetOrZero("payment") + if !payment.IsReference() || payment.GetReference() != "#/components/schemas/ReflectConformanceRoot_Payment" { + t.Fatalf("payment should reference union component, got %#v", payment) + } + paymentSchema := componentSchema(t, set, "ReflectConformanceRoot_Payment") + if len(paymentSchema.OneOf) != 2 || paymentSchema.Discriminator == nil || paymentSchema.Discriminator.PropertyName != "object" { + t.Fatalf("payment should be discriminated oneOf, got %#v", paymentSchema) + } + history := root.Properties.GetOrZero("history").Schema() + if history == nil || history.Items == nil || !history.Items.IsA() || !history.Items.A.IsReference() { + t.Fatalf("history should be array of union refs, got %#v", history) + } + if history.Items.A.GetReference() != "#/components/schemas/ReflectConformanceRoot_Payment" { + t.Fatalf("history item should reference item union, got %q", history.Items.A.GetReference()) + } + if !hasDiagnosticCode(set.Diagnostics, DiagnosticStringEncoded) { + t.Fatalf("expected string encoded diagnostic, got %#v", set.Diagnostics) + } +} + +func assertNullableRef(t *testing.T, proxy *highbase.SchemaProxy, ref string) { + t.Helper() + if proxy == nil { + t.Fatalf("nullable ref should render as anyOf wrapper for %s, got nil", ref) + } + schema := proxy.Schema() + if schema == nil || len(schema.AnyOf) != 2 { + t.Fatalf("nullable ref should render as anyOf wrapper, got %#v", proxy) + } + if !schema.AnyOf[0].IsReference() || schema.AnyOf[0].GetReference() != ref { + t.Fatalf("nullable ref first variant should be %s, got %#v", ref, schema.AnyOf[0]) + } + nullSchema := schema.AnyOf[1].Schema() + if nullSchema == nil || !schemaTypeContains(nullSchema.Type, "null") { + t.Fatalf("nullable ref second variant should be null schema, got %#v", schema.AnyOf[1]) + } +} + +func TestReflectOpenAPIConformanceGoldenDocument(t *testing.T) { + doc := renderReflectConformanceOpenAPIDocument(t, renderReflectConformanceSet(t)) + assertGolden(t, "testdata/reflect_openapi_conformance.golden.yaml", doc) + parsed, err := libopenapi.NewDocument(doc) + if err != nil { + t.Fatal(err) + } + if _, err := parsed.BuildV3Model(); err != nil { + t.Fatal(err) + } +} + +func TestReflectOpenAPIConformanceNameResolverCollision(t *testing.T) { + set, err := SchemasFromTypesWithOptions([]reflect.Type{ + reflect.TypeOf(ReflectConformanceAddress{}), + reflect.TypeOf(ReflectConformanceAttribute{}), + }, WithTypeNameResolver(func(name string) string { + if strings.HasPrefix(name, "ReflectConformance") { + return "Collision" + } + return "" + })) + if err != nil { + t.Fatal(err) + } + if !hasDiagnosticCode(set.Diagnostics, DiagnosticRootNameCollision) || !hasDiagnosticCode(set.Diagnostics, DiagnosticComponentNameCollision) { + t.Fatalf("expected root and component collision diagnostics, got %#v", set.Diagnostics) + } +} + +func renderReflectConformanceSet(t *testing.T) *SchemaSet { + t.Helper() + statusSchema := highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Enum: []*yaml.Node{ + stringNode("active"), + stringNode("paused"), + }, + }) + idSchema := highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"string"}, + Format: "uuid", + }) + set, err := SchemasFromTypesWithOptions([]reflect.Type{reflect.TypeOf(ReflectConformanceRoot{})}, + WithTypeSchema(reflect.TypeOf(ReflectConformanceID("")), idSchema), + WithTypeSchema(reflect.TypeOf(ReflectConformanceStatus("")), statusSchema), + WithOneOfTypes((*ReflectConformancePaymentMethod)(nil), ReflectConformanceCard{}, ReflectConformanceBank{}), + WithDiscriminatorMapping((*ReflectConformancePaymentMethod)(nil), "object", map[string]string{ + "bank_account": "#/components/schemas/ReflectConformanceBank", + "card": "#/components/schemas/ReflectConformanceCard", + }), + ) + if err != nil { + t.Fatal(err) + } + return set +} + +func renderReflectConformanceOpenAPIDocument(t *testing.T, set *SchemaSet) []byte { + t.Helper() + var b strings.Builder + b.WriteString("openapi: 3.1.0\n") + b.WriteString("info:\n") + b.WriteString(" title: Reflected Go Model Conformance API\n") + b.WriteString(" version: 1.0.0\n") + b.WriteString("paths: {}\n") + b.WriteString("components:\n") + b.WriteString(" schemas:\n") + for name, proxy := range set.Components.FromOldest() { + rendered, err := proxy.Render() + if err != nil { + t.Fatal(err) + } + b.WriteString(" ") + b.WriteString(name) + b.WriteString(":\n") + b.WriteString(indentString(string(rendered), " ")) + } + return []byte(b.String()) +} + +func indentString(in, prefix string) string { + lines := strings.Split(strings.TrimSuffix(in, "\n"), "\n") + var b strings.Builder + for _, line := range lines { + b.WriteString(prefix) + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} diff --git a/generator/golang/reflection_parity_test.go b/generator/golang/reflection_parity_test.go new file mode 100644 index 00000000..90aaa2ef --- /dev/null +++ b/generator/golang/reflection_parity_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "encoding/json" + "reflect" + "testing" +) + +type ReflectionParityEmbedded struct { + TraceID string `json:"trace_id"` +} + +type ReflectionParityModel struct { + *ReflectionParityEmbedded `json:",omitempty"` + Count int `json:"count,string,omitempty"` + Limit int `json:"limit,omitzero"` + Data []byte `json:"data,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +func TestReflectionParityJSONEncodingSemantics(t *testing.T) { + set, err := SchemasFromTypes(reflect.TypeOf(ReflectionParityModel{})) + if err != nil { + t.Fatal(err) + } + model := componentSchema(t, set, "ReflectionParityModel") + if _, ok := model.Properties.Get("ReflectionParityEmbedded"); ok { + t.Fatal("anonymous embedded field should be flattened when tag has no explicit name") + } + trace, ok := model.Properties.Get("trace_id") + if !ok { + t.Fatal("missing promoted trace_id property") + } + if trace.Schema().Type[0] != "string" { + t.Fatalf("trace_id should be string, got %#v", trace.Schema()) + } + if containsString(model.Required, "trace_id") { + t.Fatalf("omitempty anonymous pointer fields should not promote required children: %#v", model.Required) + } + if containsString(model.Required, "limit") { + t.Fatalf("omitzero should make limit optional: %#v", model.Required) + } + + count := model.Properties.GetOrZero("count").Schema() + if count.Type[0] != "string" { + t.Fatalf("json ,string field should render as string, got %#v", count) + } + data := model.Properties.GetOrZero("data").Schema() + if data.Type[0] != "string" || data.Format != "byte" { + t.Fatalf("[]byte should render as string byte, got %#v", data) + } + raw := model.Properties.GetOrZero("raw").Schema() + if len(raw.Type) != 0 { + t.Fatalf("json.RawMessage should render as unconstrained schema, got %#v", raw) + } + if !hasDiagnosticCode(set.Diagnostics, DiagnosticStringEncoded) { + t.Fatalf("expected string encoded diagnostic, got %#v", set.Diagnostics) + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/generator/golang/reuse_test.go b/generator/golang/reuse_test.go new file mode 100644 index 00000000..5c479876 --- /dev/null +++ b/generator/golang/reuse_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "bytes" + "reflect" + "sync" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +type reuseWidget struct { + ID string `json:"id"` + Size int `json:"size,omitempty"` +} + +func reuseWidgetSchema() *highbase.SchemaProxy { + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("id", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + props.Set("size", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"integer"}})) + return highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Properties: props, + Required: []string{"id"}, + }) +} + +// A configured generator must produce identical output when reused, with no +// per-invocation state leaking between calls. +func TestGeneratorReuseIsStable(t *testing.T) { + gen := NewGenerator(WithPackageName("models")) + schema := reuseWidgetSchema() + first, err := gen.RenderSchema("Widget", schema) + if err != nil { + t.Fatalf("first render failed: %v", err) + } + second, err := gen.RenderSchema("Widget", schema) + if err != nil { + t.Fatalf("second render failed: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatalf("reused generator produced divergent output:\n--- first ---\n%s\n--- second ---\n%s", first, second) + } + + // SchemaFromType previously reset no state; reusing it must stay correct. + a, err := gen.SchemaFromType(reflect.TypeOf(reuseWidget{})) + if err != nil || a == nil { + t.Fatalf("first SchemaFromType failed: %v", err) + } + b, err := gen.SchemaFromType(reflect.TypeOf(reuseWidget{})) + if err != nil || b == nil { + t.Fatalf("reused SchemaFromType failed: %v", err) + } +} + +// Concurrent use of a single configured generator must not corrupt output. +// Run with -race to exercise the shared-config / fresh-run-state boundary. +func TestGeneratorConcurrentReuse(t *testing.T) { + gen := NewGenerator(WithPackageName("models")) + schema := reuseWidgetSchema() + want, err := gen.RenderSchema("Widget", schema) + if err != nil { + t.Fatalf("baseline render failed: %v", err) + } + const workers = 16 + var wg sync.WaitGroup + failures := make(chan []byte, workers) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + got, renderErr := gen.RenderSchema("Widget", schema) + if renderErr != nil || !bytes.Equal(got, want) { + failures <- got + } + }() + } + wg.Wait() + close(failures) + if got, ok := <-failures; ok { + t.Fatalf("concurrent render diverged from baseline:\n%s", got) + } +} diff --git a/generator/golang/roundtrip_behavior_test.go b/generator/golang/roundtrip_behavior_test.go new file mode 100644 index 00000000..f1c4dcb2 --- /dev/null +++ b/generator/golang/roundtrip_behavior_test.go @@ -0,0 +1,232 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "strings" + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +func TestRoundTripOpenAPIIRPreservesJSONSchemaFidelity(t *testing.T) { + gen := NewGenerator() + ir, err := gen.irFromOpenAPI("round trip", schemaProxyFromYAML(t, ` +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://example.com/schemas/round-trip +$anchor: root +$dynamicAnchor: node +$comment: retained metadata +title: Round Trip Root +type: object +minProperties: 1 +maxProperties: 5 +unevaluatedProperties: + type: string +properties: + value: + type: [string, integer, "null"] + tuple: + type: array + prefixItems: + - type: string + items: false + contains: + type: string + minContains: 1 + dynamic: + $dynamicRef: '#/components/schemas/Node' + encoded: + type: string + contentEncoding: base64 + contentMediaType: application/json + contentSchema: + type: object +`), "round trip") + if err != nil { + t.Fatal(err) + } + + roundTripped := gen.openapiFromIR(ir).Schema() + if roundTripped == nil { + t.Fatal("expected round-tripped schema") + } + if roundTripped.SchemaTypeRef == "" || roundTripped.Id == "" || roundTripped.Anchor == "" || roundTripped.DynamicAnchor == "" || roundTripped.Comment == "" || roundTripped.Title != "Round Trip Root" { + t.Fatalf("metadata was not preserved: %#v", roundTripped) + } + if roundTripped.MinProperties == nil || *roundTripped.MinProperties != 1 || roundTripped.MaxProperties == nil || *roundTripped.MaxProperties != 5 { + t.Fatalf("object validation keywords were not preserved: %#v", roundTripped) + } + if roundTripped.UnevaluatedProperties == nil || !roundTripped.UnevaluatedProperties.IsA() { + t.Fatalf("schema-valued unevaluatedProperties was not preserved: %#v", roundTripped.UnevaluatedProperties) + } + + value := roundTripped.Properties.GetOrZero("value").Schema() + if got := strings.Join(value.Type, ","); got != "string,integer,null" { + t.Fatalf("multi-type schema was not preserved, got %q", got) + } + tuple := roundTripped.Properties.GetOrZero("tuple").Schema() + if tuple.Items == nil || !tuple.Items.IsB() || tuple.Items.B { + t.Fatalf("items:false was not preserved: %#v", tuple.Items) + } + if tuple.Contains == nil || tuple.MinContains == nil || *tuple.MinContains != 1 { + t.Fatalf("contains keywords were not preserved: %#v", tuple) + } + dynamic := roundTripped.Properties.GetOrZero("dynamic").Schema() + if dynamic.DynamicRef != "#/components/schemas/Node" { + t.Fatalf("dynamic ref was not preserved: %#v", dynamic) + } + encoded := roundTripped.Properties.GetOrZero("encoded").Schema() + if encoded.ContentEncoding != "base64" || encoded.ContentMediaType != "application/json" || encoded.ContentSchema == nil { + t.Fatalf("content keywords were not preserved: %#v", encoded) + } +} + +func TestGeneratedBehaviorDiscriminatedUnionJSON(t *testing.T) { + catProperties := orderedmap.New[string, *highbase.SchemaProxy]() + catProperties.Set("kind", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + catProperties.Set("name", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + dogProperties := orderedmap.New[string, *highbase.SchemaProxy]() + dogProperties.Set("kind", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + dogProperties.Set("bark", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"string"}})) + mapping := orderedmap.New[string, string]() + mapping.Set("cat", "#/components/schemas/Cat") + mapping.Set("dog", "#/components/schemas/Dog") + schemas := orderedmap.New[string, *highbase.SchemaProxy]() + schemas.Set("Cat", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Required: []string{"kind", "name"}, + Properties: catProperties, + })) + schemas.Set("Dog", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Required: []string{"kind", "bark"}, + Properties: dogProperties, + })) + schemas.Set("Pet", highbase.CreateSchemaProxy(&highbase.Schema{ + OneOf: []*highbase.SchemaProxy{ + highbase.CreateSchemaProxyRef("#/components/schemas/Cat"), + highbase.CreateSchemaProxyRef("#/components/schemas/Dog"), + }, + Discriminator: &highbase.Discriminator{ + PropertyName: "kind", + Mapping: mapping, + }, + })) + holderProperties := orderedmap.New[string, *highbase.SchemaProxy]() + holderProperties.Set("pet", highbase.CreateSchemaProxyRef("#/components/schemas/Pet")) + schemas.Set("Holder", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{"object"}, + Required: []string{"pet"}, + Properties: holderProperties, + })) + file, err := NewGenerator().RenderSchemas(schemas) + if err != nil { + t.Fatal(err) + } + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestDiscriminatedUnionJSON(t *testing.T) { + var pet PetUnion + if err := json.Unmarshal([]byte("{\"kind\":\"cat\",\"name\":\"milo\"}"), &pet); err != nil { + t.Fatal(err) + } + cat, ok := pet.Value.(Cat) + if !ok || cat.Kind != "cat" || cat.Name != "milo" { + t.Fatalf("unexpected cat value: %#v", pet.Value) + } + out, err := json.Marshal(pet) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "\"kind\":\"cat\"") || !strings.Contains(string(out), "\"name\":\"milo\"") { + t.Fatalf("unexpected marshal output: %s", out) + } + if err := json.Unmarshal([]byte("{\"kind\":\"lizard\"}"), &pet); err == nil { + t.Fatal("expected unknown discriminator error") + } + var holder Holder + if err := json.Unmarshal([]byte("{\"pet\":{\"kind\":\"dog\",\"bark\":\"woof\"}}"), &holder); err != nil { + t.Fatal(err) + } + dog, ok := holder.Pet.Value.(Dog) + if !ok || dog.Bark != "woof" { + t.Fatalf("ref to union field did not decode through union wrapper: %#v", holder.Pet.Value) + } +} +`) +} + +func TestGeneratedBehaviorRawUnionAndNullableEnumJSON(t *testing.T) { + source, err := RenderSchema("union holder", schemaProxyFromYAML(t, ` +type: object +required: [status] +properties: + value: + type: [string, integer] + status: + enum: + - null + - active +`)) + if err != nil { + t.Fatal(err) + } + assertParsesCompilesAndTests(t, source, `package models + +import ( + "encoding/json" + "testing" +) + +func TestRawUnionAndNullableEnumJSON(t *testing.T) { + var holder UnionHolder + if err := json.Unmarshal([]byte("{\"value\":123,\"status\":\"active\"}"), &holder); err != nil { + t.Fatal(err) + } + if holder.Value == nil || string(holder.Value.Bytes()) != "123" { + t.Fatalf("raw union did not capture bytes: %#v", holder.Value) + } + copied := holder.Value.Bytes() + copied[0] = '9' + if string(holder.Value.Bytes()) != "123" { + t.Fatal("raw union Bytes should return a copy") + } + if holder.Status == nil || *holder.Status != UnionHolder_Status("active") { + t.Fatalf("nullable enum did not decode active value: %#v", holder.Status) + } + out, err := json.Marshal(UnionHolder{Value: &UnionHolder_ValueUnion{Raw: json.RawMessage("\"abc\"")}}) + if err != nil { + t.Fatal(err) + } + if string(out) != "{\"value\":\"abc\",\"status\":null}" { + t.Fatalf("unexpected raw union marshal: %s", out) + } + var empty UnionHolder_ValueUnion + if !empty.IsZero() { + t.Fatal("zero raw union should report IsZero") + } + out, err = json.Marshal(empty) + if err != nil { + t.Fatal(err) + } + if string(out) != "null" { + t.Fatalf("zero raw union should marshal null, got %s", out) + } + if err := json.Unmarshal([]byte("{\"status\":null}"), &holder); err != nil { + t.Fatal(err) + } + if holder.Status != nil { + t.Fatalf("nullable enum should decode null to nil, got %#v", holder.Status) + } +} +`) +} diff --git a/generator/golang/roundtrip_components_test.go b/generator/golang/roundtrip_components_test.go new file mode 100644 index 00000000..95a52284 --- /dev/null +++ b/generator/golang/roundtrip_components_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + whatchanged "github.com/pb33f/libopenapi/what-changed/model" +) + +// TestTrainTravelComponentsRoundTrip codifies the bi-directional identity of the +// generator over the train-travel components.schemas: +// +// components.schemas --RenderSchemas--> Go classes (+ metadata sidecar) +// Go classes --SchemasFromTypes--> reconstructed components.schemas +// +// The reconstructed schemas must be semantically identical to the originals. +// "Identical" is judged by libopenapi's own what-changed diff engine rather than +// raw bytes: the back half rebuilds and re-renders each schema, so key ordering +// is not preserved, but the meaning must be. A perfect round trip yields zero +// changes; any drift is reported per component. +// +// The backward leg needs the generated types as runtime reflect types, so this +// test compiles the generated package in a temp module and runs it (it depends +// on the Go toolchain being available, like TestTrainTravelFullCircle...). +func TestTrainTravelComponentsRoundTrip(t *testing.T) { + file := renderTrainTravel(t, trainTravelFullCircleOptions()...) + if file.SchemaMetadata == nil { + t.Fatal("expected a schema metadata sidecar for a high-fidelity round trip") + } + + reconstructed := reflectComponentsRoundTrip(t, file) + + spec, err := os.ReadFile("testdata/train-travel.yaml") + if err != nil { + t.Fatal(err) + } + specDoc, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatalf("cannot parse train-travel spec: %v", err) + } + model, err := specDoc.BuildV3Model() + if err != nil { + t.Fatalf("cannot build train-travel model: %v", err) + } + original := assembleComponentsDoc(t, model.Model.Components.Schemas) + + originalDoc, err := libopenapi.NewDocument(original) + if err != nil { + t.Fatalf("cannot parse original components doc: %v", err) + } + reconstructedDoc, err := libopenapi.NewDocument(reconstructed) + if err != nil { + t.Fatalf("cannot parse reconstructed components doc:\n%s\nerror: %v", reconstructed, err) + } + + changes, err := libopenapi.CompareDocuments(originalDoc, reconstructedDoc) + if err != nil { + t.Fatalf("cannot compare documents: %v", err) + } + if changes == nil || changes.TotalChanges() == 0 { + return // perfect round trip + } + + // There are differences; codify them per component for a precise report. + schemaChanges := changes.ComponentsChanges.SchemaChanges + for name := range model.Model.Components.Schemas.FromOldest() { + component := name + t.Run(component, func(t *testing.T) { + sc := schemaChanges[component] + if sc != nil && sc.TotalChanges() > 0 { + t.Fatalf("component %q is not identical after a round trip:\n%s", component, formatChanges(sc.GetAllChanges())) + } + }) + } + t.Run("document", func(t *testing.T) { + if changes.TotalChanges() > 0 { + t.Fatalf("round trip introduced %d change(s) beyond the original components:\n%s", + changes.TotalChanges(), formatChanges(changes.GetAllChanges())) + } + }) +} + +// reflectComponentsRoundTrip compiles the generated package in a throwaway +// module and reflects its types back into a minimal OpenAPI document containing +// just the reconstructed components.schemas. +func reflectComponentsRoundTrip(t *testing.T, file *GeneratedFile) []byte { + t.Helper() + repoRoot := repoRootDir(t) + dir := t.TempDir() + writeTempModule(t, dir, repoRoot) + writeTempFile(t, filepath.Join(dir, "internal", "trainmodels", "models_gen.go"), file.Source) + writeTempFile(t, filepath.Join(dir, "internal", "trainmodels", file.SchemaMetadata.Name), file.SchemaMetadata.Source) + program := strings.Replace(roundTripDriverProgram, "__TYPES__", reflectTypeList(file), 1) + writeTempFile(t, filepath.Join(dir, "cmd", "roundtrip", "main.go"), []byte(program)) + + out := filepath.Join(dir, "reconstructed.yaml") + cmd := exec.Command("go", "run", "./cmd/roundtrip") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod", "ROUNDTRIP_OUT="+out) + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("reflect round-trip command failed: %v\n%s", err, combined) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + return data +} + +// reflectTypeList emits the reflect.TypeOf lines for every generated root object +// type, so the driver always reflects exactly the components that were emitted. +func reflectTypeList(file *GeneratedFile) string { + var b strings.Builder + for _, ty := range file.Types { + if ty.Kind == KindObject || ty.Kind == KindAllOf { + fmt.Fprintf(&b, "\t\treflect.TypeOf(trainmodels.%s{}),\n", ty.Name) + } + } + return b.String() +} + +// assembleComponentsDoc wraps a set of schemas into a minimal OpenAPI 3.1 +// document so two component sets can be diffed like for like. +func assembleComponentsDoc(t *testing.T, schemas *orderedmap.Map[string, *highbase.SchemaProxy]) []byte { + t.Helper() + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo:\n title: roundtrip\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n") + for name, proxy := range schemas.FromOldest() { + rendered, err := proxy.Render() + if err != nil { + t.Fatalf("cannot render schema %q: %v", name, err) + } + b.WriteString(" ") + b.WriteString(name) + b.WriteString(":\n") + b.WriteString(indentSchemaYAML(string(rendered), " ")) + } + return []byte(b.String()) +} + +func formatChanges(changes []*whatchanged.Change) string { + var b strings.Builder + for _, c := range changes { + if c == nil { + continue + } + fmt.Fprintf(&b, " - %s: %q -> %q (breaking=%v)\n", c.Property, c.Original, c.New, c.Breaking) + } + return b.String() +} + +const roundTripDriverProgram = `package main + +import ( + "os" + "strings" + + gogenerator "github.com/pb33f/libopenapi/generator/golang" + "reflect" + + "trainfullcircle/internal/trainmodels" +) + +func main() { + set, err := gogenerator.SchemasFromTypes( +__TYPES__ ) + if err != nil { + panic(err) + } + var b strings.Builder + b.WriteString("openapi: 3.1.0\ninfo:\n title: roundtrip\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n") + for name, proxy := range set.Components.FromOldest() { + rendered, err := proxy.Render() + if err != nil { + panic(err) + } + b.WriteString(" ") + b.WriteString(name) + b.WriteString(":\n") + for _, line := range strings.Split(strings.TrimRight(string(rendered), "\n"), "\n") { + b.WriteString(" ") + b.WriteString(line) + b.WriteByte('\n') + } + } + if err := os.WriteFile(os.Getenv("ROUNDTRIP_OUT"), []byte(b.String()), 0o600); err != nil { + panic(err) + } +} +` diff --git a/generator/golang/schema_metadata.go b/generator/golang/schema_metadata.go new file mode 100644 index 00000000..f7c9278c --- /dev/null +++ b/generator/golang/schema_metadata.go @@ -0,0 +1,435 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "encoding/json" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +type SchemaMetadataProvider interface { + OpenAPISchemaMetadata() any +} + +type providerSchemaMetadata struct { + Ref string + SchemaTypeRef string + ExclusiveMaximum *providerDynamicBoolNumber + ExclusiveMinimum *providerDynamicBoolNumber + Type []string + AllOf []*providerSchemaMetadata + OneOf []*providerSchemaMetadata + AnyOf []*providerSchemaMetadata + Discriminator *providerDiscriminatorMetadata + Examples []*providerYAMLNode + PrefixItems []*providerSchemaMetadata + Contains *providerSchemaMetadata + MinContains *providerInt + MaxContains *providerInt + If *providerSchemaMetadata + Else *providerSchemaMetadata + Then *providerSchemaMetadata + DependentSchemas []providerNamedSchemaMetadata + DependentRequired []providerStringList + PatternProperties []providerNamedSchemaMetadata + PropertyNames *providerSchemaMetadata + UnevaluatedItems *providerSchemaMetadata + UnevaluatedProperties *providerDynamicSchemaBool + Items *providerDynamicSchemaBool + ID string + Anchor string + DynamicAnchor string + DynamicRef string + Comment string + ContentSchema *providerSchemaMetadata + Vocabulary []providerStringBool + Not *providerSchemaMetadata + Properties []providerNamedSchemaMetadata + Title string + MultipleOf *providerFloat + Maximum *providerFloat + Minimum *providerFloat + MaxLength *providerInt + MinLength *providerInt + Pattern string + Format string + MaxItems *providerInt + MinItems *providerInt + UniqueItems *providerBool + MaxProperties *providerInt + MinProperties *providerInt + Required []string + Enum []*providerYAMLNode + AdditionalProperties *providerDynamicSchemaBool + Description string + ContentEncoding string + ContentMediaType string + Default *providerYAMLNode + Const *providerYAMLNode + Nullable *providerBool + ReadOnly *providerBool + WriteOnly *providerBool + Example *providerYAMLNode + Deprecated *providerBool + Extensions []providerNamedYAMLNode +} + +type providerDynamicBoolNumber struct { + Bool *providerBool + Number *providerFloat +} + +type providerDynamicSchemaBool struct { + Schema *providerSchemaMetadata + Bool *providerBool +} + +type providerDiscriminatorMetadata struct { + PropertyName string + Mapping []providerStringString + DefaultMapping string +} + +type providerNamedSchemaMetadata struct { + Name string + Schema *providerSchemaMetadata +} + +type providerNamedYAMLNode struct { + Name string + Value *providerYAMLNode +} + +type providerStringBool struct { + Name string + Value bool +} + +type providerStringString struct { + Name string + Value string +} + +type providerStringList struct { + Name string + Values []string +} + +type providerYAMLNode struct { + Kind string + Style int + Tag string + Value string + Anchor string + Content []*providerYAMLNode + Alias *providerYAMLNode +} + +type providerFloat struct { + Value float64 +} + +type providerInt struct { + Value int64 +} + +type providerBool struct { + Value bool +} + +func schemaProxyFromProviderMetadata(value any) (*highbase.SchemaProxy, error) { + if value == nil { + return nil, ErrNilSchema + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var metadata providerSchemaMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, err + } + return schemaProxyFromMetadata(&metadata), nil +} + +func schemaProxyFromMetadata(metadata *providerSchemaMetadata) *highbase.SchemaProxy { + if metadata == nil { + return nil + } + if metadata.Ref != "" { + if schemaMetadataHasSiblings(metadata) { + return highbase.CreateSchemaProxyRefWithSchema(metadata.Ref, schemaFromMetadata(metadata)) + } + return highbase.CreateSchemaProxyRef(metadata.Ref) + } + return highbase.CreateSchemaProxy(schemaFromMetadata(metadata)) +} + +func schemaFromMetadata(metadata *providerSchemaMetadata) *highbase.Schema { + if metadata == nil { + return nil + } + return &highbase.Schema{ + SchemaTypeRef: metadata.SchemaTypeRef, + ExclusiveMaximum: dynamicBoolNumberFromMetadata(metadata.ExclusiveMaximum), + ExclusiveMinimum: dynamicBoolNumberFromMetadata(metadata.ExclusiveMinimum), + Type: append([]string(nil), metadata.Type...), + AllOf: schemaSliceFromMetadata(metadata.AllOf), + OneOf: schemaSliceFromMetadata(metadata.OneOf), + AnyOf: schemaSliceFromMetadata(metadata.AnyOf), + Discriminator: discriminatorFromMetadata(metadata.Discriminator), + Examples: yamlNodeSliceFromMetadata(metadata.Examples), + PrefixItems: schemaSliceFromMetadata(metadata.PrefixItems), + Contains: schemaProxyFromMetadata(metadata.Contains), + MinContains: intFromMetadata(metadata.MinContains), + MaxContains: intFromMetadata(metadata.MaxContains), + If: schemaProxyFromMetadata(metadata.If), + Else: schemaProxyFromMetadata(metadata.Else), + Then: schemaProxyFromMetadata(metadata.Then), + DependentSchemas: schemaMapFromMetadata(metadata.DependentSchemas), + DependentRequired: stringListMapFromMetadata(metadata.DependentRequired), + PatternProperties: schemaMapFromMetadata(metadata.PatternProperties), + PropertyNames: schemaProxyFromMetadata(metadata.PropertyNames), + UnevaluatedItems: schemaProxyFromMetadata(metadata.UnevaluatedItems), + UnevaluatedProperties: dynamicSchemaBoolFromMetadata(metadata.UnevaluatedProperties), + Items: dynamicSchemaBoolFromMetadata(metadata.Items), + Id: metadata.ID, + Anchor: metadata.Anchor, + DynamicAnchor: metadata.DynamicAnchor, + DynamicRef: metadata.DynamicRef, + Comment: metadata.Comment, + ContentSchema: schemaProxyFromMetadata(metadata.ContentSchema), + Vocabulary: stringBoolMapFromMetadata(metadata.Vocabulary), + Not: schemaProxyFromMetadata(metadata.Not), + Properties: schemaMapFromMetadata(metadata.Properties), + Title: metadata.Title, + MultipleOf: floatFromMetadata(metadata.MultipleOf), + Maximum: floatFromMetadata(metadata.Maximum), + Minimum: floatFromMetadata(metadata.Minimum), + MaxLength: intFromMetadata(metadata.MaxLength), + MinLength: intFromMetadata(metadata.MinLength), + Pattern: metadata.Pattern, + Format: metadata.Format, + MaxItems: intFromMetadata(metadata.MaxItems), + MinItems: intFromMetadata(metadata.MinItems), + UniqueItems: boolFromMetadata(metadata.UniqueItems), + MaxProperties: intFromMetadata(metadata.MaxProperties), + MinProperties: intFromMetadata(metadata.MinProperties), + Required: append([]string(nil), metadata.Required...), + Enum: yamlNodeSliceFromMetadata(metadata.Enum), + AdditionalProperties: dynamicSchemaBoolFromMetadata(metadata.AdditionalProperties), + Description: metadata.Description, + ContentEncoding: metadata.ContentEncoding, + ContentMediaType: metadata.ContentMediaType, + Default: yamlNodeFromMetadata(metadata.Default), + Const: yamlNodeFromMetadata(metadata.Const), + Nullable: boolFromMetadata(metadata.Nullable), + ReadOnly: boolFromMetadata(metadata.ReadOnly), + WriteOnly: boolFromMetadata(metadata.WriteOnly), + Example: yamlNodeFromMetadata(metadata.Example), + Deprecated: boolFromMetadata(metadata.Deprecated), + Extensions: extensionsFromMetadata(metadata.Extensions), + } +} + +func schemaMetadataHasSiblings(metadata *providerSchemaMetadata) bool { + if metadata == nil { + return false + } + cp := *metadata + cp.Ref = "" + return !schemaMetadataEmpty(&cp) +} + +func schemaMetadataEmpty(metadata *providerSchemaMetadata) bool { + return metadata == nil || + (metadata.SchemaTypeRef == "" && metadata.ExclusiveMaximum == nil && metadata.ExclusiveMinimum == nil && + len(metadata.Type) == 0 && len(metadata.AllOf) == 0 && len(metadata.OneOf) == 0 && len(metadata.AnyOf) == 0 && + metadata.Discriminator == nil && len(metadata.Examples) == 0 && len(metadata.PrefixItems) == 0 && + metadata.Contains == nil && metadata.MinContains == nil && metadata.MaxContains == nil && metadata.If == nil && + metadata.Else == nil && metadata.Then == nil && len(metadata.DependentSchemas) == 0 && + len(metadata.DependentRequired) == 0 && len(metadata.PatternProperties) == 0 && metadata.PropertyNames == nil && + metadata.UnevaluatedItems == nil && metadata.UnevaluatedProperties == nil && metadata.Items == nil && + metadata.ID == "" && metadata.Anchor == "" && metadata.DynamicAnchor == "" && metadata.DynamicRef == "" && + metadata.Comment == "" && metadata.ContentSchema == nil && len(metadata.Vocabulary) == 0 && metadata.Not == nil && + len(metadata.Properties) == 0 && metadata.Title == "" && metadata.MultipleOf == nil && metadata.Maximum == nil && + metadata.Minimum == nil && metadata.MaxLength == nil && metadata.MinLength == nil && metadata.Pattern == "" && + metadata.Format == "" && metadata.MaxItems == nil && metadata.MinItems == nil && metadata.UniqueItems == nil && + metadata.MaxProperties == nil && metadata.MinProperties == nil && len(metadata.Required) == 0 && + len(metadata.Enum) == 0 && metadata.AdditionalProperties == nil && metadata.Description == "" && + metadata.ContentEncoding == "" && metadata.ContentMediaType == "" && metadata.Default == nil && + metadata.Const == nil && metadata.Nullable == nil && metadata.ReadOnly == nil && metadata.WriteOnly == nil && + metadata.Example == nil && metadata.Deprecated == nil && len(metadata.Extensions) == 0) +} + +func dynamicBoolNumberFromMetadata(metadata *providerDynamicBoolNumber) *highbase.DynamicValue[bool, float64] { + if metadata == nil { + return nil + } + if metadata.Number != nil { + return &highbase.DynamicValue[bool, float64]{N: 1, B: metadata.Number.Value} + } + if metadata.Bool != nil { + return &highbase.DynamicValue[bool, float64]{A: metadata.Bool.Value} + } + return nil +} + +func dynamicSchemaBoolFromMetadata(metadata *providerDynamicSchemaBool) *highbase.DynamicValue[*highbase.SchemaProxy, bool] { + if metadata == nil { + return nil + } + if metadata.Bool != nil { + return &highbase.DynamicValue[*highbase.SchemaProxy, bool]{N: 1, B: metadata.Bool.Value} + } + return &highbase.DynamicValue[*highbase.SchemaProxy, bool]{A: schemaProxyFromMetadata(metadata.Schema)} +} + +func discriminatorFromMetadata(metadata *providerDiscriminatorMetadata) *highbase.Discriminator { + if metadata == nil { + return nil + } + return &highbase.Discriminator{ + PropertyName: metadata.PropertyName, + Mapping: stringStringMapFromMetadata(metadata.Mapping), + DefaultMapping: metadata.DefaultMapping, + } +} + +func schemaSliceFromMetadata(values []*providerSchemaMetadata) []*highbase.SchemaProxy { + if len(values) == 0 { + return nil + } + out := make([]*highbase.SchemaProxy, 0, len(values)) + for _, value := range values { + out = append(out, schemaProxyFromMetadata(value)) + } + return out +} + +func schemaMapFromMetadata(values []providerNamedSchemaMetadata) *orderedmap.Map[string, *highbase.SchemaProxy] { + if len(values) == 0 { + return nil + } + out := orderedmap.New[string, *highbase.SchemaProxy]() + for _, value := range values { + out.Set(value.Name, schemaProxyFromMetadata(value.Schema)) + } + return out +} + +func stringBoolMapFromMetadata(values []providerStringBool) *orderedmap.Map[string, bool] { + if len(values) == 0 { + return nil + } + out := orderedmap.New[string, bool]() + for _, value := range values { + out.Set(value.Name, value.Value) + } + return out +} + +func stringStringMapFromMetadata(values []providerStringString) *orderedmap.Map[string, string] { + if len(values) == 0 { + return nil + } + out := orderedmap.New[string, string]() + for _, value := range values { + out.Set(value.Name, value.Value) + } + return out +} + +func stringListMapFromMetadata(values []providerStringList) *orderedmap.Map[string, []string] { + if len(values) == 0 { + return nil + } + out := orderedmap.New[string, []string]() + for _, value := range values { + out.Set(value.Name, append([]string(nil), value.Values...)) + } + return out +} + +func extensionsFromMetadata(values []providerNamedYAMLNode) *orderedmap.Map[string, *yaml.Node] { + if len(values) == 0 { + return nil + } + out := orderedmap.New[string, *yaml.Node]() + for _, value := range values { + out.Set(value.Name, yamlNodeFromMetadata(value.Value)) + } + return out +} + +func yamlNodeSliceFromMetadata(values []*providerYAMLNode) []*yaml.Node { + if len(values) == 0 { + return nil + } + out := make([]*yaml.Node, 0, len(values)) + for _, value := range values { + out = append(out, yamlNodeFromMetadata(value)) + } + return out +} + +func yamlNodeFromMetadata(metadata *providerYAMLNode) *yaml.Node { + if metadata == nil { + return nil + } + node := &yaml.Node{ + Kind: yamlKindFromMetadata(metadata.Kind), + Style: yaml.Style(metadata.Style), + Tag: metadata.Tag, + Value: metadata.Value, + Anchor: metadata.Anchor, + Alias: yamlNodeFromMetadata(metadata.Alias), + } + for _, child := range metadata.Content { + node.Content = append(node.Content, yamlNodeFromMetadata(child)) + } + return node +} + +func yamlKindFromMetadata(kind string) yaml.Kind { + switch kind { + case "document": + return yaml.DocumentNode + case "sequence": + return yaml.SequenceNode + case "mapping": + return yaml.MappingNode + case "alias": + return yaml.AliasNode + default: + return yaml.ScalarNode + } +} + +func floatFromMetadata(metadata *providerFloat) *float64 { + if metadata == nil { + return nil + } + value := metadata.Value + return &value +} + +func intFromMetadata(metadata *providerInt) *int64 { + if metadata == nil { + return nil + } + value := metadata.Value + return &value +} + +func boolFromMetadata(metadata *providerBool) *bool { + if metadata == nil { + return nil + } + value := metadata.Value + return &value +} diff --git a/generator/golang/tags.go b/generator/golang/tags.go new file mode 100644 index 00000000..fcb6403f --- /dev/null +++ b/generator/golang/tags.go @@ -0,0 +1,71 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + "strings" +) + +type fieldTag struct { + name string + skip bool + omitempty bool + stringEncoded bool + hasName bool + openapi openAPIMetadata +} + +func parseJSONTag(field reflect.StructField) fieldTag { + tag := field.Tag.Get("json") + if tag == "-" { + return fieldTag{skip: true} + } + if tag == "" { + return fieldTag{name: field.Name, openapi: parseOpenAPITag(field.Tag.Get("openapi"))} + } + parts := strings.Split(tag, ",") + name := parts[0] + hasName := name != "" + if name == "" { + name = field.Name + } + ft := fieldTag{name: name, hasName: hasName, openapi: parseOpenAPITag(field.Tag.Get("openapi"))} + for _, opt := range parts[1:] { + if opt == "omitempty" || opt == "omitzero" { + ft.omitempty = true + } + if opt == "string" { + ft.stringEncoded = true + } + } + return ft +} + +func tagLiteral(name string, required bool, jsonTags, yamlTags, omitEmpty bool, openapiTag string) string { + var tags []string + value := name + if !required && omitEmpty { + value += ",omitempty" + } + if jsonTags { + tags = append(tags, `json:"`+escapeStructTagValue(value)+`"`) + } + if yamlTags { + tags = append(tags, `yaml:"`+escapeStructTagValue(value)+`"`) + } + if openapiTag != "" { + tags = append(tags, `openapi:"`+escapeStructTagValue(openapiTag)+`"`) + } + if len(tags) == 0 { + return "" + } + return "`" + strings.Join(tags, " ") + "`" +} + +func escapeStructTagValue(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + return value +} diff --git a/generator/golang/testdata/jsonschema-2020-12.yaml b/generator/golang/testdata/jsonschema-2020-12.yaml new file mode 100644 index 00000000..8aaa8e63 --- /dev/null +++ b/generator/golang/testdata/jsonschema-2020-12.yaml @@ -0,0 +1,238 @@ +openapi: 3.1.0 +info: + title: JSON Schema 2020-12 Model Torture API + version: 1.0.0 + summary: OpenAPI 3.1 schema coverage for generator/golang. +components: + schemas: + TortureDocument: + $schema: https://json-schema.org/draft/2020-12/schema + $id: https://pb33f.io/schemas/torture-document + $anchor: torture-document + $comment: Exercises JSON Schema 2020-12 model-generation edge cases. + type: object + required: + - id + - kind + - payment + minProperties: 3 + maxProperties: 20 + properties: + id: + type: string + format: uuid + readOnly: true + kind: + type: string + const: torture + multi_value: + description: A nullable multi-type value. + type: + - string + - integer + - "null" + nullable_status: + $ref: '#/components/schemas/NullableStatus' + mixed_enum: + $ref: '#/components/schemas/MixedEnum' + string_enum: + $ref: '#/components/schemas/StringEnum' + int_enum: + $ref: '#/components/schemas/IntEnum' + float_enum: + $ref: '#/components/schemas/FloatEnum' + bool_enum: + $ref: '#/components/schemas/BoolEnum' + closed_config: + $ref: '#/components/schemas/ClosedConfig' + labels: + $ref: '#/components/schemas/StringMap' + tuple: + $ref: '#/components/schemas/TupleProbe' + object_rules: + $ref: '#/components/schemas/ObjectRules' + encoded_payload: + $ref: '#/components/schemas/EncodedPayload' + payment: + $ref: '#/components/schemas/PaymentSource' + loose_choice: + $ref: '#/components/schemas/LooseChoice' + dynamic_node: + $dynamicRef: '#tree-node' + dependentRequired: + payment: + - labels + dependentSchemas: + encoded_payload: + required: + - object_rules + unevaluatedProperties: + type: string + StringEnum: + type: string + enum: + - draft + - published + IntEnum: + type: integer + enum: + - 1 + - 2 + FloatEnum: + type: number + enum: + - 1.5 + - 2 + BoolEnum: + type: boolean + enum: + - true + - false + NullableStatus: + enum: + - null + - active + - inactive + MixedEnum: + enum: + - "off" + - 1 + - true + ClosedConfig: + type: object + required: + - enabled + properties: + enabled: + type: boolean + threshold: + type: number + minimum: 0 + exclusiveMaximum: 100 + additionalProperties: false + StringMap: + type: object + additionalProperties: + type: string + TupleProbe: + type: array + prefixItems: + - type: string + - type: integer + items: false + contains: + type: string + minContains: 1 + maxContains: 2 + minItems: 1 + maxItems: 2 + uniqueItems: true + unevaluatedItems: + type: boolean + ObjectRules: + type: object + minProperties: 1 + maxProperties: 6 + propertyNames: + pattern: '^[a-z_]+$' + patternProperties: + '^x-': + type: string + properties: + name: + type: string + minLength: 1 + maxLength: 30 + pattern: '^[A-Za-z0-9 _-]+$' + count: + type: integer + multipleOf: 1 + minimum: 0 + dependentRequired: + name: + - count + dependentSchemas: + count: + properties: + name: + type: string + if: + required: + - name + then: + required: + - count + else: + maxProperties: 2 + not: + required: + - forbidden + unevaluatedProperties: false + EncodedPayload: + type: string + contentEncoding: base64 + contentMediaType: application/json + contentSchema: + type: object + properties: + payload_id: + type: string + TreeNode: + $id: https://pb33f.io/schemas/tree-node + $dynamicAnchor: tree-node + type: object + properties: + name: + type: string + children: + type: array + items: + $dynamicRef: '#tree-node' + PaymentSource: + description: A discriminated payment source. + oneOf: + - $ref: '#/components/schemas/CardSource' + - $ref: '#/components/schemas/BankSource' + discriminator: + propertyName: object + mapping: + card: '#/components/schemas/CardSource' + bank_account: '#/components/schemas/BankSource' + CardSource: + type: object + required: + - object + - number + - cvc + properties: + object: + type: string + const: card + number: + type: string + minLength: 12 + maxLength: 19 + cvc: + type: string + minLength: 3 + maxLength: 4 + writeOnly: true + additionalProperties: false + BankSource: + type: object + required: + - object + - account_number + properties: + object: + type: string + const: bank_account + account_number: + type: string + bank_name: + type: string + additionalProperties: false + LooseChoice: + anyOf: + - type: string + - type: integer diff --git a/generator/golang/testdata/jsonschema_2020_12_default.golden.go b/generator/golang/testdata/jsonschema_2020_12_default.golden.go new file mode 100644 index 00000000..2b863d61 --- /dev/null +++ b/generator/golang/testdata/jsonschema_2020_12_default.golden.go @@ -0,0 +1,218 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type TortureDocument_MultiValueUnion struct { + Raw json.RawMessage +} + +func (u *TortureDocument_MultiValueUnion) UnmarshalJSON(data []byte) error { + u.Raw = append(u.Raw[:0], data...) + return nil +} + +func (u TortureDocument_MultiValueUnion) MarshalJSON() ([]byte, error) { + if len(u.Raw) == 0 { + return []byte("null"), nil + } + return u.Raw, nil +} + +func (u TortureDocument_MultiValueUnion) IsZero() bool { + return len(u.Raw) == 0 +} + +func (u TortureDocument_MultiValueUnion) Bytes() []byte { + return append([]byte(nil), u.Raw...) +} + +type TortureDocument struct { + // ID readOnly. + ID string `json:"id"` + Kind string `json:"kind"` + // MultiValue A nullable multi-type value. + MultiValue *TortureDocument_MultiValueUnion `json:"multi_value,omitempty"` + NullableStatus *NullableStatus `json:"nullable_status,omitempty"` + MixedEnum *MixedEnum `json:"mixed_enum,omitempty"` + StringEnum *StringEnum `json:"string_enum,omitempty"` + IntEnum *IntEnum `json:"int_enum,omitempty"` + FloatEnum *FloatEnum `json:"float_enum,omitempty"` + BoolEnum *BoolEnum `json:"bool_enum,omitempty"` + ClosedConfig *ClosedConfig `json:"closed_config,omitempty"` + Labels *StringMap `json:"labels,omitempty"` + Tuple *TupleProbe `json:"tuple,omitempty"` + ObjectRules *ObjectRules `json:"object_rules,omitempty"` + EncodedPayload *EncodedPayload `json:"encoded_payload,omitempty"` + Payment PaymentSourceUnion `json:"payment"` + LooseChoice *LooseChoiceUnion `json:"loose_choice,omitempty"` + DynamicNode *TreeNode `json:"dynamic_node,omitempty"` +} + +type StringEnum string + +type IntEnum int + +type FloatEnum float64 + +type BoolEnum bool + +type NullableStatus string + +type MixedEnum any + +type ClosedConfig struct { + Enabled bool `json:"enabled"` + Threshold *float64 `json:"threshold,omitempty"` +} + +type StringMap struct { + AdditionalProperties map[string]string `json:"-"` +} + +func (m *StringMap) UnmarshalJSON(data []byte) error { + type Alias StringMap + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = StringMap(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) == 0 { + return nil + } + m.AdditionalProperties = make(map[string]string, len(raw)) + for key, value := range raw { + var decoded string + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties[key] = decoded + } + return nil +} + +func (m StringMap) MarshalJSON() ([]byte, error) { + type Alias StringMap + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type TupleProbe []any + +type ObjectRules struct { + Name *string `json:"name,omitempty"` + Count *int `json:"count,omitempty"` +} + +type EncodedPayload string + +type TreeNode struct { + Name *string `json:"name,omitempty"` + Children []TreeNode `json:"children,omitempty"` +} + +type PaymentSource interface { + isPaymentSource() +} + +func (CardSource) isPaymentSource() {} + +func (BankSource) isPaymentSource() {} + +type PaymentSourceUnion struct { + Value PaymentSource +} + +func (u PaymentSourceUnion) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + +func (u PaymentSourceUnion) IsZero() bool { + return u.Value == nil +} + +func (u *PaymentSourceUnion) UnmarshalJSON(data []byte) error { + var discriminator struct { + Value string `json:"object"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + switch discriminator.Value { + case "bank_account": + var v BankSource + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + case "card": + var v CardSource + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + default: + return fmt.Errorf("unknown object discriminator value %q", discriminator.Value) + } + return nil +} + +type CardSource struct { + Object string `json:"object"` + Number string `json:"number"` + // CVC writeOnly. + CVC string `json:"cvc"` +} + +type BankSource struct { + Object string `json:"object"` + AccountNumber string `json:"account_number"` + BankName *string `json:"bank_name,omitempty"` +} + +type LooseChoiceUnion struct { + Raw json.RawMessage +} + +func (u *LooseChoiceUnion) UnmarshalJSON(data []byte) error { + u.Raw = append(u.Raw[:0], data...) + return nil +} + +func (u LooseChoiceUnion) MarshalJSON() ([]byte, error) { + if len(u.Raw) == 0 { + return []byte("null"), nil + } + return u.Raw, nil +} + +func (u LooseChoiceUnion) IsZero() bool { + return len(u.Raw) == 0 +} + +func (u LooseChoiceUnion) Bytes() []byte { + return append([]byte(nil), u.Raw...) +} diff --git a/generator/golang/testdata/jsonschema_2020_12_options.golden.go b/generator/golang/testdata/jsonschema_2020_12_options.golden.go new file mode 100644 index 00000000..0e40ae7c --- /dev/null +++ b/generator/golang/testdata/jsonschema_2020_12_options.golden.go @@ -0,0 +1,198 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type TortureDocument_MultiValueUnion struct { + Raw json.RawMessage +} + +func (u *TortureDocument_MultiValueUnion) UnmarshalJSON(data []byte) error { + u.Raw = append(u.Raw[:0], data...) + return nil +} + +func (u TortureDocument_MultiValueUnion) MarshalJSON() ([]byte, error) { + if len(u.Raw) == 0 { + return []byte("null"), nil + } + return u.Raw, nil +} + +func (u TortureDocument_MultiValueUnion) IsZero() bool { + return len(u.Raw) == 0 +} + +func (u TortureDocument_MultiValueUnion) Bytes() []byte { + return append([]byte(nil), u.Raw...) +} + +type TortureDocument struct { + // ID readOnly. + ID string `json:"id"` + Kind string `json:"kind"` + // MultiValue A nullable multi-type value. + MultiValue *TortureDocument_MultiValueUnion `json:"multi_value,omitempty"` + NullableStatus *NullableStatus `json:"nullable_status,omitempty"` + MixedEnum *MixedEnum `json:"mixed_enum,omitempty"` + StringEnum *StringEnum `json:"string_enum,omitempty"` + IntEnum *IntEnum `json:"int_enum,omitempty"` + FloatEnum *FloatEnum `json:"float_enum,omitempty"` + BoolEnum *BoolEnum `json:"bool_enum,omitempty"` + ClosedConfig *ClosedConfig `json:"closed_config,omitempty"` + Labels *StringMap `json:"labels,omitempty"` + Tuple *TupleProbe `json:"tuple,omitempty"` + ObjectRules *ObjectRules `json:"object_rules,omitempty"` + EncodedPayload *EncodedPayload `json:"encoded_payload,omitempty"` + Payment PaymentSourceUnion `json:"payment"` + LooseChoice *LooseChoiceUnion `json:"loose_choice,omitempty"` + DynamicNode *TreeNode `json:"dynamic_node,omitempty"` +} + +type StringEnum string + +const ( + StringEnumDraft StringEnum = "draft" + StringEnumPublished StringEnum = "published" +) + +type IntEnum int + +const ( + IntEnumValue1 IntEnum = 1 + IntEnumValue2 IntEnum = 2 +) + +type FloatEnum float64 + +const ( + FloatEnumValue15 FloatEnum = 1.5 + FloatEnumValue2 FloatEnum = 2 +) + +type BoolEnum bool + +const ( + BoolEnumTrue BoolEnum = true + BoolEnumFalse BoolEnum = false +) + +type NullableStatus string + +const ( + NullableStatusActive NullableStatus = "active" + NullableStatusInactive NullableStatus = "inactive" +) + +type MixedEnum any + +type ClosedConfig struct { + Enabled bool `json:"enabled"` + Threshold *float64 `json:"threshold,omitempty"` +} + +type StringMap struct { + AdditionalProperties map[string]string `json:"-"` +} + +type TupleProbe []any + +type ObjectRules struct { + Name *string `json:"name,omitempty"` + Count *int `json:"count,omitempty"` +} + +type EncodedPayload string + +type TreeNode struct { + Name *string `json:"name,omitempty"` + Children []TreeNode `json:"children,omitempty"` +} + +type PaymentSource interface { + isPaymentSource() +} + +func (CardSource) isPaymentSource() {} + +func (BankSource) isPaymentSource() {} + +type PaymentSourceUnion struct { + Value PaymentSource +} + +func (u PaymentSourceUnion) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + +func (u PaymentSourceUnion) IsZero() bool { + return u.Value == nil +} + +func (u *PaymentSourceUnion) UnmarshalJSON(data []byte) error { + var discriminator struct { + Value string `json:"object"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + switch discriminator.Value { + case "bank_account": + var v BankSource + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + case "card": + var v CardSource + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + default: + return fmt.Errorf("unknown object discriminator value %q", discriminator.Value) + } + return nil +} + +type CardSource struct { + Object string `json:"object"` + Number string `json:"number"` + // CVC writeOnly. + CVC string `json:"cvc"` +} + +type BankSource struct { + Object string `json:"object"` + AccountNumber string `json:"account_number"` + BankName *string `json:"bank_name,omitempty"` +} + +type LooseChoiceUnion struct { + Raw json.RawMessage +} + +func (u *LooseChoiceUnion) UnmarshalJSON(data []byte) error { + u.Raw = append(u.Raw[:0], data...) + return nil +} + +func (u LooseChoiceUnion) MarshalJSON() ([]byte, error) { + if len(u.Raw) == 0 { + return []byte("null"), nil + } + return u.Raw, nil +} + +func (u LooseChoiceUnion) IsZero() bool { + return len(u.Raw) == 0 +} + +func (u LooseChoiceUnion) Bytes() []byte { + return append([]byte(nil), u.Raw...) +} diff --git a/generator/golang/testdata/name-collisions.yaml b/generator/golang/testdata/name-collisions.yaml new file mode 100644 index 00000000..2a58f823 --- /dev/null +++ b/generator/golang/testdata/name-collisions.yaml @@ -0,0 +1,163 @@ +openapi: 3.1.0 +info: + title: Go Name Collision Model Torture API + version: 1.0.0 + summary: OpenAPI 3.1 fixture for generated Go identifier collision coverage. +components: + schemas: + collision-root: + type: object + required: + - type + - user-id + - user_id + - UserID + - map + - choice + - map_ref + - alias + - enum + properties: + type: + type: string + user-id: + $ref: '#/components/schemas/user-id' + user_id: + $ref: '#/components/schemas/user_id' + UserID: + $ref: '#/components/schemas/UserID' + map: + $ref: '#/components/schemas/map' + choice: + $ref: '#/components/schemas/choice' + recursive: + $ref: '#/components/schemas/recursive-node' + map_ref: + $ref: '#/components/schemas/string-map' + alias: + $ref: '#/components/schemas/alias-value' + enum: + $ref: '#/components/schemas/enum-collision' + additional_properties: + type: string + nested: + type: object + required: + - value-id + - value_id + properties: + value-id: + type: string + value_id: + type: integer + dup-obj: + type: object + properties: + name: + type: string + dup_obj: + type: object + properties: + count: + type: integer + inline-item: + type: object + properties: + func: + type: string + additionalProperties: + type: string + user-id: + type: object + required: + - id + properties: + id: + type: string + user_id: + type: object + required: + - id + properties: + id: + type: integer + UserID: + type: object + required: + - id + properties: + id: + type: boolean + map: + type: object + required: + - func + - type + - interface + properties: + func: + type: string + type: + type: string + interface: + type: string + additionalProperties: false + choice: + oneOf: + - $ref: '#/components/schemas/choice-card' + - $ref: '#/components/schemas/choice_card' + discriminator: + propertyName: type + mapping: + card: '#/components/schemas/choice-card' + card_duplicate: '#/components/schemas/choice_card' + choice-card: + type: object + required: + - type + - value + properties: + type: + type: string + const: card + value: + type: string + additionalProperties: false + choice_card: + type: object + required: + - type + - value + properties: + type: + type: string + const: card_duplicate + value: + type: integer + additionalProperties: false + recursive-node: + type: object + properties: + next: + $ref: '#/components/schemas/recursive-node' + children: + type: array + items: + $ref: '#/components/schemas/recursive-node' + string-map: + type: object + additionalProperties: + type: string + alias-value: + type: string + enum-collision: + type: string + enum: + - "" + - in-progress + - in progress + - "true" + - "True" + - "1.5" + - "1_5" + - "1-5" diff --git a/generator/golang/testdata/name_collisions_compact_delimiter.golden.go b/generator/golang/testdata/name_collisions_compact_delimiter.golden.go new file mode 100644 index 00000000..828101cf --- /dev/null +++ b/generator/golang/testdata/name_collisions_compact_delimiter.golden.go @@ -0,0 +1,245 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type CollisionRootNestedDupObj struct { + Name *string `json:"name,omitempty"` +} + +type CollisionRootNestedDupObj__2 struct { + Count *int `json:"count,omitempty"` +} + +type CollisionRootNestedInlineItem struct { + Func *string `json:"func,omitempty"` +} + +type CollisionRootNested struct { + ValueID string `json:"value-id"` + ValueID__2 int `json:"value_id"` + DupObj *CollisionRootNestedDupObj `json:"dup-obj,omitempty"` + DupObj__2 *CollisionRootNestedDupObj__2 `json:"dup_obj,omitempty"` + InlineItem *CollisionRootNestedInlineItem `json:"inline-item,omitempty"` +} + +type CollisionRoot struct { + Type string `json:"type"` + UserID UserID `json:"user-id"` + UserID__2 UserID__2 `json:"user_id"` + UserID__3 UserID__3 `json:"UserID"` + Map Map `json:"map"` + Choice ChoiceUnion `json:"choice"` + Recursive *RecursiveNode `json:"recursive,omitempty"` + MapRef StringMap `json:"map_ref"` + Alias AliasValue `json:"alias"` + Enum EnumCollision `json:"enum"` + AdditionalProperties *string `json:"additional_properties,omitempty"` + Nested *CollisionRootNested `json:"nested,omitempty"` + AdditionalProperties__2 map[string]string `json:"-"` +} + +func (m *CollisionRoot) UnmarshalJSON(data []byte) error { + type Alias CollisionRoot + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = CollisionRoot(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + delete(raw, "type") + delete(raw, "user-id") + delete(raw, "user_id") + delete(raw, "UserID") + delete(raw, "map") + delete(raw, "choice") + delete(raw, "recursive") + delete(raw, "map_ref") + delete(raw, "alias") + delete(raw, "enum") + delete(raw, "additional_properties") + delete(raw, "nested") + if len(raw) == 0 { + return nil + } + m.AdditionalProperties__2 = make(map[string]string, len(raw)) + for key, value := range raw { + var decoded string + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties__2[key] = decoded + } + return nil +} + +func (m CollisionRoot) MarshalJSON() ([]byte, error) { + type Alias CollisionRoot + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties__2 { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type UserID struct { + ID string `json:"id"` +} + +type UserID__2 struct { + ID int `json:"id"` +} + +type UserID__3 struct { + ID bool `json:"id"` +} + +type Map struct { + Func string `json:"func"` + Type string `json:"type"` + Interface string `json:"interface"` +} + +type Choice interface { + isChoice() +} + +func (ChoiceCard) isChoice() {} + +func (ChoiceCard__2) isChoice() {} + +type ChoiceUnion struct { + Value Choice +} + +func (u ChoiceUnion) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + +func (u ChoiceUnion) IsZero() bool { + return u.Value == nil +} + +func (u *ChoiceUnion) UnmarshalJSON(data []byte) error { + var discriminator struct { + Value string `json:"type"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + switch discriminator.Value { + case "card": + var v ChoiceCard + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + case "card_duplicate": + var v ChoiceCard__2 + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + default: + return fmt.Errorf("unknown type discriminator value %q", discriminator.Value) + } + return nil +} + +type ChoiceCard struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type ChoiceCard__2 struct { + Type string `json:"type"` + Value int `json:"value"` +} + +type RecursiveNode struct { + Next *RecursiveNode `json:"next,omitempty"` + Children []RecursiveNode `json:"children,omitempty"` +} + +type StringMap struct { + AdditionalProperties map[string]string `json:"-"` +} + +func (m *StringMap) UnmarshalJSON(data []byte) error { + type Alias StringMap + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = StringMap(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) == 0 { + return nil + } + m.AdditionalProperties = make(map[string]string, len(raw)) + for key, value := range raw { + var decoded string + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties[key] = decoded + } + return nil +} + +func (m StringMap) MarshalJSON() ([]byte, error) { + type Alias StringMap + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type AliasValue string + +type EnumCollision string + +const ( + EnumCollisionEmpty EnumCollision = "" + EnumCollisionInProgress EnumCollision = "in-progress" + EnumCollisionInProgress__2 EnumCollision = "in progress" + EnumCollisionTrue EnumCollision = "true" + EnumCollisionTrue__2 EnumCollision = "True" + EnumCollisionValue15 EnumCollision = "1.5" + EnumCollisionValue15__2 EnumCollision = "1_5" + EnumCollisionValue15__3 EnumCollision = "1-5" +) diff --git a/generator/golang/testdata/name_collisions_default.golden.go b/generator/golang/testdata/name_collisions_default.golden.go new file mode 100644 index 00000000..a32b3a59 --- /dev/null +++ b/generator/golang/testdata/name_collisions_default.golden.go @@ -0,0 +1,234 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type CollisionRoot_Nested_DupObj struct { + Name *string `json:"name,omitempty"` +} + +type CollisionRoot_Nested_DupObj__2 struct { + Count *int `json:"count,omitempty"` +} + +type CollisionRoot_Nested_InlineItem struct { + Func *string `json:"func,omitempty"` +} + +type CollisionRoot_Nested struct { + ValueID string `json:"value-id"` + ValueID__2 int `json:"value_id"` + DupObj *CollisionRoot_Nested_DupObj `json:"dup-obj,omitempty"` + DupObj__2 *CollisionRoot_Nested_DupObj__2 `json:"dup_obj,omitempty"` + InlineItem *CollisionRoot_Nested_InlineItem `json:"inline-item,omitempty"` +} + +type CollisionRoot struct { + Type string `json:"type"` + UserID UserID `json:"user-id"` + UserID__2 UserID__2 `json:"user_id"` + UserID__3 UserID__3 `json:"UserID"` + Map Map `json:"map"` + Choice ChoiceUnion `json:"choice"` + Recursive *RecursiveNode `json:"recursive,omitempty"` + MapRef StringMap `json:"map_ref"` + Alias AliasValue `json:"alias"` + Enum EnumCollision `json:"enum"` + AdditionalProperties *string `json:"additional_properties,omitempty"` + Nested *CollisionRoot_Nested `json:"nested,omitempty"` + AdditionalProperties__2 map[string]string `json:"-"` +} + +func (m *CollisionRoot) UnmarshalJSON(data []byte) error { + type Alias CollisionRoot + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = CollisionRoot(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + delete(raw, "type") + delete(raw, "user-id") + delete(raw, "user_id") + delete(raw, "UserID") + delete(raw, "map") + delete(raw, "choice") + delete(raw, "recursive") + delete(raw, "map_ref") + delete(raw, "alias") + delete(raw, "enum") + delete(raw, "additional_properties") + delete(raw, "nested") + if len(raw) == 0 { + return nil + } + m.AdditionalProperties__2 = make(map[string]string, len(raw)) + for key, value := range raw { + var decoded string + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties__2[key] = decoded + } + return nil +} + +func (m CollisionRoot) MarshalJSON() ([]byte, error) { + type Alias CollisionRoot + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties__2 { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type UserID struct { + ID string `json:"id"` +} + +type UserID__2 struct { + ID int `json:"id"` +} + +type UserID__3 struct { + ID bool `json:"id"` +} + +type Map struct { + Func string `json:"func"` + Type string `json:"type"` + Interface string `json:"interface"` +} + +type Choice interface { + isChoice() +} + +func (ChoiceCard) isChoice() {} + +func (ChoiceCard__2) isChoice() {} + +type ChoiceUnion struct { + Value Choice +} + +func (u ChoiceUnion) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + +func (u ChoiceUnion) IsZero() bool { + return u.Value == nil +} + +func (u *ChoiceUnion) UnmarshalJSON(data []byte) error { + var discriminator struct { + Value string `json:"type"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + switch discriminator.Value { + case "card": + var v ChoiceCard + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + case "card_duplicate": + var v ChoiceCard__2 + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + default: + return fmt.Errorf("unknown type discriminator value %q", discriminator.Value) + } + return nil +} + +type ChoiceCard struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type ChoiceCard__2 struct { + Type string `json:"type"` + Value int `json:"value"` +} + +type RecursiveNode struct { + Next *RecursiveNode `json:"next,omitempty"` + Children []RecursiveNode `json:"children,omitempty"` +} + +type StringMap struct { + AdditionalProperties map[string]string `json:"-"` +} + +func (m *StringMap) UnmarshalJSON(data []byte) error { + type Alias StringMap + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = StringMap(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) == 0 { + return nil + } + m.AdditionalProperties = make(map[string]string, len(raw)) + for key, value := range raw { + var decoded string + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties[key] = decoded + } + return nil +} + +func (m StringMap) MarshalJSON() ([]byte, error) { + type Alias StringMap + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type AliasValue string + +type EnumCollision string diff --git a/generator/golang/testdata/reflect_openapi_conformance.golden.yaml b/generator/golang/testdata/reflect_openapi_conformance.golden.yaml new file mode 100644 index 00000000..2330a094 --- /dev/null +++ b/generator/golang/testdata/reflect_openapi_conformance.golden.yaml @@ -0,0 +1,135 @@ +openapi: 3.1.0 +info: + title: Reflected Go Model Conformance API + version: 1.0.0 +paths: {} +components: + schemas: + ReflectConformanceAddress: + type: object + properties: + line1: + type: string + line2: + type: + - string + - "null" + city: + type: string + required: + - city + - line1 + ReflectConformanceAttribute: + type: object + properties: + name: + type: string + value: + type: string + required: + - name + ReflectConformanceBank: + type: object + properties: + object: + type: string + account_number: + type: string + bank_name: + type: + - string + - "null" + required: + - account_number + - object + ReflectConformanceCard: + type: object + properties: + object: + type: string + number: + type: string + cvc: + type: string + required: + - number + - object + ReflectConformanceEmbedded: + type: object + properties: + trace_id: + type: string + required: + - trace_id + ReflectConformanceRoot: + type: object + properties: + trace_id: + type: string + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/ReflectConformanceStatus' + active: + type: boolean + count: + type: string + limit: + type: integer + nickname: + type: + - string + - "null" + data: + type: string + format: byte + raw: {} + tags: + type: array + items: + type: string + labels: + $ref: '#/components/schemas/ReflectConformanceRoot_Labels' + attributes: + $ref: '#/components/schemas/ReflectConformanceRoot_Attributes' + address: + anyOf: + - $ref: '#/components/schemas/ReflectConformanceAddress' + - type: "null" + payment: + $ref: '#/components/schemas/ReflectConformanceRoot_Payment' + history: + type: array + items: + $ref: '#/components/schemas/ReflectConformanceRoot_Payment' + self: + anyOf: + - $ref: '#/components/schemas/ReflectConformanceRoot' + - type: "null" + required: + - active + - id + - status + ReflectConformanceRoot_Attributes: + type: object + additionalProperties: + $ref: '#/components/schemas/ReflectConformanceAttribute' + ReflectConformanceRoot_Labels: + type: object + additionalProperties: + type: string + ReflectConformanceRoot_Payment: + oneOf: + - $ref: '#/components/schemas/ReflectConformanceCard' + - $ref: '#/components/schemas/ReflectConformanceBank' + discriminator: + propertyName: object + mapping: + bank_account: '#/components/schemas/ReflectConformanceBank' + card: '#/components/schemas/ReflectConformanceCard' + ReflectConformanceStatus: + type: string + enum: + - active + - paused diff --git a/generator/golang/testdata/train-travel.yaml b/generator/golang/testdata/train-travel.yaml new file mode 100644 index 00000000..b503cab4 --- /dev/null +++ b/generator/golang/testdata/train-travel.yaml @@ -0,0 +1,158 @@ +openapi: 3.1.0 +info: + title: Train Travel API + version: 1.2.1 +paths: {} +components: + schemas: + Station: + description: A train station. + type: object + required: + - id + - name + - address + - country_code + properties: + id: + type: string + format: uuid + name: + type: string + address: + type: string + country_code: + type: string + format: iso-country-code + timezone: + type: string + Trip: + description: A train trip. + type: object + properties: + id: + type: string + format: uuid + origin: + type: string + destination: + type: string + departure_time: + type: string + format: date-time + arrival_time: + type: string + format: date-time + price: + type: number + bicycles_allowed: + type: boolean + dogs_allowed: + type: boolean + Booking: + description: A booking for a train trip. + type: object + properties: + id: + type: string + format: uuid + readOnly: true + trip_id: + type: string + format: uuid + passenger_name: + type: string + has_bicycle: + type: boolean + has_dog: + type: boolean + BookingPayment: + description: A payment for a booking. + type: object + properties: + id: + type: string + format: uuid + readOnly: true + amount: + type: number + exclusiveMinimum: 0 + currency: + type: string + enum: + - bam + - bgn + - chf + - eur + - gbp + - nok + - sek + - try + source: + unevaluatedProperties: false + description: The payment source to take the payment from. + oneOf: + - title: Card + description: A card to take payment from. + type: object + properties: + object: + type: string + const: card + name: + type: string + number: + type: string + cvc: + type: string + minLength: 3 + maxLength: 4 + writeOnly: true + exp_month: + type: integer + format: int64 + exp_year: + type: integer + format: int64 + address_country: + type: string + required: + - name + - number + - cvc + - exp_month + - exp_year + - address_country + - title: Bank Account + description: A bank account to take payment from. + type: object + properties: + object: + const: bank_account + type: string + name: + type: string + number: + type: string + account_type: + enum: + - individual + - company + type: string + bank_name: + type: string + country: + type: string + required: + - name + - number + - account_type + - bank_name + - country + status: + type: string + enum: + - pending + - succeeded + - failed + readOnly: true diff --git a/generator/golang/testdata/train_travel_default.golden.go b/generator/golang/testdata/train_travel_default.golden.go new file mode 100644 index 00000000..90e66523 --- /dev/null +++ b/generator/golang/testdata/train_travel_default.golden.go @@ -0,0 +1,75 @@ +package models + +import "encoding/json" + +// Station A train station. +type Station struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + CountryCode string `json:"country_code"` + Timezone *string `json:"timezone,omitempty"` +} + +// Trip A train trip. +type Trip struct { + ID *string `json:"id,omitempty"` + Origin *string `json:"origin,omitempty"` + Destination *string `json:"destination,omitempty"` + DepartureTime *string `json:"departure_time,omitempty"` + ArrivalTime *string `json:"arrival_time,omitempty"` + Price *float64 `json:"price,omitempty"` + BicyclesAllowed *bool `json:"bicycles_allowed,omitempty"` + DogsAllowed *bool `json:"dogs_allowed,omitempty"` +} + +// Booking A booking for a train trip. +type Booking struct { + // ID readOnly. + ID *string `json:"id,omitempty"` + TripID *string `json:"trip_id,omitempty"` + PassengerName *string `json:"passenger_name,omitempty"` + HasBicycle *bool `json:"has_bicycle,omitempty"` + HasDog *bool `json:"has_dog,omitempty"` +} + +type BookingPayment_Currency string + +type BookingPayment_SourceUnion struct { + Raw json.RawMessage +} + +func (u *BookingPayment_SourceUnion) UnmarshalJSON(data []byte) error { + u.Raw = append(u.Raw[:0], data...) + return nil +} + +func (u BookingPayment_SourceUnion) MarshalJSON() ([]byte, error) { + if len(u.Raw) == 0 { + return []byte("null"), nil + } + return u.Raw, nil +} + +func (u BookingPayment_SourceUnion) IsZero() bool { + return len(u.Raw) == 0 +} + +func (u BookingPayment_SourceUnion) Bytes() []byte { + return append([]byte(nil), u.Raw...) +} + +// BookingPayment_Status readOnly. +type BookingPayment_Status string + +// BookingPayment A payment for a booking. +type BookingPayment struct { + // ID readOnly. + ID *string `json:"id,omitempty"` + Amount *float64 `json:"amount,omitempty"` + Currency *BookingPayment_Currency `json:"currency,omitempty"` + // Source The payment source to take the payment from. + Source *BookingPayment_SourceUnion `json:"source,omitempty"` + // Status readOnly. + Status *BookingPayment_Status `json:"status,omitempty"` +} diff --git a/generator/golang/testdata/train_travel_typed_union.golden.go b/generator/golang/testdata/train_travel_typed_union.golden.go new file mode 100644 index 00000000..6942f356 --- /dev/null +++ b/generator/golang/testdata/train_travel_typed_union.golden.go @@ -0,0 +1,127 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +// Station A train station. +type Station struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + CountryCode string `json:"country_code"` + Timezone *string `json:"timezone,omitempty"` +} + +// Trip A train trip. +type Trip struct { + ID *string `json:"id,omitempty"` + Origin *string `json:"origin,omitempty"` + Destination *string `json:"destination,omitempty"` + DepartureTime *string `json:"departure_time,omitempty"` + ArrivalTime *string `json:"arrival_time,omitempty"` + Price *float64 `json:"price,omitempty"` + BicyclesAllowed *bool `json:"bicycles_allowed,omitempty"` + DogsAllowed *bool `json:"dogs_allowed,omitempty"` +} + +// Booking A booking for a train trip. +type Booking struct { + // ID readOnly. + ID *string `json:"id,omitempty"` + TripID *string `json:"trip_id,omitempty"` + PassengerName *string `json:"passenger_name,omitempty"` + HasBicycle *bool `json:"has_bicycle,omitempty"` + HasDog *bool `json:"has_dog,omitempty"` +} + +type BookingPayment_Currency string + +// BookingPayment_Source_Card A card to take payment from. +type BookingPayment_Source_Card struct { + Object *string `json:"object,omitempty"` + Name string `json:"name"` + Number string `json:"number"` + // CVC writeOnly. + CVC string `json:"cvc"` + ExpMonth int64 `json:"exp_month"` + ExpYear int64 `json:"exp_year"` + AddressCountry string `json:"address_country"` +} + +type BookingPayment_Source_BankAccount_AccountType string + +// BookingPayment_Source_BankAccount A bank account to take payment from. +type BookingPayment_Source_BankAccount struct { + Object *string `json:"object,omitempty"` + Name string `json:"name"` + Number string `json:"number"` + AccountType BookingPayment_Source_BankAccount_AccountType `json:"account_type"` + BankName string `json:"bank_name"` + Country string `json:"country"` +} + +type BookingPayment_Source interface { + isBookingPayment_Source() +} + +func (BookingPayment_Source_Card) isBookingPayment_Source() {} + +func (BookingPayment_Source_BankAccount) isBookingPayment_Source() {} + +type BookingPayment_SourceUnion struct { + Value BookingPayment_Source +} + +func (u BookingPayment_SourceUnion) MarshalJSON() ([]byte, error) { + if u.Value == nil { + return []byte("null"), nil + } + return json.Marshal(u.Value) +} + +func (u BookingPayment_SourceUnion) IsZero() bool { + return u.Value == nil +} + +func (u *BookingPayment_SourceUnion) UnmarshalJSON(data []byte) error { + var discriminator struct { + Value string `json:"object"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + switch discriminator.Value { + case "bank_account": + var v BookingPayment_Source_BankAccount + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + case "card": + var v BookingPayment_Source_Card + if err := json.Unmarshal(data, &v); err != nil { + return err + } + u.Value = v + default: + return fmt.Errorf("unknown object discriminator value %q", discriminator.Value) + } + return nil +} + +// BookingPayment_Status readOnly. +type BookingPayment_Status string + +// BookingPayment A payment for a booking. +type BookingPayment struct { + // ID readOnly. + ID *string `json:"id,omitempty"` + Amount *float64 `json:"amount,omitempty"` + Currency *BookingPayment_Currency `json:"currency,omitempty"` + // Source The payment source to take the payment from. + Source *BookingPayment_SourceUnion `json:"source,omitempty"` + // Status readOnly. + Status *BookingPayment_Status `json:"status,omitempty"` +} diff --git a/generator/golang/to_go.go b/generator/golang/to_go.go new file mode 100644 index 00000000..1bd508c7 --- /dev/null +++ b/generator/golang/to_go.go @@ -0,0 +1,499 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "go/format" + "sort" + "strconv" + "strings" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" +) + +var formatSource = format.Source + +func (g *Generator) renderFile(irs []*SchemaIR) (*GeneratedFile, error) { + if err := validatePackageName(g.packageName); err != nil { + return nil, err + } + g.imports = make(map[string]struct{}) + g.decls = nil + g.seenDecls = make(map[string]struct{}) + g.metadataSchemas = make(map[string]*highbase.Schema) + g.metadataOrder = nil + types := make([]*GeneratedType, 0, len(irs)) + for _, ir := range irs { + if ir == nil { + continue + } + g.renderDecl(ir) + types = append(types, &GeneratedType{Name: ir.Name, Kind: ir.Kind}) + } + metadataSource, err := g.renderSchemaMetadataSource() + if err != nil { + return nil, err + } + var b strings.Builder + if g.generatedComment { + b.WriteString("// Code generated by libopenapi generator/golang. DO NOT EDIT.\n") + } + if g.headerComment != "" { + writeLineCommentBlock(&b, g.headerComment) + } + if g.generatedComment || g.headerComment != "" { + b.WriteByte('\n') + } + if g.packageComment != "" { + b.WriteString("// Package ") + b.WriteString(g.packageName) + b.WriteByte(' ') + b.WriteString(strings.TrimSpace(g.packageComment)) + if !strings.HasSuffix(strings.TrimSpace(g.packageComment), ".") { + b.WriteByte('.') + } + b.WriteByte('\n') + } + b.WriteString("package ") + b.WriteString(g.packageName) + b.WriteString("\n\n") + g.writeImports(&b) + for _, decl := range g.decls { + b.WriteString(decl) + b.WriteByte('\n') + } + src, err := formatSource([]byte(b.String())) + if err != nil { + return nil, err + } + return &GeneratedFile{ + PackageName: g.packageName, + Source: src, + SchemaMetadata: metadataSource, + Types: types, + Diagnostics: append([]Diagnostic(nil), g.diagnostics...), + }, nil +} + +func (g *Generator) renderSchemaMetadataSource() (*GeneratedSourceFile, error) { + decl := g.renderSchemaMetadataSidecarDecl() + if decl == "" { + return nil, nil + } + var b strings.Builder + if g.generatedComment { + b.WriteString("// Code generated by libopenapi generator/golang. DO NOT EDIT.\n") + } + if g.headerComment != "" { + writeLineCommentBlock(&b, g.headerComment) + } + if g.generatedComment || g.headerComment != "" { + b.WriteByte('\n') + } + b.WriteString("package ") + b.WriteString(g.packageName) + b.WriteString("\n\n") + b.WriteString(decl) + b.WriteByte('\n') + src, err := formatSource([]byte(b.String())) + if err != nil { + return nil, err + } + return &GeneratedSourceFile{Name: SchemaMetadataFileName, Source: src}, nil +} + +func (g *Generator) writeImports(b *strings.Builder) { + if len(g.imports) == 0 { + return + } + imports := make([]string, 0, len(g.imports)) + for path := range g.imports { + imports = append(imports, path) + } + sort.Strings(imports) + if len(imports) == 1 { + b.WriteString("import ") + b.WriteString(strconv.Quote(imports[0])) + b.WriteString("\n\n") + return + } + b.WriteString("import (\n") + for _, path := range imports { + b.WriteByte('\t') + b.WriteString(strconv.Quote(path)) + b.WriteByte('\n') + } + b.WriteString(")\n\n") +} + +func (g *Generator) renderDecl(ir *SchemaIR) { + if ir == nil || ir.Name == "" || ir.Kind == KindRef { + return + } + switch ir.Kind { + case KindUnion: + g.renderUnionDecl(ir) + case KindObject, KindAllOf: + if shouldRenderObjectAlias(ir) { + g.renderAliasDecl(ir) + return + } + g.renderObjectDecl(ir) + case KindEnum: + g.renderEnumDecl(ir) + default: + g.renderAliasDecl(ir) + } +} + +func (g *Generator) rememberDecl(name string) bool { + if name == "" { + return false + } + if _, ok := g.seenDecls[name]; ok { + return false + } + g.seenDecls[name] = struct{}{} + return true +} + +func (g *Generator) renderObjectDecl(ir *SchemaIR) { + if !g.rememberDecl(ir.Name) { + return + } + g.renderChildren(ir) + var b strings.Builder + writeIRComments(&b, ir) + b.WriteString("type ") + b.WriteString(ir.Name) + b.WriteString(" struct {\n") + fields := newNameRegistry() + additionalFieldName := "AdditionalProperties" + if ir.AllOf != nil { + for _, embed := range ir.AllOf { + if embed != nil && embed.Kind == KindRef { + b.WriteByte('\t') + b.WriteString(g.goType(embed, true, false)) + b.WriteByte('\n') + } + } + } + if ir.Properties != nil { + for propName, prop := range ir.Properties.FromOldest() { + required := isRequired(ir, propName) + fieldName, collision := fields.resolve(propName, g.fieldName(propName)) + if collision { + g.addDiagnostic(DiagnosticFieldNameCollision, ir.Name+"."+propName, "field name collision resolved as "+fieldName) + } + fieldType := g.goType(prop, required, true) + writeFieldComments(&b, fieldName, prop) + b.WriteByte('\t') + b.WriteString(fieldName) + b.WriteByte(' ') + b.WriteString(fieldType) + if tag := tagLiteral(propName, required, g.jsonTags, g.yamlTags, g.omitEmpty, g.openAPITagLiteral(prop, fieldType)); tag != "" { + b.WriteByte(' ') + b.WriteString(tag) + } + b.WriteByte('\n') + } + } + var additionalValueType string + if ir.AdditionalProperties != nil { + additionalValueType = g.goType(ir.AdditionalProperties, true, false) + var collision bool + additionalFieldName, collision = fields.resolve("$additionalProperties", "AdditionalProperties") + if collision { + g.addDiagnostic(DiagnosticFieldNameCollision, ir.Name+".additionalProperties", "additionalProperties field name collision resolved as "+additionalFieldName) + } + b.WriteByte('\t') + b.WriteString(additionalFieldName) + b.WriteString(" map[string]") + b.WriteString(additionalValueType) + b.WriteString(" `json:\"-\"`\n") + } + b.WriteString("}\n") + if ir.AdditionalProperties != nil && g.additionalPropertiesMethods { + g.addImport("encoding/json") + writeAdditionalPropertiesMethods(&b, ir, additionalFieldName, additionalValueType) + } + g.decls = append(g.decls, b.String()) + g.recordSchemaMetadata(ir.Name, ir.SourceSchema) +} + +func (g *Generator) renderChildren(ir *SchemaIR) { + if ir.Properties != nil { + for _, prop := range ir.Properties.FromOldest() { + g.renderNested(prop) + } + } + if ir.PatternProperties != nil { + for _, prop := range ir.PatternProperties.FromOldest() { + g.renderNested(prop) + } + } + if ir.Items != nil { + g.renderNested(ir.Items) + } + for _, item := range ir.PrefixItems { + g.renderNested(item) + } + if ir.AdditionalProperties != nil { + g.renderNested(ir.AdditionalProperties) + } + for _, child := range ir.AllOf { + g.renderNested(child) + } +} + +func (g *Generator) renderNested(ir *SchemaIR) { + if ir == nil { + return + } + switch ir.Kind { + case KindObject, KindAllOf, KindUnion, KindEnum: + if ir.Name != "" { + g.renderDecl(ir) + } + case KindArray: + g.renderNested(ir.Items) + case KindMap: + g.renderNested(ir.AdditionalProperties) + } +} + +func (g *Generator) renderAliasDecl(ir *SchemaIR) { + if !g.rememberDecl(ir.Name) { + return + } + g.renderChildren(ir) + var b strings.Builder + writeIRComments(&b, ir) + b.WriteString("type ") + b.WriteString(ir.Name) + b.WriteByte(' ') + b.WriteString(g.goType(ir, true, false)) + b.WriteByte('\n') + g.decls = append(g.decls, b.String()) + g.recordSchemaMetadata(ir.Name, ir.SourceSchema) +} + +func shouldRenderObjectAlias(ir *SchemaIR) bool { + return ir != nil && + ir.Kind == KindObject && + (ir.Properties == nil || ir.Properties.Len() == 0) && + (ir.PatternProperties == nil || ir.PatternProperties.Len() == 0) && + len(ir.AllOf) == 0 && + ir.AdditionalProperties == nil && + (ir.AdditionalAllowed == nil || *ir.AdditionalAllowed) +} + +func (g *Generator) renderEnumDecl(ir *SchemaIR) { + if !g.rememberDecl(ir.Name) { + return + } + var b strings.Builder + writeIRComments(&b, ir) + b.WriteString("type ") + b.WriteString(ir.Name) + b.WriteByte(' ') + shape := enumShapeFor(ir.Enum) + baseType := shape.goType + b.WriteString(baseType) + b.WriteByte('\n') + if g.enumConstants && shape.constants { + b.WriteString("\nconst (\n") + used := make(map[string]struct{}) + for _, node := range ir.Enum { + literal := enumLiteral(node, baseType) + if literal == "" { + continue + } + name := uniqueName(ir.Name+g.enumValueName(node.Value), used) + b.WriteByte('\t') + b.WriteString(name) + b.WriteByte(' ') + b.WriteString(ir.Name) + b.WriteString(" = ") + b.WriteString(literal) + b.WriteByte('\n') + } + b.WriteString(")\n") + } + g.decls = append(g.decls, b.String()) + g.recordSchemaMetadata(ir.Name, ir.SourceSchema) +} + +func writeAdditionalPropertiesMethods(b *strings.Builder, ir *SchemaIR, fieldName, valueType string) { + b.WriteString("\nfunc (m *") + b.WriteString(ir.Name) + b.WriteString(") UnmarshalJSON(data []byte) error {\n") + b.WriteString("\ttype Alias ") + b.WriteString(ir.Name) + b.WriteString("\n\tvar known Alias\n\tif err := json.Unmarshal(data, &known); err != nil {\n\t\treturn err\n\t}\n\t*m = ") + b.WriteString(ir.Name) + b.WriteString("(known)\n\tvar raw map[string]json.RawMessage\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n") + if ir.Properties != nil { + for propName := range ir.Properties.FromOldest() { + b.WriteString("\tdelete(raw, ") + b.WriteString(strconv.Quote(propName)) + b.WriteString(")\n") + } + } + b.WriteString("\tif len(raw) == 0 {\n\t\treturn nil\n\t}\n\tm.") + b.WriteString(fieldName) + b.WriteString(" = make(map[string]") + b.WriteString(valueType) + b.WriteString(", len(raw))\n\tfor key, value := range raw {\n\t\tvar decoded ") + b.WriteString(valueType) + b.WriteString("\n\t\tif err := json.Unmarshal(value, &decoded); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tm.") + b.WriteString(fieldName) + b.WriteString("[key] = decoded\n\t}\n\treturn nil\n}\n\n") + b.WriteString("func (m ") + b.WriteString(ir.Name) + b.WriteString(") MarshalJSON() ([]byte, error) {\n\ttype Alias ") + b.WriteString(ir.Name) + b.WriteString("\n\tencoded, err := json.Marshal(Alias(m))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar object map[string]json.RawMessage\n\tif err := json.Unmarshal(encoded, &object); err != nil {\n\t\treturn nil, err\n\t}\n\tfor key, value := range m.") + b.WriteString(fieldName) + b.WriteString(" {\n\t\tencodedValue, err := json.Marshal(value)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tobject[key] = encodedValue\n\t}\n\treturn json.Marshal(object)\n}\n") +} + +func (g *Generator) goType(ir *SchemaIR, required bool, field bool) string { + if ir == nil { + return "any" + } + var typ string + switch ir.Kind { + case KindRef: + typ = ir.Name + if typ == "" { + typ = g.refTypeName(ir.Ref) + } + if g.componentKinds[typ] == KindUnion { + typ += "Union" + } + case KindObject, KindAllOf: + if ir.Properties != nil && ir.Properties.Len() > 0 || len(ir.AllOf) > 0 { + typ = ir.Name + } else if ir.AdditionalAllowed != nil && !*ir.AdditionalAllowed && ir.Name != "" { + typ = ir.Name + } else if ir.AdditionalProperties != nil { + typ = "map[string]" + g.goType(ir.AdditionalProperties, true, false) + } else { + typ = "map[string]any" + } + case KindArray: + if len(ir.PrefixItems) > 0 { + typ = "[]any" + } else { + typ = "[]" + g.goType(ir.Items, true, false) + } + case KindMap: + typ = "map[string]" + g.goType(ir.AdditionalProperties, true, false) + case KindString: + typ = g.formatType(ir.Format, "string") + case KindInteger: + switch ir.Format { + case "int32": + typ = "int32" + case "int64": + typ = "int64" + default: + typ = "int" + } + case KindNumber: + if ir.Format == "float" { + typ = "float32" + } else { + typ = "float64" + } + case KindBoolean: + typ = "bool" + case KindEnum: + if ir.Name != "" { + typ = ir.Name + } else { + typ = "string" + } + case KindUnion: + typ = ir.Name + "Union" + default: + typ = "any" + } + if field && shouldPointer(typ, ir, required, g.optionalFieldsAsPointers, g.nullableAsPointer) { + return "*" + typ + } + return typ +} + +func (g *Generator) formatType(format, fallback string) string { + if mapping, ok := g.formatMappings[format]; ok { + g.addImport(mapping.importPath) + return mapping.goType + } + return fallback +} + +func shouldPointer(typ string, ir *SchemaIR, required, optionalPointers, nullablePointer bool) bool { + if typ == "any" || strings.HasPrefix(typ, "[]") || strings.HasPrefix(typ, "map[") { + return false + } + if ir != nil && ir.Nullable && nullablePointer { + return true + } + return !required && optionalPointers +} + +func writeComment(b *strings.Builder, name, text string) { + if text == "" { + return + } + writeLineComment(b, name+" "+strings.TrimSpace(strings.Split(text, "\n")[0])) +} + +func writeIRComments(b *strings.Builder, ir *SchemaIR) { + if ir == nil { + return + } + description := ir.Description + if description == "" { + description = ir.Title + } + writeComment(b, ir.Name, description) + for _, comment := range ir.Comments { + writeLineComment(b, ir.Name+" "+comment) + } +} + +func writeFieldComments(b *strings.Builder, fieldName string, ir *SchemaIR) { + if ir == nil { + return + } + description := ir.Description + if description == "" { + description = ir.Title + } + writeComment(b, fieldName, description) + for _, comment := range ir.Comments { + writeLineComment(b, fieldName+" "+comment) + } +} + +func writeLineCommentBlock(b *strings.Builder, text string) { + for _, line := range strings.Split(strings.TrimSpace(text), "\n") { + writeLineComment(b, strings.TrimSpace(line)) + } +} + +func writeLineComment(b *strings.Builder, line string) { + if line == "" { + return + } + b.WriteString("// ") + b.WriteString(line) + if !strings.HasSuffix(line, ".") { + b.WriteByte('.') + } + b.WriteByte('\n') +} diff --git a/generator/golang/to_openapi.go b/generator/golang/to_openapi.go new file mode 100644 index 00000000..e4c4e728 --- /dev/null +++ b/generator/golang/to_openapi.go @@ -0,0 +1,339 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "sort" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +func (g *Generator) openapiFromIR(ir *SchemaIR) *highbase.SchemaProxy { + if ir == nil { + return highbase.CreateSchemaProxy(&highbase.Schema{}) + } + if ir.Kind == KindRef { + if ir.Nullable { + return g.nullableRefProxy(ir) + } + if ir.DynamicRef { + schema := &highbase.Schema{DynamicRef: ir.Ref} + applySchemaFidelity(schema, ir) + return highbase.CreateSchemaProxy(schema) + } + return highbase.CreateSchemaProxyRef(ir.Ref) + } + if ir.Name != "" && ir.Name != g.currentComponent && isComponentKind(ir.Kind) { + if _, ok := g.componentNames[ir.Name]; ok { + ref := "#/components/schemas/" + ir.Name + if ir.Nullable { + return nullableReferenceProxy(ref, false, ir) + } + return referenceProxy(ref, ir) + } + } + if ir.ExactSource && ir.SourceSchema != nil && !ir.Nullable { + return highbase.CreateSchemaProxy(ir.SourceSchema) + } + if ir.ExactSource && ir.SourceSchema != nil && ir.Nullable { + schema := *ir.SourceSchema + applyNativeNullability(&schema, ir) + return highbase.CreateSchemaProxy(&schema) + } + schema := &highbase.Schema{ + Description: ir.Description, + Title: ir.Title, + Format: ir.Format, + Enum: ir.Enum, + Const: ir.Const, + Extensions: ir.Extensions, + } + applySchemaFidelity(schema, ir) + switch ir.Kind { + case KindAny: + case KindString: + schema.Type = []string{"string"} + case KindInteger: + schema.Type = []string{"integer"} + case KindNumber: + schema.Type = []string{"number"} + case KindBoolean: + schema.Type = []string{"boolean"} + case KindArray: + schema.Type = []string{"array"} + if ir.Items != nil { + schema.Items = &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: g.openapiFromIR(ir.Items), + } + } + for _, item := range ir.PrefixItems { + schema.PrefixItems = append(schema.PrefixItems, g.openapiFromIR(item)) + } + case KindObject, KindAllOf: + g.populateOpenAPIObject(schema, ir) + case KindUnion: + g.populateOpenAPIUnion(schema, ir) + case KindEnum: + switch enumShapeFor(ir.Enum).goType { + case "int": + schema.Type = []string{"integer"} + case "float64": + schema.Type = []string{"number"} + case "bool": + schema.Type = []string{"boolean"} + case "any": + schema.Type = nil + default: + schema.Type = []string{"string"} + } + schema.Enum = ir.Enum + default: + schema.Type = []string{"object"} + } + applyIRBooleans(schema, ir) + applyNativeNullability(schema, ir) + return highbase.CreateSchemaProxy(schema) +} + +func applyIRBooleans(schema *highbase.Schema, ir *SchemaIR) { + if schema == nil || ir == nil { + return + } + if ir.ReadOnly { + schema.ReadOnly = boolPtr(true) + } + if ir.WriteOnly { + schema.WriteOnly = boolPtr(true) + } + if ir.Deprecated { + schema.Deprecated = boolPtr(true) + } +} + +func (g *Generator) nullableRefProxy(ir *SchemaIR) *highbase.SchemaProxy { + return nullableReferenceProxy(ir.Ref, ir.DynamicRef, ir) +} + +func nullableReferenceProxy(target string, dynamic bool, ir *SchemaIR) *highbase.SchemaProxy { + var ref *highbase.SchemaProxy + if dynamic { + refSchema := &highbase.Schema{DynamicRef: target} + applySchemaFidelity(refSchema, ir) + ref = highbase.CreateSchemaProxy(refSchema) + } else { + ref = referenceProxy(target, ir) + } + return highbase.CreateSchemaProxy(&highbase.Schema{ + AnyOf: []*highbase.SchemaProxy{ + ref, + highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"null"}}), + }, + }) +} + +func referenceProxy(target string, ir *SchemaIR) *highbase.SchemaProxy { + if ir != nil && ir.FieldMetadata { + return highbase.CreateSchemaProxyRefWithSchema(target, refSiblingSchema(ir)) + } + return highbase.CreateSchemaProxyRef(target) +} + +func refSiblingSchema(ir *SchemaIR) *highbase.Schema { + schema := &highbase.Schema{ + Description: ir.Description, + Title: ir.Title, + Format: ir.Format, + Enum: ir.Enum, + Const: ir.Const, + Extensions: ir.Extensions, + } + applySchemaFidelity(schema, ir) + applyIRBooleans(schema, ir) + schema.Nullable = nil + return schema +} + +func (g *Generator) populateOpenAPIObject(schema *highbase.Schema, ir *SchemaIR) { + schema.Type = []string{"object"} + if ir.Properties != nil && ir.Properties.Len() > 0 { + props := orderedmap.New[string, *highbase.SchemaProxy]() + for name, prop := range ir.Properties.FromOldest() { + props.Set(name, g.openapiFromIR(prop)) + } + schema.Properties = props + } + if ir.PatternProperties != nil && ir.PatternProperties.Len() > 0 { + patternProps := orderedmap.New[string, *highbase.SchemaProxy]() + for name, prop := range ir.PatternProperties.FromOldest() { + patternProps.Set(name, g.openapiFromIR(prop)) + } + schema.PatternProperties = patternProps + } + if len(ir.Required) > 0 { + required := make([]string, 0, len(ir.Required)) + for name := range ir.Required { + required = append(required, name) + } + sort.Strings(required) + schema.Required = required + } + if ir.AdditionalProperties != nil { + schema.AdditionalProperties = &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: g.openapiFromIR(ir.AdditionalProperties), + } + } else if ir.AdditionalAllowed != nil { + schema.AdditionalProperties = &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + N: 1, + B: *ir.AdditionalAllowed, + } + } +} + +func (g *Generator) populateOpenAPIUnion(schema *highbase.Schema, ir *SchemaIR) { + if ir.Union == nil { + return + } + if ir.Union.FromMultiType && ir.SourceSchema != nil && len(ir.SourceSchema.Type) > 0 { + schema.Type = append([]string(nil), ir.SourceSchema.Type...) + return + } + variants := make([]*highbase.SchemaProxy, 0, len(ir.Union.Variants)) + for _, variant := range ir.Union.Variants { + variants = append(variants, g.openapiFromIR(variant)) + } + if ir.Union.Kind == UnionAnyOf { + schema.AnyOf = variants + } else { + schema.OneOf = variants + } + if ir.Union.Discriminator != nil { + mapping := orderedmap.New[string, string]() + keys := make([]string, 0, len(ir.Union.Discriminator.Mapping)) + for k := range ir.Union.Discriminator.Mapping { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + mapping.Set(k, ir.Union.Discriminator.Mapping[k]) + } + schema.Discriminator = &highbase.Discriminator{ + PropertyName: ir.Union.Discriminator.PropertyName, + Mapping: mapping, + } + } +} + +func applySchemaFidelity(schema *highbase.Schema, ir *SchemaIR) { + if schema == nil || ir == nil || ir.SourceSchema == nil { + return + } + src := ir.SourceSchema + schema.Description = src.Description + schema.Title = src.Title + schema.Format = src.Format + schema.Extensions = src.Extensions + schema.SchemaTypeRef = src.SchemaTypeRef + schema.ExclusiveMaximum = src.ExclusiveMaximum + schema.ExclusiveMinimum = src.ExclusiveMinimum + schema.Examples = src.Examples + schema.Contains = src.Contains + schema.MinContains = src.MinContains + schema.MaxContains = src.MaxContains + schema.If = src.If + schema.Else = src.Else + schema.Then = src.Then + schema.DependentSchemas = src.DependentSchemas + schema.DependentRequired = src.DependentRequired + schema.PropertyNames = src.PropertyNames + schema.UnevaluatedItems = src.UnevaluatedItems + schema.UnevaluatedProperties = src.UnevaluatedProperties + if src.Items != nil && src.Items.IsB() { + schema.Items = src.Items + } + schema.Id = src.Id + schema.Anchor = src.Anchor + schema.DynamicAnchor = src.DynamicAnchor + if src.DynamicRef != "" && !ir.DynamicRef { + schema.DynamicRef = src.DynamicRef + } + schema.Comment = src.Comment + schema.ContentSchema = src.ContentSchema + schema.Vocabulary = src.Vocabulary + schema.Not = src.Not + schema.MultipleOf = src.MultipleOf + schema.Maximum = src.Maximum + schema.Minimum = src.Minimum + schema.MaxLength = src.MaxLength + schema.MinLength = src.MinLength + schema.Pattern = src.Pattern + schema.MaxItems = src.MaxItems + schema.MinItems = src.MinItems + schema.UniqueItems = src.UniqueItems + schema.MaxProperties = src.MaxProperties + schema.MinProperties = src.MinProperties + schema.ContentEncoding = src.ContentEncoding + schema.ContentMediaType = src.ContentMediaType + schema.Default = src.Default + schema.Example = src.Example + schema.ReadOnly = src.ReadOnly + schema.WriteOnly = src.WriteOnly + schema.Deprecated = src.Deprecated + schema.XML = src.XML + schema.ExternalDocs = src.ExternalDocs +} + +func applyNativeNullability(schema *highbase.Schema, ir *SchemaIR) { + if schema == nil || ir == nil || !ir.Nullable { + return + } + schema.Nullable = nil + if schemaNeedsNullAlternative(schema) { + original := *schema + original.Nullable = nil + *schema = highbase.Schema{ + AnyOf: []*highbase.SchemaProxy{ + highbase.CreateSchemaProxy(&original), + highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"null"}}), + }, + } + return + } + if len(schema.Type) > 0 && !schemaTypeContains(schema.Type, "null") { + schema.Type = append(append([]string(nil), schema.Type...), "null") + } + if len(schema.Enum) > 0 && !enumHasNull(schema.Enum) { + schema.Enum = append(append([]*yaml.Node(nil), schema.Enum...), nullNode()) + } +} + +func schemaNeedsNullAlternative(schema *highbase.Schema) bool { + if schema == nil { + return false + } + return len(schema.OneOf) > 0 || + len(schema.AnyOf) > 0 || + len(schema.AllOf) > 0 || + schema.Not != nil || + schema.Const != nil || + schema.DynamicRef != "" +} + +func stringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func nullNode() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} +} + +func schemaTypeContains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/generator/golang/unions.go b/generator/golang/unions.go new file mode 100644 index 00000000..bec85f28 --- /dev/null +++ b/generator/golang/unions.go @@ -0,0 +1,116 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "sort" + "strconv" + "strings" +) + +func (g *Generator) renderUnionDecl(ir *SchemaIR) { + if ir == nil || ir.Union == nil { + return + } + if ir.Union.Strategy == UnionDiscriminator { + g.renderDiscriminatedUnion(ir) + return + } + g.renderRawUnion(ir) +} + +func (g *Generator) renderRawUnion(ir *SchemaIR) { + name := ir.Name + "Union" + if !g.rememberDecl(name) { + return + } + g.addImport("encoding/json") + var b strings.Builder + b.WriteString("type ") + b.WriteString(name) + b.WriteString(" struct {\n\tRaw json.RawMessage\n}\n\n") + b.WriteString("func (u *") + b.WriteString(name) + b.WriteString(") UnmarshalJSON(data []byte) error {\n\tu.Raw = append(u.Raw[:0], data...)\n\treturn nil\n}\n\n") + b.WriteString("func (u ") + b.WriteString(name) + b.WriteString(") MarshalJSON() ([]byte, error) {\n\tif len(u.Raw) == 0 {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn u.Raw, nil\n}\n") + b.WriteString("\nfunc (u ") + b.WriteString(name) + b.WriteString(") IsZero() bool {\n\treturn len(u.Raw) == 0\n}\n") + b.WriteString("\nfunc (u ") + b.WriteString(name) + b.WriteString(") Bytes() []byte {\n\treturn append([]byte(nil), u.Raw...)\n}\n") + g.decls = append(g.decls, b.String()) + g.recordSchemaMetadata(name, ir.SourceSchema) +} + +func (g *Generator) renderDiscriminatedUnion(ir *SchemaIR) { + if ir.Union == nil || ir.Union.Discriminator == nil { + g.renderRawUnion(ir) + return + } + for _, variant := range ir.Union.Variants { + g.renderNested(variant) + } + if !g.rememberDecl(ir.Name + "Union") { + return + } + g.addImport("encoding/json") + g.addImport("fmt") + var b strings.Builder + b.WriteString("type ") + b.WriteString(ir.Name) + b.WriteString(" interface {\n\tis") + b.WriteString(ir.Name) + b.WriteString("()\n}\n\n") + for _, variant := range ir.Union.Variants { + if variant == nil || variant.Name == "" { + continue + } + b.WriteString("func (") + b.WriteString(variant.Name) + b.WriteString(") is") + b.WriteString(ir.Name) + b.WriteString("() {}\n\n") + } + b.WriteString("type ") + b.WriteString(ir.Name) + b.WriteString("Union struct {\n\tValue ") + b.WriteString(ir.Name) + b.WriteString("\n}\n\n") + b.WriteString("func (u ") + b.WriteString(ir.Name) + b.WriteString("Union) MarshalJSON() ([]byte, error) {\n\tif u.Value == nil {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn json.Marshal(u.Value)\n}\n\n") + b.WriteString("func (u ") + b.WriteString(ir.Name) + b.WriteString("Union) IsZero() bool {\n\treturn u.Value == nil\n}\n\n") + b.WriteString("func (u *") + b.WriteString(ir.Name) + b.WriteString("Union) UnmarshalJSON(data []byte) error {\n\tvar discriminator struct {\n\t\tValue string `json:\"") + b.WriteString(ir.Union.Discriminator.PropertyName) + b.WriteString("\"`\n\t}\n\tif err := json.Unmarshal(data, &discriminator); err != nil {\n\t\treturn err\n\t}\n\tswitch discriminator.Value {\n") + values := make([]string, 0, len(ir.Union.Discriminator.Mapping)) + for value := range ir.Union.Discriminator.Mapping { + values = append(values, value) + } + sort.Strings(values) + for _, value := range values { + target := ir.Union.Discriminator.Mapping[value] + typeName := target + if strings.HasPrefix(target, "#") || strings.Contains(target, "/") || strings.Contains(target, ".") { + typeName = g.refTypeName(target) + } + b.WriteString("\tcase ") + b.WriteString(strconv.Quote(value)) + b.WriteString(":\n\t\tvar v ") + b.WriteString(typeName) + b.WriteString("\n\t\tif err := json.Unmarshal(data, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.Value = v\n") + } + b.WriteString("\tdefault:\n\t\treturn fmt.Errorf(\"unknown ") + b.WriteString(ir.Union.Discriminator.PropertyName) + b.WriteString(" discriminator value %q\", discriminator.Value)\n\t}\n\treturn nil\n}\n") + g.decls = append(g.decls, b.String()) + g.recordSchemaMetadata(ir.Name+"Union", ir.SourceSchema) +}