From 593584c1c52964c0d935e69ebe05aa68a96c3a7e Mon Sep 17 00:00:00 2001 From: quobix Date: Wed, 7 Oct 2026 12:03:11 -0400 Subject: [PATCH] feat(generator): idiomatic Go SDK output for imperfect vendor specs A real-world vendor spec (OpenAPI 3.1, inline schemas, nullable in 3.1, a text/plain 401) failed to generate, and the output after working around the 401 was noisy: underscore type names, ID__2, "example value is defined" comments, raw HTML in parameter docs, and any/map[string]any for undescribed JSON. gosdk - Declared non-JSON error responses keep their raw body in APIError.Body instead of failing generation. - Types for a named operation derive from Resource+Method (PeopleMatchParams, PeopleMatchResponse, PeopleMatchBadRequest). - Inline payload types take their property name when free (Person, Organization, EmploymentHistoryItem); component children stay qualified. - Parameter docs render as wrapped godoc above each field. - Defaults: idiomatic names, json.RawMessage for undescribed JSON, enum constants (all overridable through Options.Models). - Error cases are grouped, sorted, and ordered exact > range > default; response-only models use single pointers; the client declares only the helpers its operations call. generator/golang - NameStyleIdiomatic, WithInlineRoots, WithReservedTypeNames, WithUntypedAsRawMessage, WithDecodeOnlyRoots, WithFallbackDescriptions, FieldNames, NewIdiomaticNameRegistry, DiagnosticNullableKeyword. - Comments: descriptions rendered whole and wrapped; noise notes removed. - Fixes: properties beside allOf were dropped; unused nested map and tuple declarations removed; plural initialisms and digit word breaks. Standalone model generator naming defaults are unchanged. Co-Authored-By: Claude Opus 5.5 --- generator/golang/README.md | 30 +- generator/golang/allof_siblings_test.go | 40 + generator/golang/coverage_test.go | 19 +- generator/golang/from_openapi.go | 166 ++-- generator/golang/from_reflect.go | 2 +- generator/golang/generator.go | 42 +- generator/golang/golden_test.go | 7 + generator/golang/idiomatic_names_test.go | 625 ++++++++++++++ generator/golang/ir.go | 6 + generator/golang/name_registry.go | 78 +- generator/golang/names.go | 105 ++- generator/golang/options.go | 87 ++ generator/golang/phase_two_test.go | 13 +- .../jsonschema_2020_12_default.golden.go | 6 +- .../jsonschema_2020_12_options.golden.go | 6 +- .../name_collisions_idiomatic.golden.go | 245 ++++++ .../testdata/train_travel_default.golden.go | 17 +- .../train_travel_typed_union.golden.go | 23 +- generator/golang/to_go.go | 251 ++++-- generator/internal/gocomment/gocomment.go | 190 +++++ .../internal/gocomment/gocomment_test.go | 114 +++ generator/sdk/README.md | 50 +- generator/sdk/gosdk/coverage_test.go | 146 +++- generator/sdk/gosdk/generator.go | 464 +++++++--- generator/sdk/gosdk/generator_test.go | 2 +- generator/sdk/gosdk/people_match_test.go | 172 ++++ generator/sdk/gosdk/templates/client.tmpl | 52 ++ generator/sdk/gosdk/templates/resources.tmpl | 94 +-- generator/sdk/gosdk/templates/workflows.tmpl | 3 +- .../sdk/gosdk/testdata/people-match.yaml | 799 ++++++++++++++++++ .../testdata/people_match_client.golden.go | 511 +++++++++++ .../testdata/people_match_models.golden.go | 306 +++++++ .../testdata/people_match_resources.golden.go | 218 +++++ generator/sdk/gosdk/view.go | 34 +- generator/sdk/model.go | 4 + generator/sdk/prepare.go | 5 +- generator/sdk/prepare_test.go | 34 +- generator/typescript/generator.go | 48 +- generator/typescript/regression_test.go | 11 +- generator/typescript/review_test.go | 2 +- 40 files changed, 4607 insertions(+), 420 deletions(-) create mode 100644 generator/golang/allof_siblings_test.go create mode 100644 generator/golang/idiomatic_names_test.go create mode 100644 generator/golang/testdata/name_collisions_idiomatic.golden.go create mode 100644 generator/internal/gocomment/gocomment.go create mode 100644 generator/internal/gocomment/gocomment_test.go create mode 100644 generator/sdk/gosdk/people_match_test.go create mode 100644 generator/sdk/gosdk/testdata/people-match.yaml create mode 100644 generator/sdk/gosdk/testdata/people_match_client.golden.go create mode 100644 generator/sdk/gosdk/testdata/people_match_models.golden.go create mode 100644 generator/sdk/gosdk/testdata/people_match_resources.golden.go diff --git a/generator/golang/README.md b/generator/golang/README.md index 12194354..f0552bce 100644 --- a/generator/golang/README.md +++ b/generator/golang/README.md @@ -145,6 +145,7 @@ Current diagnostic codes: - `DiagnosticMultiTypeSchema` - `DiagnosticNotSchema` - `DiagnosticNullEnum` +- `DiagnosticNullableKeyword` - `DiagnosticOptionalConstDiscriminator` - `DiagnosticPatternProperties` - `DiagnosticPrefixItems` @@ -161,12 +162,37 @@ Diagnostics are intentionally not validation errors. They report lossy model-sha ## Naming -The default naming path handles common Go initialisms such as `ID`, `URL`, `UUID`, `CVC`, `IBAN`, and `JWT`. +The default naming path handles common Go initialisms such as `ID`, `URL`, `UUID`, `CVC`, `IBAN`, `JWT`, `MD5`, and `SHA256`, their plurals (`label_ids` is `LabelIDs`), and word breaks after digits (`base64Encoded` is `Base64Encoded`). 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. +`WithNameStyle(NameStyleIdiomatic)` names inline schemas the way a Go author would and never uses underscores: + +- A child is named after its nearest declared ancestor and its property: `AccountStatus`. Words shared by both are written once, so `Contact` and `contact_email` give `ContactEmail`. +- Array elements take the singular of the property name (`funding_events` gives `OrganizationFundingEvent`), or `Item` when the name is not a plural the generator can reduce (`EmploymentHistoryItem`). +- Map values end in `Value`: `ErrorDetailsContextValue`. +- Struct fields whose JSON names differ only by a leading symbol keep the plain spelling for the plain name and spell the symbol out for the other: `id` is `ID` and `_id` is `UnderscoreID`. +- Remaining collisions take a plain number (`UserID2`), or a letter after a name that ends in a digit (`PlaybookV2B`). +- With `WithEnumConstants(true)`, enum constant names are claimed from the same registry, so a constant never takes a type's name. + +`WithInlineRoots` marks top-level schemas that a document declared inline, such as an operation's response body. Their parts have no names in the document except their property names, so with the idiomatic style a nested schema takes its bare property name (`Person`) when no other type has it, and its qualified name otherwise. `WithReservedTypeNames` keeps generated types clear of names that other code in the package declares. + +## Comments + +Schema descriptions render as Go doc comments: Markdown links and inline HTML become plain text, paragraphs wrap at 76 columns, and multi-paragraph descriptions are kept whole. A declaration's comment starts with its name (`Booking is a booking for a train trip.`); a field's comment is the description alone. Fields marked `readOnly` or `writeOnly` gain a note saying so, and deprecated schemas gain a `Deprecated:` paragraph that Go tooling recognizes. `WithFallbackDescriptions` documents top-level schemas that have no description of their own. + +## Untyped JSON + +A schema that does not describe its JSON shape renders as `any`, or `map[string]any` for an object without properties. `WithUntypedAsRawMessage(true)` renders these as `json.RawMessage` (and arrays without `items` as `[]json.RawMessage`) instead. The raw bytes are kept exactly as received, including an explicit `null`, and callers decode them into types they own. + +## Nullability + +`nullable: true` makes a schema nullable in every OpenAPI version. OpenAPI 3.1 replaced the keyword with `type: [..., "null"]` and validators ignore it, but documents still use it, so the generator honours the author's intent and reports `DiagnosticNullableKeyword` for each occurrence in a 3.1 or later document. + +`WithOptionalNullableAsDoublePointer` gives optional nullable fields a three-state `**T` so encoding can send an explicit `null`. Decoding cannot tell `null` from an absent field, so `WithDecodeOnlyRoots` keeps a single pointer under schemas that are only ever decoded, such as response bodies. + Use resolvers when project-specific naming is required: - `WithTypeNameResolver` @@ -178,5 +204,5 @@ Use resolvers when project-specific naming is required: - 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`. +- Tuple-like `prefixItems` render as `[]any` (`[]json.RawMessage` with `WithUntypedAsRawMessage`). - `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/allof_siblings_test.go b/generator/golang/allof_siblings_test.go new file mode 100644 index 00000000..7d97b9d7 --- /dev/null +++ b/generator/golang/allof_siblings_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import "testing" + +func TestAllOfKeepsMembersDeclaredBesideIt(t *testing.T) { + file := renderSpec(t, `openapi: 3.1.0 +info: + title: allOf siblings + version: 1.0.0 +paths: {} +components: + schemas: + Base: + type: object + properties: + kind: + type: string + Derived: + allOf: + - $ref: '#/components/schemas/Base' + - type: object + properties: + inherited: + type: string + required: [extra] + properties: + extra: + type: string + additionalProperties: + type: integer +`) + assertContainsCode(t, string(file.Source), + "type Derived struct { Base Inherited *string `json:\"inherited,omitempty\"` Extra string `json:\"extra\"` AdditionalProperties map[string]int `json:\"-\"` }", + `delete(raw, "extra")`, + ) + assertParsesAndCompiles(t, file.Source) +} diff --git a/generator/golang/coverage_test.go b/generator/golang/coverage_test.go index 426f7bf4..c417d1d7 100644 --- a/generator/golang/coverage_test.go +++ b/generator/golang/coverage_test.go @@ -5,7 +5,6 @@ package golang import ( "reflect" - "strings" "testing" "github.com/pb33f/go-yaml" @@ -277,13 +276,17 @@ func TestInternalBranchCoverage(t *testing.T) { if pointerDepth("string", &SchemaIR{Nullable: true}, true, true, true, false) == 0 { 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 lines := docLines("Thing", &SchemaIR{Description: "\n"}); lines != nil { + t.Fatalf("blank description wrote comment lines %q", lines) + } + if lines := docLines("Thing", &SchemaIR{Title: "missing"}); len(lines) != 1 || lines[0] != "Thing missing." { + t.Fatalf("title comment = %q", lines) + } + if lines := docLines("", &SchemaIR{Comments: []string{"Encoded as a JSON string."}}); len(lines) != 1 || lines[0] != "Encoded as a JSON string." { + t.Fatalf("note-only comment = %q", lines) + } + if lines := docLines("Thing", &SchemaIR{ReadOnly: true, WriteOnly: true, Deprecated: true}); len(lines) != 1 || lines[0] != "Deprecated: the API marks this as deprecated." { + t.Fatalf("declaration notes = %q", lines) } if gen.stringEncodedIR(nil, "nil") != nil { t.Fatal("nil string encoded IR should stay nil") diff --git a/generator/golang/from_openapi.go b/generator/golang/from_openapi.go index 7d6bf658..cb1c3ace 100644 --- a/generator/golang/from_openapi.go +++ b/generator/golang/from_openapi.go @@ -11,11 +11,25 @@ import ( "github.com/pb33f/libopenapi/orderedmap" ) +// nameSeed carries what a schema's Go name is derived from. A root seed holds +// the schema's key. A nested seed holds the default candidate name and, for +// NameStyleIdiomatic, the nearest declared ancestor (owner), the name of the +// path below it (leaf), whether leaf is a document-given name that may stand +// alone (short), and the candidates to try in order. +type nameSeed struct { + name string + nested bool + owner string + leaf string + short bool + candidates []string +} + func (g *Generator) irFromOpenAPI(name string, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { - return g.irFromOpenAPIName(name, false, proxy, path) + return g.irFromOpenAPIName(nameSeed{name: name}, proxy, path) } -func (g *Generator) irFromOpenAPIName(name string, nameResolved bool, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { +func (g *Generator) irFromOpenAPIName(seed nameSeed, proxy *highbase.SchemaProxy, path string) (*SchemaIR, error) { if proxy == nil { return nil, wrapPath(ErrNilSchema, path) } @@ -27,7 +41,7 @@ func (g *Generator) irFromOpenAPIName(name string, nameResolved bool, proxy *hig if err != nil { return nil, wrapPath(err, path) } - ir := g.irFromSchema(name, nameResolved, schema, path) + ir := g.irFromSchema(seed, schema, path) g.openapiCache[proxy] = ir return ir, nil } @@ -49,7 +63,7 @@ func (g *Generator) irFromOpenAPIName(name string, nameResolved bool, proxy *hig if schema == nil { return nil, wrapPath(ErrNilSchema, path) } - ir := g.irFromSchema(name, nameResolved, schema, path) + ir := g.irFromSchema(seed, schema, path) g.openapiCache[proxy] = ir return ir, nil } @@ -57,16 +71,54 @@ func (g *Generator) irFromOpenAPIName(name string, nameResolved bool, proxy *hig // 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) +func (g *Generator) childIR(seed nameSeed, proxy *highbase.SchemaProxy, path string) *SchemaIR { + ir, err := g.irFromOpenAPIName(seed, 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 &SchemaIR{Name: seed.name, Kind: KindAny} } return ir } -func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbase.Schema, path string) *SchemaIR { +// childSeed names a nested schema of parent. segment is the child's name +// segment under NameStyleQualified. Under NameStyleIdiomatic, leaf names the +// child below parent and short reports whether leaf is a document-given name +// that can stand alone. A parent without its own declaration is transparent: +// the child continues its parent's owner and leaf. +func (g *Generator) childSeed(parent *SchemaIR, segment, leaf string, short bool) nameSeed { + if g.nameStyle != NameStyleIdiomatic { + return nameSeed{name: g.nestedTypeName(parent.Name, segment), nested: true} + } + if !parent.declared { + return g.idiomaticSeed(parent.seed.owner, parent.seed.leaf+leaf, parent.seed.short) + } + return g.idiomaticSeed(parent.Name, leaf, short) +} + +// itemSeed names the element schema of array. Under NameStyleIdiomatic an +// element takes the singular of the array's name. +func (g *Generator) itemSeed(array *SchemaIR) nameSeed { + if g.nameStyle != NameStyleIdiomatic { + return nameSeed{name: g.nestedTypeName(array.Name, "Item"), nested: true} + } + if !array.declared { + return g.idiomaticSeed(array.seed.owner, itemName(array.seed.leaf), array.seed.short) + } + seed := g.idiomaticSeed(array.Name, "Item", false) + seed.candidates = append([]string{itemName(array.Name)}, seed.candidates...) + return seed +} + +func (g *Generator) idiomaticSeed(owner, leaf string, short bool) nameSeed { + seed := nameSeed{name: joinTypeName(owner, leaf), nested: true, owner: owner, leaf: leaf, short: short} + if short && g.inlineRoot { + seed.candidates = append(seed.candidates, leaf) + } + seed.candidates = append(seed.candidates, seed.name) + return seed +} + +func (g *Generator) irFromSchema(seed nameSeed, schema *highbase.Schema, path string) *SchemaIR { g.collectShapeDiagnostics(path, schema) if schema.DynamicRef != "" && schemaHasOnlyDynamicRefShape(schema) { nullable := schema.Nullable != nil && *schema.Nullable @@ -83,9 +135,11 @@ func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbas SourceSchema: schema, } } - typeName := g.openapiSchemaTypeName(name, nameResolved, schema, path) + typeName, declared := g.openapiSchemaTypeName(seed, schema, path) ir := &SchemaIR{ Name: typeName, + seed: seed, + declared: declared, Format: schema.Format, Description: schema.Description, Title: schema.Title, @@ -98,25 +152,13 @@ func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbas } if schema.Nullable != nil && *schema.Nullable { ir.Nullable = true + if declaredInOpenAPI31(schema) { + g.addDiagnostic(DiagnosticNullableKeyword, path, "nullable is an OpenAPI 3.0 keyword that OpenAPI 3.1 ignores; the schema was generated as nullable as the document intends, and should use type: [..., \"null\"]") + } } - 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") - } + ir.ReadOnly = schema.ReadOnly != nil && *schema.ReadOnly + ir.WriteOnly = schema.WriteOnly != nil && *schema.WriteOnly + ir.Deprecated = schema.Deprecated != nil && *schema.Deprecated for _, t := range schema.Type { if t == "null" { ir.Nullable = true @@ -132,8 +174,11 @@ func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbas 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")) + segment := "AllOf" + intString(i+1) + ir.AllOf = append(ir.AllOf, g.childIR(g.childSeed(ir, segment, segment, false), child, path+".allOf")) } + // Members declared beside allOf apply alongside its subschemas. + g.populateObjectMembers(ir, schema, path) g.mergeAllOf(ir) return ir } @@ -176,18 +221,23 @@ func (g *Generator) irFromSchema(name string, nameResolved bool, schema *highbas 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) +// openapiSchemaTypeName returns the Go name for a schema and whether the +// schema gets its own declaration. A nested schema that renders inline, such +// as an array or a map, keeps an unclaimed name its children build on. +func (g *Generator) openapiSchemaTypeName(seed nameSeed, schema *highbase.Schema, path string) (string, bool) { + if !seed.nested { + if g.componentTypeNames != nil { + return g.componentTypeName(seed.name), true + } + return g.resolveTypeName(path, path, g.publicName(seed.name)), true } - candidate := name - if !nameResolved { - candidate = g.publicName(name) + if !schemaDeclaresType(schema) { + return seed.name, false } - if !nameResolved || schemaDeclaresType(schema) { - return g.resolveTypeName(path, candidate, path) + if seed.candidates == nil { + return g.resolveTypeName(path, path, seed.name), true } - return candidate + return g.resolveTypeName(path, path, seed.candidates...), true } func schemaDeclaresType(schema *highbase.Schema) bool { @@ -204,9 +254,10 @@ func schemaDeclaresType(schema *highbase.Schema) bool { if typ != "object" { return false } + // An object without properties renders as a map unless it forbids + // additional properties, which keeps it a named empty struct. return (schema.Properties != nil && schema.Properties.Len() > 0) || - schema.AdditionalProperties != nil || - (schema.PatternProperties != nil && schema.PatternProperties.Len() > 0) + (schema.AdditionalProperties != nil && schema.AdditionalProperties.IsB() && !schema.AdditionalProperties.B) } func (g *Generator) populateSchemaShape(ir *SchemaIR, schema *highbase.Schema, path string) { @@ -229,12 +280,13 @@ func (g *Generator) populateSchemaShape(ir *SchemaIR, schema *highbase.Schema, p 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") + ir.Items = g.childIR(g.itemSeed(ir), 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")) + segment := "Tuple" + intString(i+1) + ir.PrefixItems = append(ir.PrefixItems, g.childIR(g.childSeed(ir, segment, segment, false), prefixItem, path+".prefixItems")) } if len(ir.PrefixItems) > 0 { g.addDiagnostic(DiagnosticPrefixItems, path, "prefixItems tuple shape rendered as []any") @@ -275,20 +327,20 @@ func (g *Generator) populateObjectMembers(ir *SchemaIR, schema *highbase.Schema, 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)) + ir.Properties.Set(propName, g.childIR(g.childSeed(ir, propName, g.publicName(propName), true), 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")) + ir.PatternProperties.Set(pattern, g.childIR(g.childSeed(ir, "PatternProperty", "PatternProperty", false), 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") + ir.AdditionalProperties = g.childIR(g.childSeed(ir, "AdditionalProperty", "Value", false), schema.AdditionalProperties.A, path+".additionalProperties") case schema.AdditionalProperties.IsB(): allowed := schema.AdditionalProperties.B ir.AdditionalAllowed = &allowed @@ -353,11 +405,12 @@ func (g *Generator) populateUnion(ir *SchemaIR, schema *highbase.Schema, path st } variants := make([]*SchemaIR, 0, len(children)) for i, child := range children { - variantName := g.nestedTypeName(ir.Name, "Variant"+intString(i+1)) + segment := "Variant" + intString(i+1) + seed := g.childSeed(ir, segment, segment, false) if built, err := child.BuildSchema(); err == nil && built != nil && built.Title != "" { - variantName = g.nestedTypeName(ir.Name, built.Title) + seed = g.childSeed(ir, built.Title, g.publicName(built.Title), true) } - variants = append(variants, g.childIR(variantName, child, path+".union")) + variants = append(variants, g.childIR(seed, child, path+".union")) } // A oneOf/anyOf whose non-null variants are all scalar consts is an // OpenAPI 3.1 const-based enum, not a structural union: fold it before @@ -415,6 +468,8 @@ func (g *Generator) mergeAllOf(ir *SchemaIR) { merged.ExactSource = ir.ExactSource merged.Comments = append([]string(nil), ir.Comments...) merged.SourceSchema = ir.SourceSchema + merged.seed = ir.seed + merged.declared = ir.declared for req := range ir.Required { merged.Required[req] = struct{}{} } @@ -437,6 +492,12 @@ func (g *Generator) mergeAllOf(ir *SchemaIR) { } merged.AllOf = append(merged.AllOf, child) } + for name, prop := range ir.Properties.FromOldest() { + merged.Properties.Set(name, prop) + } + merged.PatternProperties = ir.PatternProperties + merged.AdditionalProperties = ir.AdditionalProperties + merged.AdditionalAllowed = ir.AdditionalAllowed *ir = *merged } @@ -500,6 +561,17 @@ func kindForJSONType(typ string) Kind { } } +// declaredInOpenAPI31 reports whether schema belongs to an OpenAPI 3.1 or +// later document, where JSON Schema 2020-12 replaces the nullable keyword. +func declaredInOpenAPI31(schema *highbase.Schema) bool { + low := schema.GoLow() + if low == nil { + return false + } + version, known := low.Index.ResolveDocumentVersion() + return known && version >= 3.1 +} + func schemaHasOnlyDynamicRefShape(schema *highbase.Schema) bool { return schema.DynamicRef != "" && len(schema.Type) == 0 && diff --git a/generator/golang/from_reflect.go b/generator/golang/from_reflect.go index 682d0bac..944722f8 100644 --- a/generator/golang/from_reflect.go +++ b/generator/golang/from_reflect.go @@ -313,7 +313,7 @@ func (g *Generator) irFromFieldSchema(fieldType reflect.Type, name, path string, nullable = true fieldType = fieldType.Elem() } - ir, err := g.irFromOpenAPIName(name, true, schema, path) + ir, err := g.irFromOpenAPIName(nameSeed{name: name, nested: true, leaf: name}, schema, path) if err != nil { return nil, err } diff --git a/generator/golang/generator.go b/generator/golang/generator.go index 982ca702..6d8b6416 100644 --- a/generator/golang/generator.go +++ b/generator/golang/generator.go @@ -30,7 +30,13 @@ type Generator struct { generatedComment bool openapiTags bool schemaMetadataSidecar bool + untypedAsRawMessage bool nestedTypeNameDelimiter string + nameStyle NameStyle + inlineRoots map[string]struct{} + decodeOnlyRoots map[string]struct{} + fallbackDescriptions map[string]string + reservedTypeNames []string nameResolver NameResolver typeNameResolver NameResolver @@ -61,6 +67,9 @@ type Generator struct { componentTypeNames map[string]string componentKinds map[string]Kind currentComponent string + inlineRoot bool + decodeOnlyNames map[string]struct{} + decodeOnly bool oneOfRegistrations map[reflect.Type][]reflect.Type discriminatorRegistrations map[reflect.Type]discriminatorRegistration @@ -178,9 +187,21 @@ func (g *Generator) run() *Generator { r.componentTypeNames = nil r.componentKinds = nil r.currentComponent = "" + r.inlineRoot = false + r.decodeOnlyNames = nil + r.decodeOnly = false return &r } +// newTypeRegistry creates the registry that type names are claimed from, +// numbering collisions in the configured name style. +func (g *Generator) newTypeRegistry() *NameRegistry { + if g.nameStyle == NameStyleIdiomatic { + return NewIdiomaticNameRegistry(g.reservedTypeNames...) + } + return NewNameRegistry(g.reservedTypeNames...) +} + // 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) @@ -226,7 +247,7 @@ func (g *Generator) RenderSchema(name string, schema *highbase.SchemaProxy) ([]b return nil, wrapPath(ErrNilSchema, name) } r := g.run() - r.typeNames = newNameRegistry() + r.typeNames = r.newTypeRegistry() ir, err := r.irFromOpenAPI(name, schema, name) if err != nil { return nil, err @@ -275,14 +296,22 @@ func (g *Generator) SchemaIRs(schemas *orderedmap.Map[string, *highbase.SchemaPr } func (g *Generator) componentIRs(schemas *orderedmap.Map[string, *highbase.SchemaProxy]) ([]*SchemaIR, error) { - g.typeNames = newNameRegistry() + g.typeNames = g.newTypeRegistry() g.componentTypeNames = g.resolveComponentTypeNames(schemas) irs := make([]*SchemaIR, 0, schemas.Len()) + g.decodeOnlyNames = make(map[string]struct{}) for name, schema := range schemas.FromOldest() { + _, g.inlineRoot = g.inlineRoots[name] ir, err := g.irFromOpenAPI(name, schema, name) if err != nil { return nil, err } + if _, decodeOnly := g.decodeOnlyRoots[name]; decodeOnly { + g.decodeOnlyNames[ir.Name] = struct{}{} + } + if description, ok := g.fallbackDescriptions[name]; ok && ir.Description == "" && ir.Title == "" { + ir.Description = description + } if schema != nil && schema.IsReference() { aliasName := g.componentTypeName(name) if aliasName != ir.Name { @@ -319,11 +348,14 @@ func (g *Generator) resolveComponentTypeNames(schemas *orderedmap.Map[string, *h return names } -func (g *Generator) resolveTypeName(original, candidate, path string) string { +// resolveTypeName claims the first free candidate for original. Without a +// registry, as when building shapes Go does not render, it returns the last +// candidate unclaimed. +func (g *Generator) resolveTypeName(original, path string, candidates ...string) string { if g.typeNames == nil { - return candidate + return candidates[len(candidates)-1] } - resolved, collision := g.typeNames.resolve(original, candidate) + resolved, collision := g.typeNames.ResolveFirst(original, candidates...) if collision { g.addDiagnostic(DiagnosticTypeNameCollision, path, "type name collision resolved as "+resolved) } diff --git a/generator/golang/golden_test.go b/generator/golang/golden_test.go index d32baf64..2017a1df 100644 --- a/generator/golang/golden_test.go +++ b/generator/golang/golden_test.go @@ -41,6 +41,13 @@ func TestNameCollisionsGoldenCompactDelimiter(t *testing.T) { ).Source) } +func TestNameCollisionsGoldenIdiomatic(t *testing.T) { + assertGolden(t, "testdata/name_collisions_idiomatic.golden.go", renderNameCollisions(t, + WithNameStyle(NameStyleIdiomatic), + WithEnumConstants(true), + ).Source) +} + func assertGolden(t *testing.T, path string, got []byte) { t.Helper() if os.Getenv("LIBOPENAPI_GENERATOR_UPDATE_GOLDENS") == "true" { diff --git a/generator/golang/idiomatic_names_test.go b/generator/golang/idiomatic_names_test.go new file mode 100644 index 00000000..3e2ca79c --- /dev/null +++ b/generator/golang/idiomatic_names_test.go @@ -0,0 +1,625 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package golang + +import ( + "reflect" + "regexp" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" +) + +const idiomaticNamesSpec = `openapi: 3.1.0 +info: + title: Idiomatic names + version: 1.0.0 +paths: {} +components: + schemas: + Account: + type: object + properties: + status: + type: string + enum: [active, closed] + account_type: + type: string + enum: [person, company] + tags: + type: array + items: + type: object + properties: + label: + type: string + history: + type: array + items: + type: object + properties: + at: + type: string + settings: + type: object + additionalProperties: + type: object + properties: + value: + type: string + pair: + type: array + prefixItems: + - type: object + properties: + left: + type: string + extra: + type: object + closed: + type: object + additionalProperties: false + Pet: + type: object + properties: + name: + type: string + Pets: + type: array + items: + type: object + properties: + name: + type: string + Dogs: + type: array + items: + type: object + properties: + name: + type: string + X: + type: object + properties: + id: + type: string + DupX: + type: object + properties: + id: + type: string + Shape: + oneOf: + - title: circle + type: object + required: [kind] + properties: + kind: + type: string + const: circle + radius: + type: number + - type: object + required: [kind] + properties: + kind: + type: string + const: square + width: + type: number + properties: + label: + type: object + properties: + text: + type: string + PeopleMatchResponse: + type: object + properties: + person: + type: object + properties: + organization: + type: object + properties: + name: + type: string + contact_emails: + type: array + items: + type: object + properties: + email: + type: string + account: + type: object + properties: + id: + type: string + client: + type: object + properties: + id: + type: string + OtherResponse: + type: object + properties: + organization: + type: object + properties: + id: + type: integer + Dup: + type: object + properties: + x: + type: object + properties: + name: + type: string +` + +// assertContainsCode checks for code with gofmt's column alignment collapsed. +func assertContainsCode(t *testing.T, src string, expected ...string) { + t.Helper() + collapsed := strings.Join(strings.Fields(src), " ") + for _, code := range expected { + if !strings.Contains(collapsed, strings.Join(strings.Fields(code), " ")) { + t.Fatalf("expected %q in:\n%s", code, src) + } + } +} + +func renderSpec(t *testing.T, spec string, opts ...Option) *GeneratedFile { + t.Helper() + file, err := NewGenerator(opts...).RenderSchemas(componentsFromSpec(t, spec)) + if err != nil { + t.Fatal(err) + } + return file +} + +func componentsFromSpec(t *testing.T, spec string) *orderedmap.Map[string, *highbase.SchemaProxy] { + t.Helper() + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + return model.Model.Components.Schemas +} + +func TestIdiomaticNamesQualifyComponentChildren(t *testing.T) { + file := renderSpec(t, idiomaticNamesSpec, WithNameStyle(NameStyleIdiomatic), WithReservedTypeNames("Client"), + WithInlineRoots("PeopleMatchResponse", "OtherResponse", "Dup")) + src := string(file.Source) + assertContainsCode(t, src, + "type AccountStatus string", + "type AccountType string", + "Tags []AccountTag ", + "History []AccountHistoryItem ", + "Settings map[string]AccountSettingsValue ", + "Pair []any ", + "Extra map[string]any ", + "Closed *AccountClosed ", + "type AccountClosed struct {\n}", + "type Pets []PetsItem", + "type Dogs []Dog", + "type ShapeCircle struct", + "type ShapeVariant2 struct", + "Person *Person ", + "Organization *Organization ", + "ContactEmails []ContactEmail ", + "Account *PersonAccount ", + "Client *PersonClient ", + "Organization *OtherResponseOrganization ", + "X *DupX2 ", + ) + for _, generated := range file.Types { + if strings.Contains(generated.Name, "_") { + t.Fatalf("type name %q contains an underscore", generated.Name) + } + } + for _, unexpected := range []string{"AccountExtra", "AccountPairTuple1", "type AccountSettings map", "ShapeLabel"} { + assertNotContains(t, src, unexpected) + } + collisions := 0 + for _, diagnostic := range file.Diagnostics { + if diagnostic.Code == DiagnosticTypeNameCollision { + collisions++ + if diagnostic.Message != "type name collision resolved as DupX2" { + t.Fatalf("unexpected collision diagnostic %#v", diagnostic) + } + } + } + if collisions != 1 { + t.Fatalf("expected one numbered collision, got %#v", file.Diagnostics) + } + assertParsesAndCompiles(t, file.Source) +} + +func TestIdiomaticFieldNamesSpellOutLeadingSymbols(t *testing.T) { + file := renderSpec(t, `openapi: 3.1.0 +info: + title: Fields + version: 1.0.0 +paths: {} +components: + schemas: + Base: + type: object + properties: + kind: + type: string + Record: + allOf: + - $ref: '#/components/schemas/Base' + - type: object + properties: + _id: + type: string + id: + type: string + "@type": + type: string + type: + type: string + __v: + type: integer + user_id: + type: string + userId: + type: string + base: + type: string + additionalProperties: + type: string + properties: + additionalProperties: + type: string +`, WithNameStyle(NameStyleIdiomatic)) + src := string(file.Source) + assertContainsCode(t, src, + "UnderscoreID *string `json:\"_id,omitempty\"`", + "ID *string `json:\"id,omitempty\"`", + "AtType *string `json:\"@type,omitempty\"`", + "Type *string `json:\"type,omitempty\"`", + "V *int `json:\"__v,omitempty\"`", + "UserID *string `json:\"user_id,omitempty\"`", + "UserID2 *string `json:\"userId,omitempty\"`", + "Base2 *string `json:\"base,omitempty\"`", + "AdditionalProperties2 map[string]string `json:\"-\"`", + ) + var fields []string + for _, diagnostic := range file.Diagnostics { + if diagnostic.Code == DiagnosticFieldNameCollision { + fields = append(fields, diagnostic.Path) + } + } + if want := []string{"Record.userId", "Record.base", "Record.additionalProperties"}; !reflect.DeepEqual(fields, want) { + t.Fatalf("field collisions = %q, want %q", fields, want) + } + assertParsesAndCompiles(t, file.Source) +} + +func TestQualifiedFieldNamesKeepEmbeddedTypes(t *testing.T) { + file := renderSpec(t, `openapi: 3.1.0 +info: + title: Fields + version: 1.0.0 +paths: {} +components: + schemas: + Base: + type: object + properties: + kind: + type: string + Record: + allOf: + - $ref: '#/components/schemas/Base' + - type: object + properties: + base: + type: string +`) + assertContains(t, string(file.Source), "Base__2 *string `json:\"base,omitempty\"`") + assertParsesAndCompiles(t, file.Source) +} + +func TestUntypedSchemasAsRawMessage(t *testing.T) { + file := renderSpec(t, `openapi: 3.0.3 +info: + title: Untyped + version: 1.0.0 +paths: {} +components: + schemas: + Loose: + type: object + required: [required_anything] + properties: + anything: {} + required_anything: {} + nullable_anything: + nullable: true + object: + type: object + open_values: + type: object + additionalProperties: {} + list: + type: array + tuple: + type: array + items: + type: string + prefixItems: + - type: string + typed: + type: object + properties: + name: + type: string + additionalProperties: {} + Anything: {} + Bag: + type: object +`, WithUntypedAsRawMessage(true), WithOptionalNullableAsDoublePointer(true)) + src := string(file.Source) + assertContainsCode(t, src, + "Anything json.RawMessage `json:\"anything,omitempty\"`", + "RequiredAnything json.RawMessage `json:\"required_anything\"`", + "NullableAnything json.RawMessage `json:\"nullable_anything,omitempty\"`", + "Object json.RawMessage `json:\"object,omitempty\"`", + "OpenValues json.RawMessage `json:\"open_values,omitempty\"`", + "List []json.RawMessage `json:\"list,omitempty\"`", + "Tuple []json.RawMessage `json:\"tuple,omitempty\"`", + "AdditionalProperties map[string]json.RawMessage `json:\"-\"`", + "type Anything = json.RawMessage", + "type Bag = json.RawMessage", + ) + if regexp.MustCompile(`\bany\b`).MatchString(src) { + t.Fatalf("untyped schemas rendered as any:\n%s", src) + } + assertParsesCompilesAndTests(t, file.Source, `package models + +import ( + "encoding/json" + "testing" +) + +func TestRawMessageRoundTrip(t *testing.T) { + input := []byte(`+"`"+`{"anything":{"a":[1,2]},"required_anything":null,"object":{"b":true},"list":[1,"x"]}`+"`"+`) + var loose Loose + if err := json.Unmarshal(input, &loose); err != nil { + t.Fatal(err) + } + if string(loose.Anything) != `+"`"+`{"a":[1,2]}`+"`"+` || string(loose.RequiredAnything) != "null" || len(loose.List) != 2 { + t.Fatalf("decoded %#v", loose) + } + output, err := json.Marshal(loose) + if err != nil { + t.Fatal(err) + } + if string(output) != string(input) { + t.Fatalf("round trip = %s", output) + } + var bag Bag = json.RawMessage(`+"`"+`{"k":1}`+"`"+`) + if encoded, _ := json.Marshal(bag); string(encoded) != `+"`"+`{"k":1}`+"`"+` { + t.Fatalf("alias lost RawMessage methods: %s", encoded) + } +} +`) +} + +func TestNullableKeywordInOpenAPI31IsHonouredWithDiagnostic(t *testing.T) { + for _, test := range []struct { + version string + diagnostic bool + }{{"3.1.0", true}, {"3.0.3", false}} { + file := renderSpec(t, `openapi: `+test.version+` +info: + title: Nullable + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + required: [confidence] + properties: + confidence: + type: number + nullable: true +`) + assertContains(t, string(file.Source), "Confidence *float64 `json:\"confidence\"`") + if got := hasDiagnosticCode(file.Diagnostics, DiagnosticNullableKeyword); got != test.diagnostic { + t.Fatalf("OpenAPI %s nullable diagnostic = %v, want %v: %#v", test.version, got, test.diagnostic, file.Diagnostics) + } + } + nullable := true + built := highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"number"}, Nullable: &nullable}) + file, err := NewGenerator().RenderSchemas(singleSchemaMap(t, "Built", built)) + if err != nil { + t.Fatal(err) + } + if hasDiagnosticCode(file.Diagnostics, DiagnosticNullableKeyword) { + t.Fatal("a schema without a source document cannot be judged as OpenAPI 3.1") + } +} + +func TestIdiomaticRenderSchemaNamesRootAndChildren(t *testing.T) { + source, err := RenderSchema("widget-list", schemaProxyFromYAML(t, ` +type: array +items: + type: object + properties: + name: + type: string +`), WithNameStyle(NameStyleIdiomatic)) + if err != nil { + t.Fatal(err) + } + assertContains(t, string(source), "type WidgetList []WidgetListItem") + assertContains(t, string(source), "type WidgetListItem struct") +} + +func TestPublicNameWordsAndInitialisms(t *testing.T) { + for input, want := range map[string]string{ + "label_ids": "LabelIDs", + "emailer_urls": "EmailerURLs", + "email_md5": "EmailMD5", + "email_sha256": "EmailSHA256", + "base64Encoded": "Base64Encoded", + "OAuth2Token": "OAuth2Token", + "Status200Response": "Status200Response", + "employment_history": "EmploymentHistory", + } { + if got := PublicName(input); got != want { + t.Fatalf("PublicName(%q) = %q, want %q", input, got, want) + } + } +} + +func TestItemNameSingularizesConfidentPlurals(t *testing.T) { + for input, want := range map[string]string{ + "FundingEvents": "FundingEvent", + "CurrentTechnologies": "CurrentTechnology", + "Addresses": "Address", + "Boxes": "Box", + "Matches": "Match", + "Hashes": "Hash", + "Buzzes": "Buzz", + "People": "Person", + "Statuses": "Status", + "LabelIDs": "LabelID", + "Pets": "Pet", + "EmploymentHistory": "EmploymentHistoryItem", + "Class": "ClassItem", + "Status": "StatusItem", + "Analysis": "AnalysisItem", + "Data": "DataItem", + "Series": "SeriesItem", + "Gas": "GasItem", + "Ties": "Tie", + } { + if got := itemName(input); got != want { + t.Fatalf("itemName(%q) = %q, want %q", input, got, want) + } + } +} + +func TestJoinTypeNameWritesSharedWordsOnce(t *testing.T) { + for _, test := range []struct{ owner, leaf, want string }{ + {"Contact", "ContactEmail", "ContactEmail"}, + {"BookAccount", "AccountStatus", "BookAccountStatus"}, + {"Account", "Account", "AccountAccount"}, + {"Person", "Organization", "PersonOrganization"}, + {"", "Person", "Person"}, + } { + if got := joinTypeName(test.owner, test.leaf); got != test.want { + t.Fatalf("joinTypeName(%q, %q) = %q, want %q", test.owner, test.leaf, got, test.want) + } + } +} + +func TestNameRegistryClaimFirstAndIdiomaticNumbers(t *testing.T) { + registry := NewIdiomaticNameRegistry("Client") + if got := registry.ClaimFirst("Client", "PeopleClient"); got != "PeopleClient" { + t.Fatalf("ClaimFirst() = %q", got) + } + if got := registry.ClaimFirst("Client", "PeopleClient"); got != "PeopleClient2" { + t.Fatalf("ClaimFirst() numbered = %q", got) + } + if got := registry.Claim("Client", ""); got != "Client2" { + t.Fatalf("Claim() numbered = %q", got) + } +} + +func TestFieldNamesResolvesRepeatedAndSymbolicSources(t *testing.T) { + got := FieldNames([]string{"_id", "id", "id", "body"}, "Body") + if want := []string{"UnderscoreID", "ID", "ID2", "Body2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("FieldNames() = %q, want %q", got, want) + } +} + +func TestIdiomaticRegistryLettersNamesEndingInDigits(t *testing.T) { + registry := NewIdiomaticNameRegistry("PlaybookV2") + if got := registry.ClaimFirst("PlaybookV2"); got != "PlaybookV2B" { + t.Fatalf("ClaimFirst() = %q", got) + } + for letter := 'C'; letter <= 'Z'; letter++ { + registry.ClaimFirst("PlaybookV2") + } + if got := registry.ClaimFirst("PlaybookV2"); got != "PlaybookV2Z2" { + t.Fatalf("ClaimFirst() after the alphabet = %q", got) + } +} + +func TestDecodeOnlyRootsAndFallbackDescriptions(t *testing.T) { + file := renderSpec(t, `openapi: 3.1.0 +info: + title: Roots + version: 1.0.0 +paths: {} +components: + schemas: + Sent: + type: object + properties: + note: + type: [string, "null"] + Received: + type: object + properties: + note: + type: [string, "null"] + nested: + type: object + properties: + note: + type: [string, "null"] + Described: + description: Its own words. + type: string +`, WithOptionalNullableAsDoublePointer(true), WithDecodeOnlyRoots("Received"), + WithFallbackDescriptions(map[string]string{"Sent": "The request body.", "Described": "Ignored."})) + assertContainsCode(t, string(file.Source), + "// Sent is the request body.\ntype Sent struct { Note **string `json:\"note,omitempty\"` }", + "type Received struct { Note *string `json:\"note,omitempty\"` Nested *Received_Nested `json:\"nested,omitempty\"` }", + "type Received_Nested struct { Note *string `json:\"note,omitempty\"` }", + "// Described Its own words.\ntype Described string", + ) +} + +type IdiomaticFieldSchemaOwner struct { + Entries []any `json:"entries"` +} + +func TestIdiomaticNamesFollowReflectedFieldSchemas(t *testing.T) { + entries := schemaProxyFromYAML(t, ` +type: array +items: + type: object + properties: + name: + type: string +`) + set, err := SchemasFromTypesWithOptions([]reflect.Type{reflect.TypeOf(IdiomaticFieldSchemaOwner{})}, + WithNameStyle(NameStyleIdiomatic), + WithFieldSchema(reflect.TypeOf(IdiomaticFieldSchemaOwner{}), "Entries", entries)) + if err != nil { + t.Fatal(err) + } + if set.Root == nil { + t.Fatal("no root schema") + } +} diff --git a/generator/golang/ir.go b/generator/golang/ir.go index b7f51edf..4363ee6e 100644 --- a/generator/golang/ir.go +++ b/generator/golang/ir.go @@ -92,6 +92,12 @@ type SchemaIR struct { ExactSource bool Comments []string SourceSchema *highbase.Schema + + // seed records what Name was derived from, and declared reports whether + // Name was claimed for a declaration of its own. Nested schemas build + // their names from these. + seed nameSeed + declared bool } type UnionIR struct { diff --git a/generator/golang/name_registry.go b/generator/golang/name_registry.go index 03b8987d..27fbcd2e 100644 --- a/generator/golang/name_registry.go +++ b/generator/golang/name_registry.go @@ -7,12 +7,25 @@ const conflictNameDelimiter = "__" // NameRegistry allocates deterministic, collision-free Go names. type NameRegistry struct { - used map[string]string + used map[string]string + separator string } // NewNameRegistry creates a registry with reserved names already claimed. +// Collisions take a "__N" suffix. func NewNameRegistry(reserved ...string) *NameRegistry { - registry := &NameRegistry{used: make(map[string]string, len(reserved))} + return newRegistry(conflictNameDelimiter, reserved) +} + +// NewIdiomaticNameRegistry creates a registry with reserved names already +// claimed. Collisions take a plain numeric suffix, so a second Widget becomes +// Widget2 rather than Widget__2. +func NewIdiomaticNameRegistry(reserved ...string) *NameRegistry { + return newRegistry("", reserved) +} + +func newRegistry(separator string, reserved []string) *NameRegistry { + registry := &NameRegistry{used: make(map[string]string, len(reserved)), separator: separator} for _, name := range reserved { registry.used[name] = name } @@ -28,19 +41,24 @@ 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 + return r.ResolveFirst(original, candidate) +} + +// ResolveFirst returns the first candidate that is free or already claimed by +// original. When every candidate belongs to another original it numbers the +// last candidate and reports the collision. +func (r *NameRegistry) ResolveFirst(original string, candidates ...string) (string, bool) { + for _, candidate := range candidates { + if existing, ok := r.used[candidate]; !ok { + r.used[candidate] = original + return candidate, false + } else if existing == original { + return candidate, false } } + next := r.next(candidates[len(candidates)-1]) + r.used[next] = original + return next, true } func (r *NameRegistry) resolve(original, candidate string) (string, bool) { @@ -64,11 +82,41 @@ func (r *NameRegistry) Claim(preferred, collisionSuffix string) string { return candidate } } - for suffix := 2; ; suffix++ { - candidate := preferred + conflictNameDelimiter + intString(suffix) + next := r.next(preferred) + r.used[next] = next + return next +} + +// ClaimFirst reserves the first free candidate for a distinct declaration. +// When every candidate is taken it numbers the last one. +func (r *NameRegistry) ClaimFirst(candidates ...string) string { + for _, candidate := range candidates { if _, exists := r.used[candidate]; !exists { r.used[candidate] = candidate return candidate } } + next := r.next(candidates[len(candidates)-1]) + r.used[next] = next + return next +} + +// next returns the first free numbered form of candidate without claiming it. +// Without a separator, a number after a name that ends in a digit would read +// as part of it (Value152), so such names take a letter instead (Value15B). +func (r *NameRegistry) next(candidate string) string { + if last := candidate[len(candidate)-1]; r.separator == "" && last >= '0' && last <= '9' { + for letter := 'B'; letter <= 'Z'; letter++ { + if _, exists := r.used[candidate+string(letter)]; !exists { + return candidate + string(letter) + } + } + candidate += "Z" + } + for suffix := 2; ; suffix++ { + next := candidate + r.separator + intString(suffix) + if _, exists := r.used[next]; !exists { + return next + } + } } diff --git a/generator/golang/names.go b/generator/golang/names.go index 07eed8c4..034ec128 100644 --- a/generator/golang/names.go +++ b/generator/golang/names.go @@ -14,12 +14,30 @@ 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", + "MD5": "MD5", "QPS": "QPS", "RAM": "RAM", "RPC": "RPC", "SHA1": "SHA1", "SHA256": "SHA256", + "SHA512": "SHA512", "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", } +// symbolWords names the leading symbols that distinguish JSON property names +// such as "_id" and "@type" from their plain spellings. +var symbolWords = map[rune]string{ + '_': "Underscore", '$': "Dollar", '@': "At", '#': "Hash", + '+': "Plus", '-': "Minus", '.': "Dot", '~': "Tilde", +} + +// irregularPlurals holds plurals that the suffix rules in singular would get +// wrong. An empty value marks a word that has no distinct singular. +var irregularPlurals = map[string]string{ + "aliases": "alias", "analyses": "analysis", "analytics": "", "caches": "cache", + "children": "child", "cookies": "cookie", "criteria": "criterion", "data": "", + "indices": "index", "matrices": "matrix", "media": "", "men": "man", "movies": "movie", + "news": "", "people": "person", "series": "", "species": "", "statuses": "status", + "vertices": "vertex", "women": "woman", +} + func (g *Generator) publicName(name string) string { if g.typeNameResolver != nil { if resolved := g.typeNameResolver(name); resolved != "" { @@ -99,6 +117,12 @@ func toPublicName(name string) string { b.WriteString(v) continue } + // A plural initialism such as IDS reaches here only with its S removed. + if v, ok := initialisms[strings.TrimSuffix(upper, "S")]; ok { + b.WriteString(v) + b.WriteByte('s') + continue + } rs := []rune(strings.ToLower(p)) rs[0] = unicode.ToUpper(rs[0]) b.WriteString(string(rs)) @@ -164,8 +188,9 @@ func splitCamel(value string) []string { 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) + lowerToUpper := (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(cur) + acronymToWord := unicode.IsUpper(prev) && unicode.IsUpper(cur) && next != 0 && unicode.IsLower(next) && + !pluralAcronym(rs, i+1) if lowerToUpper || acronymToWord { parts = append(parts, string(rs[start:i])) start = i @@ -175,6 +200,80 @@ func splitCamel(value string) []string { return parts } +// joinTypeName qualifies leaf with owner. Words that end owner and also begin +// leaf are written once, so Contact and ContactEmail join as ContactEmail. +func joinTypeName(owner, leaf string) string { + ownerWords, leafWords := splitCamel(owner), splitCamel(leaf) + for overlap := min(len(ownerWords), len(leafWords)-1); overlap > 0; overlap-- { + if strings.Join(ownerWords[len(ownerWords)-overlap:], "") == strings.Join(leafWords[:overlap], "") { + return owner + strings.Join(leafWords[overlap:], "") + } + } + return owner + leaf +} + +// itemName names the element type of an array from the array's Go name. +// A plural final word becomes singular (FundingEvents is FundingEvent); +// otherwise Item is appended (EmploymentHistory is EmploymentHistoryItem). +func itemName(name string) string { + words := splitCamel(name) + last := words[len(words)-1] + if singular, ok := singular(last); ok { + return strings.Join(words[:len(words)-1], "") + singular + } + return name + "Item" +} + +// singular returns the singular Go word for an English plural, or false when +// word is not a plural it can confidently reduce. +func singular(word string) (string, bool) { + if initialism, ok := initialisms[strings.ToUpper(strings.TrimSuffix(word, "s"))]; ok && strings.HasSuffix(word, "s") { + return initialism, true + } + lower := strings.ToLower(word) + if irregular, ok := irregularPlurals[lower]; ok { + return toPublicName(irregular), irregular != "" + } + switch { + case strings.HasSuffix(lower, "ies") && len(lower) > 4: + lower = strings.TrimSuffix(lower, "ies") + "y" + case strings.HasSuffix(lower, "sses"), strings.HasSuffix(lower, "xes"), + strings.HasSuffix(lower, "ches"), strings.HasSuffix(lower, "shes"), strings.HasSuffix(lower, "zzes"): + lower = strings.TrimSuffix(lower, "es") + case strings.HasSuffix(lower, "ss"), strings.HasSuffix(lower, "us"), strings.HasSuffix(lower, "is"): + return "", false + case strings.HasSuffix(lower, "s") && len(lower) > 3: + lower = strings.TrimSuffix(lower, "s") + default: + return "", false + } + return toPublicName(lower), true +} + +// symbolPrefixedName spells out the leading symbols of a JSON property name +// before its base Go name, so "_id" becomes UnderscoreID. The result is false +// when the name has no leading symbol with a word. +func symbolPrefixedName(source, base string) (string, bool) { + var prefix strings.Builder + for _, r := range source { + word, ok := symbolWords[r] + if !ok { + break + } + prefix.WriteString(word) + } + if prefix.Len() == 0 { + return "", false + } + return prefix.String() + base, true +} + +// pluralAcronym reports whether the lowercase s at rs[at] pluralizes the +// acronym before it, as in IDs or URLsFor, rather than starting a word. +func pluralAcronym(rs []rune, at int) bool { + return rs[at] == 's' && (at+1 == len(rs) || !unicode.IsLower(rs[at+1])) +} + // RefName returns the RFC 6901-decoded final path segment of ref. func RefName(ref string) string { if ref == "" { diff --git a/generator/golang/options.go b/generator/golang/options.go index 48bbbb97..e052ae79 100644 --- a/generator/golang/options.go +++ b/generator/golang/options.go @@ -47,6 +47,7 @@ const ( DiagnosticMultiTypeSchema = "multiTypeSchema" DiagnosticNotSchema = "notSchema" DiagnosticNullEnum = "nullEnum" + DiagnosticNullableKeyword = "nullableKeyword" DiagnosticOptionalConstDiscriminator = "optionalConstDiscriminator" DiagnosticPatternProperties = "patternProperties" DiagnosticPrefixItems = "prefixItems" @@ -60,6 +61,26 @@ const ( DiagnosticValidationKeyword = "validationKeyword" ) +// NameStyle selects how the generator names inline schemas and resolves name +// collisions. +type NameStyle int + +const ( + // NameStyleQualified joins each inline schema's name to its parent's with + // the nested type name delimiter (Parent_Child, Parent_Items_Item) and + // resolves collisions with "__N" suffixes. It is the default. + NameStyleQualified NameStyle = iota + // NameStyleIdiomatic names inline schemas the way a Go author would. A + // child is named after its nearest declared ancestor and its property + // (AccountStatus), array elements take the singular property name + // (OrganizationFundingEvent), and map values end in Value. Under a root + // marked with WithInlineRoots, a child takes its bare property name + // (Person) when it is free. Struct fields whose JSON names differ only by + // a leading symbol are told apart with words (_id is UnderscoreID), and any + // remaining collision takes a plain numeric suffix. + NameStyleIdiomatic +) + type formatMapping struct { goType string importPath string @@ -242,6 +263,72 @@ func WithNestedTypeNameDelimiter(delimiter string) Option { } } +// WithNameStyle sets how inline schemas are named and collisions resolved. +func WithNameStyle(style NameStyle) Option { + return func(g *Generator) { + g.nameStyle = style + } +} + +// WithInlineRoots marks top-level schemas, by their key in the rendered map, +// that a document declared inline, such as an operation's response body, +// rather than as named components. The document gives the parts of such a +// schema no names except their property names, so with NameStyleIdiomatic +// their nested types are named for the property alone when that name is free. +func WithInlineRoots(keys ...string) Option { + return func(g *Generator) { + if g.inlineRoots == nil { + g.inlineRoots = make(map[string]struct{}, len(keys)) + } + for _, key := range keys { + g.inlineRoots[key] = struct{}{} + } + } +} + +// WithDecodeOnlyRoots marks top-level schemas, by their key in the rendered +// map, that are only ever decoded, such as response bodies. Decoding cannot +// tell a JSON null from an absent field, so optional nullable fields under +// these schemas keep a single pointer even with +// WithOptionalNullableAsDoublePointer. +func WithDecodeOnlyRoots(keys ...string) Option { + return func(g *Generator) { + if g.decodeOnlyRoots == nil { + g.decodeOnlyRoots = make(map[string]struct{}, len(keys)) + } + for _, key := range keys { + g.decodeOnlyRoots[key] = struct{}{} + } + } +} + +// WithFallbackDescriptions documents top-level schemas, by their key in the +// rendered map, that carry no description or title of their own. +func WithFallbackDescriptions(descriptions map[string]string) Option { + return func(g *Generator) { + g.fallbackDescriptions = descriptions + } +} + +// WithReservedTypeNames keeps generated type names clear of names that other +// code in the same package declares. +func WithReservedTypeNames(names ...string) Option { + return func(g *Generator) { + g.reservedTypeNames = append(g.reservedTypeNames, names...) + } +} + +// WithUntypedAsRawMessage renders schemas that do not describe their JSON +// shape as json.RawMessage instead of any or map[string]any. This covers +// empty schemas, objects without declared properties, and arrays without +// items (rendered as []json.RawMessage). The raw bytes are kept exactly as +// received, and callers decode them into types they own. +func WithUntypedAsRawMessage(enabled bool) Option { + return func(g *Generator) { + g.untypedAsRawMessage = enabled + } +} + // WithExternalRefTypeResolver sets a resolver for external OpenAPI $ref values // when rendering Go type names. The resolver is not used for local component // references. diff --git a/generator/golang/phase_two_test.go b/generator/golang/phase_two_test.go index db2aa9f8..41cac749 100644 --- a/generator/golang/phase_two_test.go +++ b/generator/golang/phase_two_test.go @@ -177,12 +177,11 @@ properties: 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, "// Read-only: the API returns this value; requests should not send it.\n\tID") + assertNotContains(t, src, "default value is defined") + assertNotContains(t, src, "example value is defined") + assertContains(t, src, "// Write-only: requests send this value; the API does not return it.\n\t//\n\t// Deprecated: the API marks this as deprecated.\n\tSecret") + assertContains(t, src, "// Display title.\n\tTitled") assertContains(t, src, "Tuple") assertContains(t, src, "[]any") assertContains(t, src, "`json:\"tuple,omitempty\"`") @@ -301,8 +300,6 @@ func TestPhaseTwoCommentAndShapeHelpers(t *testing.T) { } 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.") { diff --git a/generator/golang/testdata/jsonschema_2020_12_default.golden.go b/generator/golang/testdata/jsonschema_2020_12_default.golden.go index 2b863d61..4f6daa0f 100644 --- a/generator/golang/testdata/jsonschema_2020_12_default.golden.go +++ b/generator/golang/testdata/jsonschema_2020_12_default.golden.go @@ -30,10 +30,10 @@ func (u TortureDocument_MultiValueUnion) Bytes() []byte { } type TortureDocument struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. ID string `json:"id"` Kind string `json:"kind"` - // MultiValue A nullable multi-type value. + // A nullable multi-type value. MultiValue *TortureDocument_MultiValueUnion `json:"multi_value,omitempty"` NullableStatus *NullableStatus `json:"nullable_status,omitempty"` MixedEnum *MixedEnum `json:"mixed_enum,omitempty"` @@ -183,7 +183,7 @@ func (u *PaymentSourceUnion) UnmarshalJSON(data []byte) error { type CardSource struct { Object string `json:"object"` Number string `json:"number"` - // CVC writeOnly. + // Write-only: requests send this value; the API does not return it. CVC string `json:"cvc"` } diff --git a/generator/golang/testdata/jsonschema_2020_12_options.golden.go b/generator/golang/testdata/jsonschema_2020_12_options.golden.go index 0e40ae7c..626585f2 100644 --- a/generator/golang/testdata/jsonschema_2020_12_options.golden.go +++ b/generator/golang/testdata/jsonschema_2020_12_options.golden.go @@ -30,10 +30,10 @@ func (u TortureDocument_MultiValueUnion) Bytes() []byte { } type TortureDocument struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. ID string `json:"id"` Kind string `json:"kind"` - // MultiValue A nullable multi-type value. + // A nullable multi-type value. MultiValue *TortureDocument_MultiValueUnion `json:"multi_value,omitempty"` NullableStatus *NullableStatus `json:"nullable_status,omitempty"` MixedEnum *MixedEnum `json:"mixed_enum,omitempty"` @@ -163,7 +163,7 @@ func (u *PaymentSourceUnion) UnmarshalJSON(data []byte) error { type CardSource struct { Object string `json:"object"` Number string `json:"number"` - // CVC writeOnly. + // Write-only: requests send this value; the API does not return it. CVC string `json:"cvc"` } diff --git a/generator/golang/testdata/name_collisions_idiomatic.golden.go b/generator/golang/testdata/name_collisions_idiomatic.golden.go new file mode 100644 index 00000000..1c1ad75f --- /dev/null +++ b/generator/golang/testdata/name_collisions_idiomatic.golden.go @@ -0,0 +1,245 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type CollisionRootNestedDupObj struct { + Name *string `json:"name,omitempty"` +} + +type CollisionRootNestedDupObj2 struct { + Count *int `json:"count,omitempty"` +} + +type CollisionRootNestedInlineItem struct { + Func *string `json:"func,omitempty"` +} + +type CollisionRootNested struct { + ValueID string `json:"value-id"` + ValueID2 int `json:"value_id"` + DupObj *CollisionRootNestedDupObj `json:"dup-obj,omitempty"` + DupObj2 *CollisionRootNestedDupObj2 `json:"dup_obj,omitempty"` + InlineItem *CollisionRootNestedInlineItem `json:"inline-item,omitempty"` +} + +type CollisionRoot struct { + Type string `json:"type"` + UserID UserID `json:"user-id"` + UserID2 UserID2 `json:"user_id"` + UserID3 UserID3 `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"` + AdditionalProperties2 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.AdditionalProperties2 = 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.AdditionalProperties2[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.AdditionalProperties2 { + 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 UserID2 struct { + ID int `json:"id"` +} + +type UserID3 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 (ChoiceCard2) 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 ChoiceCard2 + 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 ChoiceCard2 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" + EnumCollisionInProgress2 EnumCollision = "in progress" + EnumCollisionTrue EnumCollision = "true" + EnumCollisionTrue2 EnumCollision = "True" + EnumCollisionValue15 EnumCollision = "1.5" + EnumCollisionValue15B EnumCollision = "1_5" + EnumCollisionValue15C EnumCollision = "1-5" +) diff --git a/generator/golang/testdata/train_travel_default.golden.go b/generator/golang/testdata/train_travel_default.golden.go index 90e66523..477b2394 100644 --- a/generator/golang/testdata/train_travel_default.golden.go +++ b/generator/golang/testdata/train_travel_default.golden.go @@ -2,7 +2,7 @@ package models import "encoding/json" -// Station A train station. +// Station is a train station. type Station struct { ID string `json:"id"` Name string `json:"name"` @@ -11,7 +11,7 @@ type Station struct { Timezone *string `json:"timezone,omitempty"` } -// Trip A train trip. +// Trip is a train trip. type Trip struct { ID *string `json:"id,omitempty"` Origin *string `json:"origin,omitempty"` @@ -23,9 +23,9 @@ type Trip struct { DogsAllowed *bool `json:"dogs_allowed,omitempty"` } -// Booking A booking for a train trip. +// Booking is a booking for a train trip. type Booking struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. ID *string `json:"id,omitempty"` TripID *string `json:"trip_id,omitempty"` PassengerName *string `json:"passenger_name,omitempty"` @@ -59,17 +59,16 @@ 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. +// BookingPayment is a payment for a booking. type BookingPayment struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. 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. + // The payment source to take the payment from. Source *BookingPayment_SourceUnion `json:"source,omitempty"` - // Status readOnly. + // Read-only: the API returns this value; requests should not send it. 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 index 6942f356..a5ab40ef 100644 --- a/generator/golang/testdata/train_travel_typed_union.golden.go +++ b/generator/golang/testdata/train_travel_typed_union.golden.go @@ -5,7 +5,7 @@ import ( "fmt" ) -// Station A train station. +// Station is a train station. type Station struct { ID string `json:"id"` Name string `json:"name"` @@ -14,7 +14,7 @@ type Station struct { Timezone *string `json:"timezone,omitempty"` } -// Trip A train trip. +// Trip is a train trip. type Trip struct { ID *string `json:"id,omitempty"` Origin *string `json:"origin,omitempty"` @@ -26,9 +26,9 @@ type Trip struct { DogsAllowed *bool `json:"dogs_allowed,omitempty"` } -// Booking A booking for a train trip. +// Booking is a booking for a train trip. type Booking struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. ID *string `json:"id,omitempty"` TripID *string `json:"trip_id,omitempty"` PassengerName *string `json:"passenger_name,omitempty"` @@ -38,12 +38,12 @@ type Booking struct { type BookingPayment_Currency string -// BookingPayment_Source_Card A card to take payment from. +// BookingPayment_Source_Card is 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. + // Write-only: requests send this value; the API does not return it. CVC string `json:"cvc"` ExpMonth int64 `json:"exp_month"` ExpYear int64 `json:"exp_year"` @@ -52,7 +52,7 @@ type BookingPayment_Source_Card struct { type BookingPayment_Source_BankAccount_AccountType string -// BookingPayment_Source_BankAccount A bank account to take payment from. +// BookingPayment_Source_BankAccount is a bank account to take payment from. type BookingPayment_Source_BankAccount struct { Object *string `json:"object,omitempty"` Name string `json:"name"` @@ -111,17 +111,16 @@ func (u *BookingPayment_SourceUnion) UnmarshalJSON(data []byte) error { return nil } -// BookingPayment_Status readOnly. type BookingPayment_Status string -// BookingPayment A payment for a booking. +// BookingPayment is a payment for a booking. type BookingPayment struct { - // ID readOnly. + // Read-only: the API returns this value; requests should not send it. 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. + // The payment source to take the payment from. Source *BookingPayment_SourceUnion `json:"source,omitempty"` - // Status readOnly. + // Read-only: the API returns this value; requests should not send it. Status *BookingPayment_Status `json:"status,omitempty"` } diff --git a/generator/golang/to_go.go b/generator/golang/to_go.go index 514a4525..2775a694 100644 --- a/generator/golang/to_go.go +++ b/generator/golang/to_go.go @@ -10,6 +10,7 @@ import ( "strings" highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/generator/internal/gocomment" ) var formatSource = format.Source @@ -28,6 +29,7 @@ func (g *Generator) renderFile(irs []*SchemaIR) (*GeneratedFile, error) { if ir == nil { continue } + _, g.decodeOnly = g.decodeOnlyNames[ir.Name] fields := g.renderDecl(ir) generatedType := &GeneratedType{Name: ir.Name, Kind: ir.Kind, Fields: fields} for _, embedded := range ir.AllOf { @@ -143,7 +145,7 @@ func (g *Generator) renderDecl(ir *SchemaIR) []GeneratedField { case KindUnion: g.renderUnionDecl(ir) case KindObject, KindAllOf: - if shouldRenderObjectAlias(ir) { + if g.shouldRenderObjectAlias(ir) { g.renderAliasDecl(ir) return nil } @@ -191,28 +193,25 @@ func (g *Generator) renderObjectDecl(ir *SchemaIR) []GeneratedField { b.WriteString("type ") b.WriteString(ir.Name) b.WriteString(" struct {\n") - fields := newNameRegistry() var generatedFields []GeneratedField 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') - } + var embedded []string + for _, embed := range ir.AllOf { + if embed != nil && embed.Kind == KindRef { + embedded = append(embedded, g.goType(embed, true, false)) + b.WriteByte('\t') + b.WriteString(embedded[len(embedded)-1]) + b.WriteByte('\n') } } + fieldNames, fields := g.structFieldNames(ir, embedded) 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) - } + fieldName := fieldNames[propName] fieldType := g.goType(prop, required, true) generatedFields = append(generatedFields, GeneratedField{Name: fieldName, Source: propName, Type: fieldType}) - writeFieldComments(&b, fieldName, prop) + writeFieldComments(&b, prop) b.WriteByte('\t') b.WriteString(fieldName) b.WriteByte(' ') @@ -248,6 +247,81 @@ func (g *Generator) renderObjectDecl(ir *SchemaIR) []GeneratedField { return generatedFields } +// structFieldNames assigns a Go field name to each property of ir. The names +// of embedded types are claimed first so no property can shadow them. Under +// NameStyleIdiomatic, plain property names claim their Go names before names +// with a leading symbol, which then spell the symbol out: with both "_id" and +// "id", id is ID and _id is UnderscoreID. +func (g *Generator) structFieldNames(ir *SchemaIR, embedded []string) (map[string]string, *NameRegistry) { + names := make(map[string]string) + if g.nameStyle != NameStyleIdiomatic { + fields := NewNameRegistry(embedded...) + for propName := range ir.Properties.FromOldest() { + names[propName] = g.claimFieldName(fields, ir, propName, g.fieldName(propName)) + } + return names, fields + } + fields := NewIdiomaticNameRegistry(embedded...) + var sources []string + for propName := range ir.Properties.FromOldest() { + sources = append(sources, propName) + } + resolved := distinctFieldNames(fields, sources, g.fieldName, func(propName, fieldName string) { + g.addDiagnostic(DiagnosticFieldNameCollision, ir.Name+"."+propName, "field name collision resolved as "+fieldName) + }) + for index, propName := range sources { + names[propName] = resolved[index] + } + return names, fields +} + +func (g *Generator) claimFieldName(fields *NameRegistry, ir *SchemaIR, propName string, candidates ...string) string { + fieldName, collision := fields.ResolveFirst(propName, candidates...) + if collision { + g.addDiagnostic(DiagnosticFieldNameCollision, ir.Name+"."+propName, "field name collision resolved as "+fieldName) + } + return fieldName +} + +// FieldNames returns a distinct exported Go field name for each JSON name, +// in order, avoiding reserved names. Collisions are resolved as +// NameStyleIdiomatic resolves struct fields: plain names claim their Go names +// first, a leading symbol is spelled out (_id is UnderscoreID), and any +// remaining collision takes a numeric suffix. +func FieldNames(sources []string, reserved ...string) []string { + return distinctFieldNames(NewIdiomaticNameRegistry(reserved...), sources, toPublicName, func(string, string) {}) +} + +// distinctFieldNames claims one field name per source from fields. Sources +// with a leading symbol claim after plain ones so the plain spelling keeps +// the base name. numbered is told about each name that needed a number. +func distinctFieldNames(fields *NameRegistry, sources []string, base func(string) string, numbered func(source, name string)) []string { + names := make([]string, len(sources)) + claim := func(index int, candidates ...string) { + // Sources may repeat, as parameters with one name in two locations do, + // so each claim is owned by its position. + name, collision := fields.ResolveFirst(sources[index]+"\x00"+intString(index), candidates...) + if collision { + numbered(sources[index], name) + } + names[index] = name + } + var symbolic []int + for index, source := range sources { + if _, ok := symbolPrefixedName(source, ""); ok { + symbolic = append(symbolic, index) + continue + } + claim(index, base(source)) + } + for _, index := range symbolic { + name := base(sources[index]) + spelled, _ := symbolPrefixedName(sources[index], name) + claim(index, name, spelled) + } + return names +} + func (g *Generator) renderChildren(ir *SchemaIR) { if ir.Properties != nil { for _, prop := range ir.Properties.FromOldest() { @@ -259,12 +333,10 @@ func (g *Generator) renderChildren(ir *SchemaIR) { g.renderNested(prop) } } - if ir.Items != nil { + // Tuples render as untyped slices, so prefixItems need no declarations. + if ir.Items != nil && len(ir.PrefixItems) == 0 { g.renderNested(ir.Items) } - for _, item := range ir.PrefixItems { - g.renderNested(item) - } if ir.AdditionalProperties != nil { g.renderNested(ir.AdditionalProperties) } @@ -278,7 +350,15 @@ func (g *Generator) renderNested(ir *SchemaIR) { return } switch ir.Kind { - case KindObject, KindAllOf, KindUnion, KindEnum: + case KindObject, KindAllOf: + // An object that renders inline as a map has no declaration of its + // own, but the types its values use still need theirs. + if g.goType(ir, true, false) != ir.Name { + g.renderChildren(ir) + return + } + g.renderDecl(ir) + case KindUnion, KindEnum: if ir.Name != "" { g.renderDecl(ir) } @@ -299,22 +379,45 @@ func (g *Generator) renderAliasDecl(ir *SchemaIR) { b.WriteString("type ") b.WriteString(ir.Name) b.WriteByte(' ') - b.WriteString(g.goType(ir, true, false)) + typ := g.goType(ir, true, false) + // json.RawMessage carries its JSON methods only through an alias. + if typ == rawMessageGoType { + b.WriteString("= ") + } + b.WriteString(typ) 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) && +func (g *Generator) shouldRenderObjectAlias(ir *SchemaIR) bool { + return ir.Kind == KindObject && (ir.PatternProperties == nil || ir.PatternProperties.Len() == 0) && - len(ir.AllOf) == 0 && - ir.AdditionalProperties == nil && + g.untypedObject(ir) +} + +// untypedObject reports whether an object schema leaves its members +// unconstrained: no properties, no allOf, and additional properties that are +// allowed but not described. Values described only as {} count as not +// described when untyped schemas render as json.RawMessage. +func (g *Generator) untypedObject(ir *SchemaIR) bool { + open := ir.AdditionalProperties == nil || (g.untypedAsRawMessage && ir.AdditionalProperties.Kind == KindAny) + return (ir.Properties == nil || ir.Properties.Len() == 0) && + len(ir.AllOf) == 0 && open && (ir.AdditionalAllowed == nil || *ir.AdditionalAllowed) } +const rawMessageGoType = "json.RawMessage" + +// untypedType is the Go type for JSON whose shape a schema does not describe. +func (g *Generator) untypedType() string { + if g.untypedAsRawMessage { + g.addImport("encoding/json") + return rawMessageGoType + } + return "any" +} + func (g *Generator) renderEnumDecl(ir *SchemaIR) { if !g.rememberDecl(ir.Name) { return @@ -336,7 +439,7 @@ func (g *Generator) renderEnumDecl(ir *SchemaIR) { if literal == "" { continue } - name := uniqueName(ir.Name+g.enumValueName(node.Value), used) + name := g.enumConstantName(ir.Name+g.enumValueName(node.Value), used) b.WriteByte('\t') b.WriteString(name) b.WriteByte(' ') @@ -351,6 +454,16 @@ func (g *Generator) renderEnumDecl(ir *SchemaIR) { g.recordSchemaMetadata(ir.Name, ir.SourceSchema) } +// enumConstantName names one enum constant. Under NameStyleIdiomatic the name +// is claimed from the type registry, so a constant never takes the name of a +// type or of a reserved declaration. +func (g *Generator) enumConstantName(candidate string, used map[string]struct{}) string { + if g.nameStyle == NameStyleIdiomatic { + return g.typeNames.ClaimFirst(candidate) + } + return uniqueName(candidate, used) +} + func writeAdditionalPropertiesMethods(b *strings.Builder, ir *SchemaIR, fieldName, valueType string) { b.WriteString("\nfunc (m *") b.WriteString(ir.Name) @@ -387,7 +500,7 @@ func writeAdditionalPropertiesMethods(b *strings.Builder, ir *SchemaIR, fieldNam func (g *Generator) goType(ir *SchemaIR, required bool, field bool) string { if ir == nil { - return "any" + return g.untypedType() } var typ string switch ir.Kind { @@ -404,6 +517,8 @@ func (g *Generator) goType(ir *SchemaIR, required bool, field bool) string { typ = ir.Name } else if ir.AdditionalAllowed != nil && !*ir.AdditionalAllowed && ir.Name != "" { typ = ir.Name + } else if g.untypedAsRawMessage && g.untypedObject(ir) { + typ = g.untypedType() } else if ir.AdditionalProperties != nil { typ = "map[string]" + g.goType(ir.AdditionalProperties, true, false) } else { @@ -411,7 +526,7 @@ func (g *Generator) goType(ir *SchemaIR, required bool, field bool) string { } case KindArray: if len(ir.PrefixItems) > 0 { - typ = "[]any" + typ = "[]" + g.untypedType() } else { typ = "[]" + g.goType(ir.Items, true, false) } @@ -430,10 +545,10 @@ func (g *Generator) goType(ir *SchemaIR, required bool, field bool) string { case KindUnion: typ = ir.Name + "Union" default: - typ = "any" + typ = g.untypedType() } if field { - depth := pointerDepth(typ, ir, required, g.optionalFieldsAsPointers, g.nullableAsPointer, g.optionalNullableAsDoublePointer) + depth := pointerDepth(typ, ir, required, g.optionalFieldsAsPointers, g.nullableAsPointer, g.optionalNullableAsDoublePointer && !g.decodeOnly) return strings.Repeat("*", depth) + typ } return typ @@ -472,6 +587,10 @@ func builtinScalarType(kind Kind, format string) string { } func pointerDepth(typ string, ir *SchemaIR, required, optionalPointers, nullablePointer, optionalNullableDoublePointer bool) int { + // Raw JSON already distinguishes absent (nil) from null ("null"). + if typ == rawMessageGoType { + return 0 + } compound := typ == "any" || strings.HasPrefix(typ, "[]") || strings.HasPrefix(typ, "map[") nullable := ir != nil && ir.Nullable && nullablePointer optional := !required && optionalPointers @@ -490,39 +609,59 @@ func pointerDepth(typ string, ir *SchemaIR, required, optionalPointers, nullable return 0 } -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 - } +// docLines returns the comment for a declaration or struct field: the +// schema's description, or its title when it has none, followed by one +// paragraph per note. A declaration's comment starts with its name, as Go doc +// comments do; a field's comment is the description alone. Read-only and +// write-only notes describe how a property is used, so only fields carry +// them; a deprecation applies to both. +func docLines(name string, ir *SchemaIR) []string { description := ir.Description if description == "" { description = ir.Title } - writeComment(b, ir.Name, description) - for _, comment := range ir.Comments { - writeLineComment(b, ir.Name+" "+comment) + lines := gocomment.Lines(description) + if name != "" && lines != nil { + lines = gocomment.Lines(name + " " + declarationSentence(description)) } -} - -func writeFieldComments(b *strings.Builder, fieldName string, ir *SchemaIR) { - if ir == nil { - return + var notes []string + if name == "" && ir.ReadOnly { + notes = append(notes, "Read-only: the API returns this value; requests should not send it.") } - description := ir.Description - if description == "" { - description = ir.Title + if name == "" && ir.WriteOnly { + notes = append(notes, "Write-only: requests send this value; the API does not return it.") + } + notes = append(notes, ir.Comments...) + if ir.Deprecated { + notes = append(notes, "Deprecated: the API marks this as deprecated.") + } + for _, note := range notes { + if lines != nil { + lines = append(lines, "") + } + lines = append(lines, gocomment.Lines(note)...) } - writeComment(b, fieldName, description) - for _, comment := range ir.Comments { - writeLineComment(b, fieldName+" "+comment) + return lines +} + +// declarationSentence continues a declaration's name with its description. +// A description that opens with an article becomes a sentence about the +// name: "A booking." documents Booking as "Booking is a booking." +func declarationSentence(description string) string { + for _, article := range []string{"A ", "An ", "The "} { + if strings.HasPrefix(description, article) { + return "is " + strings.ToLower(article[:1]) + description[1:] + } } + return description +} + +func writeIRComments(b *strings.Builder, ir *SchemaIR) { + gocomment.Write(b, "", docLines(ir.Name, ir)) +} + +func writeFieldComments(b *strings.Builder, ir *SchemaIR) { + gocomment.Write(b, "", docLines("", ir)) } func writeLineCommentBlock(b *strings.Builder, text string) { diff --git a/generator/internal/gocomment/gocomment.go b/generator/internal/gocomment/gocomment.go new file mode 100644 index 00000000..96ad0691 --- /dev/null +++ b/generator/internal/gocomment/gocomment.go @@ -0,0 +1,190 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package gocomment turns OpenAPI description text into Go comment lines. +// +// Descriptions are CommonMark and often carry inline HTML. Go comments are +// plain text, so markup is translated to readable text: links keep their text +// and absolute target, line-break tags become paragraph breaks, and other tags +// and emphasis markers are removed. Paragraphs are wrapped to a fixed width so +// generated comments read like hand-written ones. +package gocomment + +import ( + "html" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// Width is the preferred maximum length of comment text on one line. Words +// longer than the width, such as URLs, are never split. +const Width = 76 + +var ( + breakTag = regexp.MustCompile(`(?i)`) + paragraphTag = regexp.MustCompile(`(?i)]*)?>`) + listTag = regexp.MustCompile(`(?i)]*)?>`) + itemTag = regexp.MustCompile(`(?i)]*)?>`) + anchorTag = regexp.MustCompile(`(?is)]*?href\s*=\s*["']([^"']*)["'][^<>]*>(.*?)`) + codeTag = regexp.MustCompile(`(?is)]*)?>(.*?)`) + markupTag = regexp.MustCompile(`(?i)]*)?/?>`) + markdownLink = regexp.MustCompile(`!?\[([^\]]*)\]\(([^()\s]*)\)`) + heading = regexp.MustCompile(`^#{1,6}\s+`) + listItem = regexp.MustCompile(`^(?:[-*+]|\d+[.)])\s+`) +) + +// Lines returns text as comment lines without comment markers. Paragraphs are +// wrapped to Width and separated by an empty string. It returns nil when text +// holds nothing printable. +func Lines(text string) []string { + var lines []string + for _, block := range blocks(plain(text)) { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, block...) + } + return lines +} + +// Write appends lines to b as Go line comments, each starting with indent. +// An empty line becomes a bare comment marker. +func Write(b *strings.Builder, indent string, lines []string) { + for _, line := range lines { + b.WriteString(indent) + if line == "" { + b.WriteString("//\n") + continue + } + b.WriteString("// ") + b.WriteString(line) + b.WriteByte('\n') + } +} + +// plain translates inline HTML and Markdown markup to text. Break and +// paragraph tags become blank lines; list item tags become list lines. +func plain(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = anchorTag.ReplaceAllStringFunc(text, func(match string) string { + parts := anchorTag.FindStringSubmatch(match) + return link(parts[2], parts[1]) + }) + text = codeTag.ReplaceAllString(text, "`$1`") + text = breakTag.ReplaceAllString(text, "\n\n") + text = paragraphTag.ReplaceAllString(text, "\n\n") + text = listTag.ReplaceAllString(text, "\n") + text = itemTag.ReplaceAllString(text, "\n- ") + text = markupTag.ReplaceAllString(text, "") + text = markdownLink.ReplaceAllStringFunc(text, func(match string) string { + parts := markdownLink.FindStringSubmatch(match) + if strings.HasPrefix(match, "!") { + return parts[1] + } + return link(parts[1], parts[2]) + }) + text = strings.ReplaceAll(text, "**", "") + return html.UnescapeString(text) +} + +// link renders a hyperlink as its text followed by its absolute target. +// Relative and document-local targets mean nothing outside the source +// documentation, so only their text is kept. +func link(text, target string) string { + text = strings.TrimSpace(markupTag.ReplaceAllString(text, "")) + absolute := strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") + switch { + case !absolute || text == target: + return text + case text == "": + return target + default: + return text + " (" + target + ")" + } +} + +// blocks splits text into wrapped blocks: paragraphs, headings, lists, and +// fenced code. Lists keep Go doc comment indentation and fenced code keeps +// its own lines, indented as a Go doc code block. +func blocks(text string) [][]string { + var result [][]string + var paragraph, items, code []string + fenced := false + flushParagraph := func() { + if len(paragraph) > 0 { + result = append(result, wrap(sentence(strings.Join(paragraph, " ")), "", "")) + paragraph = nil + } + } + flush := func() { + flushParagraph() + if len(items) > 0 { + var list []string + for _, item := range items { + marker := listItem.FindString(item) + list = append(list, wrap(item[len(marker):], " "+strings.TrimSpace(marker)+" ", " ")...) + } + result = append(result, list) + items = nil + } + } + for raw := range strings.SplitSeq(text, "\n") { + line := strings.TrimSpace(raw) + switch { + case strings.HasPrefix(line, "```"): + flush() + if fenced && len(code) > 0 { + result = append(result, code) + } + fenced, code = !fenced, nil + case fenced: + code = append(code, "\t"+strings.TrimRight(raw, " \t")) + case line == "": + flush() + case heading.MatchString(line): + flush() + result = append(result, wrap(heading.ReplaceAllString(line, ""), "", "")) + case listItem.MatchString(line): + flushParagraph() + items = append(items, line) + case len(items) > 0: + items[len(items)-1] += " " + line + default: + paragraph = append(paragraph, line) + } + } + flush() + if len(code) > 0 { + result = append(result, code) + } + return result +} + +// sentence ends a paragraph that stops on a letter or digit with a period, +// as Go doc comments do. +func sentence(text string) string { + last, _ := utf8.DecodeLastRuneInString(text) + if unicode.IsLetter(last) || unicode.IsDigit(last) { + return text + "." + } + return text +} + +// wrap breaks non-blank text into lines of at most Width characters. first +// prefixes the first line and rest prefixes each following line. +func wrap(text, first, rest string) []string { + words := strings.Fields(text) + var lines []string + line := first + words[0] + for _, word := range words[1:] { + if len(line)+1+len(word) > Width { + lines = append(lines, line) + line = rest + word + continue + } + line += " " + word + } + return append(lines, line) +} diff --git a/generator/internal/gocomment/gocomment_test.go b/generator/internal/gocomment/gocomment_test.go new file mode 100644 index 00000000..c64b719d --- /dev/null +++ b/generator/internal/gocomment/gocomment_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package gocomment + +import ( + "reflect" + "strings" + "testing" +) + +func TestLinesTranslatesInlineHTML(t *testing.T) { + // Shaped like a real vendor's parameter description. + got := Lines(`The ID for the person.

To find IDs, call the people API search endpoint and identify the values for person_id.

Example: ` + "`587cf802f65125cad923a266`") + want := []string{ + "The ID for the person.", + "", + "To find IDs, call the people API search endpoint", + "(https://docs.example.com/reference/people-api-search) and identify the", + "values for `person_id`.", + "", + "Example: `587cf802f65125cad923a266`", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Lines() =\n%q\nwant\n%q", got, want) + } +} + +func TestLinesTranslatesMarkdown(t *testing.T) { + got := Lines("## Endpoint essentials\n\n**Credit usage:** see [pricing](https://docs.example.com/docs/api-pricing), [webhooks](#webhook-details) and ![diagram](https://example.com/d.png).\nSoft wrapped & joined.") + want := []string{ + "Endpoint essentials", + "", + "Credit usage: see pricing (https://docs.example.com/docs/api-pricing),", + "webhooks and diagram. Soft wrapped & joined.", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Lines() =\n%q\nwant\n%q", got, want) + } +} + +func TestLinesKeepsListsAsGoDocLists(t *testing.T) { + got := Lines("Match confidence:\n- `high`: The service matched the information to a person record and every field was verified against several sources.\n- `none`: no match\n continued item text\n1. first\n\nAfter.
  • html item
") + want := []string{ + "Match confidence:", + "", + " - `high`: The service matched the information to a person record and every", + " field was verified against several sources.", + " - `none`: no match continued item text", + " 1. first", + "", + "After.", + "", + " - html item", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Lines() =\n%q\nwant\n%q", got, want) + } +} + +func TestLinesKeepsFencedCode(t *testing.T) { + got := Lines("Example:\n```json\n{\n \"id\": 1\n}\n```\n```\n```\nTrailing.\n```\nunclosed") + want := []string{ + "Example:", + "", + "\t{", + "\t \"id\": 1", + "\t}", + "", + "Trailing.", + "", + "\tunclosed", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Lines() =\n%q\nwant\n%q", got, want) + } +} + +func TestLinkRendering(t *testing.T) { + for _, test := range []struct{ text, target, want string }{ + {"docs", "https://example.com", "docs (https://example.com)"}, + {"https://example.com", "https://example.com", "https://example.com"}, + {"", "http://example.com", "http://example.com"}, + {"guide", "doc:guide", "guide"}, + } { + if got := link(test.text, test.target); got != test.want { + t.Fatalf("link(%q, %q) = %q, want %q", test.text, test.target, got, test.want) + } + } +} + +func TestLinesIgnoresBlankAndLeavesOtherAngles(t *testing.T) { + if got := Lines(" \n
\n "); got != nil { + t.Fatalf("Lines() = %q, want nil", got) + } + if got := Lines("Map where a < b"); !reflect.DeepEqual(got, []string{"Map where a < b."}) { + t.Fatalf("Lines() = %q", got) + } +} + +func TestWrapNeverSplitsLongWords(t *testing.T) { + long := strings.Repeat("x", Width+5) + if got := wrap("see "+long+" now", "", ""); !reflect.DeepEqual(got, []string{"see", long, "now"}) { + t.Fatalf("wrap() = %q", got) + } +} + +func TestWrite(t *testing.T) { + var b strings.Builder + Write(&b, "\t", []string{"First.", "", "Second."}) + if got, want := b.String(), "\t// First.\n\t//\n\t// Second.\n"; got != want { + t.Fatalf("Write() = %q, want %q", got, want) + } +} diff --git a/generator/sdk/README.md b/generator/sdk/README.md index fd88ac7f..90072a7b 100644 --- a/generator/sdk/README.md +++ b/generator/sdk/README.md @@ -38,6 +38,39 @@ Unsupported selected wire behavior returns an error naming the operation and feature; the emitter does not silently drop parameters, bodies, responses, or security requirements. +## Generated names + +Types generated for an operation take the public name chosen in +`PrepareOptions.Names`. Without a chosen method name they take the +operationId. For `people-enrichment` named `People.Match`: + +- parameters: `PeopleMatchParams`; +- request body: `PeopleMatchRequest`; +- success body: `PeopleMatchResponse`; +- error bodies: the HTTP status text, such as `PeopleMatchBadRequest` and + `PeopleMatchTooManyRequests`; ranges are `ClientError`, `ServerError`, + `Redirect`, and `Informational`; the default response is `PeopleMatchError`. + +Inline schemas inside an operation's bodies are named for their property when +no other type has that name (`Person`, `Organization`, `EmploymentHistoryItem`), +and are qualified by their parent otherwise. Inline schemas inside components +are always qualified by their parent (`AccountStatus`). Names never contain +underscores, and parameter fields that collide are told apart readably +(`_id` is `UnderscoreID`). Enums declare a constant for each value. + +Schema descriptions become Go doc comments: Markdown and inline HTML become +plain text and paragraphs wrap. Types that the SDK names get a comment saying +what they are, such as `PeopleMatchResponse is the 200 response body of +[PeopleResource.Match].` Each method comment names its request and lists the +typed values a declared error can carry. + +Schemas that do not describe their JSON shape, such as `{}` or an object +without properties, are `json.RawMessage`: the bytes arrive as sent and the +caller decodes them into its own types. Pass +`modelgen.WithUntypedAsRawMessage(false)` in `Options.Models` to use `any` and +`map[string]any` instead. The other model defaults, the idiomatic name style +and enum constants, can be changed the same way. + ## Generated runtime The generated client accepts an absolute server URL, an injected `HTTPDoer`, @@ -56,18 +89,25 @@ its redirect and TLS policy. The initial wire surface supports JSON request and response bodies, scalar and array path/query/header parameters using their standard styles, explicit 2xx -statuses, declared JSON errors, cancellation, and bounded response buffering. -Cookie parameters, object/deep-object parameters, `allowReserved`, form and -multipart bodies, binary downloads, and streaming produce generation errors -until their dedicated runtime paths land. +statuses, declared errors, cancellation, and bounded response buffering. A +declared error with a JSON body is decoded into `APIError.Value`; a declared +error with any other body, such as `text/plain`, keeps it raw in +`APIError.Body`. Cookie parameters, object/deep-object parameters, +`allowReserved`, form and multipart bodies, non-JSON success bodies, binary +downloads, and streaming produce generation errors until their dedicated +runtime paths land. -Optional nullable generated model fields use an opt-in double-pointer shape: +Optional nullable fields of models that requests encode use a double-pointer +shape: - `nil` outer pointer: omit the field; - non-nil outer pointer containing `nil`: encode JSON `null`; - two non-nil pointers: encode the value, including its zero value. The generated runtime provides `Null[T]` and `NullableValue[T]` helpers. +Decoding cannot tell `null` from an absent field, so models that only +responses use keep a single pointer. The client declares only the unexported +encoding and decoding helpers that the generated operations call. ## Finite Arazzo workflows diff --git a/generator/sdk/gosdk/coverage_test.go b/generator/sdk/gosdk/coverage_test.go index f4b63f4d..5347c16e 100644 --- a/generator/sdk/gosdk/coverage_test.go +++ b/generator/sdk/gosdk/coverage_test.go @@ -5,6 +5,7 @@ package gosdk import ( "errors" + "reflect" "strings" "testing" "text/template" @@ -179,7 +180,6 @@ func TestPrepareOperationRejectsUnsupportedParametersAndBodies(t *testing.T) { {name: "unknown location", parameter: &sdk.Parameter{Name: "id", In: "matrix", Style: "simple", Schema: stringSchema}, want: "unsupported location"}, {name: "allow reserved", parameter: &sdk.Parameter{Name: "q", In: "query", Style: "form", AllowReserved: true, Schema: stringSchema}, want: "allowReserved"}, {name: "unsupported style", parameter: &sdk.Parameter{Name: "q", In: "query", Style: "spaceDelimited", Schema: stringSchema}, want: "unsupported query style"}, - {name: "body field collision", parameter: &sdk.Parameter{Name: "body", In: "query", Style: "form", Schema: stringSchema}, body: jsonBody(stringSchema), want: "collides with request body"}, {name: "unsupported body media", body: &sdk.RequestBody{Content: unsupportedMedia}, want: "unsupported media types"}, {name: "body media without schema", body: &sdk.RequestBody{Content: mediaWithoutSchema}, want: "request body has no schema"}, } @@ -191,7 +191,7 @@ func TestPrepareOperationRejectsUnsupportedParametersAndBodies(t *testing.T) { } operation.RequestBody = test.body emitter := newTestEmitter(contractWith(operation), "client", nil) - _, err := emitter.prepareOperation(operation, "Probe") + _, err := emitter.prepareOperation(operation, things, "Probe") if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("expected error containing %q, got %v", test.want, err) } @@ -207,18 +207,30 @@ func TestPrepareOperationCoversOptionalAndSchemaFailureBranches(t *testing.T) { } operation.RequestBody = &sdk.RequestBody{Required: false, Content: jsonContent(schema("string", ""))} emitter := newTestEmitter(contractWith(operation), "client", nil) - view, err := emitter.prepareOperation(operation, "Probe") + view, err := emitter.prepareOperation(operation, things, "Probe") if err != nil || view.Parameters[0].Type != "*string" || view.Body.FieldType != "*string" { t.Fatalf("unexpected optional types: %#v, %v", view, err) } duplicate := operationWithSuccess("duplicate") duplicate.Parameters = []*sdk.Parameter{ + {Name: "_id", In: "query", Style: "form", Schema: schema("string", "")}, {Name: "user-id", In: "query", Style: "form", Schema: schema("string", "")}, {Name: "user_id", In: "query", Style: "form", Schema: schema("string", "")}, + {Name: "body", In: "query", Style: "form", Schema: schema("string", "")}, + {Name: "id", In: "query", Style: "form", Schema: schema("string", "")}, } - if _, err := newTestEmitter(contractWith(duplicate), "client", nil).prepareOperation(duplicate, "Duplicate"); err == nil || !strings.Contains(err.Error(), "same Go field name") { - t.Fatalf("expected field collision, got %v", err) + duplicate.RequestBody = jsonBody(schema("string", "")) + view, err = newTestEmitter(contractWith(duplicate), "client", nil).prepareOperation(duplicate, things, "Duplicate") + if err != nil { + t.Fatal(err) + } + var fields []string + for _, field := range view.Fields { + fields = append(fields, field.Name) + } + if want := []string{"UnderscoreID", "UserID", "UserID2", "Body2", "ID", "Body"}; !reflect.DeepEqual(fields, want) { + t.Fatalf("parameter fields = %q, want %q", fields, want) } for _, test := range []struct { @@ -242,7 +254,7 @@ func TestPrepareOperationCoversOptionalAndSchemaFailureBranches(t *testing.T) { t.Run(test.name, func(t *testing.T) { operation := operationWithSuccess("broken") test.set(operation) - _, err := newTestEmitter(contractWith(operation), "client", nil).prepareOperation(operation, "Broken") + _, err := newTestEmitter(contractWith(operation), "client", nil).prepareOperation(operation, things, "Broken") if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("expected error containing %q, got %v", test.want, err) } @@ -263,9 +275,9 @@ func TestPrepareOperationCoversScalarArrayAndInlineTypes(t *testing.T) { {name: "float", schema: schema("number", "float"), want: "float32"}, {name: "number", schema: schema("number", ""), want: "float64"}, {name: "boolean", schema: schema("boolean", ""), want: "bool"}, - {name: "untyped array", schema: schema("array", ""), want: "[]any"}, + {name: "untyped array", schema: schema("array", ""), want: "Payload"}, {name: "typed array", schema: arraySchema(schema("string", "")), want: "[]string"}, - {name: "unknown", schema: highbase.CreateSchemaProxy(&highbase.Schema{}), want: "any"}, + {name: "unknown", schema: highbase.CreateSchemaProxy(&highbase.Schema{}), want: "Payload"}, } for _, test := range types { t.Run(test.name, func(t *testing.T) { @@ -292,6 +304,8 @@ func TestPrepareOperationCoversScalarArrayAndInlineTypes(t *testing.T) { func TestPrepareResponsesContracts(t *testing.T) { emitter := newTestEmitter(&sdk.Contract{}, "client", nil) + undefinedMedia := orderedmap.New[string, *highv3.MediaType]() + undefinedMedia.Set("application/json", nil) tests := []struct { name string responses []*sdk.Response @@ -303,28 +317,73 @@ func TestPrepareResponsesContracts(t *testing.T) { {Status: "201", Content: jsonContent(schema("integer", ""))}, }, want: "incompatible success response types"}, {name: "unsupported response media", responses: []*sdk.Response{{Status: "200", Content: media("text/plain", schema("string", ""))}}, want: "unsupported media types"}, + {name: "undefined error media", responses: []*sdk.Response{{Status: "200"}, {Status: "400", Content: undefinedMedia}}, want: "has no definition"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { operation := &sdk.Operation{ID: "read", Responses: test.responses} - _, _, _, _, err := emitter.prepareResponses(operation) + err := emitter.prepareResponses(operation, "Read", "[ThingsResource.Read]", &operationView{}) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("expected error containing %q, got %v", test.want, err) } }) } - responseType, successes, decodedSuccesses, failures, err := emitter.prepareResponses(&sdk.Operation{ID: "read", Responses: []*sdk.Response{ + var view operationView + err := emitter.prepareResponses(&sdk.Operation{ID: "read", Responses: []*sdk.Response{ nil, {Status: "200", Content: jsonContent(schema("string", ""))}, + {Status: "401", Content: media("text/plain", schema("string", ""))}, {Status: "default", Content: jsonContent(schema("object", ""))}, - }}) - if err != nil || responseType != "string" || len(successes) != 1 || len(decodedSuccesses) != 1 || decodedSuccesses[0] != "200" || len(failures) != 1 || failures[0].Status != "default" { - t.Fatalf("unexpected prepared responses: %q %#v %#v %#v %v", responseType, successes, decodedSuccesses, failures, err) + }}, "Read", "[ThingsResource.Read]", &view) + if err != nil || view.ResponseType != "string" || !reflect.DeepEqual(view.SuccessStatuses, []string{"200"}) || !view.DecodeAll || + len(view.ErrorCases) != 1 || !view.ErrorCases[0].Default || view.ErrorCases[0].Type != "ReadError" || view.ErrorSwitch != "response.StatusCode" { + t.Fatalf("unexpected prepared responses: %#v %v", view, err) + } + view = operationView{} + err = emitter.prepareResponses(&sdk.Operation{ID: "wildcard", Responses: []*sdk.Response{{Status: "2xx"}}}, "Wildcard", "[ThingsResource.Wildcard]", &view) + if err != nil || !reflect.DeepEqual(view.SuccessStatuses, []string{"2XX"}) || view.ResponseType != "struct{}" { + t.Fatalf("lowercase wildcard was not normalized: %#v, %v", view, err) + } +} + +func TestErrorSwitchOrdersAndGroupsCases(t *testing.T) { + responses := []errorResponseView{ + {Statuses: []string{"default"}, Type: "Problem"}, + {Statuses: []string{"4XX"}, Type: "ClientProblem"}, + {Statuses: []string{"409"}, Type: "Conflict"}, + {Statuses: []string{"404"}, Type: "Problem"}, + {Statuses: []string{"5XX"}, Type: "ClientProblem"}, + {Statuses: []string{"400"}, Type: "Problem"}, + } + cases, tag := errorSwitch(responses) + var got []string + for _, errorCase := range cases { + got = append(got, errorCase.Case+"="+errorCase.Type) + } + want := []string{ + "response.StatusCode == 400, response.StatusCode == 404=Problem", + "response.StatusCode == 409=Conflict", + "response.StatusCode/100 == 4, response.StatusCode/100 == 5=ClientProblem", + "=Problem", + } + if tag != "" || !reflect.DeepEqual(got, want) || !cases[3].Default { + t.Fatalf("errorSwitch() = %q %q, want %q", tag, got, want) + } + cases, tag = errorSwitch(responses[3:4]) + if tag != "response.StatusCode" || cases[0].Case != "404" { + t.Fatalf("exact codes should switch on the status: %q %#v", tag, cases) } - _, successes, _, _, err = emitter.prepareResponses(&sdk.Operation{ID: "wildcard", Responses: []*sdk.Response{{Status: "2xx"}}}) - if err != nil || len(successes) != 1 || successes[0] != "2XX" { - t.Fatalf("lowercase wildcard was not normalized: %#v, %v", successes, err) +} + +func TestStatusTypeNames(t *testing.T) { + for status, want := range map[string]string{ + "default": "Error", "1XX": "Informational", "3XX": "Redirect", "4XX": "ClientError", "5XX": "ServerError", + "400": "BadRequest", "429": "TooManyRequests", "500": "InternalServerError", "599": "Status599", + } { + if got := statusTypeName(status); got != want { + t.Fatalf("statusTypeName(%q) = %q, want %q", status, got, want) + } } } @@ -685,14 +744,8 @@ func TestHelpersCoverEdgeContracts(t *testing.T) { t.Fatalf("supportedStyle(%q, %q) = %t", test.location, test.style, got) } } - if statusName("default") != "Default" || statusName("2xx") != "Status2XX" { - t.Fatal("unexpected status names") - } - if commentLine(" first line\nsecond */ line") != "first line" || commentLine(" \n") != "" { - t.Fatal("unexpected comment sanitization") - } - if commentLine("unsafe */ suffix") != "unsafe * / suffix" { - t.Fatal("comment terminator was not sanitized") + if _, _, err := requireJSONMedia(nil); err != nil { + t.Fatalf("absent content is not unsupported media: %v", err) } registry := modelgen.NewNameRegistry("Value", "Thing", "ThingModel", "Thing__2") if got := registry.Claim("", ""); got != "Value__2" { @@ -755,7 +808,7 @@ func TestPrepareOperationRejectsInvalidResponseStatuses(t *testing.T) { t.Run(test.name, func(t *testing.T) { operation := operationWithSuccess("read") operation.Responses = test.responses - _, err := newTestEmitter(contractWith(operation), "client", nil).prepareOperation(operation, "Things") + _, err := newTestEmitter(contractWith(operation), "client", nil).prepareOperation(operation, things, "Read") if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("expected %q error, got %v", test.want, err) } @@ -945,6 +998,8 @@ func contractWith(operations ...*sdk.Operation) *sdk.Contract { return &sdk.Contract{Operations: operations, Schemas: orderedmap.New[string, *highbase.SchemaProxy]()} } +var things = resourceView{Name: "Things", TypeName: "ThingsResource"} + func newTestEmitter(contract *sdk.Contract, packageName string, workflows []*sdk.Workflow) *emitter { return newEmitter(contract, packageName, workflows, modelgen.NewGenerator()) } @@ -957,3 +1012,44 @@ func workflowInputs(t *testing.T, source string) *yaml.Node { } return node.Content[0] } + +func TestDecodeOnlyRootsExcludeWhatRequestsEncode(t *testing.T) { + components := orderedmap.New[string, *highbase.SchemaProxy]() + sharedProperties := orderedmap.New[string, *highbase.SchemaProxy]() + sharedProperties.Set("self", highbase.CreateSchemaProxyRef("#/components/schemas/Shared")) + components.Set("Shared", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}, Properties: sharedProperties})) + components.Set("Received", schema("string", "")) + operation := operationWithSuccess("write") + operation.RequestBody = jsonBody(highbase.CreateSchemaProxy(&highbase.Schema{AllOf: []*highbase.SchemaProxy{ + nil, highbase.CreateSchemaProxyRef("#/components/schemas/Shared"), + }})) + operation.Responses = []*sdk.Response{{Status: "200", Content: jsonContent(highbase.CreateSchemaProxyRef("#/components/schemas/Received"))}} + contract := contractWith(operation) + contract.Schemas = components + emitter := newTestEmitter(contract, "client", nil) + if _, err := emitter.prepareView(); err != nil { + t.Fatal(err) + } + if got := emitter.decodeOnlyRoots(); !reflect.DeepEqual(got, []string{"Received"}) { + t.Fatalf("decodeOnlyRoots() = %q", got) + } +} + +func TestInlineSchemaReachedTwiceIsOneModel(t *testing.T) { + properties := orderedmap.New[string, *highbase.SchemaProxy]() + properties.Set("id", schema("string", "")) + shared := highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{"object"}, Properties: properties}) + operation := operationWithSuccess("upsert") + operation.Responses = []*sdk.Response{ + {Status: "200", Content: jsonContent(shared)}, + {Status: "201", Content: jsonContent(shared)}, + } + result, err := GenerateContract(contractWith(operation), Options{PackageName: "anchors"}) + if err != nil { + t.Fatal(err) + } + if models := string(result.Files[0].Content); strings.Count(models, "struct {") != 1 || !strings.Contains(models, "type UpsertResponse struct") { + t.Fatalf("shared inline schema was not one model:\n%s", models) + } + compileGeneratedFiles(t, result.Files) +} diff --git a/generator/sdk/gosdk/generator.go b/generator/sdk/gosdk/generator.go index 8cb01ffd..e93d3125 100644 --- a/generator/sdk/gosdk/generator.go +++ b/generator/sdk/gosdk/generator.go @@ -11,6 +11,7 @@ import ( "fmt" "go/format" "go/token" + "net/http" "sort" "strconv" "strings" @@ -21,6 +22,7 @@ import ( highv3 "github.com/pb33f/libopenapi/datamodel/high/v3" lowbase "github.com/pb33f/libopenapi/datamodel/low/base" modelgen "github.com/pb33f/libopenapi/generator/golang" + "github.com/pb33f/libopenapi/generator/internal/gocomment" "github.com/pb33f/libopenapi/generator/sdk" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/orderedmap" @@ -36,8 +38,7 @@ var resourcesTemplateSource string var workflowsTemplateSource string var templateFunctions = template.FuncMap{ - "quote": strconv.Quote, - "comment": commentLine, + "quote": strconv.Quote, } var clientTemplate = template.Must(template.New("client").Funcs(templateFunctions).Parse(clientTemplateSource)) @@ -70,21 +71,30 @@ func GenerateContract(contract *sdk.Contract, options Options) (*sdk.Result, err if !token.IsIdentifier(packageName) || token.Lookup(packageName).IsKeyword() { return nil, fmt.Errorf("gosdk: invalid package name %q", packageName) } - var sdkEmitter *emitter - modelOptions := append([]modelgen.Option(nil), options.Models...) + sdkEmitter := newEmitter(contract, packageName, options.Workflows, modelgen.NewGenerator(options.Models...)) + view, err := sdkEmitter.prepareView() + if err != nil { + return nil, err + } + // SDK defaults come first so options.Models can change them; the package, + // pointer shape and names the SDK runtime depends on come last. + modelOptions := []modelgen.Option{ + modelgen.WithNameStyle(modelgen.NameStyleIdiomatic), + modelgen.WithUntypedAsRawMessage(true), + modelgen.WithEnumConstants(true), + } + modelOptions = append(modelOptions, options.Models...) modelOptions = append(modelOptions, modelgen.WithPackageName(packageName), modelgen.WithGeneratedComment(true), modelgen.WithOptionalNullableAsDoublePointer(true), modelgen.WithTypeNameResolver(func(name string) string { return sdkEmitter.typeNames[name] }), + modelgen.WithInlineRoots(sdkEmitter.inlineRoots...), + modelgen.WithDecodeOnlyRoots(sdkEmitter.decodeOnlyRoots()...), + modelgen.WithFallbackDescriptions(sdkEmitter.descriptions), + modelgen.WithReservedTypeNames(sdkEmitter.reserved...), ) - modelsGenerator := modelgen.NewGenerator(modelOptions...) - sdkEmitter = newEmitter(contract, packageName, options.Workflows, modelsGenerator) - view, err := sdkEmitter.prepareView() - if err != nil { - return nil, err - } - models, err := modelsGenerator.RenderSchemas(sdkEmitter.schemas) + models, err := modelgen.NewGenerator(modelOptions...).RenderSchemas(sdkEmitter.schemas) if err != nil { return nil, fmt.Errorf("gosdk: generate models: %w", err) } @@ -138,22 +148,33 @@ type emitter struct { schemas *orderedmap.Map[string, *highbase.SchemaProxy] typeNames map[string]string names *modelgen.NameRegistry + reserved []string + inlineRoots []string + inlineTypes map[*highbase.SchemaProxy]string + responseRoots []string + roles map[string]string + descriptions map[string]string collectedComponents map[string]struct{} reachableComponents map[string]struct{} visitedSchemas map[*highbase.SchemaProxy]struct{} collectErr error workflows []*sdk.Workflow - models *modelgen.Generator + scalars *modelgen.Generator } -func newEmitter(contract *sdk.Contract, packageName string, workflows []*sdk.Workflow, models *modelgen.Generator) *emitter { +// inlineSchemaKey prefixes the model key of a schema declared inline in an +// operation or workflow. OpenAPI component keys cannot contain "#" or "/", so +// these keys never meet a component's. +const inlineSchemaKey = "#/inline/" + +func newEmitter(contract *sdk.Contract, packageName string, workflows []*sdk.Workflow, scalars *modelgen.Generator) *emitter { reserved := []string{ "APIError", "APIKey", "BasicAuth", "BearerToken", "Client", "Credential", "CredentialFunc", "DefaultServer", "File", "HTTPDoer", "MutualTLS", "NewClient", "Null", "NullableValue", "Option", "RequestOption", "Response", "ResponseDecodeError", "SecurityScheme", "WithCredential", "WithDefaultHeader", "WithHTTPClient", "WithMaxResponseBody", "WithRequestHeader", "WithTimeout", } - names := modelgen.NewNameRegistry(reserved...) + names := modelgen.NewIdiomaticNameRegistry(reserved...) components := contract.Schemas if components == nil { components = orderedmap.New[string, *highbase.SchemaProxy]() @@ -161,9 +182,11 @@ func newEmitter(contract *sdk.Contract, packageName string, workflows []*sdk.Wor emitter := &emitter{ contract: contract, packageName: packageName, componentSchemas: components, schemas: orderedmap.New[string, *highbase.SchemaProxy](), typeNames: make(map[string]string), - names: names, collectedComponents: make(map[string]struct{}), + names: names, reserved: reserved, inlineTypes: make(map[*highbase.SchemaProxy]string), + roles: make(map[string]string), descriptions: make(map[string]string), + collectedComponents: make(map[string]struct{}), reachableComponents: make(map[string]struct{}), visitedSchemas: make(map[*highbase.SchemaProxy]struct{}), - workflows: workflows, models: models, + workflows: workflows, scalars: scalars, } emitter.findReachableComponents() for name := range components.FromOldest() { @@ -238,8 +261,16 @@ func componentSchemaRefName(ref string) (string, bool) { return modelgen.RefName(ref), true } +// claim reserves a name for an SDK declaration that is not a model, so the +// model generator keeps clear of it. +func (e *emitter) claim(candidates ...string) string { + name := e.names.ClaimFirst(candidates...) + e.reserved = append(e.reserved, name) + return name +} + func (e *emitter) prepareView() (*clientView, error) { - view := &clientView{PackageName: e.packageName} + view := &clientView{PackageName: e.packageName, Uses: make(map[string]bool)} if len(e.contract.Servers) > 0 { view.DefaultServer = e.contract.Servers[0] } @@ -285,21 +316,21 @@ func (e *emitter) prepareView() (*clientView, error) { resourceIndex = len(view.Resources) resourceIndexes[resourceName] = resourceIndex resourceMethods[resourceName] = make(map[string]string) - view.Resources = append(view.Resources, resourceView{Name: resourceName, TypeName: e.names.Claim(resourceName+"Resource", "Resource")}) + view.Resources = append(view.Resources, resourceView{Name: resourceName, TypeName: e.claim(resourceName + "Resource")}) } methodName := modelgen.PublicName(operation.Name) if prior, exists := resourceMethods[resourceName][methodName]; exists { return nil, fmt.Errorf("gosdk: operations %q and %q both map to %s.%s", prior, operation.ID, resourceName, methodName) } resourceMethods[resourceName][methodName] = operation.ID - operationView, err := e.prepareOperation(operation, methodName) + operationView, err := e.prepareOperation(operation, view.Resources[resourceIndex], methodName) if err != nil { return nil, err } - operationView.ResourceName = resourceName if operationView.HasQueryParams { view.HasQueryParams = true } + recordUses(view.Uses, operationView) view.Resources[resourceIndex].Operations = append(view.Resources[resourceIndex].Operations, operationView) } operationViews := make(map[string]*operationView, len(e.contract.Operations)) @@ -322,14 +353,25 @@ func (e *emitter) prepareView() (*clientView, error) { return view, nil } -func (e *emitter) prepareOperation(operation *sdk.Operation, methodName string) (operationView, error) { +// typeBase is the prefix of every type generated for an operation. A caller +// who names the operation (People.Match) names its types (PeopleMatchParams); +// otherwise the operationId does (PeopleEnrichmentParams). +func typeBase(operation *sdk.Operation, resourceName, methodName string) string { + if operation.Named { + return resourceName + methodName + } + return modelgen.PublicName(operation.ID) +} + +func (e *emitter) prepareOperation(operation *sdk.Operation, resource resourceView, methodName string) (operationView, error) { + base := typeBase(operation, resource.Name, methodName) + method := "[" + resource.TypeName + "." + methodName + "]" view := operationView{ - ID: operation.ID, MethodName: methodName, - ParamsType: e.names.Claim(modelgen.PublicName(operation.ID)+"Params", "Params"), - Summary: operation.Summary, + ID: operation.ID, ResourceName: resource.Name, ResourceType: resource.TypeName, MethodName: methodName, + ParamsType: e.claim(base + "Params"), HTTPMethod: operation.Method, Path: operation.Path, } - fieldNames := make(map[string]string) + var sources []string for _, parameter := range operation.Parameters { if parameter == nil { continue @@ -352,43 +394,37 @@ func (e *emitter) prepareOperation(operation *sdk.Operation, methodName string) if !supportedStyle(parameter.In, parameter.Style) { return view, fmt.Errorf("gosdk: operation %q parameter %q uses unsupported %s style %q", operation.ID, parameter.Name, parameter.In, parameter.Style) } - typeName, err := e.schemaType(parameter.Schema, operation.ID+modelgen.PublicName(parameter.Name)+"Parameter") + typeName, err := e.describedType(parameter.Schema, base+modelgen.PublicName(parameter.Name), "The "+parameter.Name+" parameter of "+method+".") if err != nil { return view, fmt.Errorf("gosdk: operation %q parameter %q: %w", operation.ID, parameter.Name, err) } if !parameter.Required { typeName = "*" + typeName } - fieldName := modelgen.PublicName(parameter.Name) - if prior, ok := fieldNames[fieldName]; ok { - return view, fmt.Errorf("gosdk: operation %q parameters %q and %q have the same Go field name %q", operation.ID, prior, parameter.Name, fieldName) - } - fieldNames[fieldName] = parameter.Name encoder, array, err := e.parameterEncoder(parameter.Schema) if err != nil { return view, fmt.Errorf("gosdk: operation %q parameter %q: %w", operation.ID, parameter.Name, err) } view.Parameters = append(view.Parameters, parameterView{ - Name: parameter.Name, FieldName: fieldName, Type: typeName, In: parameter.In, + Name: parameter.Name, Type: typeName, In: parameter.In, Required: parameter.Required, Explode: parameter.Explode, Description: parameter.Description, Encoder: encoder, Array: array, }) - if parameter.In == "query" { - view.HasQueryParams = true - } + sources = append(sources, parameter.Name) + view.HasQueryParams = view.HasQueryParams || parameter.In == "query" + view.HasPathParams = view.HasPathParams || parameter.In == "path" } + var reserved []string if operation.RequestBody != nil { - if prior, exists := fieldNames["Body"]; exists { - return view, fmt.Errorf("gosdk: operation %q parameter %q collides with request body field Body", operation.ID, prior) - } - mediaType, schema, err := jsonMedia(operation.RequestBody.Content) + reserved = append(reserved, "Body") + mediaType, schema, err := requireJSONMedia(operation.RequestBody.Content) if err != nil { return view, fmt.Errorf("gosdk: operation %q request body: %w", operation.ID, err) } if schema == nil { return view, fmt.Errorf("gosdk: operation %q request body has no schema", operation.ID) } - typeName, err := e.schemaType(schema, operation.ID+"Request") + typeName, err := e.describedType(schema, base+"Request", "The request body of "+method+".") if err != nil { return view, fmt.Errorf("gosdk: operation %q request body: %w", operation.ID, err) } @@ -398,30 +434,24 @@ func (e *emitter) prepareOperation(operation *sdk.Operation, methodName string) } view.Body = &bodyView{Type: typeName, FieldType: fieldType, Required: operation.RequestBody.Required, ContentType: mediaType, Description: operation.RequestBody.Description} } - view.HasParameters = len(view.Parameters) > 0 || view.Body != nil - responseType, successes, decodedSuccesses, errorResponses, err := e.prepareResponses(operation) - if err != nil { - return view, err + for index, fieldName := range modelgen.FieldNames(sources, reserved...) { + view.Parameters[index].FieldName = fieldName + view.Fields = append(view.Fields, fieldView{Name: fieldName, Type: view.Parameters[index].Type, Doc: view.Parameters[index].Description}) } - view.ResponseType = responseType - view.SuccessStatuses = successes - if len(decodedSuccesses) > 0 { - view.DecodeCondition, err = decodeStatusCondition(decodedSuccesses, successes) - if err != nil { - return view, fmt.Errorf("gosdk: operation %q JSON success responses: %w", operation.ID, err) - } + if view.Body != nil { + view.Fields = append(view.Fields, fieldView{Name: "Body", Type: view.Body.FieldType, Doc: view.Body.Description}) } - view.SuccessCondition, err = statusCondition(successes) - if err != nil { - return view, fmt.Errorf("gosdk: operation %q success responses: %w", operation.ID, err) + for index := range view.Fields { + view.Fields[index].Comment = goComment("\t", gocomment.Lines(view.Fields[index].Doc)) + // A documented field after the first stands apart, as hand-written + // structs do, so each comment reads with its own field. + view.Fields[index].Spaced = index > 0 && view.Fields[index].Comment != "" } - for index := range errorResponses { - errorResponses[index].Condition, err = statusCondition([]string{errorResponses[index].Status}) - if err != nil { - return view, fmt.Errorf("gosdk: operation %q error response %s: %w", operation.ID, errorResponses[index].Status, err) - } + view.HasParameters = len(view.Fields) > 0 + if err := e.prepareResponses(operation, base, method, &view); err != nil { + return view, err } - view.ErrorResponses = errorResponses + view.Comment = methodComment(view, operation.Summary) for _, alternative := range operation.Security { if alternative == nil { view.Security = append(view.Security, nil) @@ -447,9 +477,8 @@ func (e *emitter) prepareWorkflow(workflow *sdk.Workflow, operations map[string] return workflowView{}, fmt.Errorf("gosdk: workflow %q operation %q is not generated", workflow.ID, workflow.Operation.ID) } view := workflowView{ - ID: workflow.ID, MethodName: e.names.Claim(modelgen.PublicName(workflow.ID), "Workflow"), - InputType: e.names.Claim(modelgen.PublicName(workflow.ID)+"Input", "Input"), - Summary: workflow.Summary, + ID: workflow.ID, MethodName: e.names.ClaimFirst(modelgen.PublicName(workflow.ID), modelgen.PublicName(workflow.ID)+"Workflow"), + InputType: e.names.ClaimFirst(modelgen.PublicName(workflow.ID) + "Input"), ResourceName: operation.ResourceName, OperationMethod: operation.MethodName, OperationParams: operation.ParamsType, HasParameters: operation.HasParameters, ResponseType: operation.ResponseType, @@ -458,9 +487,12 @@ func (e *emitter) prepareWorkflow(workflow *sdk.Workflow, operations map[string] if err != nil { return workflowView{}, fmt.Errorf("gosdk: workflow %q inputs: %w", workflow.ID, err) } - inputKey := "__workflow_" + view.InputType + view.Comment = goComment("", workflowDoc(view.MethodName, workflow)) + inputKey := inlineSchemaKey + view.InputType + e.descriptions[inputKey] = "The input of [Client." + view.MethodName + "]." e.schemas.Set(inputKey, inputSchema) e.typeNames[inputKey] = view.InputType + e.inlineRoots = append(e.inlineRoots, inputKey) e.collectSchema(inputSchema) view.SuccessCondition, err = statusCondition(workflow.SuccessStatuses) if err != nil { @@ -613,49 +645,162 @@ func generatedField(types map[string]*modelgen.GeneratedType, generatedType *mod return modelgen.GeneratedField{}, false } -func (e *emitter) prepareResponses(operation *sdk.Operation) (string, []string, []string, []errorResponseView, error) { - responseType := "struct{}" +// prepareResponses names and classifies an operation's responses. Every 2xx +// response shares one success type. Each other response with a JSON body is +// decoded into its own type, named for its status (PeopleMatchBadRequest); +// a response with any other body keeps it raw in APIError.Body. +func (e *emitter) prepareResponses(operation *sdk.Operation, base, method string, view *operationView) error { var selectedType string - var successes []string var decodedSuccesses []string - var errorResponses []errorResponseView + var errorCases []errorResponseView for _, response := range operation.Responses { if response == nil { continue } status := normalizeStatus(response.Status) isSuccess := statusIsSuccess(status) - _, schema, err := jsonMedia(response.Content) + var schema *highbase.SchemaProxy + var err error + if isSuccess { + _, schema, err = requireJSONMedia(response.Content) + } else { + _, schema, err = jsonMedia(response.Content) + } if err != nil { - return "", nil, nil, nil, fmt.Errorf("gosdk: operation %q response %s: %w", operation.ID, status, err) + return fmt.Errorf("gosdk: operation %q response %s: %w", operation.ID, status, err) } typeName := "" if schema != nil { - typeName, err = e.schemaType(schema, operation.ID+statusName(status)+"Response") + hint := base + "Response" + if !isSuccess { + hint = base + statusTypeName(status) + } + // Every model declared while typing a response body is only decoded. + declared := len(e.inlineRoots) + typeName, err = e.describedType(schema, hint, "The "+status+" response body of "+method+".") if err != nil { - return "", nil, nil, nil, fmt.Errorf("gosdk: operation %q response %s: %w", operation.ID, status, err) + return fmt.Errorf("gosdk: operation %q response %s: %w", operation.ID, status, err) } + e.responseRoots = append(e.responseRoots, e.inlineRoots[declared:]...) } if isSuccess { - successes = append(successes, status) + view.SuccessStatuses = append(view.SuccessStatuses, status) if typeName != "" { decodedSuccesses = append(decodedSuccesses, status) if selectedType != "" && selectedType != typeName { - return "", nil, nil, nil, fmt.Errorf("gosdk: operation %q has incompatible success response types %s and %s", operation.ID, selectedType, typeName) + return fmt.Errorf("gosdk: operation %q has incompatible success response types %s and %s", operation.ID, selectedType, typeName) } selectedType = typeName } continue } - errorResponses = append(errorResponses, errorResponseView{Status: status, Type: typeName}) + if _, err := statusCondition([]string{status}); err != nil { + return fmt.Errorf("gosdk: operation %q error response %s: %w", operation.ID, status, err) + } + if typeName != "" { + errorCases = append(errorCases, errorResponseView{Statuses: []string{status}, Type: typeName}) + } } - if len(successes) == 0 { - return "", nil, nil, nil, fmt.Errorf("gosdk: operation %q has no declared 2xx response", operation.ID) + if len(view.SuccessStatuses) == 0 { + return fmt.Errorf("gosdk: operation %q has no declared 2xx response", operation.ID) } + var err error + view.ResponseType = "struct{}" if selectedType != "" { - responseType = selectedType + view.ResponseType = selectedType + view.DecodeCondition, err = decodeStatusCondition(decodedSuccesses, view.SuccessStatuses) + if err != nil { + return fmt.Errorf("gosdk: operation %q JSON success responses: %w", operation.ID, err) + } + } + view.SuccessCondition, err = statusCondition(view.SuccessStatuses) + if err != nil { + return fmt.Errorf("gosdk: operation %q success responses: %w", operation.ID, err) + } + view.DecodeAll = view.DecodeCondition == view.SuccessCondition + view.ErrorCases, view.ErrorSwitch = errorSwitch(errorCases) + for _, errorCase := range view.ErrorCases { + statuses := strings.Join(errorCase.Statuses, ", ") + if errorCase.Default { + statuses = "default" + } + view.ErrorDoc = append(view.ErrorDoc, "*"+errorCase.Type+" ("+statuses+")") + } + return nil +} + +// errorSwitch orders typed error responses the way OpenAPI resolves them: +// exact codes, then ranges, then default. Codes and ranges sharing a type +// share a case, and cases list their statuses in ascending order. With no +// ranges the switch is on the status code itself. +func errorSwitch(responses []errorResponseView) ([]errorResponseView, string) { + var exact, ranges, fallback []errorResponseView + group := func(cases []errorResponseView, response errorResponseView) []errorResponseView { + for index := range cases { + if cases[index].Type == response.Type { + cases[index].Statuses = append(cases[index].Statuses, response.Statuses...) + return cases + } + } + return append(cases, response) + } + for _, response := range responses { + switch status := response.Statuses[0]; { + case status == "default": + fallback = []errorResponseView{{Type: response.Type, Default: true}} + case strings.HasSuffix(status, "XX"): + ranges = group(ranges, response) + default: + exact = group(exact, response) + } + } + for _, cases := range [][]errorResponseView{exact, ranges} { + for index := range cases { + sort.Strings(cases[index].Statuses) + } + sort.Slice(cases, func(left, right int) bool { return cases[left].Statuses[0] < cases[right].Statuses[0] }) } - return responseType, successes, decodedSuccesses, errorResponses, nil + tagged := len(ranges) == 0 + cases := append(append(exact, ranges...), fallback...) + for index := range cases { + var conditions []string + for _, status := range cases[index].Statuses { + // prepareResponses has already rejected invalid statuses. + condition, _ := statusCondition([]string{status}) + if tagged { + condition = status + } + conditions = append(conditions, condition) + } + cases[index].Case = strings.Join(conditions, ", ") + } + if tagged { + return cases, "response.StatusCode" + } + return cases, "" +} + +// statusTypeName names an error response type for its status: the HTTP +// status text for a code (BadRequest), the class for a range (ClientError), +// and Error for the default response. +func statusTypeName(status string) string { + switch status { + case "default": + return "Error" + case "1XX": + return "Informational" + case "3XX": + return "Redirect" + case "4XX": + return "ClientError" + case "5XX": + return "ServerError" + } + code, _ := strconv.Atoi(status) + if text := http.StatusText(code); text != "" { + return modelgen.PublicName(text) + } + return "Status" + status } func (e *emitter) schemaType(proxy *highbase.SchemaProxy, hint string) (string, error) { @@ -686,7 +831,7 @@ func (e *emitter) schemaType(proxy *highbase.SchemaProxy, hint string) (string, } } if len(types) == 1 { - if goType, scalar := e.models.ScalarType(types[0], schema.Format); scalar { + if goType, scalar := e.scalars.ScalarType(types[0], schema.Format); scalar { // A string format mapped to a named Go type is emitted as a model so // models.gen.go owns the import. if types[0] == "string" && goType != "string" { @@ -697,7 +842,7 @@ func (e *emitter) schemaType(proxy *highbase.SchemaProxy, hint string) (string, switch types[0] { case "array": if schema.Items == nil || !schema.Items.IsA() { - return "[]any", nil + return e.addInlineSchema(hint, proxy), nil } itemType, err := e.schemaType(schema.Items.A, hint+"Item") if err != nil { @@ -708,29 +853,42 @@ func (e *emitter) schemaType(proxy *highbase.SchemaProxy, hint string) (string, return e.addInlineSchema(hint, proxy), nil } } - if schema.Properties != nil || len(schema.AllOf) > 0 || len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { - name := e.addInlineSchema(hint, proxy) - if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { - name += "Union" - } - return name, nil + name := e.addInlineSchema(hint, proxy) + if len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + name += "Union" } - return "any", nil + // Anything else, including a schema that leaves its JSON undescribed, is + // a model too, so the model generator decides its Go shape. + return name, nil } -func (e *emitter) addInlineSchema(hint string, proxy *highbase.SchemaProxy) string { - key := hint - for suffix := 2; ; suffix++ { - _, generated := e.schemas.Get(key) - _, component := e.reachableComponents[key] - if !generated && !component { - break - } - key = fmt.Sprintf("%s%d", hint, suffix) +// describedType returns the Go type for one of an operation's schemas. role +// says what the schema is, such as "The 200 response body of +// [PeopleResource.Match].", and documents a model declared for it that has no +// description of its own. +func (e *emitter) describedType(proxy *highbase.SchemaProxy, name, role string) (string, error) { + e.roles[name] = role + defer delete(e.roles, name) + return e.schemaType(proxy, name) +} + +// addInlineSchema declares a model for a schema written inline in an +// operation, named from typeName or a numbered form of it when taken. A schema +// reached twice, as through a YAML anchor, is one model. +func (e *emitter) addInlineSchema(typeName string, proxy *highbase.SchemaProxy) string { + if declared, ok := e.inlineTypes[proxy]; ok { + return declared + } + role, described := e.roles[typeName] + typeName = e.names.ClaimFirst(typeName) + key := inlineSchemaKey + typeName + if described { + e.descriptions[key] = role } e.schemas.Set(key, proxy) - typeName := e.names.Claim(modelgen.PublicName(key), "Model") e.typeNames[key] = typeName + e.inlineTypes[proxy] = typeName + e.inlineRoots = append(e.inlineRoots, key) e.collectSchema(proxy) return typeName } @@ -812,8 +970,10 @@ func forEachShapeChild(schema *highbase.Schema, visit func(*highbase.SchemaProxy } } +// jsonMedia returns the first JSON media type in content and its schema. It +// returns no media type when content declares no JSON media. func jsonMedia(content *orderedmap.Map[string, *highv3.MediaType]) (string, *highbase.SchemaProxy, error) { - if content == nil || content.Len() == 0 { + if content == nil { return "", nil, nil } for mediaType, value := range content.FromOldest() { @@ -824,6 +984,16 @@ func jsonMedia(content *orderedmap.Map[string, *highv3.MediaType]) (string, *hig return mediaType, value.Schema, nil } } + return "", nil, nil +} + +// requireJSONMedia is jsonMedia for bodies the SDK must encode or decode: +// content that declares only other media types is unsupported. +func requireJSONMedia(content *orderedmap.Map[string, *highv3.MediaType]) (string, *highbase.SchemaProxy, error) { + mediaType, schema, err := jsonMedia(content) + if err != nil || mediaType != "" || content == nil || content.Len() == 0 { + return mediaType, schema, err + } keys := make([]string, 0, content.Len()) for mediaType := range content.FromOldest() { keys = append(keys, mediaType) @@ -888,7 +1058,7 @@ func (e *emitter) parameterEncoder(proxy *highbase.SchemaProxy) (string, bool, e } switch nonNullType { case "string": - if mapped, _ := e.models.ScalarType("string", schema.Format); mapped != "string" { + if mapped, _ := e.scalars.ScalarType("string", schema.Format); mapped != "string" { return "", false, fmt.Errorf("custom scalar format %q maps to %s and is not supported for parameter serialization", schema.Format, mapped) } return "encodeString", false, nil @@ -982,17 +1152,93 @@ func normalizeStatus(status string) string { return status } -func statusName(status string) string { - if status == "default" { - return "Default" +// decodeOnlyRoots returns the model keys of schemas that responses decode and +// requests never encode: inline response bodies, and components that only +// responses reach. +func (e *emitter) decodeOnlyRoots() []string { + encoded := make(map[string]struct{}) + var walk func(*highbase.SchemaProxy) + walk = func(proxy *highbase.SchemaProxy) { + if proxy == nil { + return + } + if name, component := componentSchemaRefName(proxy.GetReference()); component { + if _, seen := encoded[name]; seen { + return + } + encoded[name] = struct{}{} + proxy, _ = e.componentSchemas.Get(name) + } + if schema := proxy.Schema(); schema != nil { + forEachShapeChild(schema, walk) + } + } + for _, operation := range e.contract.Operations { + if operation != nil && operation.RequestBody != nil { + _, schema, _ := jsonMedia(operation.RequestBody.Content) + walk(schema) + } + } + roots := append([]string(nil), e.responseRoots...) + for name := range e.collectedComponents { + if _, written := encoded[name]; !written { + roots = append(roots, name) + } + } + return roots +} + +// goComment renders comment lines as Go line comments, each starting with +// indent. +func goComment(indent string, lines []string) string { + var b strings.Builder + gocomment.Write(&b, indent, lines) + return b.String() +} + +// methodComment documents a resource method: the request it sends, the +// operation summary, and the typed values a declared error carries. +func methodComment(view operationView, summary string) string { + lines := gocomment.Lines(view.MethodName + " calls " + view.HTTPMethod + " " + view.Path + " (" + view.ID + ").") + if summary := gocomment.Lines(summary); summary != nil { + lines = append(append(lines, ""), summary...) } - return "Status" + strings.ToUpper(status) + if len(view.ErrorDoc) > 0 { + values := view.ErrorDoc[0] + if count := len(view.ErrorDoc); count > 1 { + values = strings.Join(view.ErrorDoc[:count-1], ", ") + " or " + view.ErrorDoc[count-1] + } + lines = append(append(lines, ""), gocomment.Lines("A declared error response returns an [APIError] whose Value is "+values+".")...) + } + return goComment("", lines) } -func commentLine(value string) string { - value = strings.TrimSpace(strings.Split(value, "\n")[0]) - if value == "" { - return "" +// workflowDoc documents a workflow method with its summary, or with the +// workflow it runs when it has none. +func workflowDoc(methodName string, workflow *sdk.Workflow) []string { + if workflow.Summary == "" { + return gocomment.Lines(methodName + " runs the " + workflow.ID + " workflow.") } - return strings.ReplaceAll(value, "*/", "* /") + return gocomment.Lines(methodName + " runs the " + workflow.ID + " workflow: " + workflow.Summary) +} + +// recordUses notes the runtime helpers an operation's code calls, so the +// client declares only those. +func recordUses(uses map[string]bool, operation operationView) { + for _, parameter := range operation.Parameters { + uses[parameter.Encoder] = true + switch { + case parameter.In == "path" && parameter.Array: + uses["encodePathArray"] = true + case parameter.In == "path": + uses["encodePathParameter"] = true + case parameter.In == "query" && parameter.Array && parameter.Explode: + uses["addExplodedQueryParameter"] = true + case parameter.Array: + uses["encodeArray"] = true + } + } + uses["replacePathParameter"] = uses["replacePathParameter"] || operation.HasPathParams + uses["decodeResponse"] = uses["decodeResponse"] || operation.ResponseType != "struct{}" + uses["decodeErrorValue"] = uses["decodeErrorValue"] || len(operation.ErrorCases) > 0 } diff --git a/generator/sdk/gosdk/generator_test.go b/generator/sdk/gosdk/generator_test.go index 9af522c4..1e7c4dfd 100644 --- a/generator/sdk/gosdk/generator_test.go +++ b/generator/sdk/gosdk/generator_test.go @@ -784,7 +784,7 @@ func TestGeneratedClient(t *testing.T) { if page.Value.Items[0].ID != "a-1" || page.Header.Get("X-Page") != "one" { t.Fatalf("unexpected page: %#v", page) } widget, err := client.Widgets.GetWidget(context.Background(), &GetWidgetParams{TenantID: "tenant-1", WidgetID: "widget/1"}) if err != nil || widget.Value.ID != "widget/1" { t.Fatalf("unexpected widget: %#v %v", widget, err) } - segments, err := client.Segments.Inspect(context.Background(), &InspectSegmentsParams{TenantID: "tenant-1", Parts: []string{"alpha/one", "beta two"}}) + segments, err := client.Segments.Inspect(context.Background(), &SegmentsInspectParams{TenantID: "tenant-1", Parts: []string{"alpha/one", "beta two"}}) if err != nil || segments.StatusCode != http.StatusNoContent { t.Fatalf("unexpected segments response: %#v %v", segments, err) } run, err := client.StartJob(context.Background(), &StartJobInput{Tenant: "tenant-1", TaskID: "task-1", IdempotencyKey: "i-1"}) if err != nil || run.StatusCode != http.StatusCreated || run.Value.ID != "run-1" { t.Fatalf("unexpected run: %#v %v", run, err) } diff --git a/generator/sdk/gosdk/people_match_test.go b/generator/sdk/gosdk/people_match_test.go new file mode 100644 index 00000000..31cb7f9b --- /dev/null +++ b/generator/sdk/gosdk/people_match_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package gosdk + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" + modelgen "github.com/pb33f/libopenapi/generator/golang" + "github.com/pb33f/libopenapi/generator/sdk" +) + +// generatePeopleMatch generates a client for a real-world vendor's +// people-enrichment operation, named People.Match. +func generatePeopleMatch(t *testing.T) *sdk.Result { + t.Helper() + spec, err := os.ReadFile("testdata/people-match.yaml") + if err != nil { + t.Fatal(err) + } + document, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatal(err) + } + contract, err := sdk.Prepare(&model.Model, sdk.PrepareOptions{ + Operations: []string{"people-enrichment"}, + Names: map[string]sdk.OperationName{"people-enrichment": {Resource: "People", Method: "Match"}}, + }) + if err != nil { + t.Fatal(err) + } + result, err := GenerateContract(contract, Options{PackageName: "people"}) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestPeopleMatchGolden(t *testing.T) { + result := generatePeopleMatch(t) + for _, file := range result.Files { + golden := filepath.Join("testdata", "people_match_"+strings.TrimSuffix(file.Path, ".gen.go")+".golden.go") + if os.Getenv("LIBOPENAPI_GENERATOR_UPDATE_GOLDENS") == "true" { + if err := os.WriteFile(golden, file.Content, 0o600); err != nil { + t.Fatal(err) + } + continue + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if strings.ReplaceAll(string(want), "\r\n", "\n") != string(file.Content) { + t.Fatalf("%s does not match %s; regenerate with LIBOPENAPI_GENERATOR_UPDATE_GOLDENS=true and review the diff", file.Path, golden) + } + } + var nullable int + for _, diagnostic := range result.Diagnostics { + if diagnostic.Code != modelgen.DiagnosticNullableKeyword { + t.Fatalf("unexpected diagnostic %#v", diagnostic) + } + nullable++ + } + if nullable != 3 { + t.Fatalf("expected the three nullable: true schemas to be reported, got %d", nullable) + } +} + +func TestPeopleMatchClientDecodesDeclaredResponses(t *testing.T) { + result := generatePeopleMatch(t) + directory := t.TempDir() + for _, file := range result.Files { + if err := os.WriteFile(filepath.Join(directory, file.Path), file.Content, 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(directory, "go.mod"), []byte("module generated.example/people\n\ngo 1.25.0\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "people_test.go"), []byte(peopleRuntimeTest), 0o600); err != nil { + t.Fatal(err) + } + command := exec.Command("go", "test", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("generated people client failed: %v\n%s", err, output) + } +} + +const peopleRuntimeTest = `package people + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestPeopleMatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/people/match" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + switch r.URL.Query().Get("email") { + case "": + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Invalid API key.")) + case "bad@example.com": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(` + "`" + `{"error_details":{"code":"SEARCH.VALIDATION.WEBHOOK_URL_REQUIRED","context":{"parameter":{"value":"reveal_phone_number"}}}}` + "`" + `)) + case "busy@example.com": + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(` + "`" + `{"error_details":{"code":"USAGE.RATE_LIMIT","context":{"window":{"value":"hour"}}}}` + "`" + `)) + default: + if r.Header.Get("X-Api-Key") != "key" || r.URL.Query().Get("reveal_personal_emails") != "true" { + t.Errorf("credentials or query not sent: %v %s", r.Header, r.URL.RawQuery) + } + _, _ = w.Write([]byte(` + "`" + `{"person":{"name":"Jordan Blake","twitter_url":null,"extrapolated_email_confidence":0.9,"match_confidence":"high","employment_history":[{"_id":"a","id":"b"}],"organization":{"primary_phone":{"number":"+1"}}}}` + "`" + `)) + } + })) + defer server.Close() + client, err := NewClient(server.URL+"/api/v1", WithCredential("apiKey", APIKey("key"))) + if err != nil { + t.Fatal(err) + } + email, reveal := "jordan@example.com", true + response, err := client.People.Match(context.Background(), &PeopleMatchParams{Email: &email, RevealPersonalEmails: &reveal}) + if err != nil { + t.Fatal(err) + } + person := response.Value.Person + if *person.Name != "Jordan Blake" || string(person.TwitterURL) != "null" || *person.ExtrapolatedEmailConfidence != 0.9 || + *person.MatchConfidence != MatchConfidence("high") || *person.EmploymentHistory[0].UnderscoreID != "a" || + *person.EmploymentHistory[0].ID != "b" || string(person.Organization.PrimaryPhone) != ` + "`" + `{"number":"+1"}` + "`" + ` { + t.Fatalf("decoded person = %#v", person) + } + + var apiError *APIError + _, err = client.People.Match(context.Background(), nil) + if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized || string(apiError.Body) != "Invalid API key." || apiError.Value != nil || apiError.Cause != nil { + t.Fatalf("text/plain 401 = %#v %v", apiError, err) + } + bad := "bad@example.com" + _, err = client.People.Match(context.Background(), &PeopleMatchParams{Email: &bad}) + if !errors.As(err, &apiError) { + t.Fatalf("400 = %v", err) + } + if value, ok := apiError.Value.(*PeopleMatchBadRequest); !ok || *value.ErrorDetails.Code != "SEARCH.VALIDATION.WEBHOOK_URL_REQUIRED" { + t.Fatalf("400 value = %#v", apiError.Value) + } + busy := "busy@example.com" + _, err = client.People.Match(context.Background(), &PeopleMatchParams{Email: &busy}) + if !errors.As(err, &apiError) { + t.Fatalf("429 = %v", err) + } + if value, ok := apiError.Value.(*PeopleMatchTooManyRequests); !ok || *value.ErrorDetails.Context["window"].Value != "hour" { + t.Fatalf("429 value = %#v", apiError.Value) + } +} +` diff --git a/generator/sdk/gosdk/templates/client.tmpl b/generator/sdk/gosdk/templates/client.tmpl index 3bc9a1b4..9058ad34 100644 --- a/generator/sdk/gosdk/templates/client.tmpl +++ b/generator/sdk/gosdk/templates/client.tmpl @@ -11,7 +11,9 @@ import ( "io" "net/http" "net/url" +{{- if or .Uses.encodeInteger .Uses.encodeFloat32 .Uses.encodeFloat64 .Uses.encodeBoolean}} "strconv" +{{- end}} "strings" "time" ) @@ -468,6 +470,7 @@ func (err *APIError) Unwrap() error { return err.Cause } + // Null returns the explicit-null state for an optional nullable generated field. func Null[T any]() **T { var value *T @@ -479,6 +482,32 @@ func NullableValue[T any](value T) **T { inner := &value return &inner } +{{- if .Uses.decodeResponse}} + +// decodeResponse decodes a declared JSON success body. A body that does not +// match its declaration returns a ResponseDecodeError with the HTTP evidence. +func decodeResponse[T any](statusCode int, header http.Header, payload []byte) (*Response[T], error) { + result := &Response[T]{StatusCode: statusCode, Header: header} + if err := decodeJSON(payload, &result.Value); err != nil { + return nil, &ResponseDecodeError{StatusCode: statusCode, Header: header, Body: payload, Cause: err} + } + return result, nil +} +{{- end}} +{{- if .Uses.decodeErrorValue}} + +// decodeErrorValue decodes a declared JSON error body into apiError.Value. A +// body that does not match its declaration is reported through Cause. +func decodeErrorValue[T any](apiError *APIError) { + var value T + if err := decodeJSON(apiError.Body, &value); err != nil { + apiError.Cause = fmt.Errorf("decode declared error response: %w", err) + return + } + apiError.Value = &value +} +{{- end}} +{{- if or .Uses.decodeResponse .Uses.decodeErrorValue}} func decodeJSON(body []byte, target any) error { if len(body) == 0 { @@ -489,24 +518,38 @@ func decodeJSON(body []byte, target any) error { } return nil } +{{- end}} +{{- if .Uses.encodeString}} func encodeString[T ~string](value T) string { return string(value) } +{{- end}} +{{- if .Uses.encodeInteger}} func encodeInteger[T ~int | ~int32 | ~int64](value T) string { return strconv.FormatInt(int64(value), 10) } +{{- end}} +{{- if .Uses.encodeFloat32}} func encodeFloat32[T ~float32](value T) string { return strconv.FormatFloat(float64(value), 'g', -1, 32) } +{{- end}} +{{- if .Uses.encodeFloat64}} func encodeFloat64[T ~float64](value T) string { return strconv.FormatFloat(float64(value), 'g', -1, 64) } +{{- end}} +{{- if .Uses.encodeBoolean}} func encodeBoolean[T ~bool](value T) string { return strconv.FormatBool(bool(value)) } +{{- end}} +{{- if .Uses.encodeAny}} func encodeAny[T any](value T) string { return fmt.Sprint(value) } +{{- end}} +{{- if .Uses.encodeArray}} func encodeArray[T any](values []T, encode func(T) string) string { parts := make([]string, len(values)) @@ -515,8 +558,12 @@ func encodeArray[T any](values []T, encode func(T) string) string { } return strings.Join(parts, ",") } +{{- end}} +{{- if or .Uses.encodePathParameter .Uses.encodePathArray}} func encodePathParameter(value string) string { return url.PathEscape(value) } +{{- end}} +{{- if .Uses.encodePathArray}} func encodePathArray[T any](values []T, encode func(T) string) string { parts := make([]string, len(values)) @@ -525,13 +572,18 @@ func encodePathArray[T any](values []T, encode func(T) string) string { } return strings.Join(parts, ",") } +{{- end}} +{{- if .Uses.replacePathParameter}} func replacePathParameter(path, name, value string) string { return strings.ReplaceAll(path, "{"+name+"}", value) } +{{- end}} +{{- if .Uses.addExplodedQueryParameter}} func addExplodedQueryParameter[T any](query url.Values, name string, values []T, encode func(T) string) { for _, value := range values { query.Add(name, encode(value)) } } +{{- end}} diff --git a/generator/sdk/gosdk/templates/resources.tmpl b/generator/sdk/gosdk/templates/resources.tmpl index f9c83729..f0e5d809 100644 --- a/generator/sdk/gosdk/templates/resources.tmpl +++ b/generator/sdk/gosdk/templates/resources.tmpl @@ -4,75 +4,68 @@ package {{.PackageName}} import ( "context" - "fmt" + "errors" {{- if .HasQueryParams}} "net/url" {{- end}} ) - {{range .Resources}} {{$resource := .}} // {{.TypeName}} provides operations for the {{.Name}} resource. type {{.TypeName}} struct { client *Client } - {{range .Operations}} -{{if .HasParameters}} -// {{.ParamsType}} contains the parameters for {{.ID}}. +{{- if .HasParameters}} +// {{.ParamsType}} holds the parameters for [{{.ResourceType}}.{{.MethodName}}]. type {{.ParamsType}} struct { -{{- range .Parameters}} - {{.FieldName}} {{.Type}}{{if .Description}} // {{comment .Description}}{{end}} -{{- end}} -{{- with .Body}} - Body {{.FieldType}}{{if .Description}} // {{comment .Description}}{{end}} +{{- range .Fields}} +{{if .Spaced}} +{{end}}{{.Comment}} {{.Name}} {{.Type}} {{- end}} } {{end}} -{{if .Security}} +{{- if .Security}} var security{{$resource.Name}}{{.MethodName}} = [][]securityRequirement{ {{- range .Security}} - { {{range .}}{Name: {{quote .Name}}, Scopes: []string{ {{range .Scopes}}{{quote .}}, {{end}} }}, {{end}} }, + { {{- range .}}{Name: {{quote .Name}}{{if .Scopes}}, Scopes: []string{ {{- range $index, $scope := .Scopes}}{{if $index}}, {{end}}{{quote $scope}}{{end -}} }{{end}}}, {{end -}} }, {{- end}} } {{end}} - -// {{.MethodName}} {{if .Summary}}{{comment .Summary}}{{else}}executes {{.ID}}.{{end}} -func (resource *{{$resource.TypeName}}) {{.MethodName}}(ctx context.Context{{if .HasParameters}}, input *{{.ParamsType}}{{end}}, requestOptions ...RequestOption) (*Response[{{.ResponseType}}], error) { +{{.Comment}}func (resource *{{$resource.TypeName}}) {{.MethodName}}(ctx context.Context{{if .HasParameters}}, input *{{.ParamsType}}{{end}}, requestOptions ...RequestOption) (*Response[{{.ResponseType}}], error) { if resource == nil || resource.client == nil { - return nil, fmt.Errorf("{{.ID}}: client is required") + return nil, errors.New("{{.ID}}: client is required") } {{- if .HasParameters}} if input == nil { input = &{{.ParamsType}}{} } {{- end}} +{{- if .HasPathParams}} path := {{quote .Path}} +{{- end}} {{- if .HasQueryParams}} query := make(url.Values) {{- end}} {{- range .Parameters}} -{{- if or (eq .In "path") (eq .In "query")}} -{{- if .Required}} - { -{{- else}} +{{- if ne .In "header"}} +{{- $value := printf "input.%s" .FieldName}}{{if not .Required}}{{$value = printf "*input.%s" .FieldName}} if input.{{.FieldName}} != nil { {{- end}} {{- if eq .In "path"}} - encoded := {{if .Array}}encodePathArray({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}}, {{.Encoder}}){{else}}encodePathParameter({{.Encoder}}({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}})){{end}} - path = replacePathParameter(path, {{quote .Name}}, encoded) -{{- else if eq .In "query"}} -{{- if and .Array .Explode}} - addExplodedQueryParameter(query, {{quote .Name}}, {{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}}, {{.Encoder}}) + path = replacePathParameter(path, {{quote .Name}}, {{if .Array}}encodePathArray({{$value}}, {{.Encoder}}){{else}}encodePathParameter({{.Encoder}}({{$value}})){{end}}) +{{- else if and .Array .Explode}} + addExplodedQueryParameter(query, {{quote .Name}}, {{$value}}, {{.Encoder}}) {{- else if .Array}} - query.Add({{quote .Name}}, encodeArray({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}}, {{.Encoder}})) + query.Add({{quote .Name}}, encodeArray({{$value}}, {{.Encoder}})) {{- else}} - query.Add({{quote .Name}}, {{.Encoder}}({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}})) -{{- end}} + query.Add({{quote .Name}}, {{.Encoder}}({{$value}})) {{- end}} +{{- if not .Required}} } {{- end}} {{- end}} +{{- end}} {{- with .Body}} {{- if not .Required}} var body any @@ -81,7 +74,7 @@ func (resource *{{$resource.TypeName}}) {{.MethodName}}(ctx context.Context{{if } {{- end}} {{- end}} - request, err := resource.client.newRequest(ctx, {{quote .HTTPMethod}}, path, {{if .HasQueryParams}}query{{else}}nil{{end}}, {{with .Body}}{{if .Required}}input.Body{{else}}body{{end}}, {{quote .ContentType}}{{else}}nil, ""{{end}}) + request, err := resource.client.newRequest(ctx, {{quote .HTTPMethod}}, {{if .HasPathParams}}path{{else}}{{quote .Path}}{{end}}, {{if .HasQueryParams}}query{{else}}nil{{end}}, {{with .Body}}{{if .Required}}input.Body{{else}}body{{end}}, {{quote .ContentType}}{{else}}nil, ""{{end}}) if err != nil { return nil, err } @@ -90,15 +83,14 @@ func (resource *{{$resource.TypeName}}) {{.MethodName}}(ctx context.Context{{if } {{- range .Parameters}} {{- if eq .In "header"}} -{{- if .Required}} - { -{{- else}} +{{- $value := printf "input.%s" .FieldName}}{{if not .Required}}{{$value = printf "*input.%s" .FieldName}} if input.{{.FieldName}} != nil { {{- end}} - encoded := {{if .Array}}encodeArray({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}}, {{.Encoder}}){{else}}{{.Encoder}}({{if .Required}}input.{{.FieldName}}{{else}}*input.{{.FieldName}}{{end}}){{end}} - request.Header.Set({{quote .Name}}, encoded) + request.Header.Set({{quote .Name}}, {{if .Array}}encodeArray({{$value}}, {{.Encoder}}){{else}}{{.Encoder}}({{$value}}){{end}}) +{{- if not .Required}} } {{- end}} +{{- end}} {{- end}} response, payload, err := resource.client.do(request, {{if .Security}}security{{$resource.Name}}{{.MethodName}}{{else}}nil{{end}}) if err != nil { @@ -106,33 +98,31 @@ func (resource *{{$resource.TypeName}}) {{.MethodName}}(ctx context.Context{{if } header := response.Header.Clone() if {{.SuccessCondition}} { - result := &Response[{{.ResponseType}}]{StatusCode: response.StatusCode, Header: header} -{{- if ne .ResponseType "struct{}"}} +{{- if .DecodeAll}} + return decodeResponse[{{.ResponseType}}](response.StatusCode, header, payload) +{{- else}} +{{- if .DecodeCondition}} if {{.DecodeCondition}} { - if err := decodeJSON(payload, &result.Value); err != nil { - return nil, &ResponseDecodeError{StatusCode: response.StatusCode, Header: header, Body: payload, Cause: err} - } + return decodeResponse[{{.ResponseType}}](response.StatusCode, header, payload) } {{- end}} - return result, nil + return &Response[{{.ResponseType}}]{StatusCode: response.StatusCode, Header: header}, nil +{{- end}} } apiError := &APIError{StatusCode: response.StatusCode, Header: header, Body: payload} -{{- if .ErrorResponses}} - switch { -{{- range .ErrorResponses}} - case {{.Condition}}: -{{- if .Type}} - var value {{.Type}} - if err := decodeJSON(payload, &value); err != nil { - apiError.Cause = fmt.Errorf("decode declared error response: %w", err) - return nil, apiError - } - apiError.Value = &value +{{- if .ErrorCases}} + switch {{.ErrorSwitch}} { +{{- range .ErrorCases}} +{{- if .Default}} + default: +{{- else}} + case {{.Case}}: {{- end}} + decodeErrorValue[{{.Type}}](apiError) {{- end}} } {{- end}} return nil, apiError } {{end}} -{{end}} +{{- end}} diff --git a/generator/sdk/gosdk/templates/workflows.tmpl b/generator/sdk/gosdk/templates/workflows.tmpl index 6c4bfc01..3623e397 100644 --- a/generator/sdk/gosdk/templates/workflows.tmpl +++ b/generator/sdk/gosdk/templates/workflows.tmpl @@ -8,8 +8,7 @@ import ( ) {{range .Workflows}} -// {{.MethodName}} {{if .Summary}}{{comment .Summary}}{{else}}executes the {{.ID}} workflow.{{end}} -func (client *Client) {{.MethodName}}(ctx context.Context, input *{{.InputType}}, requestOptions ...RequestOption) (*Response[{{.ResponseType}}], error) { +{{.Comment}}func (client *Client) {{.MethodName}}(ctx context.Context, input *{{.InputType}}, requestOptions ...RequestOption) (*Response[{{.ResponseType}}], error) { if client == nil { return nil, fmt.Errorf("{{.ID}}: client is required") } diff --git a/generator/sdk/gosdk/testdata/people-match.yaml b/generator/sdk/gosdk/testdata/people-match.yaml new file mode 100644 index 00000000..f966d500 --- /dev/null +++ b/generator/sdk/gosdk/testdata/people-match.yaml @@ -0,0 +1,799 @@ +# The people-enrichment operation (POST /people/match) and the components it +# uses, extracted from a public vendor OpenAPI document retrieved 2026-10-07 +# (sha256 e23f64e8016d2ff56a112eccd4cb37aec2322c705b4c8db3d2451cc1fe89ae41). +# The document is a real-world, imperfect specification: nullable inside +# OpenAPI 3.1, inline schemas throughout, empty schemas, schema examples that do +# not match their schemas, and a text/plain 401 response. Everything is kept as +# published except the response-level examples blocks, which generation never +# reads and which made up most of the size. +openapi: 3.1.0 +info: + title: apollo-rest-api + version: '1.0' +servers: +- url: https://api.apollo.io/api/v1 +security: +- apiKey: [] +- bearerAuth: [] +paths: + /people/match: + post: + summary: People enrichment + description: |- + ## Endpoint essentials + + **API key access:** `api/v1/people/match` or `Master API key` + + **OAuth scopes:** `people_match` + + **Free account access:** Requires an Apollo account registered with a work email address. Additional account eligibility requirements may apply. Paid accounts aren't affected. + + **Credit usage:** `1–9 credits per person` without waterfall enrichment + + Credits are charged only if credit-consuming data is found: 1 credit for demographics or email, plus 8 credits if a mobile phone is returned. If no credit-consuming data is found, the request consumes 0 credits. Demographic credit usage depends on `match_confidence`. Apollo doesn't charge a demographic credit when `match_confidence` is `none`. Email and mobile phone credit usage isn't determined by `match_confidence`. Learn more about [API pricing and credits](https://docs.apollo.io/docs/api-pricing). + + If waterfall enrichment is used for phone or email, credit usage depends on the returned data and the vendors included in your [waterfall enrichment configuration](https://knowledge.apollo.io/hc/en-us/articles/34071121664781-Use-Waterfall-Enrichment); some vendors consume credits per lookup even when no data is found. + + Enrichment is when you refresh existing records so your prospecting data is up-to-date.

Use the people enrichment endpoint to enrich data for one person. To enrich data for up to ten people with a single API call, use the bulk people enrichment endpoint instead.

Apollo relies on the information you pass via the endpoint's parameters to identify the correct person to enrich. If you provide more information about a person, Apollo is more likely to find a match within its database. If you only provide general information, such as a name without a domain or email address, you might receive a `200` response, but the response indicates that no records have been enriched. Check `match_confidence` in the response to understand how confidently Apollo matched the submitted information: + + - `high`: Apollo matched the information to a person record. + - `medium`: Apollo identified a likely person using additional contact data. + - `low`: Apollo couldn't match a complete person record, but returned person data assembled from available contact data. + - `none`: Apollo couldn't identify a matching person.

By default, this endpoint doesn't return personal emails or phone numbers. Use the `reveal_personal_emails` and `reveal_phone_number` parameters to retrieve emails and phone numbers.

When you use `reveal_phone_number`, a valid `webhook_url` is required so Apollo can deliver the completed phone enrichment results. Apollo returns the main enrichment response synchronously, then sends the requested phone numbers to the webhook asynchronously. [See webhook details](#webhook-details).

You can also use the `run_waterfall_email` and `run_waterfall_phone` parameters to run waterfall enrichment via this endpoint. [Waterfall enrichment](https://knowledge.apollo.io/hc/en-us/articles/34071089002509-Waterfall-Enrichment-Overview) gives you broader data coverage by checking connected third-party data sources for contact emails and phone numbers.

When you call this endpoint and include at least one waterfall parameter, a valid `webhook_url` is required so Apollo can deliver the completed waterfall enrichment results. Apollo returns an immediate synchronous response with demographic and firmographic data, along with a waterfall enrichment request status. Apollo then delivers enriched emails or phone numbers asynchronously to the configured webhook. + + ### Webhook Details + + - When using Apollo enrichment for phone number reveal, the webhook response follows: [Native webhook response details](doc:retrieve-mobile-phone-numbers-for-contacts#webhook-response). + + - When using waterfall enrichment, the webhook response follows: [Waterfall webhook response details](doc:enrich-phone-and-email-using-data-waterfall#asynchronous-webhook-response-1). + + - You can poll webhook results with the [poll webhook result](ref:poll-webhook-result) endpoint by passing the request_id from the people enrichment response. + + ### Webhook Requirements + + - **HTTPS Required:** Your endpoint must be publicly accessible over HTTPS. + + - **Rate Limiting:** Ensure your webhook endpoint can handle the volume of webhook traffic sent by Apollo. + + - **Idempotency:** Apollo may retry webhook calls; your endpoint should be idempotent to handle duplicate payloads safely. + tags: + - Enrichment + operationId: people-enrichment + parameters: + - name: first_name + in: query + description: 'The first name of the person. This is typically used in combination with the `last_name` parameter.

Example: `jordan`' + schema: + type: string + - name: last_name + in: query + description: 'The last name of the person. This is typically used in combination with the `first_name` parameter.

Example: `blake`' + schema: + type: string + - name: name + in: query + description: 'The full name of the person. This will typically be a first name and last name separated by a space. If you use this parameter, you do not need to use the `first_name` and `last_name` parameters.

Example: `jordan blake`' + schema: + type: string + - name: email + in: query + description: 'The email address of the person.

Example: `jordan@ymail.org`' + schema: + type: string + - name: hashed_email + in: query + description: 'The hashed email of the person. The email should adhere to either the MD5 or SHA-256 hash format.

Example: `8d935115b9ff4489f2d1f9249503cadf` (MD5) or `97817c0c49994eb500ad0a5e7e2d8aed51977b26424d508f66e4e8887746a152` (SHA-256)' + schema: + type: string + - name: organization_name + in: query + description: 'The name of the person''s employer. This can be the current employer or a previous employer.

Example: `northstar analytics`' + schema: + type: string + - name: domain + in: query + description: 'The domain name for the person''s employer. This can be the current employer or a previous employer. Do not include `www.`, the `@` symbol, or similar.

Example: `apollo.io` or `microsoft.com`' + schema: + type: string + - name: id + in: query + description: 'The Apollo ID for the person. Each person in the Apollo database is assigned a unique ID.

To find IDs, call the people API search endpoint and identify the values for `person_id`.

Example: `587cf802f65125cad923a266`' + schema: + type: string + - name: linkedin_url + in: query + description: 'The URL for the person''s LinkedIn profile.

Example: `https://www.linkedin.com/in/jordan-blake-4a7c21`' + schema: + type: string + - name: run_waterfall_email + in: query + description: Set to true to enable email waterfall enrichment + schema: + type: boolean + default: false + - name: run_waterfall_phone + in: query + description: Set to true to enable phone waterfall enrichment + schema: + type: boolean + default: false + - name: reveal_personal_emails + in: query + description: Set to `true` if you want to enrich the person's data with personal emails. This potentially consumes credits as part of your Apollo pricing plan. The default value is `false`.

If a person resides in a GDPR-compliant region, Apollo will not reveal their personal email. + schema: + type: boolean + default: false + - name: reveal_phone_number + in: query + description: Set to `true` if you want to enrich the person's data with all available phone numbers, including mobile phone numbers. This potentially consumes credits as part of your Apollo pricing plan. The default value is `false`.

If this parameter is set to `true`, you must enter a webhook URL for the `webhook_url` parameter. Apollo will asynchronously verify phone numbers for you, then send a JSON response that includes only details about the person's phone numbers to the webhook URL you provide. It can take several minutes for the phone numbers to be delivered. + schema: + type: boolean + default: false + - name: webhook_url + in: query + description: 'If you set the `reveal_phone_number` parameter to `true`, this parameter becomes mandatory. Otherwise, do not use this parameter.

Enter the webhook URL that specifies where Apollo should send a JSON response that includes the phone number you requested. Apollo suggests testing this flow to ensure you receive the separate response with the phone number.

If phone numbers are not delivered to the webhook URL, try applying UTF-8 encoding to the webhook URL.

Example: `https://webhook.site/11f2643a-b1b4-c6be-8e6a-6c7da2c12610`; `https%3A%2F%2Fwebhook.site%2F11f2643a-b1b4-c6be-8e6a-6c7da2c12610`

Do not use this parameter together with `poll_only=true`.' + schema: + type: string + - name: poll_only + in: query + description: Set to `true` to receive phone or waterfall enrichment results by polling instead of a webhook. When `poll_only` is `true`, omit `webhook_url` — Apollo returns a `request_id` you can pass to the poll webhook result endpoint to retrieve the result.

Passing `poll_only=true` together with `webhook_url` returns a `400` error (`WEBHOOK_URL_WITH_POLL_ONLY`). The default value is `false`. + schema: + type: boolean + default: false + responses: + '200': + description: '200' + content: + application/json: + schema: + type: object + properties: + request_id: + type: integer + example: 1039995589705121900 + description: The request ID for the enrichment request. Save this ID to map this request with the corresponding webhook result. + person: + type: object + properties: + id: + type: string + example: 672c91d4a7be42000184f2c9 + first_name: + type: string + example: Jordan + last_name: + type: string + example: Blake + name: + type: string + example: Jordan Blake + linkedin_url: + type: string + example: http://www.linkedin.com/in/jordan-blake-4a7c21 + title: + type: string + example: Founder & CEO + email_status: + type: string + example: verified + photo_url: + type: string + example: https://media.licdn.com/dms/image/v2/D5603AQH9vR4tY2wP7k/profile-displayphoto-shrink_200_200 + twitter_url: {} + github_url: {} + facebook_url: {} + extrapolated_email_confidence: + type: number + nullable: true + headline: + type: string + example: Founder & CEO at Northstar Analytics + email: + type: string + example: jordan.blake@northstaranalytics.io + organization_id: + type: string + example: 61f2d5a8c40b7200019e4f31 + match_confidence: + type: string + enum: + - high + - medium + - low + - none + example: high + employment_history: + type: array + items: + type: object + properties: + _id: + type: string + example: 672c93a1b8df4e0001f732a1 + created_at: {} + current: + type: boolean + example: true + default: true + degree: {} + description: {} + emails: {} + end_date: {} + grade_level: {} + kind: {} + major: {} + organization_id: + type: string + example: 61f2d5a8c40b7200019e4f31 + organization_name: + type: string + example: Northstar Analytics + raw_address: {} + start_date: + type: string + example: '2016-01-01' + title: + type: string + example: Founder & CEO + updated_at: {} + id: + type: string + example: 672c93a1b8df4e0001f732a1 + key: + type: string + example: 672c93a1b8df4e0001f732a1 + state: + type: string + example: California + city: + type: string + example: San Francisco + country: + type: string + example: United States + contact_id: + type: string + example: 672c95e2d2f1140001aa4c18 + contact: + type: object + properties: + contact_roles: + type: array + id: + type: string + example: 672c95e2d2f1140001aa4c18 + first_name: + type: string + example: Casey + last_name: + type: string + example: Morgan + name: + type: string + example: Casey Morgan + linkedin_url: + type: string + example: http://www.linkedin.com/in/jordan-blake-4a7c21 + title: + type: string + example: President + contact_stage_id: + type: string + example: 61f2da03c40b7200019e4f50 + owner_id: {} + creator_id: + type: string + example: 672c9613d2f1140001aa4c22 + person_id: + type: string + example: 672c91d4a7be42000184f2c9 + email_needs_tickling: {} + organization_name: + type: string + example: Northstar Analytics + source: + type: string + example: crm + original_source: + type: string + example: crm + organization_id: + type: string + example: 61f2d5a8c40b7200019e4f31 + headline: + type: string + example: President at Northstar Analytics + photo_url: {} + present_raw_address: + type: string + example: New York, New York, United States + linkedin_uid: {} + extrapolated_email_confidence: + type: number + nullable: true + salesforce_id: {} + salesforce_lead_id: {} + salesforce_contact_id: {} + salesforce_account_id: {} + crm_owner_id: {} + created_at: + type: string + example: '2024-05-23T20:00:28.527Z' + emailer_campaign_ids: + type: array + direct_dial_status: {} + direct_dial_enrichment_failed_at: {} + email_status: + type: string + example: verified + email_source: {} + account_id: + type: string + example: 672c964bd2f1140001aa4c2f + last_activity_date: {} + hubspot_vid: {} + hubspot_company_id: {} + crm_id: {} + sanitized_phone: + type: string + example: '+14155550158' + merged_crm_ids: {} + updated_at: + type: string + example: '2024-06-02T08:53:51.266Z' + queued_for_crm_push: {} + suggested_from_rule_engine_config_id: {} + email_unsubscribed: {} + label_ids: + type: array + has_pending_email_arcgate_request: + type: boolean + example: false + default: true + has_email_arcgate_request: + type: boolean + example: false + default: true + existence_level: + type: string + example: invisible + email: + type: string + example: casey.morgan@northstaranalytics.io + email_from_customer: + type: boolean + example: true + default: true + typed_custom_fields: + type: object + properties: {} + custom_field_errors: {} + crm_record_url: {} + email_status_unavailable_reason: {} + email_true_status: + type: string + example: Verified + updated_email_true_status: + type: boolean + example: false + default: true + contact_rule_config_statuses: + type: array + source_display_name: + type: string + example: Imported from CRM + contact_emails: + type: array + items: + type: object + properties: + email: + type: string + example: casey.morgan@northstaranalytics.io + email_md5: + type: string + example: 9f3b7c2d481a5e6f0d4c8b1a2e7f9c30 + email_sha256: + type: string + example: a6c79e4f2b310d9581f67ac3e4d89b20174f5c6a9d3e2b810f7c4a56e93d128b + email_status: + type: string + example: verified + email_source: {} + extrapolated_email_confidence: + type: number + nullable: true + position: + type: integer + example: 0 + default: 0 + email_from_customer: {} + free_domain: + type: boolean + example: false + default: true + time_zone: + type: string + example: America/Los_Angeles + phone_numbers: + type: array + items: + type: object + properties: + raw_number: + type: string + example: (415) 555-0158 + sanitized_number: + type: string + example: '+14155550158' + type: {} + position: + type: integer + example: 0 + default: 0 + status: + type: string + example: valid_number + dnc_status: {} + dnc_other_info: {} + dialer_flags: {} + account_phone_note: {} + free_domain: + type: boolean + example: false + default: true + is_likely_to_engage: + type: boolean + example: false + default: true + revealed_for_current_team: + type: boolean + example: true + default: true + organization: + type: object + properties: + id: + type: string + example: 61f2d5a8c40b7200019e4f31 + name: + type: string + example: Northstar Analytics + website_url: + type: string + example: https://www.northstaranalytics.io + blog_url: {} + angellist_url: {} + linkedin_url: + type: string + example: https://www.linkedin.com/company/apolloio + twitter_url: + type: string + example: https://x.com/useapolloio + facebook_url: + type: string + example: https://www.facebook.com/example-company + primary_phone: + type: object + properties: {} + languages: + type: array + alexa_ranking: + type: integer + example: 3514 + default: 0 + phone: {} + linkedin_uid: + type: string + example: '18511550' + founded_year: + type: integer + example: 2015 + default: 0 + publicly_traded_symbol: {} + publicly_traded_exchange: {} + logo_url: + type: string + example: https://zenprospect-production.s3.amazonaws.com/uploads/pictures/84acb173f920c1e65a47d920/picture + crunchbase_url: {} + primary_domain: + type: string + example: northstaranalytics.io + industry: + type: string + example: information technology & services + keywords: + type: array + items: + type: string + example: sales engagement + estimated_num_employees: + type: integer + example: 850 + default: 0 + industries: + type: array + items: + type: string + example: information technology & services + secondary_industries: + type: array + snippets_loaded: + type: boolean + example: true + default: true + industry_tag_id: + type: string + example: 61f2dbc2c40b7200019e4f61 + industry_tag_hash: + type: object + properties: + information technology & services: + type: string + example: 61f2dbc2c40b7200019e4f61 + retail_location_count: + type: integer + example: 0 + default: 0 + raw_address: + type: string + example: 220 Market St, Floor 12, San Francisco, California 94105, US + street_address: + type: string + example: 415 Mission St + city: + type: string + example: San Francisco + state: + type: string + example: California + postal_code: + type: string + example: 94105-2301 + country: + type: string + example: United States + owned_by_organization_id: {} + seo_description: + type: string + example: Northstar Analytics provides sales intelligence and engagement software for modern revenue teams. + short_description: + type: string + example: |- + Northstar Analytics combines business data, sales engagement, and automation tools in one platform. Revenue teams use Northstar Analytics to identify prospective buyers, personalize outreach, and manage sales workflows at scale. + + The company serves customers across software, financial services, and professional services. + suborganizations: + type: array + num_suborganizations: + type: integer + example: 0 + default: 0 + annual_revenue_printed: + type: string + example: 85M + annual_revenue: + type: integer + example: 85000000 + default: 0 + total_funding: + type: integer + example: 142000000 + default: 0 + total_funding_printed: + type: string + example: 142M + latest_funding_round_date: + type: string + example: '2023-08-01T00:00:00.000+00:00' + latest_funding_stage: + type: string + example: Series D + funding_events: + type: array + items: + type: object + properties: + id: + type: string + example: 672c978cd2f1140001aa4c51 + date: + type: string + example: '2023-08-01T00:00:00.000+00:00' + news_url: {} + type: + type: string + example: Series D + investors: + type: string + example: Crestline Ventures, Summit Ridge Capital, Harbor Peak Ventures, Northgate Ventures + amount: + type: string + example: 85M + currency: + type: string + example: $ + technology_names: + type: array + items: + type: string + example: AI + current_technologies: + type: array + items: + type: object + properties: + uid: + type: string + example: ai + name: + type: string + example: AI + category: + type: string + example: Other + org_chart_root_people_ids: + type: array + items: + type: string + example: 672c9709d2f1140001aa4c41 + org_chart_sector: + type: string + example: OrgChart::SectorHierarchy::Rules::IT + org_chart_removed: + type: boolean + example: false + default: true + org_chart_show_department_filter: + type: boolean + example: true + default: true + is_likely_to_engage: + type: boolean + example: true + default: true + show_intent: + type: boolean + example: false + default: true + departments: + type: array + items: + type: string + example: c_suite + subdepartments: + type: array + items: + type: string + example: executive + functions: + type: array + items: + type: string + example: entrepreneurship + seniority: + type: string + example: founder + '400': + description: '400' + content: + application/json: + schema: + type: object + properties: + error: + deprecated: true + description: Legacy error field. Removed on 2027-02-16, after which `error_details` is the only error payload. + type: string + example: Do not pass 'webhook_url' with poll_only=true + error_details: + type: object + properties: + code: + type: string + example: SEARCH.VALIDATION.WEBHOOK_URL_WITH_POLL_ONLY + message: + type: string + suggestions: + type: array + items: + type: object + context: + type: object + '401': + description: '401' + content: + text/plain: {} + '429': + description: '429' + content: + application/json: + schema: + type: object + properties: + error_details: + $ref: '#/components/schemas/ErrorDetails' + message: + deprecated: true + description: Legacy error field. Removed on 2027-02-16, after which `error_details` is the only error payload. + type: string + example: The maximum number of api calls allowed for api/v1/people/match is 600 times per hour. Please upgrade your plan from https://app.apollo.io/#/settings/plans/upgrade. + deprecated: false +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: x-api-key + description: |- + [Recommended] API key, passed in the `x-api-key` request header. + See [Create API Keys](https://docs.apollo.io/docs/create-api-key). + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: |- + OAuth 2.0 access token, used by Apollo partners building integrations. + See the [OAuth 2.0 authorization flow](https://docs.apollo.io/docs/use-oauth-20-authorization-flow-to-access-apollo-user-information-partners). + schemas: + ErrorDetails: + type: object + description: Machine-readable detail for an error that uses the new format, returned alongside the endpoint's existing error fields. The legacy error fields at the root level (`error`, `error_code`, `message`, etc) are removed on 2027-02-16, after which `error_details` is the only error payload. Some errors already return `error_details` on its own. + properties: + code: + type: string + description: Stable identifier in the form `DOMAIN.CATEGORY.REASON`. Branch on this value; the human-readable `message` can change. + example: AUTH.AUTHENTICATION.CREDENTIALS_MISSING + message: + type: string + description: Human-readable explanation of what went wrong. + example: This request carried no credential Apollo could authenticate. + suggestions: + type: array + description: Ordered, actionable next steps. Each entry carries a `label`, and where relevant the `parameter`, `header`, or `url` it refers to. + items: + type: object + properties: + label: + type: string + example: Send your API key in the X-Api-Key header + url: + type: string + example: https://docs.apollo.io/reference/authentication + parameter: + type: string + example: webhook_url + parameters: + type: array + description: Several parameters, any one of which satisfies the requirement. + items: + type: string + example: + - name + - domain + - website_url + - linkedin_url + header: + type: string + example: X-Api-Key + example: + type: string + description: A valid sample value for the parameter named above. + retry_after_seconds: + type: integer + description: On a rate-limit refusal, how long to wait before retrying. + example: 60 + context: + type: object + description: Values from your request that explain the refusal. Each entry is an object rather than a bare value, so read `context..value`. Absent when the error needs no context. + additionalProperties: + type: object + properties: + value: + type: string + description: + type: string + example: + window: + value: hourly + description: The rate-limit window your request exceeded diff --git a/generator/sdk/gosdk/testdata/people_match_client.golden.go b/generator/sdk/gosdk/testdata/people_match_client.golden.go new file mode 100644 index 00000000..e23e168c --- /dev/null +++ b/generator/sdk/gosdk/testdata/people_match_client.golden.go @@ -0,0 +1,511 @@ +// Code generated by libopenapi generator/sdk/gosdk. DO NOT EDIT. + +package people + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const defaultMaxResponseBody int64 = 8 << 20 + +// DefaultServer is the first server declared by the OpenAPI document. +const DefaultServer = "https://api.apollo.io/api/v1" + +// HTTPDoer executes HTTP requests. *http.Client implements HTTPDoer. +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +// SecurityScheme is the request placement metadata for an OpenAPI security scheme. +type SecurityScheme struct { + Name string + Type string + In string + ParameterName string + Scheme string +} + +// Credential applies one named security scheme to a request. +type Credential interface { + Apply(context.Context, SecurityScheme, []string, *http.Request) error +} + +// CredentialFunc adapts a function into a Credential. +type CredentialFunc func(context.Context, SecurityScheme, []string, *http.Request) error + +// Apply applies the credential function. +func (f CredentialFunc) Apply(ctx context.Context, scheme SecurityScheme, scopes []string, request *http.Request) error { + return f(ctx, scheme, scopes, request) +} + +// BearerToken returns a credential that writes an Authorization bearer token. +func BearerToken(token string, grantedScopes ...string) Credential { + granted := make(map[string]struct{}, len(grantedScopes)) + for _, scope := range grantedScopes { + granted[scope] = struct{}{} + } + return CredentialFunc(func(_ context.Context, _ SecurityScheme, requiredScopes []string, request *http.Request) error { + for _, scope := range requiredScopes { + if _, ok := granted[scope]; !ok { + return fmt.Errorf("credential is missing required scope %q", scope) + } + } + request.Header.Set("Authorization", "Bearer "+token) + return nil + }) +} + +// APIKey returns a credential that places a key according to the OpenAPI scheme. +func APIKey(value string) Credential { + return CredentialFunc(func(_ context.Context, scheme SecurityScheme, scopes []string, request *http.Request) error { + if len(scopes) > 0 { + return fmt.Errorf("API key credential cannot satisfy OAuth scopes") + } + switch scheme.In { + case "header": + request.Header.Set(scheme.ParameterName, value) + case "query": + query := request.URL.Query() + query.Set(scheme.ParameterName, value) + request.URL.RawQuery = query.Encode() + case "cookie": + request.AddCookie(&http.Cookie{Name: scheme.ParameterName, Value: value}) + default: + return fmt.Errorf("unsupported API key location %q", scheme.In) + } + return nil + }) +} + +// BasicAuth returns an HTTP Basic authentication credential. +func BasicAuth(username, password string) Credential { + return CredentialFunc(func(_ context.Context, _ SecurityScheme, scopes []string, request *http.Request) error { + if len(scopes) > 0 { + return fmt.Errorf("basic authentication credential cannot satisfy OAuth scopes") + } + request.SetBasicAuth(username, password) + return nil + }) +} + +// MutualTLS marks a mutual-TLS scheme as satisfied. Configure the certificate +// on the HTTP transport supplied to NewClient. +func MutualTLS() Credential { + return CredentialFunc(func(_ context.Context, _ SecurityScheme, scopes []string, _ *http.Request) error { + if len(scopes) > 0 { + return fmt.Errorf("mutual TLS credential cannot satisfy OAuth scopes") + } + return nil + }) +} + +// Option configures a Client during construction. +type Option func(*clientConfig) error + +type clientConfig struct { + httpClient HTTPDoer + timeout time.Duration + credentials map[string]Credential + headers http.Header + maxResponseBody int64 +} + +// WithHTTPClient supplies the request executor. The caller owns its redirect, +// timeout, connection, and mutual-TLS policy. +func WithHTTPClient(client HTTPDoer) Option { + return func(config *clientConfig) error { + if client == nil { + return errors.New("HTTP client is required") + } + config.httpClient = client + return nil + } +} + +// WithTimeout sets the timeout on the generated default HTTP client. Supply a +// fully configured client with WithHTTPClient for more control. +func WithTimeout(timeout time.Duration) Option { + return func(config *clientConfig) error { + if timeout <= 0 { + return errors.New("timeout must be positive") + } + config.timeout = timeout + return nil + } +} + +// WithCredential supplies a credential for one named OpenAPI security scheme. +func WithCredential(scheme string, credential Credential) Option { + return func(config *clientConfig) error { + if strings.TrimSpace(scheme) == "" || credential == nil { + return errors.New("security scheme and credential are required") + } + config.credentials[scheme] = credential + return nil + } +} + +// WithDefaultHeader adds a header copied onto every request. +func WithDefaultHeader(name, value string) Option { + return func(config *clientConfig) error { + if strings.TrimSpace(name) == "" { + return errors.New("header name is required") + } + config.headers.Add(name, value) + return nil + } +} + +// WithMaxResponseBody sets the maximum buffered response body size. +func WithMaxResponseBody(bytes int64) Option { + return func(config *clientConfig) error { + if bytes <= 0 { + return errors.New("maximum response body must be positive") + } + config.maxResponseBody = bytes + return nil + } +} + +// RequestOption configures one request without mutating shared client state. +type RequestOption func(*http.Request) error + +// WithRequestHeader sets a header on one request. +func WithRequestHeader(name, value string) RequestOption { + return func(request *http.Request) error { + if strings.TrimSpace(name) == "" { + return errors.New("header name is required") + } + request.Header.Set(name, value) + return nil + } +} + +func applyRequestOptions(request *http.Request, options []RequestOption) error { + for _, option := range options { + if option != nil { + if err := option(request); err != nil { + return err + } + } + } + return nil +} + +// Client is the generated API client. +type Client struct { + baseURL *url.URL + httpClient HTTPDoer + credentials map[string]Credential + headers http.Header + maxResponseBody int64 + People *PeopleResource +} + +// NewClient creates a client for server. Options are applied once; the result +// can be shared by concurrent callers. +func NewClient(server string, options ...Option) (*Client, error) { + if strings.TrimSpace(server) == "" { + server = DefaultServer + } + baseURL, err := url.Parse(server) + if err != nil { + return nil, fmt.Errorf("parse server URL: %w", err) + } + if baseURL.Scheme == "" || baseURL.Host == "" { + return nil, errors.New("server URL must be absolute") + } + config := clientConfig{ + credentials: make(map[string]Credential), + headers: make(http.Header), + maxResponseBody: defaultMaxResponseBody, + } + for _, option := range options { + if option != nil { + if err := option(&config); err != nil { + return nil, err + } + } + } + if config.httpClient == nil { + config.httpClient = safeHTTPClient(config.timeout) + } + client := &Client{ + baseURL: baseURL, httpClient: config.httpClient, + credentials: cloneCredentials(config.credentials), headers: config.headers.Clone(), + maxResponseBody: config.maxResponseBody, + } + client.People = &PeopleResource{client: client} + return client, nil +} + +func safeHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) == 0 || (strings.EqualFold(request.URL.Scheme, via[0].URL.Scheme) && strings.EqualFold(request.URL.Host, via[0].URL.Host)) { + return nil + } + return http.ErrUseLastResponse + }, Timeout: timeout} +} + +func cloneCredentials(source map[string]Credential) map[string]Credential { + result := make(map[string]Credential, len(source)) + for name, credential := range source { + result[name] = credential + } + return result +} + +var securitySchemes = map[string]SecurityScheme{ + "apiKey": {Name: "apiKey", Type: "apiKey", In: "header", ParameterName: "x-api-key", Scheme: ""}, + "bearerAuth": {Name: "bearerAuth", Type: "http", In: "", ParameterName: "", Scheme: "bearer"}, +} + +func (client *Client) newRequest(ctx context.Context, method, path string, query url.Values, body any, contentType string) (*http.Request, error) { + relative, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("parse operation path: %w", err) + } + endpoint := *client.baseURL + escapedPath := strings.TrimRight(endpoint.EscapedPath(), "/") + "/" + strings.TrimLeft(relative.EscapedPath(), "/") + decodedPath, err := url.PathUnescape(escapedPath) + if err != nil { + return nil, fmt.Errorf("decode operation path: %w", err) + } + endpoint.Path = decodedPath + endpoint.RawPath = escapedPath + endpoint.RawQuery = query.Encode() + var bodyReader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + bodyReader = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bodyReader) + if err != nil { + return nil, err + } + request.Header = client.headers.Clone() + if body != nil && contentType != "" { + request.Header.Set("Content-Type", contentType) + } + request.Header.Set("Accept", "application/json") + return request, nil +} + +type securityRequirement struct { + Name string + Scopes []string +} + +func (client *Client) authorize(ctx context.Context, alternatives [][]securityRequirement, request *http.Request) error { + if len(alternatives) == 0 { + return nil + } + var credentialError error + for index, alternative := range alternatives { + if len(alternative) == 0 { + return nil + } + if !client.hasCredentials(alternative) { + continue + } + // A failed Apply can leave partial writes on the request. That only + // matters when a later alternative could still run, so the request is + // cloned for that case alone and credentials are otherwise applied in place. + target := request + if client.hasFallback(alternatives[index+1:]) { + target = request.Clone(ctx) + } + applied := true + for _, requirement := range alternative { + scheme, ok := securitySchemes[requirement.Name] + if !ok { + return fmt.Errorf("security scheme %q is not defined", requirement.Name) + } + if err := client.credentials[requirement.Name].Apply(ctx, scheme, requirement.Scopes, target); err != nil { + credentialError = fmt.Errorf("apply security scheme %q: %w", requirement.Name, err) + applied = false + break + } + } + if !applied { + continue + } + if target != request { + request.Header = target.Header + request.URL = target.URL + } + return nil + } + if credentialError != nil { + return credentialError + } + return errors.New("no complete credential alternative is configured") +} + +func (client *Client) hasCredentials(alternative []securityRequirement) bool { + for _, requirement := range alternative { + if client.credentials[requirement.Name] == nil { + return false + } + } + return true +} + +// hasFallback reports whether any remaining alternative could still authorize +// the request: an empty requirement, or one whose credentials are all configured. +func (client *Client) hasFallback(alternatives [][]securityRequirement) bool { + for _, alternative := range alternatives { + if client.hasCredentials(alternative) { + return true + } + } + return false +} + +func (client *Client) do(request *http.Request, alternatives [][]securityRequirement) (*http.Response, []byte, error) { + if err := client.authorize(request.Context(), alternatives, request); err != nil { + return nil, nil, err + } + response, err := client.httpClient.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + readLimit := client.maxResponseBody + if readLimit < int64(^uint64(0)>>1) { + readLimit++ + } + body, err := readBody(io.LimitReader(response.Body, readLimit), response.ContentLength, client.maxResponseBody) + if err != nil { + return nil, nil, fmt.Errorf("read response body: %w", err) + } + if int64(len(body)) > client.maxResponseBody { + return nil, nil, fmt.Errorf("response body exceeds %d bytes", client.maxResponseBody) + } + return response, body, nil +} + +// readBody buffers a response body. bytes.Buffer.ReadFrom reserves +// bytes.MinRead before every read, including the final one that reports EOF, so +// a declared length is preallocated with that headroom. Without it the buffer +// reallocates anyway and a small body costs more than io.ReadAll, which is the +// path taken when the length is unknown, over the limit, or not addressable. +func readBody(reader io.Reader, contentLength, limit int64) ([]byte, error) { + length := int(contentLength) + if contentLength <= 0 || contentLength > limit || int64(length) != contentLength || length+bytes.MinRead < length { + return io.ReadAll(reader) + } + var buffer bytes.Buffer + buffer.Grow(length + bytes.MinRead) + if _, err := buffer.ReadFrom(reader); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +// Response contains a decoded value and its HTTP metadata. +type Response[T any] struct { + StatusCode int + Header http.Header + Value T +} + +// ResponseDecodeError reports a malformed declared success response while +// preserving the HTTP evidence needed to diagnose the contract violation. +type ResponseDecodeError struct { + StatusCode int + Header http.Header + Body []byte + Cause error +} + +// Error implements error. +func (err *ResponseDecodeError) Error() string { + return fmt.Sprintf("decode successful API response with status %d: %v", err.StatusCode, err.Cause) +} + +// Unwrap returns the response decoding failure. +func (err *ResponseDecodeError) Unwrap() error { + return err.Cause +} + +// APIError is a non-success response. Value contains a typed declared error +// model when the status is described by the OpenAPI document. +type APIError struct { + StatusCode int + Header http.Header + Body []byte + Value any + Cause error +} + +// Error implements error. +func (err *APIError) Error() string { + return fmt.Sprintf("API request failed with status %d", err.StatusCode) +} + +// Unwrap returns a response decoding failure, when one occurred. +func (err *APIError) Unwrap() error { + return err.Cause +} + +// Null returns the explicit-null state for an optional nullable generated field. +func Null[T any]() **T { + var value *T + return &value +} + +// NullableValue returns the present-value state for an optional nullable generated field. +func NullableValue[T any](value T) **T { + inner := &value + return &inner +} + +// decodeResponse decodes a declared JSON success body. A body that does not +// match its declaration returns a ResponseDecodeError with the HTTP evidence. +func decodeResponse[T any](statusCode int, header http.Header, payload []byte) (*Response[T], error) { + result := &Response[T]{StatusCode: statusCode, Header: header} + if err := decodeJSON(payload, &result.Value); err != nil { + return nil, &ResponseDecodeError{StatusCode: statusCode, Header: header, Body: payload, Cause: err} + } + return result, nil +} + +// decodeErrorValue decodes a declared JSON error body into apiError.Value. A +// body that does not match its declaration is reported through Cause. +func decodeErrorValue[T any](apiError *APIError) { + var value T + if err := decodeJSON(apiError.Body, &value); err != nil { + apiError.Cause = fmt.Errorf("decode declared error response: %w", err) + return + } + apiError.Value = &value +} + +func decodeJSON(body []byte, target any) error { + if len(body) == 0 { + return errors.New("decode response body: empty body") + } + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("decode response body: %w", err) + } + return nil +} + +func encodeString[T ~string](value T) string { return string(value) } + +func encodeBoolean[T ~bool](value T) string { return strconv.FormatBool(bool(value)) } diff --git a/generator/sdk/gosdk/testdata/people_match_models.golden.go b/generator/sdk/gosdk/testdata/people_match_models.golden.go new file mode 100644 index 00000000..3716dc04 --- /dev/null +++ b/generator/sdk/gosdk/testdata/people_match_models.golden.go @@ -0,0 +1,306 @@ +// Code generated by libopenapi generator/golang. DO NOT EDIT. + +package people + +import "encoding/json" + +type MatchConfidence string + +const ( + MatchConfidenceHigh MatchConfidence = "high" + MatchConfidenceMedium MatchConfidence = "medium" + MatchConfidenceLow MatchConfidence = "low" + MatchConfidenceNone MatchConfidence = "none" +) + +type EmploymentHistoryItem struct { + UnderscoreID *string `json:"_id,omitempty"` + CreatedAt json.RawMessage `json:"created_at,omitempty"` + Current *bool `json:"current,omitempty"` + Degree json.RawMessage `json:"degree,omitempty"` + Description json.RawMessage `json:"description,omitempty"` + Emails json.RawMessage `json:"emails,omitempty"` + EndDate json.RawMessage `json:"end_date,omitempty"` + GradeLevel json.RawMessage `json:"grade_level,omitempty"` + Kind json.RawMessage `json:"kind,omitempty"` + Major json.RawMessage `json:"major,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + OrganizationName *string `json:"organization_name,omitempty"` + RawAddress json.RawMessage `json:"raw_address,omitempty"` + StartDate *string `json:"start_date,omitempty"` + Title *string `json:"title,omitempty"` + UpdatedAt json.RawMessage `json:"updated_at,omitempty"` + ID *string `json:"id,omitempty"` + Key *string `json:"key,omitempty"` +} + +type ContactEmail struct { + Email *string `json:"email,omitempty"` + EmailMD5 *string `json:"email_md5,omitempty"` + EmailSHA256 *string `json:"email_sha256,omitempty"` + EmailStatus *string `json:"email_status,omitempty"` + EmailSource json.RawMessage `json:"email_source,omitempty"` + ExtrapolatedEmailConfidence *float64 `json:"extrapolated_email_confidence,omitempty"` + Position *int `json:"position,omitempty"` + EmailFromCustomer json.RawMessage `json:"email_from_customer,omitempty"` + FreeDomain *bool `json:"free_domain,omitempty"` +} + +type PhoneNumber struct { + RawNumber *string `json:"raw_number,omitempty"` + SanitizedNumber *string `json:"sanitized_number,omitempty"` + Type json.RawMessage `json:"type,omitempty"` + Position *int `json:"position,omitempty"` + Status *string `json:"status,omitempty"` + DncStatus json.RawMessage `json:"dnc_status,omitempty"` + DncOtherInfo json.RawMessage `json:"dnc_other_info,omitempty"` + DialerFlags json.RawMessage `json:"dialer_flags,omitempty"` +} + +type Contact struct { + ContactRoles []json.RawMessage `json:"contact_roles,omitempty"` + ID *string `json:"id,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + Name *string `json:"name,omitempty"` + LinkedinURL *string `json:"linkedin_url,omitempty"` + Title *string `json:"title,omitempty"` + ContactStageID *string `json:"contact_stage_id,omitempty"` + OwnerID json.RawMessage `json:"owner_id,omitempty"` + CreatorID *string `json:"creator_id,omitempty"` + PersonID *string `json:"person_id,omitempty"` + EmailNeedsTickling json.RawMessage `json:"email_needs_tickling,omitempty"` + OrganizationName *string `json:"organization_name,omitempty"` + Source *string `json:"source,omitempty"` + OriginalSource *string `json:"original_source,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + Headline *string `json:"headline,omitempty"` + PhotoURL json.RawMessage `json:"photo_url,omitempty"` + PresentRawAddress *string `json:"present_raw_address,omitempty"` + LinkedinUID json.RawMessage `json:"linkedin_uid,omitempty"` + ExtrapolatedEmailConfidence *float64 `json:"extrapolated_email_confidence,omitempty"` + SalesforceID json.RawMessage `json:"salesforce_id,omitempty"` + SalesforceLeadID json.RawMessage `json:"salesforce_lead_id,omitempty"` + SalesforceContactID json.RawMessage `json:"salesforce_contact_id,omitempty"` + SalesforceAccountID json.RawMessage `json:"salesforce_account_id,omitempty"` + CrmOwnerID json.RawMessage `json:"crm_owner_id,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + EmailerCampaignIDs []json.RawMessage `json:"emailer_campaign_ids,omitempty"` + DirectDialStatus json.RawMessage `json:"direct_dial_status,omitempty"` + DirectDialEnrichmentFailedAt json.RawMessage `json:"direct_dial_enrichment_failed_at,omitempty"` + EmailStatus *string `json:"email_status,omitempty"` + EmailSource json.RawMessage `json:"email_source,omitempty"` + AccountID *string `json:"account_id,omitempty"` + LastActivityDate json.RawMessage `json:"last_activity_date,omitempty"` + HubspotVid json.RawMessage `json:"hubspot_vid,omitempty"` + HubspotCompanyID json.RawMessage `json:"hubspot_company_id,omitempty"` + CrmID json.RawMessage `json:"crm_id,omitempty"` + SanitizedPhone *string `json:"sanitized_phone,omitempty"` + MergedCrmIDs json.RawMessage `json:"merged_crm_ids,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + QueuedForCrmPush json.RawMessage `json:"queued_for_crm_push,omitempty"` + SuggestedFromRuleEngineConfigID json.RawMessage `json:"suggested_from_rule_engine_config_id,omitempty"` + EmailUnsubscribed json.RawMessage `json:"email_unsubscribed,omitempty"` + LabelIDs []json.RawMessage `json:"label_ids,omitempty"` + HasPendingEmailArcgateRequest *bool `json:"has_pending_email_arcgate_request,omitempty"` + HasEmailArcgateRequest *bool `json:"has_email_arcgate_request,omitempty"` + ExistenceLevel *string `json:"existence_level,omitempty"` + Email *string `json:"email,omitempty"` + EmailFromCustomer *bool `json:"email_from_customer,omitempty"` + TypedCustomFields json.RawMessage `json:"typed_custom_fields,omitempty"` + CustomFieldErrors json.RawMessage `json:"custom_field_errors,omitempty"` + CrmRecordURL json.RawMessage `json:"crm_record_url,omitempty"` + EmailStatusUnavailableReason json.RawMessage `json:"email_status_unavailable_reason,omitempty"` + EmailTrueStatus *string `json:"email_true_status,omitempty"` + UpdatedEmailTrueStatus *bool `json:"updated_email_true_status,omitempty"` + ContactRuleConfigStatuses []json.RawMessage `json:"contact_rule_config_statuses,omitempty"` + SourceDisplayName *string `json:"source_display_name,omitempty"` + ContactEmails []ContactEmail `json:"contact_emails,omitempty"` + TimeZone *string `json:"time_zone,omitempty"` + PhoneNumbers []PhoneNumber `json:"phone_numbers,omitempty"` + AccountPhoneNote json.RawMessage `json:"account_phone_note,omitempty"` + FreeDomain *bool `json:"free_domain,omitempty"` + IsLikelyToEngage *bool `json:"is_likely_to_engage,omitempty"` +} + +type IndustryTagHash struct { + InformationTechnologyServices *string `json:"information technology & services,omitempty"` +} + +type FundingEvent struct { + ID *string `json:"id,omitempty"` + Date *string `json:"date,omitempty"` + NewsURL json.RawMessage `json:"news_url,omitempty"` + Type *string `json:"type,omitempty"` + Investors *string `json:"investors,omitempty"` + Amount *string `json:"amount,omitempty"` + Currency *string `json:"currency,omitempty"` +} + +type CurrentTechnology struct { + UID *string `json:"uid,omitempty"` + Name *string `json:"name,omitempty"` + Category *string `json:"category,omitempty"` +} + +type Organization struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + WebsiteURL *string `json:"website_url,omitempty"` + BlogURL json.RawMessage `json:"blog_url,omitempty"` + AngellistURL json.RawMessage `json:"angellist_url,omitempty"` + LinkedinURL *string `json:"linkedin_url,omitempty"` + TwitterURL *string `json:"twitter_url,omitempty"` + FacebookURL *string `json:"facebook_url,omitempty"` + PrimaryPhone json.RawMessage `json:"primary_phone,omitempty"` + Languages []json.RawMessage `json:"languages,omitempty"` + AlexaRanking *int `json:"alexa_ranking,omitempty"` + Phone json.RawMessage `json:"phone,omitempty"` + LinkedinUID *string `json:"linkedin_uid,omitempty"` + FoundedYear *int `json:"founded_year,omitempty"` + PubliclyTradedSymbol json.RawMessage `json:"publicly_traded_symbol,omitempty"` + PubliclyTradedExchange json.RawMessage `json:"publicly_traded_exchange,omitempty"` + LogoURL *string `json:"logo_url,omitempty"` + CrunchbaseURL json.RawMessage `json:"crunchbase_url,omitempty"` + PrimaryDomain *string `json:"primary_domain,omitempty"` + Industry *string `json:"industry,omitempty"` + Keywords []string `json:"keywords,omitempty"` + EstimatedNumEmployees *int `json:"estimated_num_employees,omitempty"` + Industries []string `json:"industries,omitempty"` + SecondaryIndustries []json.RawMessage `json:"secondary_industries,omitempty"` + SnippetsLoaded *bool `json:"snippets_loaded,omitempty"` + IndustryTagID *string `json:"industry_tag_id,omitempty"` + IndustryTagHash *IndustryTagHash `json:"industry_tag_hash,omitempty"` + RetailLocationCount *int `json:"retail_location_count,omitempty"` + RawAddress *string `json:"raw_address,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Country *string `json:"country,omitempty"` + OwnedByOrganizationID json.RawMessage `json:"owned_by_organization_id,omitempty"` + SeoDescription *string `json:"seo_description,omitempty"` + ShortDescription *string `json:"short_description,omitempty"` + Suborganizations []json.RawMessage `json:"suborganizations,omitempty"` + NumSuborganizations *int `json:"num_suborganizations,omitempty"` + AnnualRevenuePrinted *string `json:"annual_revenue_printed,omitempty"` + AnnualRevenue *int `json:"annual_revenue,omitempty"` + TotalFunding *int `json:"total_funding,omitempty"` + TotalFundingPrinted *string `json:"total_funding_printed,omitempty"` + LatestFundingRoundDate *string `json:"latest_funding_round_date,omitempty"` + LatestFundingStage *string `json:"latest_funding_stage,omitempty"` + FundingEvents []FundingEvent `json:"funding_events,omitempty"` + TechnologyNames []string `json:"technology_names,omitempty"` + CurrentTechnologies []CurrentTechnology `json:"current_technologies,omitempty"` + OrgChartRootPeopleIDs []string `json:"org_chart_root_people_ids,omitempty"` + OrgChartSector *string `json:"org_chart_sector,omitempty"` + OrgChartRemoved *bool `json:"org_chart_removed,omitempty"` + OrgChartShowDepartmentFilter *bool `json:"org_chart_show_department_filter,omitempty"` +} + +type Person struct { + ID *string `json:"id,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + Name *string `json:"name,omitempty"` + LinkedinURL *string `json:"linkedin_url,omitempty"` + Title *string `json:"title,omitempty"` + EmailStatus *string `json:"email_status,omitempty"` + PhotoURL *string `json:"photo_url,omitempty"` + TwitterURL json.RawMessage `json:"twitter_url,omitempty"` + GithubURL json.RawMessage `json:"github_url,omitempty"` + FacebookURL json.RawMessage `json:"facebook_url,omitempty"` + ExtrapolatedEmailConfidence *float64 `json:"extrapolated_email_confidence,omitempty"` + Headline *string `json:"headline,omitempty"` + Email *string `json:"email,omitempty"` + OrganizationID *string `json:"organization_id,omitempty"` + MatchConfidence *MatchConfidence `json:"match_confidence,omitempty"` + EmploymentHistory []EmploymentHistoryItem `json:"employment_history,omitempty"` + State *string `json:"state,omitempty"` + City *string `json:"city,omitempty"` + Country *string `json:"country,omitempty"` + ContactID *string `json:"contact_id,omitempty"` + Contact *Contact `json:"contact,omitempty"` + RevealedForCurrentTeam *bool `json:"revealed_for_current_team,omitempty"` + Organization *Organization `json:"organization,omitempty"` + IsLikelyToEngage *bool `json:"is_likely_to_engage,omitempty"` + ShowIntent *bool `json:"show_intent,omitempty"` + Departments []string `json:"departments,omitempty"` + Subdepartments []string `json:"subdepartments,omitempty"` + Functions []string `json:"functions,omitempty"` + Seniority *string `json:"seniority,omitempty"` +} + +// PeopleMatchResponse is the 200 response body of [PeopleResource.Match]. +type PeopleMatchResponse struct { + // The request ID for the enrichment request. Save this ID to map this request + // with the corresponding webhook result. + RequestID *int `json:"request_id,omitempty"` + Person *Person `json:"person,omitempty"` +} + +type PeopleMatchBadRequestErrorDetails struct { + Code *string `json:"code,omitempty"` + Message *string `json:"message,omitempty"` + Suggestions []json.RawMessage `json:"suggestions,omitempty"` + Context json.RawMessage `json:"context,omitempty"` +} + +// PeopleMatchBadRequest is the 400 response body of [PeopleResource.Match]. +type PeopleMatchBadRequest struct { + // Legacy error field. Removed on 2027-02-16, after which `error_details` is + // the only error payload. + // + // Deprecated: the API marks this as deprecated. + Error *string `json:"error,omitempty"` + ErrorDetails *PeopleMatchBadRequestErrorDetails `json:"error_details,omitempty"` +} + +// PeopleMatchTooManyRequests is the 429 response body of +// [PeopleResource.Match]. +type PeopleMatchTooManyRequests struct { + ErrorDetails *ErrorDetails `json:"error_details,omitempty"` + // Legacy error field. Removed on 2027-02-16, after which `error_details` is + // the only error payload. + // + // Deprecated: the API marks this as deprecated. + Message *string `json:"message,omitempty"` +} + +type ErrorDetailsSuggestion struct { + Label *string `json:"label,omitempty"` + URL *string `json:"url,omitempty"` + Parameter *string `json:"parameter,omitempty"` + // Several parameters, any one of which satisfies the requirement. + Parameters []string `json:"parameters,omitempty"` + Header *string `json:"header,omitempty"` + // A valid sample value for the parameter named above. + Example *string `json:"example,omitempty"` + // On a rate-limit refusal, how long to wait before retrying. + RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"` +} + +type ErrorDetailsContextValue struct { + Value *string `json:"value,omitempty"` + Description *string `json:"description,omitempty"` +} + +// ErrorDetails Machine-readable detail for an error that uses the new format, +// returned alongside the endpoint's existing error fields. The legacy error +// fields at the root level (`error`, `error_code`, `message`, etc) are removed +// on 2027-02-16, after which `error_details` is the only error payload. Some +// errors already return `error_details` on its own. +type ErrorDetails struct { + // Stable identifier in the form `DOMAIN.CATEGORY.REASON`. Branch on this + // value; the human-readable `message` can change. + Code *string `json:"code,omitempty"` + // Human-readable explanation of what went wrong. + Message *string `json:"message,omitempty"` + // Ordered, actionable next steps. Each entry carries a `label`, and where + // relevant the `parameter`, `header`, or `url` it refers to. + Suggestions []ErrorDetailsSuggestion `json:"suggestions,omitempty"` + // Values from your request that explain the refusal. Each entry is an object + // rather than a bare value, so read `context..value`. Absent when the + // error needs no context. + Context map[string]ErrorDetailsContextValue `json:"context,omitempty"` +} diff --git a/generator/sdk/gosdk/testdata/people_match_resources.golden.go b/generator/sdk/gosdk/testdata/people_match_resources.golden.go new file mode 100644 index 00000000..d14f3334 --- /dev/null +++ b/generator/sdk/gosdk/testdata/people_match_resources.golden.go @@ -0,0 +1,218 @@ +// Code generated by libopenapi generator/sdk/gosdk. DO NOT EDIT. + +package people + +import ( + "context" + "errors" + "net/url" +) + +// PeopleResource provides operations for the People resource. +type PeopleResource struct { + client *Client +} + +// PeopleMatchParams holds the parameters for [PeopleResource.Match]. +type PeopleMatchParams struct { + // The first name of the person. This is typically used in combination with the + // `last_name` parameter. + // + // Example: `jordan` + FirstName *string + + // The last name of the person. This is typically used in combination with the + // `first_name` parameter. + // + // Example: `blake` + LastName *string + + // The full name of the person. This will typically be a first name and last + // name separated by a space. If you use this parameter, you do not need to use + // the `first_name` and `last_name` parameters. + // + // Example: `jordan blake` + Name *string + + // The email address of the person. + // + // Example: `jordan@ymail.org` + Email *string + + // The hashed email of the person. The email should adhere to either the MD5 or + // SHA-256 hash format. + // + // Example: `8d935115b9ff4489f2d1f9249503cadf` (MD5) or + // `97817c0c49994eb500ad0a5e7e2d8aed51977b26424d508f66e4e8887746a152` (SHA-256) + HashedEmail *string + + // The name of the person's employer. This can be the current employer or a + // previous employer. + // + // Example: `northstar analytics` + OrganizationName *string + + // The domain name for the person's employer. This can be the current employer + // or a previous employer. Do not include `www.`, the `@` symbol, or similar. + // + // Example: `apollo.io` or `microsoft.com` + Domain *string + + // The Apollo ID for the person. Each person in the Apollo database is assigned + // a unique ID. + // + // To find IDs, call the people API search endpoint + // (https://docs.apollo.io/reference/people-api-search) and identify the values + // for `person_id`. + // + // Example: `587cf802f65125cad923a266` + ID *string + + // The URL for the person's LinkedIn profile. + // + // Example: `https://www.linkedin.com/in/jordan-blake-4a7c21` + LinkedinURL *string + + // Set to true to enable email waterfall enrichment. + RunWaterfallEmail *bool + + // Set to true to enable phone waterfall enrichment. + RunWaterfallPhone *bool + + // Set to `true` if you want to enrich the person's data with personal emails. + // This potentially consumes credits as part of your Apollo pricing plan + // (https://docs.apollo.io/docs/api-pricing). The default value is `false`. + // + // If a person resides in a GDPR + // (https://knowledge.apollo.io/hc/en-us/articles/4409141087757)-compliant + // region, Apollo will not reveal their personal email. + RevealPersonalEmails *bool + + // Set to `true` if you want to enrich the person's data with all available + // phone numbers, including mobile phone numbers. This potentially consumes + // credits as part of your Apollo pricing plan + // (https://docs.apollo.io/docs/api-pricing). The default value is `false`. + // + // If this parameter is set to `true`, you must enter a webhook URL for the + // `webhook_url` parameter. Apollo will asynchronously verify phone numbers for + // you, then send a JSON response that includes only details about the person's + // phone numbers to the webhook URL you provide. It can take several minutes + // for the phone numbers to be delivered. + RevealPhoneNumber *bool + + // If you set the `reveal_phone_number` parameter to `true`, this parameter + // becomes mandatory. Otherwise, do not use this parameter. + // + // Enter the webhook URL that specifies where Apollo should send a JSON + // response that includes the phone number you requested. Apollo suggests + // testing this flow to ensure you receive the separate response with the phone + // number. + // + // If phone numbers are not delivered to the webhook URL, try applying UTF-8 + // encoding to the webhook URL. + // + // Example: `https://webhook.site/11f2643a-b1b4-c6be-8e6a-6c7da2c12610`; + // `https%3A%2F%2Fwebhook.site%2F11f2643a-b1b4-c6be-8e6a-6c7da2c12610` + // + // Do not use this parameter together with `poll_only=true`. + WebhookURL *string + + // Set to `true` to receive phone or waterfall enrichment results by polling + // instead of a webhook. When `poll_only` is `true`, omit `webhook_url` — + // Apollo returns a `request_id` you can pass to the poll webhook result + // (https://docs.apollo.io/reference/poll-webhook-result) endpoint to retrieve + // the result. + // + // Passing `poll_only=true` together with `webhook_url` returns a `400` error + // (`WEBHOOK_URL_WITH_POLL_ONLY`). The default value is `false`. + PollOnly *bool +} + +var securityPeopleMatch = [][]securityRequirement{ + {{Name: "apiKey"}}, + {{Name: "bearerAuth"}}, +} + +// Match calls POST /people/match (people-enrichment). +// +// People enrichment. +// +// A declared error response returns an [APIError] whose Value is +// *PeopleMatchBadRequest (400) or *PeopleMatchTooManyRequests (429). +func (resource *PeopleResource) Match(ctx context.Context, input *PeopleMatchParams, requestOptions ...RequestOption) (*Response[PeopleMatchResponse], error) { + if resource == nil || resource.client == nil { + return nil, errors.New("people-enrichment: client is required") + } + if input == nil { + input = &PeopleMatchParams{} + } + query := make(url.Values) + if input.FirstName != nil { + query.Add("first_name", encodeString(*input.FirstName)) + } + if input.LastName != nil { + query.Add("last_name", encodeString(*input.LastName)) + } + if input.Name != nil { + query.Add("name", encodeString(*input.Name)) + } + if input.Email != nil { + query.Add("email", encodeString(*input.Email)) + } + if input.HashedEmail != nil { + query.Add("hashed_email", encodeString(*input.HashedEmail)) + } + if input.OrganizationName != nil { + query.Add("organization_name", encodeString(*input.OrganizationName)) + } + if input.Domain != nil { + query.Add("domain", encodeString(*input.Domain)) + } + if input.ID != nil { + query.Add("id", encodeString(*input.ID)) + } + if input.LinkedinURL != nil { + query.Add("linkedin_url", encodeString(*input.LinkedinURL)) + } + if input.RunWaterfallEmail != nil { + query.Add("run_waterfall_email", encodeBoolean(*input.RunWaterfallEmail)) + } + if input.RunWaterfallPhone != nil { + query.Add("run_waterfall_phone", encodeBoolean(*input.RunWaterfallPhone)) + } + if input.RevealPersonalEmails != nil { + query.Add("reveal_personal_emails", encodeBoolean(*input.RevealPersonalEmails)) + } + if input.RevealPhoneNumber != nil { + query.Add("reveal_phone_number", encodeBoolean(*input.RevealPhoneNumber)) + } + if input.WebhookURL != nil { + query.Add("webhook_url", encodeString(*input.WebhookURL)) + } + if input.PollOnly != nil { + query.Add("poll_only", encodeBoolean(*input.PollOnly)) + } + request, err := resource.client.newRequest(ctx, "POST", "/people/match", query, nil, "") + if err != nil { + return nil, err + } + if err := applyRequestOptions(request, requestOptions); err != nil { + return nil, err + } + response, payload, err := resource.client.do(request, securityPeopleMatch) + if err != nil { + return nil, err + } + header := response.Header.Clone() + if response.StatusCode == 200 { + return decodeResponse[PeopleMatchResponse](response.StatusCode, header, payload) + } + apiError := &APIError{StatusCode: response.StatusCode, Header: header, Body: payload} + switch response.StatusCode { + case 400: + decodeErrorValue[PeopleMatchBadRequest](apiError) + case 429: + decodeErrorValue[PeopleMatchTooManyRequests](apiError) + } + return nil, apiError +} diff --git a/generator/sdk/gosdk/view.go b/generator/sdk/gosdk/view.go index d3e81b1b..52af9cb9 100644 --- a/generator/sdk/gosdk/view.go +++ b/generator/sdk/gosdk/view.go @@ -10,6 +10,9 @@ type clientView struct { Resources []resourceView Workflows []workflowView HasQueryParams bool + // Uses names the runtime helpers that generated code calls, so the client + // declares only those. + Uses map[string]bool } type securitySchemeView struct { @@ -29,20 +32,26 @@ type resourceView struct { type operationView struct { ID string ResourceName string + ResourceType string MethodName string + Comment string ParamsType string - Summary string HTTPMethod string Path string Parameters []parameterView + Fields []fieldView HasParameters bool HasQueryParams bool + HasPathParams bool Body *bodyView ResponseType string SuccessStatuses []string SuccessCondition string DecodeCondition string - ErrorResponses []errorResponseView + DecodeAll bool + ErrorCases []errorResponseView + ErrorSwitch string + ErrorDoc []string Security [][]securityRequirementView } @@ -54,8 +63,8 @@ type securityRequirementView struct { type workflowView struct { ID string MethodName string + Comment string InputType string - Summary string ResourceName string OperationMethod string OperationParams string @@ -88,6 +97,16 @@ type parameterView struct { Array bool } +// fieldView is one field of an operation's parameters struct. Comment holds +// its rendered doc comment, and Spaced puts a blank line before it. +type fieldView struct { + Name string + Type string + Doc string + Comment string + Spaced bool +} + type bodyView struct { Type string FieldType string @@ -96,8 +115,11 @@ type bodyView struct { Description string } +// errorResponseView is one case of the switch that decodes typed error +// responses. Case is its condition; Default marks the default response. type errorResponseView struct { - Status string - Type string - Condition string + Statuses []string + Type string + Case string + Default bool } diff --git a/generator/sdk/model.go b/generator/sdk/model.go index c49a4c09..a5819da9 100644 --- a/generator/sdk/model.go +++ b/generator/sdk/model.go @@ -51,12 +51,16 @@ type PayloadBinding struct { } // Operation is one effective OpenAPI operation after path-level inheritance. +// Resource and Name are the public resource and method names. Named reports +// that the caller chose the method name through PrepareOptions.Names, so +// emitters may name generated types after it instead of the operationId. type Operation struct { ID string Method string Path string Resource string Name string + Named bool Summary string Description string Servers []string diff --git a/generator/sdk/prepare.go b/generator/sdk/prepare.go index 0cc3ee56..d457a97e 100644 --- a/generator/sdk/prepare.go +++ b/generator/sdk/prepare.go @@ -102,11 +102,12 @@ func prepareOperation(document *highv3.Document, documentServers []string, item resource = override.Resource } name := methodSeed(operation.OperationId, resource) - if strings.TrimSpace(override.Method) != "" { + named := strings.TrimSpace(override.Method) != "" + if named { name = override.Method } prepared := &Operation{ - ID: operation.OperationId, Method: method, Path: path, Resource: resource, Name: name, + ID: operation.OperationId, Method: method, Path: path, Resource: resource, Name: name, Named: named, Summary: operation.Summary, Description: operation.Description, Parameters: mergeParameters(item.Parameters, operation.Parameters), RequestBody: prepareRequestBody(operation.RequestBody), diff --git a/generator/sdk/prepare_test.go b/generator/sdk/prepare_test.go index 78f554cd..66b53bf6 100644 --- a/generator/sdk/prepare_test.go +++ b/generator/sdk/prepare_test.go @@ -27,8 +27,8 @@ func TestPrepareInheritanceSelectionAndSecurity(t *testing.T) { t.Fatalf("got %d operations", len(contract.Operations)) } operation := contract.Operations[0] - if operation.Resource != "Widgets" || operation.Name != "list" { - t.Fatalf("unexpected public name: %s.%s", operation.Resource, operation.Name) + if operation.Resource != "Widgets" || operation.Name != "list" || operation.Named { + t.Fatalf("unexpected public name: %s.%s named=%v", operation.Resource, operation.Name, operation.Named) } if len(operation.Parameters) != 2 || operation.Parameters[0].Name != "tenantId" || operation.Parameters[1].Name != "limit" { t.Fatalf("unexpected effective parameters: %#v", operation.Parameters) @@ -38,6 +38,36 @@ func TestPrepareInheritanceSelectionAndSecurity(t *testing.T) { } } +func TestPrepareRecordsCallerChosenMethodNames(t *testing.T) { + document, err := libopenapi.NewDocument([]byte(prepareFixture)) + if err != nil { + t.Fatal(err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name OperationName + named bool + }{ + {OperationName{Resource: "Inventory"}, false}, + {OperationName{Method: "Browse"}, true}, + {OperationName{Resource: "Inventory", Method: " "}, false}, + } { + contract, err := Prepare(&model.Model, PrepareOptions{ + Operations: []string{"listWidgets"}, + Names: map[string]OperationName{"listWidgets": test.name}, + }) + if err != nil { + t.Fatal(err) + } + if got := contract.Operations[0].Named; got != test.named { + t.Fatalf("%#v: Named = %v, want %v", test.name, got, test.named) + } + } +} + func TestPrepareExplicitEmptySecurityOverridesDocument(t *testing.T) { document, err := libopenapi.NewDocument([]byte(prepareFixture)) if err != nil { diff --git a/generator/typescript/generator.go b/generator/typescript/generator.go index a1d4aa47..f295872e 100644 --- a/generator/typescript/generator.go +++ b/generator/typescript/generator.go @@ -104,7 +104,7 @@ func (g *Generator) RenderSchemas(schemas *orderedmap.Map[string, *highbase.Sche if err != nil { return nil, err } - r := &render{names: make(map[string]string), components: make(map[string]*golang.SchemaIR), siblings: make(map[*golang.SchemaIR]*orderedmap.Map[string, *golang.SchemaIR])} + r := &render{names: make(map[string]string), components: make(map[string]*golang.SchemaIR)} r.forwardChildSchemaDiagnostics(irDiagnostics) var componentNames []string if schemas != nil { @@ -188,7 +188,6 @@ func verbatim(name string) string { return name } type render struct { names map[string]string components map[string]*golang.SchemaIR - siblings map[*golang.SchemaIR]*orderedmap.Map[string, *golang.SchemaIR] diagnostics []Diagnostic } @@ -321,7 +320,7 @@ func (r *render) pointerTarget(ir *golang.SchemaIR, segments []string) (*golang. return nil, false } i++ - ir, _ = r.properties(ir).Get(segments[i]) + ir, _ = properties(ir).Get(segments[i]) case "items": ir = ir.Items case "additionalProperties": @@ -407,7 +406,7 @@ func (r *render) arrayExpr(ir *golang.SchemaIR, indent string) string { // object literal but not through Record, so recursive maps such as a JSON value // type would not compile. func (r *render) objectExpr(ir *golang.SchemaIR, indent string) string { - if r.properties(ir).Len() > 0 || len(ir.Required) > 0 { + if properties(ir).Len() > 0 || len(ir.Required) > 0 { return r.objectBody(ir, indent, false) } switch { @@ -421,38 +420,13 @@ func (r *render) objectExpr(ir *golang.SchemaIR, indent string) string { return "{ [key: string]: unknown }" } -// properties returns the declared properties of ir. For an object built from -// allOf that also declares properties beside it, the shared IR keeps only the -// members' properties, so the sibling ones are built here and appended. -func (r *render) properties(ir *golang.SchemaIR) *orderedmap.Map[string, *golang.SchemaIR] { - if cached, ok := r.siblings[ir]; ok { - return cached +// properties returns the declared properties of ir, including those declared +// beside allOf, which the shared IR gathers with its members' properties. +func properties(ir *golang.SchemaIR) *orderedmap.Map[string, *golang.SchemaIR] { + if ir.Properties == nil { + return orderedmap.New[string, *golang.SchemaIR]() } - props := orderedmap.New[string, *golang.SchemaIR]() - if ir.Properties != nil { - for name, prop := range ir.Properties.FromOldest() { - props.Set(name, prop) - } - } - if schema := ir.SourceSchema; schema != nil && len(schema.AllOf) > 0 && schema.Properties != nil && schema.Properties.Len() > 0 { - siblings, diagnostics, err := golang.NewGenerator(golang.WithTypeNameResolver(verbatim)).SchemaIRs(schema.Properties) - r.forwardChildSchemaDiagnostics(diagnostics) - if err != nil { - r.diagnostics = append(r.diagnostics, Diagnostic{ - Code: golang.DiagnosticChildSchema, - Path: ir.Name, - Message: "properties declared beside allOf could not be built and were left out: " + err.Error(), - }) - siblings = nil - } - for i, name := range slices.Collect(schema.Properties.KeysFromOldest()) { - if _, declared := props.Get(name); !declared && i < len(siblings) { - props.Set(name, siblings[i]) - } - } - } - r.siblings[ir] = props - return props + return ir.Properties } func hasPatternProperties(ir *golang.SchemaIR) bool { @@ -473,7 +447,7 @@ func (r *render) objectBody(ir *golang.SchemaIR, indent string, unionSiblings bo var b strings.Builder b.WriteString("{\n") declared := make(map[string]struct{}) - for name, prop := range r.properties(ir).FromOldest() { + for name, prop := range properties(ir).FromOldest() { declared[name] = struct{}{} writeDoc(&b, prop, inner) b.WriteString(inner + propertyKey(name)) @@ -523,7 +497,7 @@ func (r *render) allOfExpr(ir *golang.SchemaIR, indent string) string { } parts = append(parts, parenthesizeUnion(part)) } - if r.properties(ir).Len() > 0 || len(ir.Required) > 0 { + if properties(ir).Len() > 0 || len(ir.Required) > 0 { parts = append(parts, r.objectBody(ir, indent, true)) } if len(parts) == 0 { diff --git a/generator/typescript/regression_test.go b/generator/typescript/regression_test.go index 8785ab78..0342c38c 100644 --- a/generator/typescript/regression_test.go +++ b/generator/typescript/regression_test.go @@ -178,22 +178,21 @@ func TestRegressionShapes(t *testing.T) { t.Errorf("missing %q in:\n%s", want, source) } } - // Nested build failures are reported for component properties and for - // properties declared beside allOf; a sibling set that cannot be built - // at all is reported and left out. + // Nested build failures are reported and render as unknown, whether the + // property belongs to an object or is declared beside allOf. paths := map[string]bool{} for _, d := range file.Diagnostics { if d.Code == golang.DiagnosticChildSchema { paths[d.Path] = true } } - for _, want := range []string{"Broken.c", "extra.c", "Unbuildable"} { + for _, want := range []string{"Broken.c", "Extended.extra.c", "Unbuildable.missing"} { if !paths[want] { t.Errorf("missing %s diagnostic for %s: %+v", golang.DiagnosticChildSchema, want, file.Diagnostics) } } - if strings.Contains(source, "missing?:") { - t.Errorf("unbuildable sibling property rendered:\n%s", source) + if !strings.Contains(source, "export type Unbuildable = Base & {\n missing?: unknown;\n};") { + t.Errorf("unbuildable sibling property was not rendered as unknown:\n%s", source) } } diff --git a/generator/typescript/review_test.go b/generator/typescript/review_test.go index a3175d2d..20922b08 100644 --- a/generator/typescript/review_test.go +++ b/generator/typescript/review_test.go @@ -104,7 +104,7 @@ func TestSchemaShapesFromReview(t *testing.T) { func TestPointerTargetRejectsPathsTheIRCannotFollow(t *testing.T) { object := &golang.SchemaIR{Kind: golang.KindObject, Properties: orderedmap.New[string, *golang.SchemaIR]()} object.Properties.Set("name", &golang.SchemaIR{Kind: golang.KindString}) - r := &render{siblings: map[*golang.SchemaIR]*orderedmap.Map[string, *golang.SchemaIR]{}} + r := &render{} for name, tc := range map[string]struct { ir *golang.SchemaIR segments []string