From 75f0225151094c727750904bd3ff968e8c710811 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:20 +0100 Subject: [PATCH 01/18] fix(gen): resolve operation security from its scheme and the document The parser recorded only a security scheme's name and scopes, so the templates never saw its type or key location and rendered no auth at all. Each operation's auth methods now carry the scheme from components, an operation without its own security takes the document's, and "security: []" opts out. Scopes carry -last, as the mustache engine has no {{^-last}}, so typescript-fetch lists them without a trailing comma. The auth blocks of typescript-fetch and dart-fetch, rendered for the first time, are indented as the code around them, and dart-fetch sends an API key that lives in a cookie. --- internal/gen/spec.go | 56 +++++++++++++++++++ internal/gen/spec_test.go | 69 ++++++++++++++++++++++++ internal/parser/operation.go | 3 +- templates/dart-fetch/apis.mustache | 13 +++-- templates/typescript-fetch/apis.mustache | 20 +++---- 5 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 internal/gen/spec_test.go diff --git a/internal/gen/spec.go b/internal/gen/spec.go index abbb86e..b5a2930 100644 --- a/internal/gen/spec.go +++ b/internal/gen/spec.go @@ -2,9 +2,12 @@ package gen import ( "fmt" + "maps" "os" + "slices" "strings" + "github.com/getkin/kin-openapi/openapi3" "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/parser" ) @@ -63,6 +66,8 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { return nil, fmt.Errorf("failed to get security schemes: %w", err) } + resolveSecurity(p.Doc, operationsByTag, securitySchemes) + if opts.Verbose { fmt.Printf("Found %d models\n", len(models)) @@ -105,6 +110,57 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { }, nil } +// resolveSecurity completes each operation's auth methods. The parser +// records only a scheme's name and the operation's scopes: the type, the +// key's name and location come from the scheme in components. An operation +// that declares no security takes the document's; "security: []" is none. +func resolveSecurity(doc *openapi3.T, operationsByTag map[string][]*generator.CodegenOperation, schemes []*generator.CodegenSecurity) { + byName := make(map[string]*generator.CodegenSecurity, len(schemes)) + for _, s := range schemes { + byName[s.Name] = s + } + + for _, ops := range operationsByTag { + for _, op := range ops { + if item := doc.Paths.Value(op.Path); item != nil { + if src := item.GetOperation(op.HttpMethod); src != nil && src.Security == nil { + op.AuthMethods = securityRequirements(doc.Security) + } + } + + for i, a := range op.AuthMethods { + if s := byName[a.Name]; s != nil { + full := *s + full.Scopes = a.Scopes + op.AuthMethods[i] = &full + } + } + + op.HasAuthMethods = len(op.AuthMethods) > 0 + } + } +} + +// securityRequirements lists the schemes of requirements with their scopes, +// names in sorted order within each requirement, as the parser does for an +// operation's own. +func securityRequirements(reqs openapi3.SecurityRequirements) []*generator.CodegenSecurity { + var out []*generator.CodegenSecurity + + for _, req := range reqs { + for _, name := range slices.Sorted(maps.Keys(req)) { + sec := &generator.CodegenSecurity{Name: name, Scopes: make([]map[string]any, len(req[name]))} + for i, scope := range req[name] { + sec.Scopes[i] = map[string]any{"scope": scope, "-last": i == len(req[name])-1} + } + + out = append(out, sec) + } + } + + return out +} + // loadSpec loads inputSpec into p, treating an http(s) prefix as a remote // URL and anything else as a local file path. func loadSpec(p *parser.Parser, inputSpec string) error { diff --git a/internal/gen/spec_test.go b/internal/gen/spec_test.go new file mode 100644 index 0000000..8536786 --- /dev/null +++ b/internal/gen/spec_test.go @@ -0,0 +1,69 @@ +package gen + +import ( + "testing" + + "github.com/xseman/openapi-generator/internal/generator/typescript" +) + +// TestResolveSecurity checks that an operation's auth methods carry their +// scheme's type and key, and that one without security of its own takes the +// document's while "security: []" opts out. +func TestResolveSecurity(t *testing.T) { + spec := writeSpec(t, "spec.yaml", `openapi: 3.0.0 +info: {title: t, version: "1"} +security: [{api_key: []}] +paths: + /inherits: + get: + operationId: inherits + responses: {"204": {description: ok}} + /none: + get: + operationId: none + security: [] + responses: {"204": {description: ok}} + /own: + get: + operationId: own + security: [{oauth: [read]}] + responses: {"204": {description: ok}} +components: + securitySchemes: + api_key: {type: apiKey, in: header, name: X-API-Key} + oauth: + type: oauth2 + flows: {implicit: {authorizationUrl: "https://example.com", scopes: {read: r, write: w}}} +`) + + data, err := assembleSpec(typescript.NewFetchGenerator(), Options{InputSpec: spec}) + if err != nil { + t.Fatal(err) + } + + ops := map[string]int{} + for _, op := range data.operationsByTag["default"] { + ops[op.OperationId]++ + + switch op.OperationId { + case "inherits": + if len(op.AuthMethods) != 1 || !op.AuthMethods[0].IsApiKey || !op.AuthMethods[0].IsKeyInHeader || op.AuthMethods[0].KeyParamName != "X-API-Key" { + t.Errorf("inherits auth = %+v, want the api_key header scheme", op.AuthMethods) + } + + case "none": + if len(op.AuthMethods) != 0 || op.HasAuthMethods { + t.Errorf("none auth = %+v, want none", op.AuthMethods) + } + + case "own": + if len(op.AuthMethods) != 1 || !op.AuthMethods[0].IsOAuth || len(op.AuthMethods[0].Scopes) != 1 || op.AuthMethods[0].Scopes[0]["scope"] != "read" { + t.Errorf("own auth = %+v, want oauth with the operation's scope read", op.AuthMethods) + } + } + } + + if len(ops) != 3 { + t.Errorf("operations = %v, want inherits, none, own", ops) + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 9872696..2e6ea4f 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -224,8 +224,9 @@ func (p *Parser) operationToCodegen(path, method string, op *openapi3.Operation, Name: name, Scopes: make([]map[string]any, len(scopes)), } + // -last for {{^-last}}, which the mustache engine lacks. for i, scope := range scopes { - sec.Scopes[i] = map[string]any{"scope": scope} + sec.Scopes[i] = map[string]any{"scope": scope, "-last": i == len(scopes)-1} } co.AuthMethods = append(co.AuthMethods, sec) diff --git a/templates/dart-fetch/apis.mustache b/templates/dart-fetch/apis.mustache index cba3fae..a09a3aa 100644 --- a/templates/dart-fetch/apis.mustache +++ b/templates/dart-fetch/apis.mustache @@ -56,9 +56,16 @@ class {{classname}} extends BaseApi { if (apiKey != null) { final v = apiKey('{{keyParamName}}'); if (v.isNotEmpty) { -{{#isKeyInHeader}} headers['{{keyParamName}}'] = v; -{{/isKeyInHeader}}{{#isKeyInQuery}} queryParams['{{keyParamName}}'] = [v]; -{{/isKeyInQuery}} } +{{#isKeyInHeader}} + headers['{{keyParamName}}'] = v; +{{/isKeyInHeader}} +{{#isKeyInQuery}} + queryParams['{{keyParamName}}'] = [v]; +{{/isKeyInQuery}} +{{#isKeyInCookie}} + headers.update('Cookie', (c) => '$c; {{keyParamName}}=$v', ifAbsent: () => '{{keyParamName}}=$v'); +{{/isKeyInCookie}} + } } } {{/isApiKey}} diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index 7fd6557..fc8edd3 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -184,29 +184,29 @@ export class {{classname}} extends runtime.BaseAPI { {{/jsonHeaderUsesModelSerializer}} {{/isArray}}{{/headerParams}}{{#authMethods}}{{#isBasic}}{{#isBasicBasic}} if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) { - headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); + headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); } {{/isBasicBasic}}{{#isBasicBearer}} if (this.configuration && this.configuration.accessToken) { - const token = this.configuration.accessToken; - const tokenString = await token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); + const token = this.configuration.accessToken; + const tokenString = await token("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); - if (tokenString) { - headerParameters["Authorization"] = `Bearer ${tokenString}`; - } + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } } {{/isBasicBearer}}{{/isBasic}}{{#isApiKey}}{{#isKeyInHeader}} if (this.configuration && this.configuration.apiKey) { - headerParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + headerParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication } {{/isKeyInHeader}}{{#isKeyInQuery}} if (this.configuration && this.configuration.apiKey) { - queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication + queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication } {{/isKeyInQuery}}{{/isApiKey}}{{#isOAuth}} if (this.configuration && this.configuration.accessToken) { - // oauth required - headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); + // oauth required + headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); } {{/isOAuth}}{{/authMethods}}{{#hasFormParams}} const consumes: runtime.Consume[] = [ From 1e7043d07688d00a11739b50b9442c6690f60172 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:31 +0100 Subject: [PATCH 02/18] fix(parser): keep the model name of an array model's $ref items An array model built its items from the resolved schema, so `items: $ref` lost the name of the model it refers to. The items are now built from the reference, as a property's are. typescript-fetch and dart-fetch output is unchanged for $ref items of objects, enums, aliases and arrays. --- internal/parser/arraymodel_test.go | 31 ++++++++++++++++++++++++++++++ internal/parser/model.go | 3 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 internal/parser/arraymodel_test.go diff --git a/internal/parser/arraymodel_test.go b/internal/parser/arraymodel_test.go new file mode 100644 index 0000000..5536404 --- /dev/null +++ b/internal/parser/arraymodel_test.go @@ -0,0 +1,31 @@ +package parser + +import "testing" + +// TestArrayModelItemsRef checks that an array schema whose items are a $ref +// types its items as the referenced model, not the model's inline shape. +func TestArrayModelItemsRef(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Pet: {type: object, properties: {name: {type: string}}} +`)); err != nil { + t.Fatalf("LoadFromData: %v", err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatalf("GetModels: %v", err) + } + + if m := findModel(t, models, "Pets"); m.Items == nil || !m.Items.IsModel || m.ArrayModelType != "Pet" { + t.Errorf("Pets items = %+v, array model type %q, want the Pet model", m.Items, m.ArrayModelType) + } +} diff --git a/internal/parser/model.go b/internal/parser/model.go index b12a071..d9bc799 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -126,7 +126,8 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co case "array": model.IsArray = true if schema.Items != nil && schema.Items.Value != nil { - model.Items = p.schemaToProperty("items", schema.Items.Value, false) + // By reference, so a $ref item keeps its model name. + model.Items = p.schemaRefToProperty("items", schema.Items, false) model.ArrayModelType = model.Items.DataType } From 11953944b01aa9f543b65f1af9d1e39cf7f421e9 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:40 +0100 Subject: [PATCH 03/18] feat(go-fetch): add a Go client generator in the shape of typescript-fetch go-fetch writes a Go client that imports only the standard library: an API type per tag with a typed, a Raw and a Request method per operation, a Params struct per operation, and models for structs, enums, aliases and oneOf/anyOf unions. Optional and nullable values are told apart (`*T`, `Opt[T]`), errors are a *ResponseError with the declared model decoded when the body has its required properties, auth comes from the Configuration, and urlencoded and multipart bodies follow the OpenAPI encoding defaults (form, exploded; deepObject key by key). Inline objects and unions get types named after their owner, union As methods check a variant's required and pinned properties, and a property every variant pins to its own value is the union's discriminator. The parser hands generators what go-fetch reads and the TypeScript and Dart templates ignore: the properties of inline objects (Vars), the members of inline unions (ComposedSchemas), a property's $ref, and the encoding of form fields. Tags are post-processed in sorted order, so names given across operations are stable. Generated files are formatted with go/format after unused imports are dropped. Sample in samples/go-fetch; options and type mapping in templates/go-fetch/README.md. --- CLAUDE.md | 2 +- Makefile | 8 +- README.md | 3 +- cmd/openapi-generator/main.go | 3 + internal/codegen/parameter.go | 13 +- internal/codegen/property.go | 3 + internal/config/config.go | 18 + internal/gen/gen.go | 28 +- internal/gen/gofetch.go | 53 + internal/gen/gofetch_test.go | 117 ++ internal/gen/index.go | 8 +- internal/gen/render.go | 7 + internal/gen/spec.go | 5 +- internal/generator/golang/base.go | 416 ++++++ internal/generator/golang/base_test.go | 95 ++ internal/generator/golang/fetch.go | 1152 +++++++++++++++++ internal/generator/golang/fetch_test.go | 279 ++++ internal/parser/operation.go | 17 +- internal/parser/parameter.go | 1 + internal/parser/property.go | 49 + internal/parser/response.go | 2 + internal/template/gofmt.go | 96 ++ internal/template/gofmt_test.go | 77 ++ internal/template/mustache.go | 16 + samples/go-fetch/.gitignore | 1 + samples/go-fetch/README.md | 29 + samples/go-fetch/config.yaml | 9 + samples/go-fetch/petstore-expanded.yaml | 124 ++ scripts/regress.sh | 1 + templates/embed.go | 2 +- templates/go-fetch/README.md | 94 ++ templates/go-fetch/README.mustache | 21 + templates/go-fetch/api.mustache | 254 ++++ templates/go-fetch/gomod.mustache | 3 + templates/go-fetch/model.mustache | 66 + templates/go-fetch/runtime.mustache | 781 +++++++++++ templates/go-fetch/union.mustache | 89 ++ .../gofetch/composition-anyof/union_test.go | 31 + .../composition-oneof-inline/shelter_test.go | 94 ++ .../gofetch/composition-oneof/union_test.go | 52 + testdata/gofetch/dates/dates_test.go | 49 + .../gofetch/discriminator-oneof/union_test.go | 40 + .../gofetch/maps-of-models/inventory_test.go | 30 + .../gofetch/naming-error-model/error_test.go | 32 + .../nested-inline-schemas/profile_test.go | 31 + .../gofetch/nullable-required/record_test.go | 50 + .../params_test.go | 47 + .../parameters-deepobject/params_test.go | 20 + .../parameters-explode-object/params_test.go | 34 + .../parameters-locations/params_test.go | 47 + .../gofetch/parameters-locations/raw_test.go | 24 + .../parameters-path-style/params_test.go | 28 + .../encoding_test.go | 47 + .../request-body-form-urlencoded/body_test.go | 42 + .../gofetch/request-body-json/body_test.go | 45 + .../request-body-multipart/body_test.go | 51 + .../responses-binary/responses_test.go | 34 + .../gofetch/responses-oneof/union_test.go | 30 + .../responses-status-codes/responses_test.go | 62 + testdata/gofetch/security-apikey/auth_test.go | 52 + testdata/gofetch/security-http/auth_test.go | 35 + testdata/gofetch/security-oauth2/auth_test.go | 28 + .../feature/composition-oneof-inline.yaml | 80 ++ .../specs/feature/discriminator-oneof.yaml | 44 + .../feature/request-body-form-encoding.yaml | 58 + 65 files changed, 5139 insertions(+), 20 deletions(-) create mode 100644 internal/gen/gofetch.go create mode 100644 internal/gen/gofetch_test.go create mode 100644 internal/generator/golang/base.go create mode 100644 internal/generator/golang/base_test.go create mode 100644 internal/generator/golang/fetch.go create mode 100644 internal/generator/golang/fetch_test.go create mode 100644 internal/template/gofmt.go create mode 100644 internal/template/gofmt_test.go create mode 100644 samples/go-fetch/.gitignore create mode 100644 samples/go-fetch/README.md create mode 100644 samples/go-fetch/config.yaml create mode 100644 samples/go-fetch/petstore-expanded.yaml create mode 100644 templates/go-fetch/README.md create mode 100644 templates/go-fetch/README.mustache create mode 100644 templates/go-fetch/api.mustache create mode 100644 templates/go-fetch/gomod.mustache create mode 100644 templates/go-fetch/model.mustache create mode 100644 templates/go-fetch/runtime.mustache create mode 100644 templates/go-fetch/union.mustache create mode 100644 testdata/gofetch/composition-anyof/union_test.go create mode 100644 testdata/gofetch/composition-oneof-inline/shelter_test.go create mode 100644 testdata/gofetch/composition-oneof/union_test.go create mode 100644 testdata/gofetch/dates/dates_test.go create mode 100644 testdata/gofetch/discriminator-oneof/union_test.go create mode 100644 testdata/gofetch/maps-of-models/inventory_test.go create mode 100644 testdata/gofetch/naming-error-model/error_test.go create mode 100644 testdata/gofetch/nested-inline-schemas/profile_test.go create mode 100644 testdata/gofetch/nullable-required/record_test.go create mode 100644 testdata/gofetch/parameters-array-style-explode/params_test.go create mode 100644 testdata/gofetch/parameters-deepobject/params_test.go create mode 100644 testdata/gofetch/parameters-explode-object/params_test.go create mode 100644 testdata/gofetch/parameters-locations/params_test.go create mode 100644 testdata/gofetch/parameters-locations/raw_test.go create mode 100644 testdata/gofetch/parameters-path-style/params_test.go create mode 100644 testdata/gofetch/request-body-form-encoding/encoding_test.go create mode 100644 testdata/gofetch/request-body-form-urlencoded/body_test.go create mode 100644 testdata/gofetch/request-body-json/body_test.go create mode 100644 testdata/gofetch/request-body-multipart/body_test.go create mode 100644 testdata/gofetch/responses-binary/responses_test.go create mode 100644 testdata/gofetch/responses-oneof/union_test.go create mode 100644 testdata/gofetch/responses-status-codes/responses_test.go create mode 100644 testdata/gofetch/security-apikey/auth_test.go create mode 100644 testdata/gofetch/security-http/auth_test.go create mode 100644 testdata/gofetch/security-oauth2/auth_test.go create mode 100644 testdata/specs/feature/composition-oneof-inline.yaml create mode 100644 testdata/specs/feature/discriminator-oneof.yaml create mode 100644 testdata/specs/feature/request-body-form-encoding.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 9327530..d9e66f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ go.yaml.in/yaml/v3. No cgo. | `internal/parser` | kin-openapi document → `codegen` structs: models, operations, parameters | | `internal/codegen` | the `Codegen*` structs templates render; fields mirror the Java originals | | `internal/gen` | the pipeline: load spec → parse → render models, apis, supporting files | -| `internal/generator` | `CodegenConfig` and the language configs, `typescript/` and `dart/` | +| `internal/generator` | `CodegenConfig` and the language configs, `typescript/`, `dart/`, `golang/` | | `internal/template` | the Mustache engine: partials, lambdas, struct-to-map via json | | `internal/config` | generator options and their config-file keys | | `internal/update` | release check, verified download, self-update | diff --git a/Makefile b/Makefile index c36e7b5..fabe9ac 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch regress all +.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch samples-go-fetch regress all GOFUMPT ?= go run mvdan.cc/gofumpt@v0.12.0 GOLANGCI ?= go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.0 @@ -72,6 +72,12 @@ samples-dart-fetch: build @rm -rf samples/dart-fetch/petstore-expanded $(OUTPUT_DIR)/openapi-generator generate -c samples/dart-fetch/config.yaml +## samples-go-fetch: Regenerate the go-fetch petstore sample +samples-go-fetch: build + @echo "Generating go-fetch petstore sample..." + @rm -rf samples/go-fetch/petstore-expanded + $(OUTPUT_DIR)/openapi-generator generate -c samples/go-fetch/config.yaml + ## regress: Diff generated output against a git ref (default HEAD): make regress REF=master regress: ./scripts/regress.sh $(REF) diff --git a/README.md b/README.md index 422ef98..fe274eb 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ go install github.com/xseman/openapi-generator/cmd/openapi-generator@latest - [typescript-fetch](./templates/typescript-fetch/README.md) - [dart-fetch](./samples/dart-fetch/README.md) +- [go-fetch](./templates/go-fetch/README.md) ### Server @@ -78,7 +79,7 @@ openapi-generator validate -i openapi.yaml --recommend | Option | Short | Description | | --------------------------- | ----- | ------------------------------------------------ | | `--input-spec` | `-i` | Location of the OpenAPI spec (file or URL) | -| `--generator-name` | `-g` | Generator to use: typescript-fetch, dart-fetch | +| `--generator-name` | `-g` | typescript-fetch, dart-fetch, go-fetch | | `--output` | `-o` | Output directory | | `--config` | `-c` | Configuration file (JSON/YAML) | | `--template-dir` | `-t` | Custom template directory | diff --git a/cmd/openapi-generator/main.go b/cmd/openapi-generator/main.go index 66cdaa7..6cacd21 100644 --- a/cmd/openapi-generator/main.go +++ b/cmd/openapi-generator/main.go @@ -105,6 +105,7 @@ var listCmd = &cobra.Command{ fmt.Println("CLIENT generators:") fmt.Println(" - typescript-fetch") fmt.Println(" - dart-fetch") + fmt.Println(" - go-fetch") fmt.Println() }, } @@ -123,6 +124,8 @@ var configHelpCmd = &cobra.Command{ gen.PrintTypeScriptFetchConfigHelp() case "dart-fetch": gen.PrintDartFetchConfigHelp() + case "go-fetch": + gen.PrintGoFetchConfigHelp() default: fmt.Printf("Unknown generator: %s\n", args[0]) } diff --git a/internal/codegen/parameter.go b/internal/codegen/parameter.go index fc63774..e0b600b 100644 --- a/internal/codegen/parameter.go +++ b/internal/codegen/parameter.go @@ -93,12 +93,13 @@ type CodegenParameter struct { IsCollectionFormatMulti bool `json:"isCollectionFormatMulti"` // Nested - ComposedModels []string `json:"composedModels"` // member models of a union/intersection type, for imports - Items *CodegenProperty `json:"items"` - AdditionalProperties *CodegenProperty `json:"additionalProperties"` - Vars []*CodegenProperty `json:"vars"` - RequiredVars []*CodegenProperty `json:"requiredVars"` - MostInnerItems *CodegenProperty `json:"mostInnerItems"` + ComposedModels []string `json:"composedModels"` // member models of a union/intersection type, for imports + Items *CodegenProperty `json:"items"` + AdditionalProperties *CodegenProperty `json:"additionalProperties"` + Vars []*CodegenProperty `json:"vars"` + RequiredVars []*CodegenProperty `json:"requiredVars"` + MostInnerItems *CodegenProperty `json:"mostInnerItems"` + ComposedSchemas *CodegenComposedSchemas `json:"composedSchemas"` // Documentation Description string `json:"description"` diff --git a/internal/codegen/property.go b/internal/codegen/property.go index f9e8a8c..ab3b773 100644 --- a/internal/codegen/property.go +++ b/internal/codegen/property.go @@ -76,6 +76,9 @@ type CodegenProperty struct { IsInnerEnum bool `json:"isInnerEnum"` // Inline enum IsEnumRef bool `json:"isEnumRef"` // Reference to enum model + // Ref is the $ref the property is typed from, as the spec writes it. + Ref string `json:"ref"` + // Enum Enum []string `json:"enum"` EnumName string `json:"enumName"` diff --git a/internal/config/config.go b/internal/config/config.go index a67de51..78f584f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -153,6 +153,24 @@ func NewDartFetchConfig() *DartFetchConfig { } } +// GoFetchConfig holds configuration specific to the go-fetch generator. +// +// The go-fetch generator produces a Go client that imports only the +// standard library, in a single package. +type GoFetchConfig struct { + // PackageName is the Go package name of the generated files. + PackageName string `json:"packageName,omitempty"` + // WithGoMod writes a go.mod, making the output a module of its own. + WithGoMod bool `json:"withGoMod,omitempty"` + // ModuleName is the module path in go.mod (default: PackageName). + ModuleName string `json:"moduleName,omitempty"` +} + +// NewGoFetchConfig creates a new GoFetchConfig with default values. +func NewGoFetchConfig() *GoFetchConfig { + return &GoFetchConfig{PackageName: "openapi"} +} + // FileNamingType represents the file naming convention. type FileNamingType string diff --git a/internal/gen/gen.go b/internal/gen/gen.go index 276222a..68fe7d6 100644 --- a/internal/gen/gen.go +++ b/internal/gen/gen.go @@ -17,6 +17,7 @@ import ( "github.com/xseman/openapi-generator/internal/config" "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/generator/dart" + "github.com/xseman/openapi-generator/internal/generator/golang" "github.com/xseman/openapi-generator/internal/generator/typescript" "github.com/xseman/openapi-generator/internal/template" ) @@ -28,8 +29,8 @@ type Options struct { InputSpec string // OutputDir is the directory generated files are written to. OutputDir string - // GeneratorName selects the target generator: "typescript-fetch" or - // "dart-fetch". + // GeneratorName selects the target generator: "typescript-fetch", + // "dart-fetch" or "go-fetch". GeneratorName string // TemplateDir, if set, overrides the embedded templates with templates // loaded from this filesystem directory. @@ -68,8 +69,10 @@ func Generate(opts Options) error { return errors.New("generator-name is required (use -g flag or generatorName in config file)") } - if opts.GeneratorName != "typescript-fetch" && opts.GeneratorName != "dart-fetch" { - return fmt.Errorf("unsupported generator: %s (supported: 'typescript-fetch', 'dart-fetch')", opts.GeneratorName) + switch opts.GeneratorName { + case "typescript-fetch", "dart-fetch", "go-fetch": + default: + return fmt.Errorf("unsupported generator: %s (supported: 'typescript-fetch', 'dart-fetch', 'go-fetch')", opts.GeneratorName) } additionalProps := parseAdditionalProperties(opts.AdditionalProperties) @@ -82,7 +85,7 @@ func Generate(opts Options) error { AdditionalProperties: additionalProps, } - // Construct the generator (TypeScript or Dart) and keep both the + // Construct the generator (TypeScript, Dart or Go) and keep both the // abstract CodegenConfig (for parser/template plumbing) and the // concrete generator (for language-specific helpers like index-file // generation). @@ -90,6 +93,7 @@ func Generate(opts Options) error { cg generator.CodegenConfig tsGen *typescript.FetchGenerator dartGen *dart.FetchGenerator + goGen *golang.FetchGenerator ) switch opts.GeneratorName { @@ -110,6 +114,15 @@ func Generate(opts Options) error { dartGen.SetConfig(cfg) dartGen.DartConfig = dartConfig cg = dartGen + + case "go-fetch": + goConfig := config.NewGoFetchConfig() + applyGoFetchAdditionalProperties(goConfig, additionalProps) + + goGen = golang.NewFetchGenerator() + goGen.SetConfig(cfg) + goGen.GoConfig = goConfig + cg = goGen } if err := cg.ProcessOpts(); err != nil { @@ -127,6 +140,10 @@ func Generate(opts Options) error { case dartGen != nil: apiPackage = dartGen.ApiPackage modelPackage = dartGen.ModelPackage + + case goGen != nil: + apiPackage = goGen.ApiPackage + modelPackage = goGen.ModelPackage } spec, err := assembleSpec(cg, opts) @@ -156,6 +173,7 @@ func Generate(opts Options) error { cg: cg, tsGen: tsGen, dartGen: dartGen, + goGen: goGen, engine: engine, outputDir: opts.OutputDir, apiPackage: apiPackage, diff --git a/internal/gen/gofetch.go b/internal/gen/gofetch.go new file mode 100644 index 0000000..a459730 --- /dev/null +++ b/internal/gen/gofetch.go @@ -0,0 +1,53 @@ +package gen + +import ( + "fmt" + + "github.com/xseman/openapi-generator/internal/config" +) + +// applyGoFetchAdditionalProperties maps CLI / config additional-property +// values onto a GoFetchConfig. +func applyGoFetchAdditionalProperties(gc *config.GoFetchConfig, props map[string]any) { + if v, ok := props["packageName"].(string); ok { + gc.PackageName = v + } + + if v, ok := props["withGoMod"].(bool); ok { + gc.WithGoMod = v + } + + if v, ok := props["moduleName"].(string); ok { + gc.ModuleName = v + } +} + +// goDeclareParams marks the operations whose Params type this API file +// declares: an operation under several tags is rendered into each tag's +// file, and its Params type must be declared in only one, the first. +func goDeclareParams(opMaps []map[string]any, declared map[string]bool) { + for _, op := range opMaps { + ext, _ := op["vendorExtensions"].(map[string]any) + name, _ := ext["x-go-params"].(string) + + op["goDeclareParams"] = !declared[name] + declared[name] = true + } +} + +// PrintGoFetchConfigHelp prints the go-fetch additional config options to +// stdout (used by the "config-help" CLI command). +func PrintGoFetchConfigHelp() { + fmt.Println("CONFIG OPTIONS for go-fetch:") + fmt.Println() + fmt.Println(" packageName") + fmt.Println(" Go package name of the generated files. (Default: openapi)") + fmt.Println() + fmt.Println(" withGoMod") + fmt.Println(" Write a go.mod, making the output a module of its own.") + fmt.Println(" (Default: false)") + fmt.Println() + fmt.Println(" moduleName") + fmt.Println(" Module path in go.mod. (Default: packageName)") + fmt.Println() +} diff --git a/internal/gen/gofetch_test.go b/internal/gen/gofetch_test.go new file mode 100644 index 0000000..398b8ec --- /dev/null +++ b/internal/gen/gofetch_test.go @@ -0,0 +1,117 @@ +package gen + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// goTool returns the go binary, skipping the test under -short or without +// one: these tests compile generated code. +func goTool(t *testing.T) string { + t.Helper() + + if testing.Short() { + t.Skip("compiles generated Go code") + } + + bin, err := exec.LookPath("go") + if err != nil { + t.Skip("no go in PATH") + } + + return bin +} + +// runGo runs the go tool in dir and fails the test with its output when it +// fails or finds no package. +func runGo(t *testing.T, bin, dir string, args ...string) { + t.Helper() + + cmd := exec.Command(bin, args...) + cmd.Dir = dir + + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + + out, err := cmd.CombinedOutput() + if err != nil || bytes.Contains(out, []byte("matched no packages")) { + t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func generateGoFetch(t *testing.T, spec, out string, props ...string) { + t.Helper() + + err := Generate(Options{ + InputSpec: spec, + OutputDir: out, + GeneratorName: "go-fetch", + TemplateDir: filepath.Join("..", "..", "templates", "go-fetch"), + AdditionalProperties: props, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("Generate %s: %v", spec, err) + } +} + +// TestGoFetchCompiles generates every feature spec into a package of one +// module, vets it and runs the tests testdata/gofetch// holds, which +// exercise the client against httptest. +func TestGoFetchCompiles(t *testing.T) { + bin := goTool(t) + root := t.TempDir() + + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module gofetchcheck\n\ngo 1.24\n"), 0o600); err != nil { + t.Fatal(err) + } + + specs, err := filepath.Glob(filepath.Join("..", "..", "testdata", "specs", "feature", "*.yaml")) + if err != nil || len(specs) == 0 { + t.Fatalf("no feature specs: %v", err) + } + + for _, spec := range specs { + name := strings.TrimSuffix(filepath.Base(spec), ".yaml") + out := filepath.Join(root, name) + generateGoFetch(t, spec, out, "packageName="+strings.ReplaceAll(name, "-", "")) + + tests, _ := filepath.Glob(filepath.Join("..", "..", "testdata", "gofetch", name, "*_test.go")) + for _, test := range tests { + data, err := os.ReadFile(test) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(out, filepath.Base(test)), data, 0o600); err != nil { + t.Fatal(err) + } + } + } + + // go test builds no package without a test file: vet compiles them all. + runGo(t, bin, root, "vet", "./...") + runGo(t, bin, root, "test", "./...") +} + +// TestGoFetchGoMod checks withGoMod on its own: a go.mod inside the module +// of TestGoFetchCompiles would cut its directory out of ./.... +func TestGoFetchGoMod(t *testing.T) { + bin := goTool(t) + out := t.TempDir() + generateGoFetch(t, filepath.Join("..", "..", "testdata", "specs", "feature", "request-body-json.yaml"), out, "withGoMod=true") + + data, err := os.ReadFile(filepath.Join(out, "go.mod")) + if err != nil { + t.Fatal(err) + } + + if want := "module openapi\n\ngo 1.24\n"; string(data) != want { + t.Errorf("go.mod = %q, want %q", data, want) + } + + runGo(t, bin, out, "vet", "./...") +} diff --git a/internal/gen/index.go b/internal/gen/index.go index b1effab..760d553 100644 --- a/internal/gen/index.go +++ b/internal/gen/index.go @@ -9,8 +9,14 @@ import ( ) // writeIndexFiles writes the per-folder barrel (index) file for models, and -// for APIs, when there is at least one model or operation respectively. +// for APIs, when there is at least one model or operation respectively; Go +// gets none. func (r *renderer) writeIndexFiles(models []*generator.CodegenModel, operationsByTag map[string][]*generator.CodegenOperation) ([]string, error) { + // A Go package is one directory: nothing to re-export. + if r.goGen != nil { + return nil, nil + } + indexExt := ".ts" if r.dartGen != nil { indexExt = ".dart" diff --git a/internal/gen/render.go b/internal/gen/render.go index 36d76cd..59dece6 100644 --- a/internal/gen/render.go +++ b/internal/gen/render.go @@ -11,6 +11,7 @@ import ( "github.com/xseman/openapi-generator/internal/generator" "github.com/xseman/openapi-generator/internal/generator/dart" + "github.com/xseman/openapi-generator/internal/generator/golang" "github.com/xseman/openapi-generator/internal/generator/typescript" "github.com/xseman/openapi-generator/internal/template" "github.com/xseman/openapi-generator/templates" @@ -112,6 +113,7 @@ type renderer struct { cg generator.CodegenConfig tsGen *typescript.FetchGenerator dartGen *dart.FetchGenerator + goGen *golang.FetchGenerator engine *template.Engine outputDir string apiPackage string @@ -283,6 +285,7 @@ func (r *renderer) renderAPIs(baseData map[string]any, operationsByTag map[strin var generatedFiles []string apiTemplates := r.cg.GetApiTemplateFiles() + goParams := make(map[string]bool) for _, tag := range sortedTags(operationsByTag) { ops := operationsByTag[tag] @@ -292,6 +295,10 @@ func (r *renderer) renderAPIs(baseData map[string]any, operationsByTag map[strin opMaps := template.ConvertSliceToMaps(ops) opMaps = template.PreprocessOperationData(opMaps) + if r.goGen != nil { + goDeclareParams(opMaps, goParams) + } + for tmplFile, ext := range apiTemplates { data := copyMap(baseData) data["classname"] = apiClassname diff --git a/internal/gen/spec.go b/internal/gen/spec.go index b5a2930..4df5206 100644 --- a/internal/gen/spec.go +++ b/internal/gen/spec.go @@ -79,9 +79,10 @@ func assembleSpec(cg generator.CodegenConfig, opts Options) (*specData, error) { fmt.Printf("Found %d operations in %d tags\n", opCount, len(operationsByTag)) } + // Tags in order: a generator may name types across operations (go-fetch). models = cg.PostProcessModels(models) - for tag, ops := range operationsByTag { - operationsByTag[tag] = cg.PostProcessOperations(ops) + for _, tag := range slices.Sorted(maps.Keys(operationsByTag)) { + operationsByTag[tag] = cg.PostProcessOperations(operationsByTag[tag]) } // A parameter typed as a model carries that model's read-only properties, so diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go new file mode 100644 index 0000000..e30610a --- /dev/null +++ b/internal/generator/golang/base.go @@ -0,0 +1,416 @@ +// Package golang provides the Go code generator; the name is golang because +// go is a keyword. +package golang + +import ( + "maps" + "strings" + "unicode" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/config" + "github.com/xseman/openapi-generator/internal/generator" +) + +// TypeMapping maps OpenAPI types to the TypeScript-shaped DataType strings +// the parser builds on (Array, any, number). Go types are derived from a +// property's flags and tree instead, see GoType. +var TypeMapping = map[string]string{ + "array": "Array", + "boolean": "boolean", + "integer": "number", + "number": "number", + "object": "any", + "string": "string", + "AnyType": "any", +} + +// RuntimeNames are the identifiers runtime.go declares; a model taking one +// is renamed with a Model suffix. +var RuntimeNames = map[string]bool{ + "BasePath": true, "Doer": true, "RequestEditorFn": true, "Configuration": true, + "ResponseError": true, "Opt": true, "Some": true, "Null": true, "Ptr": true, + "Date": true, "File": true, +} + +// keywords are Go's reserved words, for the package name. +var keywords = map[string]bool{ + "break": true, "case": true, "chan": true, "const": true, "continue": true, + "default": true, "defer": true, "else": true, "fallthrough": true, "for": true, + "func": true, "go": true, "goto": true, "if": true, "import": true, + "interface": true, "map": true, "package": true, "range": true, "return": true, + "select": true, "struct": true, "switch": true, "type": true, "var": true, +} + +// initialisms are written in one case, as golint's commonInitialisms. +var initialisms = map[string]bool{ + "ACL": true, "API": true, "ASCII": true, "CPU": true, "CSS": true, "DNS": true, + "EOF": true, "GUID": true, "HTML": true, "HTTP": true, "HTTPS": true, "ID": true, + "IP": true, "JSON": true, "LHS": true, "QPS": true, "RAM": true, "RHS": true, + "RPC": true, "SLA": true, "SMTP": true, "SQL": true, "SSH": true, "TCP": true, + "TLS": true, "TTL": true, "UDP": true, "UI": true, "UID": true, "UUID": true, + "URI": true, "URL": true, "UTF8": true, "VM": true, "XML": true, "XMPP": true, + "XSRF": true, "XSS": true, +} + +// buildSuffixes are the file name suffixes the go tool reads as a build +// constraint or a test file (model_test.go, api_linux.go). +var buildSuffixes = map[string]bool{ + "test": true, "aix": true, "android": true, "darwin": true, "dragonfly": true, + "freebsd": true, "hurd": true, "illumos": true, "ios": true, "js": true, + "linux": true, "nacl": true, "netbsd": true, "openbsd": true, "plan9": true, + "solaris": true, "wasip1": true, "windows": true, "zos": true, "386": true, + "amd64": true, "arm": true, "arm64": true, "loong64": true, "mips": true, + "mips64": true, "mips64le": true, "mipsle": true, "ppc64": true, "ppc64le": true, + "riscv64": true, "s390x": true, "wasm": true, +} + +// BaseGenerator holds what a Go generator shares: the CodegenConfig plumbing +// and Go naming. +type BaseGenerator struct { + Config *config.GeneratorConfig + + TypeMapping map[string]string + ReservedWords map[string]bool + + ModelPackage string + ApiPackage string + + ApiTemplateFiles map[string]string + ModelTemplateFiles map[string]string + SupportingFiles []generator.SupportingFile + AdditionalProperties map[string]any +} + +// NewBaseGenerator creates a new Go base generator. +func NewBaseGenerator() *BaseGenerator { + return &BaseGenerator{ + TypeMapping: maps.Clone(TypeMapping), + ReservedWords: maps.Clone(RuntimeNames), + ApiTemplateFiles: make(map[string]string), + ModelTemplateFiles: make(map[string]string), + AdditionalProperties: make(map[string]any), + } +} + +// GetTypeMapping returns the type mapping. +func (g *BaseGenerator) GetTypeMapping() map[string]string { return g.TypeMapping } + +// GetReservedWords returns the reserved words. +func (g *BaseGenerator) GetReservedWords() map[string]bool { return g.ReservedWords } + +// GetLanguageSpecificPrimitives returns the primitive types. +func (g *BaseGenerator) GetLanguageSpecificPrimitives() map[string]bool { return nil } + +// GetImportMapping returns the import mapping. +func (g *BaseGenerator) GetImportMapping() map[string]string { return nil } + +// GetAdditionalProperties returns additional properties for templates. +func (g *BaseGenerator) GetAdditionalProperties() map[string]any { return g.AdditionalProperties } + +// IsReservedWord reports whether word is a name runtime.go declares. +func (g *BaseGenerator) IsReservedWord(word string) bool { return g.ReservedWords[word] } + +// EscapeReservedWord suffixes a reserved name with Model. +func (g *BaseGenerator) EscapeReservedWord(name string) string { + if g.IsReservedWord(name) { + return name + "Model" + } + + return name +} + +// GetSchemaType returns the parser's DataType string for an OpenAPI type. +func (g *BaseGenerator) GetSchemaType(schemaType, _ string) string { + return g.TypeMapping[schemaType] +} + +// GetTypeDeclaration returns the parser's DataType string for an OpenAPI type. +func (g *BaseGenerator) GetTypeDeclaration(schemaType, format string) string { + return g.GetSchemaType(schemaType, format) +} + +// ToModelName converts a schema name to an exported Go type name. +func (g *BaseGenerator) ToModelName(name string) string { + return g.EscapeReservedWord(exported(name, "Model")) +} + +// ToApiName converts a tag to the API type name, PetAPI. +func (g *BaseGenerator) ToApiName(name string) string { + return exported(name, "Default") + "API" +} + +// ToVarName converts a property or parameter name to an exported field name. +func (g *BaseGenerator) ToVarName(name string) string { return exported(name, "Var") } + +// ToParamName converts a parameter name to an exported field name. +func (g *BaseGenerator) ToParamName(name string) string { return g.ToVarName(name) } + +// SanitizeOperationId converts an operation ID to an exported method name. +func (g *BaseGenerator) SanitizeOperationId(operationId string) string { + return exported(operationId, "Op") +} + +// ToModelFilename returns the file name of a model, model_pet. +func (g *BaseGenerator) ToModelFilename(name string) string { + return fileName("model_" + Snake(name)) +} + +// ToApiFilename returns the file name of an API type, api_pet for PetAPI. +func (g *BaseGenerator) ToApiFilename(name string) string { + return fileName("api_" + Snake(strings.TrimSuffix(name, "API"))) +} + +// FromModel is unused: the parser builds models. +func (g *BaseGenerator) FromModel(name string, _ any) *codegen.CodegenModel { + return &codegen.CodegenModel{Name: name, Classname: g.ToModelName(name)} +} + +// FromOperation is unused: the parser builds operations. +func (g *BaseGenerator) FromOperation(path, httpMethod string, _ any) *codegen.CodegenOperation { + return &codegen.CodegenOperation{Path: path, HttpMethod: strings.ToUpper(httpMethod)} +} + +// FromProperty is unused: the parser builds properties. +func (g *BaseGenerator) FromProperty(name string, _ any, required bool) *codegen.CodegenProperty { + return &codegen.CodegenProperty{Name: g.ToVarName(name), BaseName: name, Required: required} +} + +// FromParameter is unused: the parser builds parameters. +func (g *BaseGenerator) FromParameter(_ any) *codegen.CodegenParameter { + return &codegen.CodegenParameter{} +} + +// FromResponse is unused: the parser builds responses. +func (g *BaseGenerator) FromResponse(code string, _ any) *codegen.CodegenResponse { + return &codegen.CodegenResponse{Code: code} +} + +// FromSecurityScheme is unused: the parser builds security schemes. +func (g *BaseGenerator) FromSecurityScheme(name string, _ any) *codegen.CodegenSecurity { + return &codegen.CodegenSecurity{Name: name} +} + +// GetSupportingFiles returns supporting files. +func (g *BaseGenerator) GetSupportingFiles() []generator.SupportingFile { return g.SupportingFiles } + +// GetApiTemplateFiles returns API template files. +func (g *BaseGenerator) GetApiTemplateFiles() map[string]string { return g.ApiTemplateFiles } + +// GetModelTemplateFiles returns model template files. +func (g *BaseGenerator) GetModelTemplateFiles() map[string]string { return g.ModelTemplateFiles } + +// GetConfig returns the generator config. +func (g *BaseGenerator) GetConfig() *config.GeneratorConfig { return g.Config } + +// SetConfig sets the generator config. +func (g *BaseGenerator) SetConfig(cfg *config.GeneratorConfig) { g.Config = cfg } + +// GoType is the Go type of a property, from its flags and its Items tree. +// named is the type declared for an inline enum or object at its leaf, or +// the enum or union model a $ref names; "" types an enum by its values, an +// object as a map and a union as raw JSON. +func GoType(p *codegen.CodegenProperty, named string) string { + switch { + case p == nil: + return "any" + case p.IsArray: + return "[]" + itemType(p.Items, named) + case p.IsMap: + return "map[string]" + itemType(p.Items, named) + case named != "" && (p.Ref != "" || p.IsEnum || len(p.Vars) > 0 || p.ComposedSchemas != nil): + return named + case isComposite(p.DataType): + // ponytail: an intersection, or a union not named (a union model's + // own inline member), stays raw JSON; the upgrade is a named type. + return "json.RawMessage" + case p.IsEnum: + return EnumBaseType(p.AllowableValues, p.IsBoolean) + case p.IsModel: + return p.DataType + } + + return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsBinary || p.IsByteArray, p.IsInteger, p.IsLong, + p.IsNumber, p.IsFloat, p.IsBoolean, p.OpenApiType == "object") +} + +// itemType is the Go type of a slice element or map value: GoType, a +// pointer when the item is nullable and not nil-able already. +func itemType(p *codegen.CodegenProperty, named string) string { + t := GoType(p, named) + if p != nil && p.IsNullable && !isContainer(t) { + return "*" + t + } + + return t +} + +// GoParamType is the Go type of a parameter; a file is a File. named is +// the type declared for an inline object, as for GoType. +func GoParamType(p *codegen.CodegenParameter, named string) string { + switch { + case p.IsArray && p.IsFile: + return "[]File" + case p.IsArray: + return "[]" + itemType(p.Items, named) + case (len(p.Vars) > 0 || p.ComposedSchemas != nil) && named != "": + return named + case p.IsMap: + return "map[string]" + itemType(p.Items, named) + case isComposite(p.DataType): + return "json.RawMessage" + case p.IsFile || p.IsBinary: + return "File" + case p.IsEnum: + return EnumBaseType(p.AllowableValues, p.IsBoolean) + case p.IsModel: + return p.DataType + } + + return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsByteArray, p.IsInteger, p.IsLong, + p.IsNumber, p.IsFloat, p.IsBoolean, false) +} + +func scalarType(isString, isDate, isDateTime, isBytes, isInteger, isLong, isNumber, isFloat, isBoolean, isObject bool) string { + switch { + case isDate: + return "Date" + case isDateTime: + return "time.Time" + case isBytes: + return "[]byte" + case isString: + return "string" + case isLong: + return "int64" + case isInteger: + return "int" + case isFloat: + return "float32" + case isNumber: + return "float64" + case isBoolean: + return "bool" + case isObject: + return "map[string]any" + } + + return "any" +} + +// EnumBaseType is the Go type an enum's values have: string when they are +// strings (isString on the enumVars), else bool, int when every value is +// whole, float64 otherwise. +func EnumBaseType(allowable map[string]any, isBoolean bool) string { + vars, _ := allowable["enumVars"].([]map[string]any) + if len(vars) > 0 && vars[0]["isString"] == true { + return "string" + } + + if isBoolean { + return "bool" + } + + values, _ := allowable["values"].([]any) + for _, v := range values { + if f, ok := v.(float64); ok && f != float64(int64(f)) { + return "float64" + } + } + + return "int" +} + +// isComposite reports whether the parser typed a value as a union or +// intersection of members ("A | B", "A & B"). +func isComposite(dataType string) bool { + return strings.Contains(dataType, " | ") || strings.Contains(dataType, " & ") +} + +// exported converts s to an exported Go identifier: PascalCase words with +// initialisms in upper case, prefix in front when it would not start with +// an upper-case letter (a digit, an empty name). +func exported(s, prefix string) string { + var b strings.Builder + + for _, w := range splitWords(s) { + if up := strings.ToUpper(w); initialisms[up] { + b.WriteString(up) + continue + } + + r := []rune(strings.ToLower(w)) + r[0] = unicode.ToUpper(r[0]) + b.WriteString(string(r)) + } + + name := b.String() + if r := []rune(name); len(r) == 0 || !unicode.IsUpper(r[0]) { + name = prefix + name + } + + return name +} + +// Snake converts s to snake_case, keeping an initialism one word. +func Snake(s string) string { + words := splitWords(s) + for i, w := range words { + words[i] = strings.ToLower(w) + } + + return strings.Join(words, "_") +} + +// fileName suffixes a name the go tool would read as a build constraint +// or a test file with an underscore. +func fileName(name string) string { + if i := strings.LastIndexByte(name, '_'); i >= 0 && buildSuffixes[name[i+1:]] { + return name + "_" + } + + return name +} + +// splitWords splits s on what is neither letter nor digit, before an upper +// case letter that follows a lower-case one or a digit, and before the last +// letter of an upper-case run that a lower-case letter follows (URLPath -> +// URL, Path). +func splitWords(s string) []string { + var ( + words []string + cur []rune + ) + + flush := func() { + if len(cur) > 0 { + words = append(words, string(cur)) + cur = nil + } + } + + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + flush() + continue + } + + if n := len(cur); n > 0 { + prev := cur[n-1] + + switch { + case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)): + flush() + case unicode.IsLower(r) && unicode.IsUpper(prev) && n > 1 && unicode.IsUpper(cur[n-2]): + words = append(words, string(cur[:n-1])) + cur = []rune{prev} + } + } + + cur = append(cur, r) + } + + flush() + + return words +} diff --git a/internal/generator/golang/base_test.go b/internal/generator/golang/base_test.go new file mode 100644 index 0000000..a194b5a --- /dev/null +++ b/internal/generator/golang/base_test.go @@ -0,0 +1,95 @@ +package golang + +import ( + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +func TestNames(t *testing.T) { + g := NewBaseGenerator() + + tests := []struct { + got, want string + }{ + {g.ToModelName("pet_id"), "PetID"}, + {g.ToModelName("HTTPGetAction"), "HTTPGetAction"}, + {g.ToModelName("io.k8s.api.v1.Pod"), "IoK8sAPIV1Pod"}, + {g.ToModelName("PET_STATUS"), "PetStatus"}, + {g.ToModelName("File"), "FileModel"}, + {g.ToModelName("Configuration"), "ConfigurationModel"}, + {g.ToModelName("2fa"), "Model2fa"}, + {g.ToVarName("first-name"), "FirstName"}, + {g.ToVarName("last.name"), "LastName"}, + {g.ToVarName("123code"), "Var123code"}, + {g.ToVarName("@type"), "Type"}, + {g.ToVarName("has space"), "HasSpace"}, + {g.ToVarName("a+b"), "AB"}, + {g.ToVarName("_leadingUnderscore"), "LeadingUnderscore"}, + {g.ToVarName("trailingUnderscore_"), "TrailingUnderscore"}, + {g.ToVarName("userUrl"), "UserURL"}, + {g.ToVarName("URLPath"), "URLPath"}, + {g.ToVarName(""), "Var"}, + {g.SanitizeOperationId("findPetById"), "FindPetByID"}, + {g.ToApiName("pet store"), "PetStoreAPI"}, + {g.ToApiFilename("PetStoreAPI"), "api_pet_store"}, + {g.ToApiFilename("LinuxAPI"), "api_linux_"}, + {g.ToModelFilename("HTTPServer"), "model_http_server"}, + {g.ToModelFilename("Test"), "model_test_"}, + {packageName("Pet-Store"), "petstore"}, + {packageName("2pets"), "pets"}, + {packageName("go"), "goapi"}, + {packageName("type"), "typeapi"}, + {packageName("_"), "openapi"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("got %q, want %q", tt.got, tt.want) + } + } +} + +func TestGoType(t *testing.T) { + str := &codegen.CodegenProperty{IsString: true} + pet := &codegen.CodegenProperty{IsModel: true, DataType: "Pet"} + + tests := []struct { + prop *codegen.CodegenProperty + want string + }{ + {str, "string"}, + {&codegen.CodegenProperty{IsString: true, IsUuid: true}, "string"}, + {&codegen.CodegenProperty{IsString: true, IsDate: true}, "Date"}, + {&codegen.CodegenProperty{IsString: true, IsDateTime: true}, "time.Time"}, + {&codegen.CodegenProperty{IsString: true, IsByteArray: true}, "[]byte"}, + {&codegen.CodegenProperty{IsString: true, IsBinary: true}, "[]byte"}, + {&codegen.CodegenProperty{IsInteger: true}, "int"}, + {&codegen.CodegenProperty{IsInteger: true, IsLong: true}, "int64"}, + {&codegen.CodegenProperty{IsNumber: true}, "float64"}, + {&codegen.CodegenProperty{IsNumber: true, IsFloat: true}, "float32"}, + {&codegen.CodegenProperty{IsNumber: true, IsDouble: true}, "float64"}, + {&codegen.CodegenProperty{IsBoolean: true}, "bool"}, + {&codegen.CodegenProperty{IsArray: true, Items: str}, "[]string"}, + {&codegen.CodegenProperty{IsArray: true, UniqueItems: true, Items: pet}, "[]Pet"}, + {&codegen.CodegenProperty{IsArray: true, Items: &codegen.CodegenProperty{IsArray: true, Items: pet}}, "[][]Pet"}, + {&codegen.CodegenProperty{IsMap: true, Items: pet}, "map[string]Pet"}, + {&codegen.CodegenProperty{IsMap: true}, "map[string]any"}, + {&codegen.CodegenProperty{OpenApiType: "object", IsFreeFormObject: true}, "map[string]any"}, + {&codegen.CodegenProperty{IsAnyType: true}, "any"}, + {pet, "Pet"}, + {&codegen.CodegenProperty{DataType: "Cat | Dog", IsFreeFormObject: true}, "json.RawMessage"}, + {&codegen.CodegenProperty{IsArray: true, Items: &codegen.CodegenProperty{DataType: "Cat | Dog"}}, "[]json.RawMessage"}, + } + + for _, tt := range tests { + if got := GoType(tt.prop, ""); got != tt.want { + t.Errorf("GoType(%+v) = %q, want %q", tt.prop, got, tt.want) + } + } + + enum := &codegen.CodegenProperty{IsArray: true, IsEnum: true, Items: &codegen.CodegenProperty{IsEnum: true, IsString: true}} + if got := GoType(enum, "PetTagsEnum"); got != "[]PetTagsEnum" { + t.Errorf("GoType(array of enum) = %q, want []PetTagsEnum", got) + } +} diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go new file mode 100644 index 0000000..1d6fe7b --- /dev/null +++ b/internal/generator/golang/fetch.go @@ -0,0 +1,1152 @@ +package golang + +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/config" + "github.com/xseman/openapi-generator/internal/generator" +) + +// FetchGenerator implements the go-fetch generator: a client in the shape of +// typescript-fetch (an API type per tag, a Request, Raw and typed method per +// operation) that imports only the standard library. +// +// Templates read what they render from vendorExtensions keys x-go-*, which +// PostProcessModels and PostProcessOperations fill in, and the api template +// also from goDeclareParams, which the pipeline sets. +type FetchGenerator struct { + *BaseGenerator + + GoConfig *config.GoFetchConfig + + // containers are the models declared as a slice, map or any alias: like + // a slice, a field of one needs no pointer to be optional. + containers map[string]bool + + // types are the type names declared so far, inline ones included; + // opTypes the inline type names of operations, by operation and base. + types map[string]bool + opTypes map[string]string + + // refs are the enum and union models by schema name, for a property + // that refers to one: the parser types it as the enum or union inline. + refs map[string]string + + // required are the quoted required properties of each struct model, which + // a union's As method checks a value for; pinned its properties an enum + // of one value fixes (see pinned). + required map[string]string + pinned map[string][]map[string]any +} + +// NewFetchGenerator creates a new go-fetch generator. +func NewFetchGenerator() *FetchGenerator { + g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} + g.ApiTemplateFiles["api.mustache"] = ".go" + g.ModelTemplateFiles["model.mustache"] = ".go" + + return g +} + +// GetName returns the generator name. +func (g *FetchGenerator) GetName() string { return "go-fetch" } + +// GetTag returns the generator type. +func (g *FetchGenerator) GetTag() generator.GeneratorType { return generator.GeneratorTypeClient } + +// GetHelp returns the help text. +func (g *FetchGenerator) GetHelp() string { + return "Generates a Go client library that imports only the standard library." +} + +// ProcessOpts processes options and initializes the generator. +func (g *FetchGenerator) ProcessOpts() error { + cfg := g.GoConfig + if cfg == nil { + cfg = config.NewGoFetchConfig() + } + + pkg := packageName(cfg.PackageName) + + module := cfg.ModuleName + if module == "" { + module = pkg + } + + g.ApiPackage, g.ModelPackage = "", "" + + g.AdditionalProperties["packageName"] = pkg + g.AdditionalProperties["moduleName"] = module + g.AdditionalProperties["withGoMod"] = cfg.WithGoMod + + // ponytail: runtime.go's BasePath is servers[0].url as written, server + // variables are not substituted; a caller with a templated URL sets + // Configuration.BasePath. The upgrade is filling in their defaults. + g.SupportingFiles = append(g.SupportingFiles, + generator.NewSupportingFile("runtime.mustache", "", "runtime.go"), + generator.NewSupportingFile("README.mustache", "", "README.md"), + ) + if cfg.WithGoMod { + g.SupportingFiles = append(g.SupportingFiles, generator.NewSupportingFile("gomod.mustache", "", "go.mod")) + } + + return nil +} + +// packageName keeps the lower-case letters and digits of name, "openapi" +// when none are left; a keyword gets an api suffix. +func packageName(name string) string { + var b strings.Builder + + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9' && b.Len() > 0) { + b.WriteRune(r) + } + } + + switch pkg := b.String(); { + case pkg == "": + return "openapi" + case keywords[pkg]: + return pkg + "api" + default: + return pkg + } +} + +// PostProcessModels types every model for Go: struct fields with allOf +// flattened, enums, aliases and union wrappers. +func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*codegen.CodegenModel { + bySchema := make(map[string]*codegen.CodegenModel, len(models)) + for _, m := range models { + bySchema[m.SchemaName] = m + } + + for _, m := range models { + if len(m.AllOf) > 0 { + flattenAllOf(m, bySchema) + } + } + + kinds := make(map[string]string, len(models)) + + for _, m := range models { + kind := modelKind(m) + kinds[m.Classname] = kind + g.types[m.Classname] = true + + if kind == "enum" || kind == "union" { + g.refs[m.SchemaName] = m.Classname + } + + g.required[m.Classname] = requiredKeys(m.AllVars) + g.pinned[m.Classname] = pinned(m.Classname, m.AllVars) + + if kind == "alias" { + if t := aliasType(m); isContainer(t) { + g.containers[m.Classname] = true + } + } + } + + fields := make(map[string][]map[string]any) + + for _, m := range models { + ext := clone(m.VendorExtensions) + ext["x-go-doc"] = docLines(m.Description) + ext["x-go-enums"] = []map[string]any{} + ext["x-go-"+kinds[m.Classname]] = true + + switch kinds[m.Classname] { + case "struct": + fields[m.Classname] = g.structFields(m, ext, g.modelTypeName) + ext["x-go-fields"] = fields[m.Classname] + + inline, _ := ext["x-go-inline"].([]map[string]any) + for _, decl := range inline { + if decl["union"] == true { + continue + } + + name, _ := decl["name"].(string) + fields[name], _ = decl["fields"].([]map[string]any) + kinds[name] = "struct" + } + + case "enum": + ext["x-go-enums"] = []map[string]any{enumDecl(m.Classname, EnumBaseType(m.AllowableValues, m.IsBoolean), m.AllowableValues, nil)} + case "alias": + ext["x-go-alias-type"] = aliasType(m) + case "union": + ext["x-go-doc"] = docLines(m.Description, m.Classname+" holds one of its variants as raw JSON:\nthe As methods decode it, the From methods set it.") + g.unionExtensions(m, ext, bySchema) + } + + m.VendorExtensions = ext + } + + breakCycles(fields, kinds) + + return models +} + +// modelKind is how a model is declared: union (oneOf/anyOf), enum, struct +// (properties or allOf) or alias of a slice, map or scalar type. +// +// ponytail: a struct drops additionalProperties next to its properties, so +// extra keys are lost on decode; the upgrade is an AdditionalProperties map +// field with its own (Un)MarshalJSON. A discriminator without oneOf (allOf +// inheritance) makes no union either: the parent stays a plain struct; the +// upgrade is a union wrapper for the parent too. +func modelKind(m *codegen.CodegenModel) string { + switch { + case len(m.OneOf) > 0 || len(m.AnyOf) > 0: + return "union" + case m.IsEnum: + return "enum" + case len(m.AllVars) > 0 || len(m.AllOf) > 0: + return "struct" + } + + return "alias" +} + +// aliasType is the type a model without properties stands for. +func aliasType(m *codegen.CodegenModel) string { + switch { + case m.IsArray: + return "[]" + itemType(m.Items, "") + case m.AdditionalProperties != nil: + return "map[string]" + itemType(m.AdditionalProperties, "") + case m.IsString: + return "string" + case m.IsInteger: + return "int" + case m.IsNumber: + return "float64" + case m.IsBoolean: + return "bool" + case m.DataType == "any" || m.IsAdditionalPropertiesTrue: + return "map[string]any" + } + + return "any" +} + +// isContainer reports whether a type is nil-able as it is (slice, map, any, +// raw JSON), so an optional field of it needs no pointer. +func isContainer(t string) bool { + return strings.HasPrefix(t, "[]") || strings.HasPrefix(t, "map[") || + t == "any" || t == "json.RawMessage" || t == "io.Reader" +} + +// flattenAllOf gives a model built with allOf its ancestors' properties: the +// parser records the members only by name. Ancestors come first, in allOf +// order; a property declared again nearer the model replaces the inherited +// one in place, carrying its own required. +func flattenAllOf(m *codegen.CodegenModel, bySchema map[string]*codegen.CodegenModel) { + vars := allVars(m, bySchema, map[string]bool{m.SchemaName: true}) + + m.AllVars = vars + m.RequiredVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return !v.Required }) + m.OptionalVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return v.Required }) + m.ReadOnlyVars = slices.DeleteFunc(slices.Clone(vars), func(v *codegen.CodegenProperty) bool { return !v.IsReadOnly }) +} + +func allVars(m *codegen.CodegenModel, bySchema map[string]*codegen.CodegenModel, visiting map[string]bool) []*codegen.CodegenProperty { + var out []*codegen.CodegenProperty + + at := make(map[string]int) + add := func(v *codegen.CodegenProperty) { + if i, ok := at[v.BaseName]; ok { + out[i] = v + return + } + + at[v.BaseName] = len(out) + out = append(out, v) + } + + for _, name := range m.AllOf { + parent := bySchema[name] + if parent == nil || visiting[name] { + continue + } + + visiting[name] = true + + for _, v := range allVars(parent, bySchema, visiting) { + add(v) + } + + delete(visiting, name) + } + + for _, v := range m.Vars { + add(v) + } + + return out +} + +// structFields types the properties of a struct model and declares its +// inline enums and objects in ext, named by typeName. +func (g *FetchGenerator) structFields(m *codegen.CodegenModel, ext map[string]any, typeName func(string) string) []map[string]any { + var ( + fields []map[string]any + enums []map[string]any + inline []map[string]any + ) + + names := make(map[string]int) + enumNames := make(map[string]string) + + for _, v := range m.AllVars { + named := "" + + what := "the " + v.BaseName + " property of " + m.Classname + if v.IsArray || v.IsMap { + what = "an item of " + what + } + + if obj := inlineObject(v); obj != nil { + named = typeName(m.Classname + v.Name) + decls, nested := g.inlineStructs(named, what, obj.Description, obj.Vars, typeName) + inline = append(inline, decls...) + enums = append(enums, nested...) + } + + if u := inlineUnion(v); u != nil { + named = typeName(m.Classname + v.Name) + decls, nested := g.unionDecl(named, what, u.Description, u.ComposedSchemas, typeName) + inline = append(inline, decls...) + enums = append(enums, nested...) + } + + if ref := g.refs[v.Ref[strings.LastIndexByte(v.Ref, '/')+1:]]; v.Ref != "" && ref != "" { + named = ref + } else if v.IsEnum { + base := m.Classname + exported(v.EnumName, "") + + named = enumNames[base] + if named == "" { + named = typeName(base) + enumNames[base] = named + + elem := v + for elem.IsArray && elem.Items != nil { + elem = elem.Items + } + + enums = append(enums, enumDecl(named, EnumBaseType(v.AllowableValues, elem.IsBoolean), v.AllowableValues, v)) + } + } + + t := GoType(v, named) + ref := "" + tag := v.BaseName + + optional := !v.Required || v.IsReadOnly + container := isContainer(t) || g.containers[t] + + switch { + case optional && v.IsNullable: + if !container { + ref = t + } + + t = "Opt[" + t + "]" + tag += ",omitzero" + + case optional && container: + // nil is absent; an empty slice or map is still sent, as [] / {}. + tag += ",omitzero" + case optional: + t = "*" + t + tag += ",omitempty" + + case v.IsNullable && !container: + t = "*" + t + case !container: + ref = t + } + + // ponytail: a required slice or map left nil encodes as null, which a + // server may refuse; the upgrade is a MarshalJSON that sends [] / {}. + + fields = append(fields, map[string]any{ + "name": uniqueName(names, v.Name), + "type": t, + "tag": jsonTag(tag), + "doc": docLines(v.Description), + "ref": ref, + }) + } + + ext["x-go-enums"] = enums + ext["x-go-inline"] = inline + + return fields +} + +// inlineObject is the inline object a property holds, itself or as the +// element of its slices and maps; nil when there is none. +func inlineObject(p *codegen.CodegenProperty) *codegen.CodegenProperty { + for p != nil && (p.IsArray || p.IsMap) { + p = p.Items + } + + if p == nil || len(p.Vars) == 0 { + return nil + } + + return p +} + +// inlineUnion is the inline oneOf/anyOf a property holds, itself or as the +// element of its slices and maps; nil when there is none. +func inlineUnion(p *codegen.CodegenProperty) *codegen.CodegenProperty { + for p != nil && (p.IsArray || p.IsMap) { + p = p.Items + } + + if p == nil || p.ComposedSchemas == nil { + return nil + } + + return p +} + +// unionDecl declares the union wrapper name for an inline oneOf/anyOf, the +// model template's union with a variant per member and the discriminator +// its variants imply. +func (g *FetchGenerator) unionDecl(name, what, description string, members *codegen.CodegenComposedSchemas, typeName func(string) string) (decls, enums []map[string]any) { + var types []string + + for i, member := range slices.Concat(members.OneOf, members.AnyOf) { + named := g.refs[member.Ref[strings.LastIndexByte(member.Ref, '/')+1:]] + + // An inline object member is a struct of its own, named as upstream + // names it: OneOf, OneOf1, ... + if obj := inlineObject(member); obj != nil { + kind := "OneOf" + if i >= len(members.OneOf) { + kind, i = "AnyOf", i-len(members.OneOf) + } + + if i > 0 { + kind += strconv.Itoa(i) + } + + named = typeName(name + kind) + structs, nested := g.inlineStructs(named, "a variant of "+name, obj.Description, obj.Vars, typeName) + decls = append(decls, structs...) + enums = append(enums, nested...) + + g.required[named] = requiredKeys(obj.Vars) + g.pinned[named] = pinned(named, obj.Vars) + } + + types = append(types, GoType(member, named)) + } + + variants, _ := g.variants(types) + + union := map[string]any{ + "name": name, "union": true, "classname": name, + "vendorExtensions": map[string]any{ + "x-go-doc": docLines(name+" is "+what+", one of its variants as raw JSON:\nthe As methods decode it, the From methods set it.", description), + "x-go-variants": variants, + "x-go-discriminator": impliedDiscriminator(variants), + }, + } + + return append([]map[string]any{union}, decls...), enums +} + +// requiredKeys is the quoted names of the required vars, comma-separated. +func requiredKeys(vars []*codegen.CodegenProperty) string { + var required []string + + for _, v := range vars { + if v.Required { + required = append(required, strconv.Quote(v.BaseName)) + } + } + + return strings.Join(required, ", ") +} + +// pinned lists the properties of a struct an enum of one value fixes, a +// pydantic Literal or an OpenAPI 3.1 const: a union's From sets them, its +// As refuses a value with another. Fields are named as structFields does. +func pinned(class string, vars []*codegen.CodegenProperty) []map[string]any { + var out []map[string]any + + names := make(map[string]int) + + for _, v := range vars { + field := uniqueName(names, v.Name) + + values, _ := v.AllowableValues["enumVars"].([]map[string]any) + if !v.IsEnum || len(values) != 1 || !v.Required || v.IsNullable || v.IsReadOnly || v.IsArray || v.IsMap { + continue + } + + decl := enumDecl("", EnumBaseType(v.AllowableValues, v.IsBoolean), v.AllowableValues, nil) + literal, _ := decl["values"].([]map[string]any)[0]["literal"].(string) + + out = append(out, map[string]any{ + "field": field, "key": v.BaseName, "literal": literal, + "mismatch": strconv.Quote("not a " + class + ": " + strings.ReplaceAll(v.BaseName, "%", "%%") + " is %v"), + }) + } + + return out +} + +// impliedDiscriminator is the discriminator of a union whose every variant +// pins one string property to a value of its own, false when there is none. +func impliedDiscriminator(variants []map[string]any) any { + if len(variants) == 0 { + return false + } + + first, _ := variants[0]["pinned"].([]map[string]any) + + for _, candidate := range first { + key := candidate["key"] + values := make(map[string]bool) + + var mappings []map[string]any + + for _, variant := range variants { + pins, _ := variant["pinned"].([]map[string]any) + + i := slices.IndexFunc(pins, func(p map[string]any) bool { return p["key"] == key }) + if i < 0 { + break + } + + literal, _ := pins[i]["literal"].(string) + if values[literal] || !strings.HasPrefix(literal, `"`) { + break + } + + values[literal] = true + mappings = append(mappings, map[string]any{"value": literal, "type": variant["type"]}) + } + + if len(mappings) == len(variants) { + name, _ := key.(string) + + return map[string]any{"name": name, "quoted": strconv.Quote(name), "tag": jsonTag(name), "mappings": mappings} + } + } + + return false +} + +// inlineStructs declares the struct name for an inline object's properties, +// and the inline enums and objects those hold. what says where the object +// sits, for the doc comment. +func (g *FetchGenerator) inlineStructs(name, what, description string, vars []*codegen.CodegenProperty, typeName func(string) string) (decls, enums []map[string]any) { + ext := map[string]any{} + fields := g.structFields(&codegen.CodegenModel{Classname: name, AllVars: vars}, ext, typeName) + + nested, _ := ext["x-go-inline"].([]map[string]any) + enums, _ = ext["x-go-enums"].([]map[string]any) + + decl := map[string]any{"name": name, "union": false, "doc": docLines(name+" is "+what+".", description), "fields": fields} + + return append([]map[string]any{decl}, nested...), enums +} + +// modelTypeName names an inline type: base, with a count when a type has +// the name already (Foo + BarBaz and FooBar + Baz run together). +func (g *FetchGenerator) modelTypeName(base string) string { + name := base + for i := 2; g.types[name]; i++ { + name = base + strconv.Itoa(i) + } + + g.types[name] = true + + return name +} + +// opTypeNamer names the inline types of operation op as modelTypeName does, +// the same name each time it is asked again: an operation under several tags +// is processed once per tag. +func (g *FetchGenerator) opTypeNamer(op string) func(string) string { + return func(base string) string { + key := op + "\x00" + base + if name, ok := g.opTypes[key]; ok { + return name + } + + name := g.modelTypeName(base) + g.opTypes[key] = name + + return name + } +} + +// uniqueName suffixes a name already taken in a struct with a count. +func uniqueName(taken map[string]int, name string) string { + taken[name]++ + if n := taken[name]; n > 1 { + for taken[name+strconv.Itoa(n)] > 0 { + n++ + } + + name += strconv.Itoa(n) + taken[name]++ + } + + return name +} + +// jsonTag is the struct tag for a JSON name and options, as a raw string +// unless the name needs escaping. +func jsonTag(nameOpts string) string { + tag := "json:" + strconv.Quote(nameOpts) + if strings.ContainsAny(tag, "`") { + return strconv.Quote(tag) + } + + return "`" + tag + "`" +} + +// enumDecl is a named enum type with one constant per value. owner is the +// property an inline enum belongs to, nil for an enum model. +func enumDecl(name, base string, allowable map[string]any, owner *codegen.CodegenProperty) map[string]any { + vars, _ := allowable["enumVars"].([]map[string]any) + unescape := strings.NewReplacer(`\\`, `\`, `\'`, `'`) + + values := make([]map[string]any, 0, len(vars)) + for _, v := range vars { + literal, _ := v["value"].(string) + if base == "string" { + literal = strconv.Quote(unescape.Replace(literal)) + } + + member, _ := v["name"].(string) + values = append(values, map[string]any{ + "name": name + exported(member, ""), + "type": name, + "literal": literal, + }) + } + + var doc []string + if owner != nil { + doc = []string{"// " + name + " is a value of " + owner.BaseName + "."} + } + + return map[string]any{"name": name, "base": base, "values": values, "doc": doc} +} + +// breakCycles turns into a pointer each struct-typed field held by value +// (required, or in an Opt) that closes a cycle of such fields (A holds a B +// holds an A), which Go cannot lay out. Models are walked in name order, so +// the choice is stable. +func breakCycles(fields map[string][]map[string]any, kinds map[string]string) { + state := make(map[string]int) // 1 on the path, 2 done + + var visit func(name string) + + visit = func(name string) { + state[name] = 1 + + for _, f := range fields[name] { + ref, _ := f["ref"].(string) + if kinds[ref] != "struct" { + continue + } + + switch state[ref] { + case 1: + if t, _ := f["type"].(string); strings.HasPrefix(t, "Opt[") { + f["type"] = "Opt[*" + ref + "]" + } else { + f["type"] = "*" + ref + } + + case 0: + visit(ref) + } + } + + state[name] = 2 + } + + names := slices.Sorted(maps.Keys(fields)) + for _, name := range names { + if state[name] == 0 { + visit(name) + } + } +} + +// unionExtensions describes a oneOf/anyOf model's variants and, with a +// discriminator, how its values map to models. +func (g *FetchGenerator) unionExtensions(m *codegen.CodegenModel, ext map[string]any, bySchema map[string]*codegen.CodegenModel) { + var types []string + + types = append(types, m.OneOfModels...) + for _, item := range m.OneOfArrays { + types = append(types, "[]"+item) + } + + for _, p := range m.OneOfPrimitives { + types = append(types, GoType(p, "")) + } + + for _, name := range m.AnyOf { + types = append(types, g.ToModelName(name)) + } + + variants, seen := g.variants(types) + ext["x-go-variants"] = variants + + d := m.Discriminator + if d == nil { + ext["x-go-discriminator"] = impliedDiscriminator(variants) + return + } + + var mappings []map[string]any + + for _, mm := range d.MappedModels { + mappings = append(mappings, map[string]any{"value": strconv.Quote(mm.MappingName), "type": g.ToModelName(mm.ModelName)}) + } + + // Without a mapping a value names its schema, as OpenAPI defines. + if len(d.Mapping) == 0 { + implicit := make(map[string]string) + + for _, member := range bySchema { + if seen[member.Classname] { + implicit[member.SchemaName] = member.Classname + } + } + + for _, value := range slices.Sorted(maps.Keys(implicit)) { + mappings = append(mappings, map[string]any{"value": strconv.Quote(value), "type": implicit[value]}) + } + } + + ext["x-go-discriminator"] = map[string]any{ + "name": d.PropertyBaseName, + "quoted": strconv.Quote(d.PropertyBaseName), + "tag": jsonTag(d.PropertyBaseName), + "mappings": mappings, + } +} + +// variants describes a union's variant per type, in order, without repeats; +// seen holds the types. A model variant's required properties tell a value +// of it from the others: As refuses JSON that lacks one. +func (g *FetchGenerator) variants(types []string) (variants []map[string]any, seen map[string]bool) { + seen = make(map[string]bool) + + for _, t := range types { + if seen[t] { + continue + } + + seen[t] = true + variants = append(variants, map[string]any{"type": t, "method": variantName(t), "required": g.required[t], "pinned": g.pinned[t]}) + } + + return variants, seen +} + +// variantName names the AsX/FromX methods of a union variant after its +// type: Cat, CatList for []Cat, StringMap for map[string]string, Time. +func variantName(t string) string { + switch { + case strings.HasPrefix(t, "[]"): + return variantName(t[2:]) + "List" + case strings.HasPrefix(t, "map[string]"): + return variantName(t[len("map[string]"):]) + "Map" + } + + t = t[strings.LastIndexByte(t, '.')+1:] + + return strings.ToUpper(t[:1]) + t[1:] +} + +// docLines turns texts into // comment lines, a bare // between texts. +func docLines(texts ...string) []string { + lines := []string{} + + for _, text := range texts { + text = strings.TrimSpace(strings.ReplaceAll(text, "\r", "")) + if text == "" { + continue + } + + if len(lines) > 0 { + lines = append(lines, "//") + } + + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimRight(line, " \t"); line == "" { + lines = append(lines, "//") + } else { + lines = append(lines, "// "+line) + } + } + } + + return lines +} + +// clone copies vendor extensions before they are written to: the parser +// shares the spec's own map between copies of an operation. +func clone(ext map[string]any) map[string]any { + if ext == nil { + return map[string]any{} + } + + return maps.Clone(ext) +} + +// PostProcessOperations names each operation's methods and Params type and +// types its parameters, body, responses and auth for the api template. +func (g *FetchGenerator) PostProcessOperations(ops []*codegen.CodegenOperation) []*codegen.CodegenOperation { + names := make(map[string]bool, len(ops)) + for _, op := range ops { + names[g.SanitizeOperationId(op.OperationId)] = true + } + + for _, op := range ops { + base := g.SanitizeOperationId(op.OperationId) + + // FooRaw and FooRequest are Foo's own methods. + name := base + for _, suffix := range []string{"Raw", "Request"} { + if s, ok := strings.CutSuffix(base, suffix); ok && names[s] { + name = base + "Method" + } + } + + op.Nickname = name + + ext := clone(op.VendorExtensions) + ext["x-go-name"] = name + ext["x-go-params"] = g.opTypeNamer(op.OperationId)(base + "Params") + ext["x-go-has-params"] = len(op.AllParams) > 0 + ext["x-go-doc"] = docLines(name+" sends "+op.HttpMethod+" "+op.Path+".", op.Summary, op.Notes) + ext["x-go-path"] = strconv.Quote(op.Path) + ext["x-go-method"] = strconv.Quote(op.HttpMethod) + ext["x-go-auth"] = authEntries(op) + + ext["x-go-inline"], ext["x-go-inline-enums"] = []map[string]any{}, []map[string]any{} + g.paramExtensions(op, ext, base) + g.responseExtensions(op, ext, base) + + op.VendorExtensions = ext + } + + return ops +} + +// paramExtensions declares the Params fields and, per location, how each +// parameter is sent. An inline object parameter gets a type named after the +// operation (base) and the parameter. +func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) { + taken := make(map[string]int) + fields := []map[string]any{} + byLocation := map[string][]map[string]any{} + body := "" + + for _, p := range op.AllParams { + named := "" + + vars, description := p.Vars, p.Description + if obj := inlineObject(p.Items); len(vars) == 0 && obj != nil && (p.IsArray || p.IsMap) { + vars, description = obj.Vars, obj.Description + } + + typeName := g.opTypeNamer(op.OperationId) + what := "the " + p.BaseName + " parameter of " + base + + if len(vars) > 0 { + named = typeName(base + p.ParamName) + g.opInline(ext)(g.inlineStructs(named, what, description, vars, typeName)) + } + + if p.ComposedSchemas != nil { + named = typeName(base + p.ParamName) + g.opInline(ext)(g.unionDecl(named, what, p.Description, p.ComposedSchemas, typeName)) + } + + t := GoParamType(p, named) + if p.IsBodyParam && p.DataType == "Blob" { + t = "io.Reader" + } + + if !p.Required && !isContainer(t) && !g.containers[t] { + t = "*" + t + } + + field := uniqueName(taken, p.ParamName) + fields = append(fields, map[string]any{"name": field, "type": t, "doc": docLines(p.Description)}) + + entry := map[string]any{ + "field": field, + "quoted": strconv.Quote(p.BaseName), + "style": paramStyle(p), + "explode": p.IsExplode, + } + + switch { + case p.IsBodyParam: + body = field + g.bodyKind(p, t, field, ext) + + case p.IsFormParam: + byLocation["form"] = append(byLocation["form"], entry) + + case p.IsPathParam: + entry["placeholder"] = strconv.Quote("{" + p.BaseName + "}") + byLocation["path"] = append(byLocation["path"], entry) + + case p.IsQueryParam: + byLocation["query"] = append(byLocation["query"], entry) + case p.IsHeaderParam: + byLocation["header"] = append(byLocation["header"], entry) + case p.IsCookieParam: + byLocation["cookie"] = append(byLocation["cookie"], entry) + } + } + + ext["x-go-fields"] = fields + for _, loc := range []string{"path", "query", "header", "cookie", "form"} { + ext["x-go-in-"+loc] = byLocation[loc] + } + + // ponytail: a multipart body is built in a bytes.Buffer, files included, + // so a request holds its uploads in memory; the upgrade is an io.Pipe + // that a goroutine writes once the request is sent. + form := len(byLocation["form"]) > 0 + ext["x-go-has-body"] = body != "" || form + ext["x-go-body-multipart"] = form && op.IsMultipart + ext["x-go-body-form"] = form && !op.IsMultipart +} + +// bodyKind sets how a body parameter is encoded: as it is for a reader, as +// text for a string the content type does not mark as JSON, else as JSON. +func (g *FetchGenerator) bodyKind(p *codegen.CodegenParameter, t, field string, ext map[string]any) { + ext["x-go-content-type"] = strconv.Quote(p.ContentType) + + switch { + case t == "io.Reader": + ext["x-go-body-reader"] = field + case strings.TrimPrefix(t, "*") == "string" && !isJSONMime(p.ContentType): + ext["x-go-body-text"] = field + default: + ext["x-go-body-json"] = field + } +} + +// opInline returns a func adding an operation's inline type declarations and +// enums to ext, for the API file to declare next to the Params type. +func (g *FetchGenerator) opInline(ext map[string]any) func(decls, enums []map[string]any) { + return func(decls, enums []map[string]any) { + declared, _ := ext["x-go-inline"].([]map[string]any) + ext["x-go-inline"] = append(declared, decls...) + + declaredEnums, _ := ext["x-go-inline-enums"].([]map[string]any) + ext["x-go-inline-enums"] = append(declaredEnums, enums...) + } +} + +// isJSONMime reports whether a media type is JSON, application/json or a +// +json suffix, as the runtime's check. +func isJSONMime(mediaType string) bool { + mt, _, _ := strings.Cut(strings.ToLower(mediaType), ";") + mt = strings.TrimSpace(mt) + + return mt == "" || mt == "application/json" || strings.HasSuffix(mt, "+json") +} + +// paramStyle is a parameter's OpenAPI style with the default of its +// location filled in. +func paramStyle(p *codegen.CodegenParameter) string { + switch { + case p.IsDeepObject: + return "deepObject" + case p.Style != "": + return p.Style + case p.IsPathParam || p.IsHeaderParam: + return "simple" + } + + return "form" +} + +// responseType is the Go type of a response body, "" for none. +func responseType(r *codegen.CodegenResponse) string { + switch { + case r.IsBinary || r.IsFile: + return "[]byte" + case r.DataType == "": + return "" + case r.ReturnProperty == nil: + return r.DataType // a $ref, which the parser names but leaves untyped + } + + return GoType(r.ReturnProperty, "") +} + +// responseExtensions types the operation's result and the model each error +// status decodes into. A result that a 2xx status sends without a body is a +// pointer, nil for that status, unless it is nil-able already or a string, +// whose empty text says the same. An inline object result gets a type named +// after the operation (base). +func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) { + t := "" + + switch { + case op.IsResponseBinary: + t = "[]byte" + ext["x-go-decode-bytes"] = true + + case op.ReturnType == "": + ext["x-go-discard"] = true + case op.ReturnProperty == nil: + t = op.ReturnType + default: + named := "" + + typeName := g.opTypeNamer(op.OperationId) + + if obj := inlineObject(op.ReturnProperty); obj != nil { + named = typeName(base + "Response") + g.opInline(ext)(g.inlineStructs(named, "the response of "+base, obj.Description, obj.Vars, typeName)) + } + + if u := inlineUnion(op.ReturnProperty); u != nil { + named = typeName(base + "Response") + g.opInline(ext)(g.unionDecl(named, "the response of "+base, u.Description, u.ComposedSchemas, typeName)) + } + + t = GoType(op.ReturnProperty, named) + } + + if t != "" && t != "string" && !isContainer(t) && !g.containers[t] { + var empty []string + + for _, r := range op.Responses { + if r.Is2xx && responseType(r) == "" { + empty = append(empty, r.Code) + } + } + + if len(empty) > 0 { + t = "*" + t + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// The result is nil for a "+strings.Join(empty, " or ")+" response, which has no body.") + } + } + + ext["x-go-type"] = t + if t != "" && !op.IsResponseBinary { + ext["x-go-decode-json"] = t + } + + var codes, ranges []map[string]any + + var def map[string]any + + for _, r := range op.Responses { + rt := responseType(r) + if rt == "" || rt == "[]byte" || r.Is2xx { + continue + } + + // A body without the model's required properties is not that model. + keys := "nil" + if required := g.required[rt]; required != "" { + keys = "[]string{" + required + "}" + } + + switch _, err := strconv.Atoi(r.Code); { + case r.IsDefault: + def = map[string]any{"type": rt, "keys": keys} + case len(r.Code) == 3 && strings.EqualFold(r.Code[1:], "XX"): + ranges = append(ranges, map[string]any{"digit": r.Code[:1], "type": rt, "keys": keys}) + case err == nil: + codes = append(codes, map[string]any{"code": r.Code, "type": rt, "keys": keys}) + } + } + + if len(codes) > 0 || len(ranges) > 0 || def != nil { + ext["x-go-errors"] = map[string]any{ + "codes": codes, "hasCodes": len(codes) > 0, + "ranges": ranges, "hasRanges": len(ranges) > 0, + "default": def, + } + } +} + +// authEntries lists how each security scheme of the operation is sent. +func authEntries(op *codegen.CodegenOperation) []map[string]any { + opName := op.OperationIdOriginal + if opName == "" { + opName = op.OperationId + } + + entries := []map[string]any{} + + for _, a := range op.AuthMethods { + e := map[string]any{ + "op": strconv.Quote(opName), "scheme": strconv.Quote(a.Name), + "apiKey": false, "basic": false, "bearer": false, + } + + switch { + case a.IsApiKey: + in := "header" + if a.IsKeyInQuery { + in = "query" + } else if a.IsKeyInCookie { + in = "cookie" + } + + e["apiKey"], e["key"], e["in"] = true, strconv.Quote(a.KeyParamName), in + + case a.IsBasicBasic: + e["basic"] = true + case a.IsBasicBearer || a.IsOAuth || a.IsOpenId: + scopes := "nil" + + if len(a.Scopes) > 0 { + quoted := make([]string, len(a.Scopes)) + for i, s := range a.Scopes { + quoted[i] = strconv.Quote(fmt.Sprint(s["scope"])) + } + + scopes = "[]string{" + strings.Join(quoted, ", ") + "}" + } + + e["bearer"], e["scopes"] = true, scopes + + default: + continue + } + + entries = append(entries, e) + } + + return entries +} diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go new file mode 100644 index 0000000..bfc54a5 --- /dev/null +++ b/internal/generator/golang/fetch_test.go @@ -0,0 +1,279 @@ +package golang + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" + "github.com/xseman/openapi-generator/internal/parser" +) + +// parse runs a spec (a feature spec name or inline YAML) through the parser +// and the generator, as the pipeline does. +func parse(t *testing.T, spec string) (*FetchGenerator, *parser.Parser) { + t.Helper() + + g := NewFetchGenerator() + if err := g.ProcessOpts(); err != nil { + t.Fatal(err) + } + + p := parser.NewParser() + p.GetTypeFunc, p.ToModelNameFunc, p.ToVarNameFunc = g.GetSchemaType, g.ToModelName, g.ToVarName + p.SkipValidation = true + + var err error + if strings.Contains(spec, "\n") { + err = p.LoadFromData([]byte(spec)) + } else { + err = p.LoadFromFile(filepath.Join("..", "..", "..", "testdata", "specs", "feature", spec+".yaml")) + } + + if err != nil { + t.Fatal(err) + } + + return g, p +} + +func models(t *testing.T, spec string) map[string]*codegen.CodegenModel { + t.Helper() + + g, p := parse(t, spec) + + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + byName := make(map[string]*codegen.CodegenModel) + for _, m := range g.PostProcessModels(ms) { + byName[m.Classname] = m + } + + return byName +} + +// fields renders a struct model's fields as "Name Type Tag" lines. +func fields(m *codegen.CodegenModel) string { + fs, _ := m.VendorExtensions["x-go-fields"].([]map[string]any) + + lines := make([]string, len(fs)) + for i, f := range fs { + lines[i] = fmt.Sprintf("%v %v %v", f["name"], f["type"], f["tag"]) + } + + return strings.Join(lines, "\n") +} + +func TestAllOfFlattened(t *testing.T) { + tests := []struct { + spec, model, want string + }{ + {"composition-allof-inheritance", "Car", "ID string `json:\"id\"`\nMake string `json:\"make\"`\nDoors int `json:\"doors\"`\nTrunkVolumeLiters Opt[float64] `json:\"trunkVolumeLiters,omitzero\"`"}, + {"composition-allof-inheritance", "SportsCar", "ID string `json:\"id\"`\nMake string `json:\"make\"`\nDoors int `json:\"doors\"`\nTrunkVolumeLiters Opt[float64] `json:\"trunkVolumeLiters,omitzero\"`\nTopSpeedKph *int `json:\"topSpeedKph,omitempty\"`"}, + {"discriminator-mapping", "Cat", "PetType string `json:\"petType\"`\nName *string `json:\"name,omitempty\"`\nHuntingSkill *string `json:\"huntingSkill,omitempty\"`"}, + {"discriminator-mapping", "Dog", "PetType string `json:\"petType\"`\nName *string `json:\"name,omitempty\"`\nPackSize *int `json:\"packSize,omitempty\"`"}, + {"discriminator-self-ref", "LeafNode", "NodeType string `json:\"nodeType\"`\nChildren []TreeNode `json:\"children,omitzero\"`\nValue *string `json:\"value,omitempty\"`"}, + } + + for _, tt := range tests { + if got := fields(models(t, tt.spec)[tt.model]); got != tt.want { + t.Errorf("%s fields:\n%s\nwant:\n%s", tt.model, got, tt.want) + } + } +} + +func TestEnums(t *testing.T) { + ms := models(t, "enum-edge-values") + + tests := []struct { + model, base string + literals []string + }{ + {"Level", "int", []string{"-1", "1", "2"}}, + {"Severity", "string", []string{`"low"`, `"LOW"`, `"it's"`, `"a\\b"`}}, + {"State", "string", []string{`"placed"`, `"approved"`}}, + {"Rating", "int", []string{"1", "5"}}, + } + + for _, tt := range tests { + enums, _ := ms[tt.model].VendorExtensions["x-go-enums"].([]map[string]any) + if len(enums) != 1 || enums[0]["base"] != tt.base { + t.Errorf("%s enums = %v, want one of base %s", tt.model, enums, tt.base) + continue + } + + values, _ := enums[0]["values"].([]map[string]any) + + var literals []string + for _, v := range values { + literals = append(literals, fmt.Sprint(v["literal"])) + } + + if strings.Join(literals, " ") != strings.Join(tt.literals, " ") { + t.Errorf("%s literals = %v, want %v", tt.model, literals, tt.literals) + } + } + + // A property that refers to an enum model takes its type, no copy. + want := "Level Opt[Level] `json:\"level,omitzero\"`\nSeverity *Severity `json:\"severity,omitempty\"`\n" + + "State *State `json:\"state,omitempty\"`\nRating *Rating `json:\"rating,omitempty\"`" + if got := fields(ms["Levels"]); got != want { + t.Errorf("Levels fields:\n%s\nwant:\n%s", got, want) + } + + if enums, _ := ms["Levels"].VendorExtensions["x-go-enums"].([]map[string]any); len(enums) != 0 { + t.Errorf("Levels declares enums %v", enums) + } +} + +func TestCyclesAndCollisions(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + A: + type: object + required: [b] + properties: {b: {$ref: "#/components/schemas/B"}} + B: + type: object + required: [a] + properties: {a: {$ref: "#/components/schemas/A"}} + Node: + type: object + required: [next, id, ID] + properties: + next: {$ref: "#/components/schemas/Node"} + id: {type: string} + ID: {type: string} + parent: {allOf: [{$ref: "#/components/schemas/Node"}], nullable: true} + Names: + type: object + required: [foo2, foo, Foo] + properties: {foo2: {type: string}, foo: {type: string}, Foo: {type: string}} + Owner: + type: object + required: [pet] + properties: + pet: {$ref: "#/components/schemas/Pet"} + pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Pet: + oneOf: [{$ref: "#/components/schemas/A"}, {$ref: "#/components/schemas/B"}] +`) + + for model, want := range map[string]string{ + "A": "B B `json:\"b\"`", + "B": "A *A `json:\"a\"`", + "Node": "Next *Node `json:\"next\"`\nID string `json:\"id\"`\nID2 string `json:\"ID\"`\nParent Opt[*Node] `json:\"parent,omitzero\"`", + "Names": "Foo2 string `json:\"foo2\"`\nFoo string `json:\"foo\"`\nFoo3 string `json:\"Foo\"`", + "Owner": "Pet Pet `json:\"pet\"`\nPets []Pet `json:\"pets,omitzero\"`", + } { + if got := fields(ms[model]); got != want { + t.Errorf("%s fields:\n%s\nwant:\n%s", model, got, want) + } + } +} + +func TestResponseTypes(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /pet: + get: + operationId: getPet + responses: + "200": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Pet"}}}} + "4XX": {description: client, content: {application/json: {schema: {type: array, items: {type: string}}}}} + /pets: + get: + operationId: listPets + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Pet"}}}}} + /photo: + get: + operationId: getPhoto + responses: + "200": {description: ok, content: {image/png: {schema: {type: string, format: binary}}}} + /ping: + get: + operationId: ping + responses: + "204": {description: ok} + /job: + post: + operationId: startJob + responses: + "201": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Pet"}}}} + "202": {description: accepted} + get: + operationId: listJobs + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {type: string}}}}} + "204": {description: none} +components: + schemas: + Pet: {type: object, properties: {name: {type: string}}} + GetPetParams: {type: object, properties: {id: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "getPet": "Pet", "listPets": "[]Pet", "getPhoto": "[]byte", "ping": "", + "startJob": "*Pet", "listJobs": "[]string", + } + + // The GetPetParams schema takes the name of getPet's Params type. + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + g.PostProcessModels(ms) + + for _, op := range g.PostProcessOperations(byTag["default"]) { + if got := op.VendorExtensions["x-go-type"]; got != want[op.OperationId] { + t.Errorf("%s returns %q, want %q", op.OperationId, got, want[op.OperationId]) + } + + if params := op.VendorExtensions["x-go-params"]; op.OperationId == "getPet" && params != "GetPetParams2" { + t.Errorf("getPet params = %v, want GetPetParams2 next to the GetPetParams model", params) + } + + if op.OperationId == "getPet" { + errs, _ := op.VendorExtensions["x-go-errors"].(map[string]any) + if ranges, _ := errs["ranges"].([]map[string]any); len(ranges) != 1 || ranges[0]["digit"] != "4" || ranges[0]["type"] != "[]string" { + t.Errorf("getPet error ranges = %v, want 4XX of []string", errs["ranges"]) + } + } + } +} + +// TestAnyOfImplicitDiscriminator checks that an anyOf with a discriminator +// and no mapping maps each member's schema name to it, as a oneOf does. +func TestAnyOfImplicitDiscriminator(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pet: + anyOf: [{$ref: "#/components/schemas/Cat"}, {$ref: "#/components/schemas/Dog"}] + discriminator: {propertyName: kind} + Cat: {type: object, required: [kind], properties: {kind: {type: string}}} + Dog: {type: object, required: [kind], properties: {kind: {type: string}}} +`) + + d, _ := ms["Pet"].VendorExtensions["x-go-discriminator"].(map[string]any) + if got := fmt.Sprint(d["mappings"]); got != `[map[type:Cat value:"Cat"] map[type:Dog value:"Dog"]]` { + t.Errorf("mappings = %s", got) + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 2e6ea4f..3db084e 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -266,7 +266,7 @@ func (p *Parser) operationToCodegen(path, method string, op *openapi3.Operation, // CodegenParameter per property, appending them to the operation's FormParams and // AllParams. This mirrors upstream openapi-generator, which surfaces form fields (e.g. an // uploaded `file`) as individual parameters rather than a single opaque body. -func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Schema, contentType string) { +func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Schema, contentType string, encoding map[string]*openapi3.Encoding) { requiredSet := make(map[string]bool) for _, r := range schema.Required { requiredSet[r] = true @@ -311,7 +311,16 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc AllowableValues: prop.AllowableValues, EnumName: prop.EnumName, UniqueItems: prop.UniqueItems, + Vars: prop.Vars, } + + // The property's encoding with its defaults filled in: form style, + // exploded. Only an application/x-www-form-urlencoded body uses it. + sm := encoding[name].SerializationMethod() + fp.Style = sm.Style + fp.IsExplode = sm.Explode + fp.IsDeepObject = sm.Style == openapi3.SerializationDeepObject + if fp.IsArray { fp.CollectionFormat = "csv" // An array of files must be sent as FormData with one append per @@ -404,7 +413,7 @@ func (p *Parser) requestBody(co *codegen.CodegenOperation, body *openapi3.Reques isForm := strings.HasPrefix(contentType, "multipart/") || contentType == "application/x-www-form-urlencoded" if isForm && isObjectSchema(schema) && len(schema.Properties) > 0 { - p.addFormParams(co, schema, contentType) + p.addFormParams(co, schema, contentType, mediaType.Encoding) if strings.HasPrefix(contentType, "multipart/") { co.IsMultipart = true @@ -470,6 +479,10 @@ func (p *Parser) inlineBody(bodyParam *codegen.CodegenParameter, schema *openapi // bare type. Without this an array body renders as `Array` and an // undefined `ArrayToJSON` helper. prop := p.schemaToProperty("body", schema, required) + p.inlineVars(prop, schema) + p.inlineMembers(prop, schema) + bodyParam.Vars = prop.Vars + bodyParam.ComposedSchemas = prop.ComposedSchemas bodyParam.DataType = prop.DataType bodyParam.BaseType = prop.BaseType bodyParam.IsArray = prop.IsArray diff --git a/internal/parser/parameter.go b/internal/parser/parameter.go index 909c5a4..e701c88 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -81,6 +81,7 @@ func (p *Parser) parameterToCodegen(param *openapi3.Parameter) *codegen.CodegenP cp.IsModel = prop.IsModel cp.IsContainer = prop.IsContainer cp.Items = prop.Items + cp.Vars = prop.Vars cp.AllowableValues = prop.AllowableValues cp.EnumName = prop.EnumName cp.DatatypeWithEnum = prop.DatatypeWithEnum diff --git a/internal/parser/property.go b/internal/parser/property.go index c55fc56..e96fd04 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -61,6 +61,13 @@ func (p *Parser) schemaRefToProperty(name string, ref *openapi3.SchemaRef, requi } prop := p.schemaToProperty(name, ref.Value, required) + if ref.Ref == "" { + p.inlineVars(prop, ref.Value) + p.inlineMembers(prop, ref.Value) + } + + prop.Ref = ref.Ref + prop.IsEnumRef = ref.Ref != "" && prop.IsEnum // Only object-like targets need the override: arrays, enums and primitives are // already typed correctly by schemaToProperty, and clobbering them with the @@ -83,6 +90,46 @@ func (p *Parser) schemaRefToProperty(name string, ref *openapi3.SchemaRef, requi return prop } +// inlineVars gives a property typed from an inline object schema, one with +// properties and no $ref, those properties as Vars, for a generator that +// declares a type for it. The TypeScript generators type it any and read none. +// An object that also takes additionalProperties gets none: a type of its +// properties alone would drop the other keys. +func (p *Parser) inlineVars(prop *codegen.CodegenProperty, schema *openapi3.Schema) { + extra := schema.AdditionalProperties.Schema != nil || + (schema.AdditionalProperties.Has != nil && *schema.AdditionalProperties.Has) + + if !isObjectSchema(schema) || len(schema.Properties) == 0 || extra || + len(schema.AllOf) > 0 || len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + return + } + + for _, name := range p.orderedPropertyNames(schema) { + prop.Vars = append(prop.Vars, p.schemaRefToProperty(name, schema.Properties[name], slices.Contains(schema.Required, name))) + } +} + +// inlineMembers gives a property typed from an inline oneOf/anyOf of two or +// more members those members as ComposedSchemas, for a generator that +// declares a type for it. The TypeScript generators read the union from +// DataType instead. +func (p *Parser) inlineMembers(prop *codegen.CodegenProperty, schema *openapi3.Schema) { + if len(schema.OneOf)+len(schema.AnyOf) < 2 || len(schema.AllOf) > 0 || len(schema.Properties) > 0 { + return + } + + members := func(refs openapi3.SchemaRefs) []*codegen.CodegenProperty { + var out []*codegen.CodegenProperty + for _, ref := range refs { + out = append(out, p.schemaRefToProperty(prop.BaseName, ref, true)) + } + + return out + } + + prop.ComposedSchemas = &codegen.CodegenComposedSchemas{OneOf: members(schema.OneOf), AnyOf: members(schema.AnyOf)} +} + // applyMemberType resolves a single composition member ($ref or inline schema) // onto prop, copying its type-bearing fields. Returns false if the member could // not be resolved to a concrete (non-any) type, leaving prop untouched. @@ -266,6 +313,8 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required case schema.Items != nil && schema.Items.Value != nil: prop.Items = p.schemaToProperty(name+"Item", schema.Items.Value, false) + p.inlineVars(prop.Items, schema.Items.Value) + p.inlineMembers(prop.Items, schema.Items.Value) prop.DataType = "Array<" + prop.Items.DataType + ">" prop.Datatype = "Array<" + prop.Items.DataType + ">" diff --git a/internal/parser/response.go b/internal/parser/response.go index 5a9c2bb..fca5ea8 100644 --- a/internal/parser/response.go +++ b/internal/parser/response.go @@ -65,6 +65,8 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege } else if mediaType.Schema.Value != nil { schema := mediaType.Schema.Value prop := p.schemaToProperty("response", schema, false) + p.inlineVars(prop, schema) + p.inlineMembers(prop, schema) cr.DataType = prop.DataType cr.BaseType = prop.BaseType diff --git a/internal/template/gofmt.go b/internal/template/gofmt.go new file mode 100644 index 0000000..3434394 --- /dev/null +++ b/internal/template/gofmt.go @@ -0,0 +1,96 @@ +package template + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "path" + "strconv" + "strings" +) + +// formatGo drops the imports src does not use and formats it with go/format, +// so a template can import every package it might need. +func formatGo(src []byte) ([]byte, error) { + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, "", src, parser.ParseComments) + if err != nil { + return nil, err + } + + used := make(map[string]bool) + + ast.Inspect(file, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok { + used[id.Name] = true + } + } + + return true + }) + + var kept []string + + for _, imp := range file.Imports { + if !isUnusedImport(imp, used) { + line := imp.Path.Value + if imp.Name != nil { + line = imp.Name.Name + " " + line + } + + kept = append(kept, line) + } + } + + // Print the file without its import declarations, then put back a single + // block of the kept ones: removing specs in place leaves blank lines that + // gofmt reads as import groups. + decls := file.Decls[:0] + + for _, decl := range file.Decls { + if gen, ok := decl.(*ast.GenDecl); !ok || gen.Tok != token.IMPORT { + decls = append(decls, decl) + } + } + + file.Decls = decls + + var buf bytes.Buffer + if err := format.Node(&buf, fset, file); err != nil { + return nil, err + } + + out := buf.String() + + if len(kept) > 0 { + pkg := 0 + if !strings.HasPrefix(out, "package ") { + pkg = strings.Index(out, "\npackage ") + 1 + } + + end := pkg + strings.IndexByte(out[pkg:], '\n') + 1 + out = out[:end] + "\nimport (\n" + strings.Join(kept, "\n") + "\n)\n" + out[end:] + } + + return format.Source([]byte(out)) +} + +// isUnusedImport reports whether imp is a named or plain import no selector +// in the file refers to; blank and dot imports always stay. +func isUnusedImport(imp *ast.ImportSpec, used map[string]bool) bool { + importPath, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return false + } + + name := path.Base(importPath) + if imp.Name != nil { + name = imp.Name.Name + } + + return name != "_" && name != "." && !used[name] +} diff --git a/internal/template/gofmt_test.go b/internal/template/gofmt_test.go new file mode 100644 index 0000000..d9290c7 --- /dev/null +++ b/internal/template/gofmt_test.go @@ -0,0 +1,77 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFormatGo(t *testing.T) { + src := `// Code generated by openapi-generator. DO NOT EDIT. + +// Package x is a package used in a test. +package x + +import ( + "encoding/json" + "fmt" + str "strings" + _ "embed" + "net/http" +) + +func f() string { return fmt.Sprint(str.ToUpper("a")) } +` + + got, err := formatGo([]byte(src)) + if err != nil { + t.Fatal(err) + } + + want := `// Code generated by openapi-generator. DO NOT EDIT. + +// Package x is a package used in a test. +package x + +import ( + _ "embed" + "fmt" + str "strings" +) + +func f() string { return fmt.Sprint(str.ToUpper("a")) } +` + if string(got) != want { + t.Errorf("formatGo:\n%s\nwant:\n%s", got, want) + } +} + +func TestFormatGoDropsEveryImport(t *testing.T) { + got, err := formatGo([]byte("package x\n\nimport \"fmt\"\n\nconst a = 1\n")) + if err != nil { + t.Fatal(err) + } + + if want := "package x\n\nconst a = 1\n"; string(got) != want { + t.Errorf("formatGo = %q, want %q", got, want) + } +} + +func TestRenderToFileGoSyntaxError(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "x.mustache"), []byte("package x\n\nfunc {\n"), 0o600); err != nil { + t.Fatal(err) + } + + out := filepath.Join(dir, "x.go") + + err := NewEngine(dir).RenderToFile("x.mustache", map[string]any{}, out) + if err == nil || !strings.Contains(err.Error(), out) { + t.Fatalf("RenderToFile error = %v, want one naming %s", err, out) + } + + if data, _ := os.ReadFile(out); string(data) != "package x\n\nfunc {\n" { + t.Errorf("unformatted source not written: %q", data) + } +} diff --git a/internal/template/mustache.go b/internal/template/mustache.go index 0453347..3ef2df4 100644 --- a/internal/template/mustache.go +++ b/internal/template/mustache.go @@ -248,6 +248,18 @@ func (e *Engine) RenderToFile(templateName string, data any, outputPath string) result = normalizeTypeScript(result) } + // Go sources go through go/format. One that does not parse is still + // written, unformatted, so the error can be read against the file. + var formatErr error + + if strings.HasSuffix(outputPath, ".go") { + if formatted, err := formatGo([]byte(result)); err != nil { + formatErr = fmt.Errorf("failed to format %s: %w", outputPath, err) + } else { + result = string(formatted) + } + } + // Create output directory if needed dir := filepath.Dir(outputPath) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -259,6 +271,10 @@ func (e *Engine) RenderToFile(templateName string, data any, outputPath string) return fmt.Errorf("failed to write file %s: %w", outputPath, err) } + if formatErr != nil { + return formatErr + } + if e.Verbose { fmt.Printf("[TEMPLATE] Generated: %s\n", outputPath) } diff --git a/samples/go-fetch/.gitignore b/samples/go-fetch/.gitignore new file mode 100644 index 0000000..b3c095e --- /dev/null +++ b/samples/go-fetch/.gitignore @@ -0,0 +1 @@ +petstore-expanded diff --git a/samples/go-fetch/README.md b/samples/go-fetch/README.md new file mode 100644 index 0000000..92665ad --- /dev/null +++ b/samples/go-fetch/README.md @@ -0,0 +1,29 @@ +# go-fetch + +Sample usage of the `go-fetch` generator. + +[Petstore Extended](https://github.com/OAI/OpenAPI-Specification) + +The spec is vendored as `petstore-expanded.yaml` and the generator options live +in `config.yaml`, so the sample regenerates byte-for-byte: + +```bash +make samples-go-fetch +``` + +Which is equivalent to: + +```bash +go run ./cmd/openapi-generator generate \ + -g go-fetch \ + -i samples/go-fetch/petstore-expanded.yaml \ + -p packageName=petstore \ + -p withGoMod=true \ + -p moduleName=example.com/petstore \ + -o samples/go-fetch/petstore-expanded +``` + +The output is a module of its own (`withGoMod`), so `go vet ./...` in +`samples/go-fetch/petstore-expanded` compiles it. + +Run `openapi-generator config-help go-fetch` for the full list of options. diff --git a/samples/go-fetch/config.yaml b/samples/go-fetch/config.yaml new file mode 100644 index 0000000..96460ae --- /dev/null +++ b/samples/go-fetch/config.yaml @@ -0,0 +1,9 @@ +generatorName: go-fetch +inputSpec: samples/go-fetch/petstore-expanded.yaml +outputDir: samples/go-fetch/petstore-expanded +skipValidateSpec: false +verbose: false +additionalProperties: + packageName: petstore + withGoMod: true + moduleName: example.com/petstore diff --git a/samples/go-fetch/petstore-expanded.yaml b/samples/go-fetch/petstore-expanded.yaml new file mode 100644 index 0000000..6a3aa1d --- /dev/null +++ b/samples/go-fetch/petstore-expanded.yaml @@ -0,0 +1,124 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/api +paths: + /pets: + get: + summary: Returns all pets from the system that the user has access to + operationId: findPets + parameters: + - name: tags + in: query + required: false + schema: + type: array + items: + type: string + - name: limit + in: query + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Creates a new pet in the store + operationId: addPet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets/{id}: + get: + summary: Returns a pet by id + operationId: findPetById + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + delete: + summary: deletes a single pet + operationId: deletePet + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '204': + description: pet deleted +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/scripts/regress.sh b/scripts/regress.sh index 73131b0..49a2bfb 100755 --- a/scripts/regress.sh +++ b/scripts/regress.sh @@ -34,6 +34,7 @@ git archive "$ref" | tar -x -C "$tmp/src" declare -A flipped=( [typescript-fetch]="withPackageJson=true withInterfaces=true useSingleRequestParameter=false prefixParameterInterfaces=true withoutRuntimeChecks=true stringEnums=true validationAttributes=true" [dart-fetch]="useDartIoSender=false" + [go-fetch]="withGoMod=true" ) # run GEN OUT [ARGS...]: generate $spec with both builds into */OUT/$name. diff --git a/templates/embed.go b/templates/embed.go index 21705ff..39c678d 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -5,5 +5,5 @@ import "embed" // FS contains all embedded template files. // -//go:embed typescript-fetch/*.mustache typescript-fetch/*.md dart-fetch/*.mustache +//go:embed typescript-fetch/*.mustache typescript-fetch/*.md dart-fetch/*.mustache go-fetch/*.mustache go-fetch/*.md var FS embed.FS diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md new file mode 100644 index 0000000..1265802 --- /dev/null +++ b/templates/go-fetch/README.md @@ -0,0 +1,94 @@ +# go-fetch + +Generates a Go client in the shape of typescript-fetch: an API type per tag, +three methods per operation. The output imports only the standard library +and needs Go 1.24 (`omitzero`). + +## Usage + +```bash +openapi-generator generate -g go-fetch -i openapi.yaml -o ./petstore -p packageName=petstore +``` + +| Option | Default | Description | +| ------------- | ------------- | -------------------------------------------- | +| `packageName` | `openapi` | Go package name of the generated files | +| `withGoMod` | `false` | write a `go.mod`, making the output a module | +| `moduleName` | `packageName` | module path in `go.mod` | + +## Output + +| File | Contents | +| ----------------- | ---------------------------------------------------------------- | +| `runtime.go` | `Configuration`, `ResponseError`, `Opt`, `Date`, `File`, helpers | +| `api_.go` | `API`, a `Params` per operation, its methods | +| `model_.go` | one model | +| `README.md` | usage of the package | +| `go.mod` | with `withGoMod=true` | + +## The client + +```go +cfg := &petstore.Configuration{ + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + AccessToken: func(ctx context.Context, op, name string, scopes []string) (string, error) { return token, nil }, +} +pets := petstore.NewDefaultAPI(cfg) + +pet, err := pets.FindPetByID(ctx, petstore.FindPetByIDParams{ID: 1}) + +var rerr *petstore.ResponseError +if errors.As(err, &rerr) { + if e, ok := rerr.Model.(*petstore.Error); ok { + log.Print(e.Message) + } +} +``` + +| Method | Does | +| ------------ | ------------------------------------------------------------------------- | +| `Xxx` | sends and decodes the body (JSON, text for a string, `[]byte` for binary) | +| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | +| `XxxRequest` | builds the `*http.Request` without sending it | + +When a 2xx status has no body next to one that has (a 204 beside a 200), the +result is a pointer, nil for the bodiless status, unless the type is nil-able +already or a string. Each method takes trailing +`RequestEditorFn`s, which change the request after it is built. A status +outside 2xx is a `*ResponseError`; its `Model` is the body decoded into the +type the spec gives for that status (the exact code, then `4XX`/`5XX`, then +`default`), nil when the body lacks a property that type requires. + +| `Configuration` | Use | +| --------------- | ---------------------------------------------------------------------- | +| `BasePath` | replaces the spec's first server URL | +| `HTTPClient` | any `Doer`; nil is `http.DefaultClient` | +| `Headers` | added to every request | +| `Username` | with `Password`, HTTP basic auth | +| `APIKey` | returns the key named `name` for operation `op` | +| `AccessToken` | returns the bearer token for scheme `name`, operation `op`, its scopes | + +## Types + +| Schema | Go | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| required property | `T` | +| optional (or read-only) | `*T`; slices, maps and `any` stay as they are: nil is absent, empty is sent | +| required and nullable | `*T`, sent as `null` when nil | +| optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | +| `date` / `date-time` | `Date` / `time.Time` | +| enum | a named type with a constant per value | +| inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | +| nullable item | `[]*T`, `map[string]*T` | +| `allOf` | one struct with the members' properties | +| `oneOf` / `anyOf` | a wrapper over the raw JSON, `AsX` / `FromX`; `AsX` refuses a value without X's required properties; inline, named after its owner as an object is | +| ... with a discriminator | also `Discriminator()` and `ValueByDiscriminator()` | +| ... pinning a property | a variant property of one value (`enum: [cat]`, 3.1 `const`) is set by `FromX` and checked by `AsX`; one that tells every variant apart is the discriminator, inline too | +| file in a multipart body | `File{Name, Body}`; an `octet-stream` body is `io.Reader` | +| other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | +| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`) | + +Not covered: server code, XML, server variables, validation, a discriminator +without `oneOf` (the parent stays a plain struct), `additionalProperties` +next to properties (a model drops the extra keys on decode; an inline object +stays a map). diff --git a/templates/go-fetch/README.mustache b/templates/go-fetch/README.mustache new file mode 100644 index 0000000..2a28566 --- /dev/null +++ b/templates/go-fetch/README.mustache @@ -0,0 +1,21 @@ +# {{appName}} + +Go client for {{appName}} {{version}}, generated by openapi-generator +(go-fetch). It imports only the standard library and needs Go 1.24. + +```go +cfg := &{{packageName}}.Configuration{ + HTTPClient: &http.Client{Timeout: 10 * time.Second}, +} +``` + +Every operation has three methods on the API type of its tag: + +| Method | Returns | +| ------------ | ------------------------------------------------- | +| `Xxx` | the decoded body | +| `XxxRaw` | the `*http.Response` of a 2xx status, body unread | +| `XxxRequest` | the `*http.Request`, not sent | + +A status outside 2xx is a `*{{packageName}}.ResponseError`; its `Model` holds +the body decoded into the type the spec gives for that status. diff --git a/templates/go-fetch/api.mustache b/templates/go-fetch/api.mustache new file mode 100644 index 0000000..ccee57e --- /dev/null +++ b/templates/go-fetch/api.mustache @@ -0,0 +1,254 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// {{classname}} sends the operations of one tag. +type {{classname}} struct { + cfg *Configuration +} + +// New{{classname}} returns a {{classname}} sending through cfg; nil is the +// zero Configuration. +func New{{classname}}(cfg *Configuration) *{{classname}} { + if cfg == nil { + cfg = &Configuration{} + } + + return &{{classname}}{cfg: cfg} +} +{{#operation}} +{{#vendorExtensions}} +{{#goDeclareParams}} +{{#x-go-inline-enums}} + +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} {{base}} + +const ( +{{#values}} + {{name}} {{type}} = {{{literal}}} +{{/values}} +) +{{/x-go-inline-enums}} +{{#x-go-inline}} + +{{#union}} +{{>union}} +{{/union}} +{{^union}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} struct { +{{#fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/fields}} +} +{{/union}} +{{/x-go-inline}} +{{#x-go-has-params}} + +// {{x-go-params}} holds the parameters of {{x-go-name}}. +type {{x-go-params}} struct { +{{#x-go-fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} +{{/x-go-fields}} +} +{{/x-go-has-params}} +{{/goDeclareParams}} + +// {{x-go-name}}Request builds the request {{x-go-name}} sends. +func (a *{{classname}}) {{x-go-name}}Request(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (*http.Request, error) { + path := {{{x-go-path}}} +{{#x-go-in-path}} + + if err := expandPath(&path, {{{placeholder}}}, p.{{field}}, {{{quoted}}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-path}} + + query := url.Values{} +{{#x-go-in-query}} + + if err := addQuery(query, {{{quoted}}}, p.{{field}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-query}} +{{#x-go-body-json}} + + body, err := jsonBody(p.{{.}}) + if err != nil { + return nil, err + } +{{/x-go-body-json}} +{{#x-go-body-text}} + + body := textBody(p.{{.}}) +{{/x-go-body-text}} +{{#x-go-body-reader}} + + body := p.{{.}} +{{/x-go-body-reader}} +{{#x-go-body-form}} + + form := url.Values{} +{{#x-go-in-form}} + + if err := addQuery(form, {{{quoted}}}, p.{{field}}, "{{style}}", {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-form}} + + body := strings.NewReader(form.Encode()) +{{/x-go-body-form}} +{{#x-go-body-multipart}} + + body := new(bytes.Buffer) + mw := multipart.NewWriter(body) +{{#x-go-in-form}} + + if err := addPart(mw, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-form}} + + if err := mw.Close(); err != nil { + return nil, err + } +{{/x-go-body-multipart}} + + req, err := a.cfg.newRequest(ctx, {{{x-go-method}}}, path, query, {{#x-go-has-body}}body{{/x-go-has-body}}{{^x-go-has-body}}nil{{/x-go-has-body}}) + if err != nil { + return nil, err + } +{{#x-go-content-type}} + + req.Header.Set("Content-Type", {{{.}}}) +{{/x-go-content-type}} +{{#x-go-body-form}} + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") +{{/x-go-body-form}} +{{#x-go-body-multipart}} + + req.Header.Set("Content-Type", mw.FormDataContentType()) +{{/x-go-body-multipart}} +{{#x-go-in-header}} + + if err := setHeader(req, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-header}} +{{#x-go-in-cookie}} + + if err := addCookie(req, {{{quoted}}}, p.{{field}}, {{explode}}); err != nil { + return nil, err + } +{{/x-go-in-cookie}} +{{#x-go-auth}} +{{#apiKey}} + + if err := a.cfg.apiKey(ctx, req, {{{op}}}, {{{key}}}, "{{in}}"); err != nil { + return nil, err + } +{{/apiKey}} +{{#basic}} + + a.cfg.basicAuth(req) +{{/basic}} +{{#bearer}} + + if err := a.cfg.bearer(ctx, req, {{{op}}}, {{{scheme}}}, {{{scopes}}}); err != nil { + return nil, err + } +{{/bearer}} +{{/x-go-auth}} + + return edit(ctx, req, editors) +} + +// {{x-go-name}}Raw sends {{x-go-name}} and returns a 2xx response with its body +// unread, for the caller to close; any other status is a *ResponseError. +func (a *{{classname}}) {{x-go-name}}Raw(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (*http.Response, error) { + req, err := a.{{x-go-name}}Request(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...) + if err != nil { + return nil, err + } +{{#x-go-errors}} + + return a.cfg.send(req, func(status int) (any, []string) { +{{#hasCodes}} + switch status { +{{#codes}} + case {{code}}: + return new({{{type}}}), {{{keys}}} +{{/codes}} + } + +{{/hasCodes}} +{{#hasRanges}} + switch status / 100 { +{{#ranges}} + case {{digit}}: + return new({{{type}}}), {{{keys}}} +{{/ranges}} + } + +{{/hasRanges}} +{{#default}} + return new({{{type}}}), {{{keys}}} +{{/default}} +{{^default}} + return nil, nil +{{/default}} + }) +{{/x-go-errors}} +{{^x-go-errors}} + + return a.cfg.send(req, nil) +{{/x-go-errors}} +} + +{{#x-go-doc}} +{{{.}}} +{{/x-go-doc}} +{{#x-go-decode-json}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) ({{{.}}}, error) { + return decodeJSON[{{{.}}}](a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-decode-json}} +{{#x-go-decode-bytes}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) ([]byte, error) { + return decodeBytes(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-decode-bytes}} +{{#x-go-discard}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) error { + return discard(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-discard}} +{{/vendorExtensions}} +{{/operation}} diff --git a/templates/go-fetch/gomod.mustache b/templates/go-fetch/gomod.mustache new file mode 100644 index 0000000..c939d81 --- /dev/null +++ b/templates/go-fetch/gomod.mustache @@ -0,0 +1,3 @@ +module {{moduleName}} + +go 1.24 diff --git a/templates/go-fetch/model.mustache b/templates/go-fetch/model.mustache new file mode 100644 index 0000000..a582b35 --- /dev/null +++ b/templates/go-fetch/model.mustache @@ -0,0 +1,66 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" +) + +{{#vendorExtensions.x-go-enums}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} {{base}} + +const ( +{{#values}} + {{name}} {{type}} = {{{literal}}} +{{/values}} +) + +{{/vendorExtensions.x-go-enums}} +{{#vendorExtensions.x-go-struct}} +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} struct { +{{#vendorExtensions.x-go-fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/vendorExtensions.x-go-fields}} +} +{{#vendorExtensions.x-go-inline}} + +{{#union}} +{{>union}} +{{/union}} +{{^union}} +{{#doc}} +{{{.}}} +{{/doc}} +type {{name}} struct { +{{#fields}} +{{#doc}} + {{{.}}} +{{/doc}} + {{name}} {{{type}}} {{{tag}}} +{{/fields}} +} +{{/union}} +{{/vendorExtensions.x-go-inline}} +{{/vendorExtensions.x-go-struct}} +{{#vendorExtensions.x-go-alias}} +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} = {{{vendorExtensions.x-go-alias-type}}} +{{/vendorExtensions.x-go-alias}} +{{#vendorExtensions.x-go-union}} +{{>union}} +{{/vendorExtensions.x-go-union}} diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache new file mode 100644 index 0000000..90c6917 --- /dev/null +++ b/templates/go-fetch/runtime.mustache @@ -0,0 +1,781 @@ +// Code generated by openapi-generator. DO NOT EDIT. + +package {{packageName}} + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "maps" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "time" +) + +// BasePath is the server URL the spec lists first. +const BasePath = "{{{basePath}}}" + +// Doer sends an HTTP request; *http.Client is one. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// RequestEditorFn changes a request before it is sent. +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Configuration is what every API type shares. The zero value is usable: it +// sends to BasePath through http.DefaultClient without credentials. +type Configuration struct { + // BasePath replaces the package's BasePath when set. + BasePath string + // HTTPClient sends the requests; nil uses http.DefaultClient. + HTTPClient Doer + // Headers are added to every request. + Headers http.Header + // Username and Password are sent as HTTP basic authentication to an + // operation that takes it. + Username string + Password string + // APIKey returns the key named name for the operation op; an empty key + // is not sent. + APIKey func(ctx context.Context, op, name string) (string, error) + // AccessToken returns the bearer token of the security scheme name for + // the operation op and its scopes; an empty token is not sent. + AccessToken func(ctx context.Context, op, name string, scopes []string) (string, error) +} + +// ResponseError is the error an operation returns for a status outside 2xx. +type ResponseError struct { + StatusCode int + Header http.Header + Body []byte + // Model is Body decoded into the type the spec gives for the status + // (the exact code, then 4XX/5XX, then default), as a pointer; nil when + // the spec gives none, or the body does not decode or lacks a property + // the type requires. + Model any +} + +// Error returns the status, "404 Not Found". +func (e *ResponseError) Error() string { + return strconv.Itoa(e.StatusCode) + " " + http.StatusText(e.StatusCode) +} + +// Opt is a property that can be absent, null or a value. The zero value is +// absent; omitzero leaves it out of the JSON. +type Opt[T any] struct { + Value T + // Set is false when the property is absent. + Set bool + // Null is true when the property is an explicit null. + Null bool +} + +// Some returns an Opt holding v. +func Some[T any](v T) Opt[T] { return Opt[T]{Value: v, Set: true} } + +// Null returns an Opt holding an explicit null. +func Null[T any]() Opt[T] { return Opt[T]{Set: true, Null: true} } + +// IsZero reports whether o is absent. +func (o Opt[T]) IsZero() bool { return !o.Set } + +// MarshalJSON encodes o's value, or null. +func (o Opt[T]) MarshalJSON() ([]byte, error) { + if o.Null || !o.Set { + return []byte("null"), nil + } + + return json.Marshal(o.Value) +} + +// UnmarshalJSON decodes a present property, null included. +func (o *Opt[T]) UnmarshalJSON(b []byte) error { + *o = Opt[T]{Set: true, Null: string(b) == "null"} + if o.Null { + return nil + } + + return json.Unmarshal(b, &o.Value) +} + +// Ptr returns a pointer to v, for optional fields. +func Ptr[T any](v T) *T { return &v } + +// Date is a calendar date, "2006-01-02" in JSON and parameters. +type Date struct{ time.Time } + +// String returns d as "2006-01-02". +func (d Date) String() string { return d.Format(time.DateOnly) } + +// MarshalJSON encodes d as "2006-01-02". +func (d Date) MarshalJSON() ([]byte, error) { return []byte(`"` + d.String() + `"`), nil } + +// UnmarshalJSON decodes a "2006-01-02" string; null leaves d as it is. +func (d *Date) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + t, err := time.Parse(time.DateOnly, s) + d.Time = t + + return err +} + +// File is a file part of a multipart request. +type File struct { + Name string + Body io.Reader +} + +func (c *Configuration) newRequest(ctx context.Context, method, path string, query url.Values, body io.Reader) (*http.Request, error) { + base := BasePath + if c.BasePath != "" { + base = c.BasePath + } + + u := strings.TrimRight(base, "/") + path + if len(query) > 0 { + u += "?" + query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return nil, err + } + + for k, vs := range c.Headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + + return req, nil +} + +func (c *Configuration) apiKey(ctx context.Context, req *http.Request, op, name, in string) error { + if c.APIKey == nil { + return nil + } + + key, err := c.APIKey(ctx, op, name) + if err != nil || key == "" { + return err + } + + switch in { + case "query": + q := req.URL.Query() + q.Set(name, key) + req.URL.RawQuery = q.Encode() + case "cookie": + req.AddCookie(&http.Cookie{Name: name, Value: key}) + default: + req.Header.Set(name, key) + } + + return nil +} + +func (c *Configuration) basicAuth(req *http.Request) { + if c.Username != "" || c.Password != "" { + req.SetBasicAuth(c.Username, c.Password) + } +} + +func (c *Configuration) bearer(ctx context.Context, req *http.Request, op, name string, scopes []string) error { + if c.AccessToken == nil { + return nil + } + + token, err := c.AccessToken(ctx, op, name, scopes) + if err != nil || token == "" { + return err + } + + req.Header.Set("Authorization", "Bearer "+token) + + return nil +} + +func edit(ctx context.Context, req *http.Request, editors []RequestEditorFn) (*http.Request, error) { + for _, e := range editors { + if err := e(ctx, req); err != nil { + return nil, err + } + } + + return req, nil +} + +// send sends req and returns a 2xx response unread; any other status is a +// *ResponseError, its body decoded into model(status) when model gives one +// and the body holds the keys it names. +func (c *Configuration) send(req *http.Request, model func(status int) (any, []string)) (*http.Response, error) { + var doer Doer = http.DefaultClient + if c.HTTPClient != nil { + doer = c.HTTPClient + } + + resp, err := doer.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + rerr := &ResponseError{StatusCode: resp.StatusCode, Header: resp.Header, Body: body} + if model != nil { + m, keys := model(resp.StatusCode) + if m != nil && (len(keys) == 0 || requireKeys(body, keys...) == nil) && json.Unmarshal(body, m) == nil { + rerr.Model = m + } + } + + return nil, rerr +} + +var jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) + +// decodeJSON reads and closes resp's body into a T: JSON, or the text itself +// for a string the server does not send as JSON. An empty body is T's zero, +// nil for a pointer. +func decodeJSON[T any](resp *http.Response, err error) (T, error) { + var out T + if err != nil { + return out, err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil || len(body) == 0 { + return out, err + } + + if s, ok := any(&out).(*string); ok && !jsonMime.MatchString(resp.Header.Get("Content-Type")) { + *s = string(body) + + return out, nil + } + + err = json.Unmarshal(body, &out) + + return out, err +} + +// requireKeys returns an error when the JSON object b lacks one of keys. +func requireKeys(b []byte, keys ...string) error { + var m map[string]json.RawMessage + if err := json.Unmarshal(b, &m); err != nil { + return err + } + + for _, k := range keys { + if _, ok := m[k]; !ok { + return fmt.Errorf("missing required property %q", k) + } + } + + return nil +} + +// decodeBytes reads and closes resp's body. +func decodeBytes(resp *http.Response, err error) ([]byte, error) { + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + return io.ReadAll(resp.Body) +} + +// discard drains and closes resp's body. +func discard(resp *http.Response, err error) error { + if err != nil { + return err + } + + defer resp.Body.Close() + + _, err = io.Copy(io.Discard, resp.Body) + + return err +} + +// value dereferences v; ok is false for nil, a nil pointer and an empty +// slice or map, which a parameter leaves out. +func value(v any) (any, bool) { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return nil, false + } + + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Invalid: + return nil, false + case reflect.Slice, reflect.Map: + if rv.Len() == 0 { + return nil, false + } + } + + return rv.Interface(), true +} + +// paramString formats a scalar for a parameter: RFC 3339 for a time, +// 2006-01-02 for a Date, base64 for bytes, the shortest decimal for a float. +func paramString(v any) string { + switch v := v.(type) { + case time.Time: + return v.Format(time.RFC3339Nano) + case Date: + return v.String() + case json.RawMessage: + var text string + if json.Unmarshal(v, &text) == nil { + return text + } + + return string(v) + + case []byte: + return base64.StdEncoding.EncodeToString(v) + case string: + return v + } + + switch rv := reflect.ValueOf(v); rv.Kind() { + case reflect.Float32: + return strconv.FormatFloat(rv.Float(), 'f', -1, 32) + case reflect.Float64: + return strconv.FormatFloat(rv.Float(), 'f', -1, 64) + } + + return fmt.Sprint(v) +} + +// isScalar reports whether v is a parameter value of its own, not a list or +// an object. +func isScalar(v any) bool { + switch v.(type) { + case time.Time, Date, []byte, json.RawMessage: + return true + } + + switch reflect.ValueOf(v).Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct: + return false + } + + return true +} + +// paramList formats each element of a slice, a nil one as "". +func paramList(v any) []string { + rv := reflect.ValueOf(v) + out := make([]string, rv.Len()) + + for i := range out { + if e, ok := value(rv.Index(i).Interface()); ok { + out[i] = paramString(e) + } + } + + return out +} + +// paramObject flattens a struct or map to its keys, sorted, and their values, +// through JSON so the keys are the wire names; a nested value stays JSON. +func paramObject(v any) ([]string, []string, error) { + tree, err := jsonTree(v) + if err != nil { + return nil, nil, err + } + + m, ok := tree.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("parameter value %T is not an object", v) + } + + keys := slices.Sorted(maps.Keys(m)) + + values := make([]string, len(keys)) + for i, k := range keys { + values[i] = treeString(m[k]) + } + + return keys, values, nil +} + +// jsonTree is v through JSON: maps, slices and scalars, numbers as written. +func jsonTree(v any) (any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + + var tree any + err = d.Decode(&tree) + + return tree, err +} + +// treeString formats a jsonTree value: a scalar as text, an object or list +// as JSON. +func treeString(v any) string { + switch v.(type) { + case map[string]any, []any: + b, _ := json.Marshal(v) // it came from JSON + + return string(b) + } + + return fmt.Sprint(v) +} + +// addQuery adds the parameter name holding v to q, in the given OpenAPI +// style (form, spaceDelimited, pipeDelimited, deepObject). +func addQuery(q url.Values, name string, v any, style string, explode bool) error { + v, ok := value(v) + if !ok { + return nil + } + + if isScalar(v) { + q.Add(name, paramString(v)) + + return nil + } + + if style == "deepObject" { + return addDeep(q, name, v) + } + + if k := reflect.ValueOf(v).Kind(); k == reflect.Slice || k == reflect.Array { + items := paramList(v) + + switch { + case explode: + for _, s := range items { + q.Add(name, s) + } + case style == "spaceDelimited": + q.Add(name, strings.Join(items, " ")) + case style == "pipeDelimited": + q.Add(name, strings.Join(items, "|")) + default: + q.Add(name, strings.Join(items, ",")) + } + + return nil + } + + keys, values, err := paramObject(v) + if err != nil { + return err + } + + if explode { + for i, k := range keys { + q.Add(k, values[i]) + } + } else { + q.Add(name, joinPairs(keys, values, ",", ",")) + } + + return nil +} + +// addDeep adds v to q in deepObject style, nested objects and lists spelled +// out a key at a time through JSON: name[a][b]=x, name[list][0]=y. +func addDeep(q url.Values, name string, v any) error { + tree, err := jsonTree(v) + if err != nil { + return err + } + + addTree(q, name, tree) + + return nil +} + +func addTree(q url.Values, name string, v any) { + switch v := v.(type) { + case map[string]any: + for _, k := range slices.Sorted(maps.Keys(v)) { + addTree(q, name+"["+k+"]", v[k]) + } + + case []any: + for i, e := range v { + addTree(q, name+"["+strconv.Itoa(i)+"]", e) + } + + case nil: // a null leaves the key out + default: + q.Add(name, treeString(v)) + } +} + +func joinPairs(keys, values []string, kv, sep string) string { + pairs := make([]string, len(keys)) + for i, k := range keys { + pairs[i] = k + kv + values[i] + } + + return strings.Join(pairs, sep) +} + +// expandPath replaces placeholder in *path with v, formatted for the path +// parameter name in the given OpenAPI style (simple, label, matrix). +func expandPath(path *string, placeholder string, v any, name, style string, explode bool) error { + s, err := pathParam(v, name, style, explode) + *path = strings.ReplaceAll(*path, placeholder, s) + + return err +} + +// pathParam formats v for a path parameter, each value escaped on its own. +func pathParam(v any, name, style string, explode bool) (string, error) { + v, ok := value(v) + if !ok { + return "", nil + } + + var keys, values []string + + switch k := reflect.ValueOf(v).Kind(); { + case isScalar(v): + values = []string{paramString(v)} + case k == reflect.Slice || k == reflect.Array: + values = paramList(v) + default: + var err error + if keys, values, err = paramObject(v); err != nil { + return "", err + } + } + + for i := range values { + values[i] = url.PathEscape(values[i]) + } + + for i := range keys { + keys[i] = url.PathEscape(keys[i]) + } + + name = url.PathEscape(name) + + sep, prefix := ",", "" + + switch style { + case "label": + prefix = "." + if explode { + sep = "." + } + case "matrix": + prefix = ";" + name + "=" + if explode { + sep = prefix + if keys != nil { + prefix, sep = ";", ";" + } + } + } + + if keys == nil { + return prefix + strings.Join(values, sep), nil + } + + if explode { + return prefix + joinPairs(keys, values, "=", sep), nil + } + + return prefix + joinPairs(keys, values, ",", ","), nil +} + +// headerParam formats v for a header or cookie parameter (simple style); ok +// is false when there is nothing to send. +func headerParam(v any, explode bool) (string, bool, error) { + v, ok := value(v) + if !ok { + return "", false, nil + } + + if isScalar(v) { + return paramString(v), true, nil + } + + if k := reflect.ValueOf(v).Kind(); k == reflect.Slice || k == reflect.Array { + return strings.Join(paramList(v), ","), true, nil + } + + keys, values, err := paramObject(v) + if err != nil { + return "", false, err + } + + if explode { + return joinPairs(keys, values, "=", ","), true, nil + } + + return joinPairs(keys, values, ",", ","), true, nil +} + +func setHeader(req *http.Request, name string, v any, explode bool) error { + s, ok, err := headerParam(v, explode) + if ok { + req.Header.Set(name, s) + } + + return err +} + +func addCookie(req *http.Request, name string, v any, explode bool) error { + s, ok, err := headerParam(v, explode) + if ok { + req.AddCookie(&http.Cookie{Name: name, Value: s}) + } + + return err +} + +// jsonBody encodes v as a JSON body; nil (pointer, slice, map) sends none. +func jsonBody(v any) (io.Reader, error) { + switch rv := reflect.ValueOf(v); rv.Kind() { + case reflect.Invalid: + return nil, nil + case reflect.Pointer, reflect.Slice, reflect.Map: + if rv.IsNil() { + return nil, nil + } + } + + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + + return bytes.NewReader(b), nil +} + +// textBody sends a string as it is. +func textBody(v any) io.Reader { + v, ok := value(v) + if !ok { + return nil + } + + return strings.NewReader(paramString(v)) +} + +// addPart writes the form field name holding v: a File as a file part, a +// list of files as one part each, a list of scalars one part each (one +// comma-separated part unless explode), an object as a JSON part. +func addPart(w *multipart.Writer, name string, v any, explode bool) error { + v, ok := value(v) + if !ok { + return nil + } + + switch v := v.(type) { + case File: + return addFile(w, name, v) + case []File: + for _, f := range v { + if err := addFile(w, name, f); err != nil { + return err + } + } + + return nil + } + + if isScalar(v) { + return w.WriteField(name, paramString(v)) + } + + rv := reflect.ValueOf(v) + if k := rv.Kind(); (k == reflect.Slice || k == reflect.Array) && isScalar(reflect.Zero(rv.Type().Elem()).Interface()) { + if !explode { + return w.WriteField(name, strings.Join(paramList(v), ",")) + } + + for _, s := range paramList(v) { + if err := w.WriteField(name, s); err != nil { + return err + } + } + + return nil + } + + b, err := json.Marshal(v) + if err != nil { + return err + } + + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="`+strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(name)+`"`) + h.Set("Content-Type", "application/json") + + part, err := w.CreatePart(h) + if err != nil { + return err + } + + _, err = part.Write(b) + + return err +} + +func addFile(w *multipart.Writer, name string, f File) error { + filename := f.Name + if filename == "" { + filename = name + } + + part, err := w.CreateFormFile(name, filename) + if err != nil || f.Body == nil { + return err + } + + _, err = io.Copy(part, f.Body) + + return err +} diff --git a/templates/go-fetch/union.mustache b/templates/go-fetch/union.mustache new file mode 100644 index 0000000..17c6781 --- /dev/null +++ b/templates/go-fetch/union.mustache @@ -0,0 +1,89 @@ +{{#vendorExtensions.x-go-doc}} +{{{.}}} +{{/vendorExtensions.x-go-doc}} +type {{classname}} struct { + union json.RawMessage +} + +// MarshalJSON returns the value as it is, null when unset. +func (u {{classname}}) MarshalJSON() ([]byte, error) { + if u.union == nil { + return []byte("null"), nil + } + + return u.union, nil +} + +// UnmarshalJSON keeps a copy of b. +func (u *{{classname}}) UnmarshalJSON(b []byte) error { + u.union = append(json.RawMessage(nil), b...) + + return nil +} +{{#vendorExtensions.x-go-variants}} + +// As{{method}} decodes the value as a {{{type}}}{{#required}}, an error when it lacks +// a property the {{{type}}} requires{{/required}}{{#pinned}}, or its {{key}} is not {{{literal}}}{{/pinned}}. +func (u {{classname}}) As{{method}}() ({{{type}}}, error) { + var v {{{type}}} +{{#required}} + if err := requireKeys(u.union, {{{.}}}); err != nil { + return v, err + } + +{{/required}} + err := json.Unmarshal(u.union, &v) +{{#pinned}} + if err == nil && v.{{field}} != {{{literal}}} { + err = fmt.Errorf({{{mismatch}}}, v.{{field}}) + } +{{/pinned}} + + return v, err +} + +// From{{method}} sets the value to v{{#pinned}}, its {{key}} set to {{{literal}}}{{/pinned}}. +func (u *{{classname}}) From{{method}}(v {{{type}}}) error { +{{#pinned}} + v.{{field}} = {{{literal}}} +{{/pinned}} + b, err := json.Marshal(v) + if err == nil { + u.union = b + } + + return err +} +{{/vendorExtensions.x-go-variants}} +{{#vendorExtensions.x-go-discriminator}} + +// Discriminator returns the value's {{name}} property. +func (u {{classname}}) Discriminator() (string, error) { + var d struct { + Value string {{{tag}}} + } + err := json.Unmarshal(u.union, &d) + + return d.Value, err +} + +// ValueByDiscriminator decodes the value into a pointer to the model its +// {{name}} property names. +func (u {{classname}}) ValueByDiscriminator() (any, error) { + d, err := u.Discriminator() + if err != nil { + return nil, err + } + + switch d { +{{#mappings}} + case {{{value}}}: + v := new({{type}}) + + return v, json.Unmarshal(u.union, v) +{{/mappings}} + } + + return nil, errors.New("{{classname}}: unknown {{name}} " + strconv.Quote(d)) +} +{{/vendorExtensions.x-go-discriminator}} diff --git a/testdata/gofetch/composition-anyof/union_test.go b/testdata/gofetch/composition-anyof/union_test.go new file mode 100644 index 0000000..07cc68e --- /dev/null +++ b/testdata/gofetch/composition-anyof/union_test.go @@ -0,0 +1,31 @@ +package compositionanyof + +import ( + "encoding/json" + "testing" +) + +func TestAnyOf(t *testing.T) { + var c ContactMethod + if err := json.Unmarshal([]byte(`{"email":"a@b.c","phone":"1"}`), &c); err != nil { + t.Fatal(err) + } + + email, err := c.AsEmailContact() + if err != nil || email.Email != "a@b.c" { + t.Errorf("AsEmailContact = %+v, %v", email, err) + } + + phone, err := c.AsPhoneContact() + if err != nil || phone.Phone != "1" { + t.Errorf("AsPhoneContact = %+v, %v", phone, err) + } + + if err := c.FromPhoneContact(PhoneContact{Phone: "2"}); err != nil { + t.Fatal(err) + } + + if b, _ := json.Marshal(c); string(b) != `{"phone":"2"}` { + t.Errorf("Marshal = %s", b) + } +} diff --git a/testdata/gofetch/composition-oneof-inline/shelter_test.go b/testdata/gofetch/composition-oneof-inline/shelter_test.go new file mode 100644 index 0000000..2db2522 --- /dev/null +++ b/testdata/gofetch/composition-oneof-inline/shelter_test.go @@ -0,0 +1,94 @@ +package compositiononeofinline + +import ( + "encoding/json" + "io" + "testing" +) + +// TestInlineUnion decodes inline unions into their variants and encodes +// them back unchanged. +func TestInlineUnion(t *testing.T) { + const in = `{"favourite":{"kind":"cat","lives":3},"residents":[{"kind":"dog","barkVolume":2}]}` + + var s Shelter + if err := json.Unmarshal([]byte(in), &s); err != nil { + t.Fatal(err) + } + + if cat, err := s.Favourite.AsCat(); err != nil || cat.Lives != 3 { + t.Errorf("favourite AsCat = %+v, %v", cat, err) + } + + if _, err := s.Favourite.AsDog(); err == nil { + t.Error("favourite AsDog of a cat succeeded") + } + + if dog, err := s.Residents[0].AsDog(); err != nil || dog.BarkVolume != 2 { + t.Errorf("resident AsDog = %+v, %v", dog, err) + } + + out, err := json.Marshal(s) + if err != nil || string(out) != in { + t.Errorf("encoded %s, %v\nwant %s", out, err, in) + } +} + +// TestInlineUnionBody sends the variant set with From as the body. +func TestInlineUnionBody(t *testing.T) { + var body AdmitBody + if err := body.FromDog(Dog{Kind: "dog", BarkVolume: 1.5}); err != nil { + t.Fatal(err) + } + + req, err := NewDefaultAPI(&Configuration{BasePath: "http://x"}).AdmitRequest(t.Context(), AdmitParams{ID: "s1", Body: body}) + if err != nil { + t.Fatal(err) + } + + got, _ := io.ReadAll(req.Body) + if want := `{"kind":"dog","barkVolume":1.5}`; string(got) != want { + t.Errorf("body %s, want %s", got, want) + } +} + +// TestImpliedDiscriminator fills in and checks the kind each variant pins, +// and dispatches on it. +func TestImpliedDiscriminator(t *testing.T) { + var f ShelterFavourite + if err := f.FromDog(Dog{BarkVolume: 3}); err != nil { + t.Fatal(err) + } + + if b, _ := json.Marshal(f); string(b) != `{"kind":"dog","barkVolume":3}` { + t.Errorf("FromDog = %s", b) + } + + // Every key a Cat requires is there, but the kind says dog. + _ = json.Unmarshal([]byte(`{"kind":"dog","lives":1,"barkVolume":3}`), &f) + if _, err := f.AsCat(); err == nil { + t.Error("AsCat of a dog succeeded") + } + + v, err := f.ValueByDiscriminator() + if dog, ok := v.(*Dog); err != nil || !ok || dog.BarkVolume != 3 { + t.Errorf("ValueByDiscriminator = %#v, %v", v, err) + } +} + +// TestInlineObjectVariants gives each inline object member a struct, told +// apart by its required properties. +func TestInlineObjectVariants(t *testing.T) { + var s Shelter + if err := json.Unmarshal([]byte(`{"favourite":{"kind":"cat","lives":1},"location":{"lat":1.5,"lon":2}}`), &s); err != nil { + t.Fatal(err) + } + + if _, err := s.Location.AsShelterLocationOneOf(); err == nil { + t.Error("a point read as a street") + } + + if p, err := s.Location.AsShelterLocationOneOf1(); err != nil || p.Lat != 1.5 || p.Lon != 2 { + t.Errorf("AsShelterLocationOneOf1 = %+v, %v", p, err) + } +} diff --git a/testdata/gofetch/composition-oneof/union_test.go b/testdata/gofetch/composition-oneof/union_test.go new file mode 100644 index 0000000..6074f0e --- /dev/null +++ b/testdata/gofetch/composition-oneof/union_test.go @@ -0,0 +1,52 @@ +package compositiononeof + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestOneOfRoundTrip(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) // echo + })) + defer srv.Close() + + var in SearchResult + if err := in.FromCat(Cat{PetType: "cat", HuntingSkill: "lazy"}); err != nil { + t.Fatal(err) + } + + out, err := NewDefaultAPI(&Configuration{BasePath: srv.URL}).Search(context.Background(), SearchParams{SearchResult: in}) + if err != nil { + t.Fatal(err) + } + + if cat, err := out.AsCat(); err != nil || cat.HuntingSkill != "lazy" { + t.Errorf("AsCat = %+v, %v", cat, err) + } + + var list SearchResult + if err := json.Unmarshal([]byte(`[1,2]`), &list); err != nil { + t.Fatal(err) + } + + if ints, err := list.AsIntList(); err != nil || !reflect.DeepEqual(ints, []int{1, 2}) { + t.Errorf("AsIntList = %v, %v", ints, err) + } + + if _, err := list.AsString(); err == nil { + t.Error("AsString of a list succeeded") + } + + if b, err := json.Marshal(SearchResult{}); err != nil || string(b) != "null" { + t.Errorf("empty union = %s, %v", b, err) + } +} diff --git a/testdata/gofetch/dates/dates_test.go b/testdata/gofetch/dates/dates_test.go new file mode 100644 index 0000000..cbf6be0 --- /dev/null +++ b/testdata/gofetch/dates/dates_test.go @@ -0,0 +1,49 @@ +package dates + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestDates(t *testing.T) { + var query url.Values + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"startDate":"2024-01-02","createdAt":"2024-01-02T03:04:05Z","reminderDates":["2024-01-01"]}]`)) + })) + defer srv.Close() + + at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + events, err := api.ListEvents(context.Background(), ListEventsParams{Since: &at, OnDay: Date{at}}) + if err != nil { + t.Fatal(err) + } + + if query.Get("since") != "2024-01-02T03:04:05Z" || query.Get("onDay") != "2024-01-02" { + t.Errorf("query = %v", query) + } + + e := events[0] + if e.StartDate.String() != "2024-01-02" || !e.CreatedAt.Equal(at) || e.EndDate.Set || e.ReminderDates[0].String() != "2024-01-01" { + t.Errorf("event = %+v", e) + } + + out, err := json.Marshal(Event{StartDate: e.StartDate, CreatedAt: at, EndDate: Some(Date{at})}) + if err != nil { + t.Fatal(err) + } + + if want := `{"startDate":"2024-01-02","endDate":"2024-01-02","createdAt":"2024-01-02T03:04:05Z"}`; string(out) != want { + t.Errorf("Marshal = %s, want %s", out, want) + } +} diff --git a/testdata/gofetch/discriminator-oneof/union_test.go b/testdata/gofetch/discriminator-oneof/union_test.go new file mode 100644 index 0000000..90e825b --- /dev/null +++ b/testdata/gofetch/discriminator-oneof/union_test.go @@ -0,0 +1,40 @@ +package discriminatoroneof + +import ( + "encoding/json" + "fmt" + "testing" +) + +func TestValueByDiscriminator(t *testing.T) { + for _, tt := range []struct { + json, want string + }{ + {`{"petType":"cat","huntingSkill":"lazy"}`, "*discriminatoroneof.Cat"}, + {`{"petType":"dog","packSize":3}`, "*discriminatoroneof.Dog"}, + {`{"petType":"canine","packSize":3}`, "*discriminatoroneof.Dog"}, + } { + var p Pet + if err := json.Unmarshal([]byte(tt.json), &p); err != nil { + t.Fatal(err) + } + + v, err := p.ValueByDiscriminator() + if got := fmt.Sprintf("%T", v); err != nil || got != tt.want { + t.Errorf("%s: ValueByDiscriminator = %s, %v, want %s", tt.json, got, err, tt.want) + } + } + + var p Pet + if err := json.Unmarshal([]byte(`{"petType":"bird"}`), &p); err != nil { + t.Fatal(err) + } + + if d, err := p.Discriminator(); err != nil || d != "bird" { + t.Errorf("Discriminator = %q, %v", d, err) + } + + if v, err := p.ValueByDiscriminator(); err == nil { + t.Errorf("unknown petType decoded to %v", v) + } +} diff --git a/testdata/gofetch/maps-of-models/inventory_test.go b/testdata/gofetch/maps-of-models/inventory_test.go new file mode 100644 index 0000000..b919176 --- /dev/null +++ b/testdata/gofetch/maps-of-models/inventory_test.go @@ -0,0 +1,30 @@ +package mapsofmodels + +import ( + "encoding/json" + "testing" +) + +// TestEmptyContainerIsSent checks that an optional map or slice is left out +// only when nil: an empty one is a value, {} or []. +func TestEmptyContainerIsSent(t *testing.T) { + tests := []struct { + in Inventory + want string + }{ + {Inventory{}, `{}`}, + {Inventory{ByWarehouse: map[string]Stock{}}, `{"byWarehouse":{}}`}, + {Inventory{HistoryByDate: map[string][]Stock{"d": {}}}, `{"historyByDate":{"d":[]}}`}, + } + + for _, tt := range tests { + out, err := json.Marshal(tt.in) + if err != nil { + t.Fatal(err) + } + + if string(out) != tt.want { + t.Errorf("Marshal(%+v) = %s, want %s", tt.in, out, tt.want) + } + } +} diff --git a/testdata/gofetch/naming-error-model/error_test.go b/testdata/gofetch/naming-error-model/error_test.go new file mode 100644 index 0000000..dfcfb83 --- /dev/null +++ b/testdata/gofetch/naming-error-model/error_test.go @@ -0,0 +1,32 @@ +package namingerrormodel + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" +) + +type status500 string + +func (b status500) Do(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 500, Header: http.Header{}, Body: io.NopCloser(bytes.NewBufferString(string(b)))}, nil +} + +// TestErrorModelKeys decodes an error body into the declared model only +// when it holds the model's required properties. +func TestErrorModelKeys(t *testing.T) { + for body, want := range map[string]bool{`{"message":"boom"}`: true, `{"detail":"Not Found"}`: false, `not json`: false} { + _, err := NewDefaultAPI(&Configuration{HTTPClient: status500(body)}).DoRisky(t.Context()) + + var rerr *ResponseError + if !errors.As(err, &rerr) { + t.Fatalf("%s: error %v", body, err) + } + + if m, ok := rerr.Model.(*Error); ok != want || (ok && m.Message != "boom") { + t.Errorf("%s: model = %#v", body, rerr.Model) + } + } +} diff --git a/testdata/gofetch/nested-inline-schemas/profile_test.go b/testdata/gofetch/nested-inline-schemas/profile_test.go new file mode 100644 index 0000000..7762fb2 --- /dev/null +++ b/testdata/gofetch/nested-inline-schemas/profile_test.go @@ -0,0 +1,31 @@ +package nestedinlineschemas + +import ( + "encoding/json" + "testing" +) + +// TestInlineObjects checks that inline objects decode into their declared +// types, nested and as slice elements, and encode back unchanged. +func TestInlineObjects(t *testing.T) { + const in = `{"user":{"name":"ann","address":{"street":"Main","geo":{"lat":1.5,"lng":-2}}},"settings":[{"key":"k","value":{"raw":"r"}}]}` + + var p Profile + if err := json.Unmarshal([]byte(in), &p); err != nil { + t.Fatal(err) + } + + var geo *ProfileUserAddressGeo = p.User.Address.Geo + if *geo.Lat != 1.5 || *p.Settings[0].Value.Raw != "r" { + t.Errorf("decoded %+v", p) + } + + out, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + + if string(out) != in { + t.Errorf("round trip = %s", out) + } +} diff --git a/testdata/gofetch/nullable-required/record_test.go b/testdata/gofetch/nullable-required/record_test.go new file mode 100644 index 0000000..90625f8 --- /dev/null +++ b/testdata/gofetch/nullable-required/record_test.go @@ -0,0 +1,50 @@ +package nullablerequired + +import ( + "encoding/json" + "testing" +) + +func TestOptRoundTrip(t *testing.T) { + const head = `{"requiredNotNullable":"a","requiredNullable":null` + + tests := []struct { + json string + want Opt[string] + }{ + {head + `}`, Opt[string]{}}, + {head + `,"optionalNullable":null}`, Null[string]()}, + {head + `,"optionalNullable":"x"}`, Some("x")}, + } + + for _, tt := range tests { + var r Record + if err := json.Unmarshal([]byte(tt.json), &r); err != nil { + t.Fatal(err) + } + + if r.OptionalNullable != tt.want { + t.Errorf("%s: optionalNullable = %+v, want %+v", tt.json, r.OptionalNullable, tt.want) + } + + out, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + + if string(out) != tt.json { + t.Errorf("round trip of %s = %s", tt.json, out) + } + } +} + +func TestRequiredNullableIsSent(t *testing.T) { + out, err := json.Marshal(Record{RequiredNotNullable: "a", RequiredNullable: Ptr("b")}) + if err != nil { + t.Fatal(err) + } + + if want := `{"requiredNotNullable":"a","requiredNullable":"b"}`; string(out) != want { + t.Errorf("Marshal = %s, want %s", out, want) + } +} diff --git a/testdata/gofetch/parameters-array-style-explode/params_test.go b/testdata/gofetch/parameters-array-style-explode/params_test.go new file mode 100644 index 0000000..7c01546 --- /dev/null +++ b/testdata/gofetch/parameters-array-style-explode/params_test.go @@ -0,0 +1,47 @@ +package parametersarraystyleexplode + +import ( + "context" + "reflect" + "testing" +) + +func TestArrayStyles(t *testing.T) { + req, err := NewDefaultAPI(nil).FilterItemsRequest(context.Background(), FilterItemsParams{ + FormExplodeTrue: []string{"a", "b"}, + FormExplodeFalse: []string{"a", "b"}, + SpaceDelimited: []int{1, 2}, + PipeDelimited: []int{1, 2}, + PathIds: []string{"x y", "z/w"}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + for name, want := range map[string][]string{ + "formExplodeTrue": {"a", "b"}, + "formExplodeFalse": {"a,b"}, + "spaceDelimited": {"1 2"}, + "pipeDelimited": {"1|2"}, + } { + if !reflect.DeepEqual(q[name], want) { + t.Errorf("%s = %q, want %q", name, q[name], want) + } + } + + if got, want := req.URL.EscapedPath(), "/filter/x%20y,z%2Fw"; got != want { + t.Errorf("path = %s, want %s", got, want) + } +} + +func TestEmptyArrayIsLeftOut(t *testing.T) { + req, err := NewDefaultAPI(nil).FilterItemsRequest(context.Background(), FilterItemsParams{PathIds: []string{"x"}}) + if err != nil { + t.Fatal(err) + } + + if req.URL.RawQuery != "" { + t.Errorf("query = %q, want none", req.URL.RawQuery) + } +} diff --git a/testdata/gofetch/parameters-deepobject/params_test.go b/testdata/gofetch/parameters-deepobject/params_test.go new file mode 100644 index 0000000..52e3d90 --- /dev/null +++ b/testdata/gofetch/parameters-deepobject/params_test.go @@ -0,0 +1,20 @@ +package parametersdeepobject + +import ( + "context" + "testing" +) + +func TestDeepObject(t *testing.T) { + req, err := NewDefaultAPI(nil).DeepSearchRequest(context.Background(), DeepSearchParams{ + Filter: &DeepSearchFilter{Name: Ptr("cat food"), MinPrice: Ptr(1.5)}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + if q.Get("filter[name]") != "cat food" || q.Get("filter[minPrice]") != "1.5" || len(q) != 2 { + t.Errorf("query = %v", q) + } +} diff --git a/testdata/gofetch/parameters-explode-object/params_test.go b/testdata/gofetch/parameters-explode-object/params_test.go new file mode 100644 index 0000000..ac265cf --- /dev/null +++ b/testdata/gofetch/parameters-explode-object/params_test.go @@ -0,0 +1,34 @@ +package parametersexplodeobject + +import ( + "context" + "testing" +) + +func TestExplodedObjects(t *testing.T) { + req, err := NewDefaultAPI(nil).ListItemsRequest(context.Background(), ListItemsParams{ + Labels: map[string]string{"color": "red", "size": "L"}, + Extra: map[string]any{"debug": true}, + Range: map[string]int{"min": 1, "max": 5}, + }) + if err != nil { + t.Fatal(err) + } + + q := req.URL.Query() + for name, want := range map[string]string{ + "color": "red", + "size": "L", + "debug": "true", + "range[min]": "1", + "range[max]": "5", + } { + if got := q.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } + + if len(q) != 5 { + t.Errorf("query = %v, want 5 parameters", q) + } +} diff --git a/testdata/gofetch/parameters-locations/params_test.go b/testdata/gofetch/parameters-locations/params_test.go new file mode 100644 index 0000000..921f384 --- /dev/null +++ b/testdata/gofetch/parameters-locations/params_test.go @@ -0,0 +1,47 @@ +package parameterslocations + +import ( + "context" + "testing" +) + +func TestLocations(t *testing.T) { + api := NewDefaultAPI(nil) + + req, err := api.GetItemRequest(context.Background(), GetItemParams{ + OrgID: "acme corp", + ItemID: 7, + Verbose: Ptr(true), + XRequestID: Ptr("req-1"), + SessionID: Ptr("s1"), + }) + if err != nil { + t.Fatal(err) + } + + if got := req.URL.EscapedPath(); got != "/orgs/acme%20corp/items/7" { + t.Errorf("path = %s", got) + } + + if got := req.URL.Query().Get("verbose"); got != "true" { + t.Errorf("verbose = %q", got) + } + + if got := req.Header.Get("X-Request-Id"); got != "req-1" { + t.Errorf("X-Request-Id = %q", got) + } + + if c, err := req.Cookie("sessionId"); err != nil || c.Value != "s1" { + t.Errorf("sessionId cookie = %v, %v", c, err) + } + + // Optional parameters left nil are not sent. + req, err = api.GetItemRequest(context.Background(), GetItemParams{OrgID: "o", ItemID: 1}) + if err != nil { + t.Fatal(err) + } + + if req.URL.RawQuery != "" || req.Header.Get("X-Request-Id") != "" || len(req.Cookies()) != 0 { + t.Errorf("unset parameters sent: query %q, header %v", req.URL.RawQuery, req.Header) + } +} diff --git a/testdata/gofetch/parameters-locations/raw_test.go b/testdata/gofetch/parameters-locations/raw_test.go new file mode 100644 index 0000000..efc900f --- /dev/null +++ b/testdata/gofetch/parameters-locations/raw_test.go @@ -0,0 +1,24 @@ +package parameterslocations + +import ( + "encoding/json" + "net/url" + "testing" +) + +// TestRawMessageParam checks that raw JSON, the type of an inline union, +// goes out as one value: a JSON string unquoted, anything else as written. +func TestRawMessageParam(t *testing.T) { + q := url.Values{} + if err := addQuery(q, "s", json.RawMessage(`"abc"`), "form", true); err != nil { + t.Fatal(err) + } + + if err := addQuery(q, "n", json.RawMessage(`5`), "form", true); err != nil { + t.Fatal(err) + } + + if got := q.Encode(); got != "n=5&s=abc" { + t.Errorf("query = %s, want n=5&s=abc", got) + } +} diff --git a/testdata/gofetch/parameters-path-style/params_test.go b/testdata/gofetch/parameters-path-style/params_test.go new file mode 100644 index 0000000..0df9bd7 --- /dev/null +++ b/testdata/gofetch/parameters-path-style/params_test.go @@ -0,0 +1,28 @@ +package parameterspathstyle + +import ( + "context" + "net/http" + "testing" +) + +func TestPathStyles(t *testing.T) { + api := NewDefaultAPI(nil) + ctx := context.Background() + ids := []string{"a", "b"} + + for want, build := range map[string]func() (*http.Request, error){ + "/simple/a,b": func() (*http.Request, error) { return api.GetSimplePathRequest(ctx, GetSimplePathParams{ID: ids}) }, + "/matrix/;id=a,b": func() (*http.Request, error) { return api.GetMatrixPathRequest(ctx, GetMatrixPathParams{ID: ids}) }, + "/label/.a.b": func() (*http.Request, error) { return api.GetLabelPathRequest(ctx, GetLabelPathParams{ID: ids}) }, + } { + req, err := build() + if err != nil { + t.Fatal(err) + } + + if got := req.URL.EscapedPath(); got != want { + t.Errorf("path = %s, want %s", got, want) + } + } +} diff --git a/testdata/gofetch/request-body-form-encoding/encoding_test.go b/testdata/gofetch/request-body-form-encoding/encoding_test.go new file mode 100644 index 0000000..65fc98d --- /dev/null +++ b/testdata/gofetch/request-body-form-encoding/encoding_test.go @@ -0,0 +1,47 @@ +package requestbodyformencoding + +import ( + "context" + "io" + "net/url" + "reflect" + "testing" +) + +// TestFormEncoding checks each form field against its encoding: deepObject +// spells out nested objects and lists, an array explodes by default. +func TestFormEncoding(t *testing.T) { + req, err := NewDefaultAPI(nil).CreateCustomerRequest(context.Background(), CreateCustomerParams{ + Name: "ann", + Address: &CreateCustomerAddress{City: Ptr("Oslo"), Lines: []string{"a", "b"}}, + Metadata: map[string]string{"k": "v"}, + Expand: []string{"x", "y"}, + Tags: []string{"t", "u"}, + Ids: []int{1, 2}, + }) + if err != nil { + t.Fatal(err) + } + + body, _ := io.ReadAll(req.Body) + + got, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + + want := url.Values{ + "name": {"ann"}, + "address[city]": {"Oslo"}, + "address[lines][0]": {"a"}, + "address[lines][1]": {"b"}, + "metadata[k]": {"v"}, + "expand[0]": {"x"}, + "expand[1]": {"y"}, + "tags": {"t,u"}, + "ids": {"1", "2"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("form = %v\nwant %v", got, want) + } +} diff --git a/testdata/gofetch/request-body-form-urlencoded/body_test.go b/testdata/gofetch/request-body-form-urlencoded/body_test.go new file mode 100644 index 0000000..40d4328 --- /dev/null +++ b/testdata/gofetch/request-body-form-urlencoded/body_test.go @@ -0,0 +1,42 @@ +package requestbodyformurlencoded + +import ( + "context" + "io" + "net/url" + "testing" +) + +func TestFormBody(t *testing.T) { + req, err := NewDefaultAPI(nil).LoginRequest(context.Background(), LoginParams{ + Username: "ann", + Password: "s3cret&", + RememberMe: Ptr(true), + Tags: []string{"a", "b"}, + }) + if err != nil { + t.Fatal(err) + } + + if ct := req.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q", ct) + } + + body, _ := io.ReadAll(req.Body) + + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + + for name, want := range map[string]string{"username": "ann", "password": "s3cret&", "rememberMe": "true"} { + if got := form.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } + + // An array is exploded by default, one key per value. + if got := form["tags"]; len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("tags = %q, want [a b]", got) + } +} diff --git a/testdata/gofetch/request-body-json/body_test.go b/testdata/gofetch/request-body-json/body_test.go new file mode 100644 index 0000000..b072f5d --- /dev/null +++ b/testdata/gofetch/request-body-json/body_test.go @@ -0,0 +1,45 @@ +package requestbodyjson + +import ( + "context" + "io" + "testing" +) + +func TestJSONBodies(t *testing.T) { + api := NewDefaultAPI(nil) + ctx := context.Background() + + req, err := api.CreateItemRequest(ctx, CreateItemParams{Item: Item{Name: "pen"}}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `{"name":"pen"}` || req.Header.Get("Content-Type") != "application/json" { + t.Errorf("CreateItem body %s, content type %q", body, req.Header.Get("Content-Type")) + } + + req, err = api.CreateItemsRequest(ctx, CreateItemsParams{Item: []Item{{Name: "a", Quantity: Ptr(2)}}}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `[{"name":"a","quantity":2}]` { + t.Errorf("CreateItems body %s", body) + } + + // A nil slice is no body, not null. + req, err = api.CreateItemsRequest(ctx, CreateItemsParams{}) + if err != nil || req.Body != nil { + t.Errorf("CreateItems without items: body %v, %v", req.Body, err) + } + + req, err = api.SetRawCountRequest(ctx, SetRawCountParams{Body: 3}) + if err != nil { + t.Fatal(err) + } + + if body, _ := io.ReadAll(req.Body); string(body) != `3` { + t.Errorf("SetRawCount body %s", body) + } +} diff --git a/testdata/gofetch/request-body-multipart/body_test.go b/testdata/gofetch/request-body-multipart/body_test.go new file mode 100644 index 0000000..b5b02f8 --- /dev/null +++ b/testdata/gofetch/request-body-multipart/body_test.go @@ -0,0 +1,51 @@ +package requestbodymultipart + +import ( + "context" + "io" + "strings" + "testing" +) + +func TestMultipartBody(t *testing.T) { + req, err := NewDefaultAPI(nil).UploadFileRequest(context.Background(), UploadFileParams{ + File: File{Name: "a.txt", Body: strings.NewReader("hello")}, + Attachments: []File{{Name: "b.txt", Body: strings.NewReader("b")}, {Name: "c.txt", Body: strings.NewReader("c")}}, + Description: Ptr("two files"), + }) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data; boundary=") { + t.Errorf("Content-Type = %q", req.Header.Get("Content-Type")) + } + + if err := req.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + + form := req.MultipartForm + if got := form.Value["description"]; len(got) != 1 || got[0] != "two files" { + t.Errorf("description = %q", got) + } + + if got := form.File["attachments"]; len(got) != 2 || got[0].Filename != "b.txt" || got[1].Filename != "c.txt" { + t.Errorf("attachments = %v", got) + } + + files := form.File["file"] + if len(files) != 1 || files[0].Filename != "a.txt" { + t.Fatalf("file = %v", files) + } + + f, err := files[0].Open() + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if data, _ := io.ReadAll(f); string(data) != "hello" { + t.Errorf("file content = %q", data) + } +} diff --git a/testdata/gofetch/responses-binary/responses_test.go b/testdata/gofetch/responses-binary/responses_test.go new file mode 100644 index 0000000..6c01109 --- /dev/null +++ b/testdata/gofetch/responses-binary/responses_test.go @@ -0,0 +1,34 @@ +package responsesbinary + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBinary(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte{0, 1, 2}) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + data, err := api.DownloadFile(context.Background(), DownloadFileParams{ID: "1"}) + if err != nil || string(data) != "\x00\x01\x02" { + t.Errorf("DownloadFile = %v, %v", data, err) + } + + resp, err := api.GetThumbnailRaw(context.Background(), GetThumbnailParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if data, _ := io.ReadAll(resp.Body); len(data) != 3 { + t.Errorf("GetThumbnailRaw body = %v", data) + } +} diff --git a/testdata/gofetch/responses-oneof/union_test.go b/testdata/gofetch/responses-oneof/union_test.go new file mode 100644 index 0000000..2a35853 --- /dev/null +++ b/testdata/gofetch/responses-oneof/union_test.go @@ -0,0 +1,30 @@ +package responsesoneof + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOneOfResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accountType":"business","companyName":"Acme"}`)) + })) + defer srv.Close() + + acc, err := NewDefaultAPI(&Configuration{BasePath: srv.URL}).GetAccount(context.Background(), GetAccountParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + + if b, err := acc.AsBusinessAccount(); err != nil || b.CompanyName != "Acme" { + t.Errorf("AsBusinessAccount = %+v, %v", b, err) + } + + // A business account lacks ownerName, which a personal one requires. + if p, err := acc.AsPersonalAccount(); err == nil { + t.Errorf("AsPersonalAccount = %+v, want an error", p) + } +} diff --git a/testdata/gofetch/responses-status-codes/responses_test.go b/testdata/gofetch/responses-status-codes/responses_test.go new file mode 100644 index 0000000..67ba15f --- /dev/null +++ b/testdata/gofetch/responses-status-codes/responses_test.go @@ -0,0 +1,62 @@ +package responsesstatuscodes + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusCodes(t *testing.T) { + status, body := 0, "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + ctx := context.Background() + + status, body = 200, `{"id":"1","title":"t"}` + if task, err := api.GetTask(ctx, GetTaskParams{ID: "1"}); err != nil || *task.Title != "t" { + t.Errorf("200: %+v, %v", task, err) + } + + for _, code := range []int{404, 500} { + status, body = code, `{"message":"nope"}` + _, err := api.GetTask(ctx, GetTaskParams{ID: "1"}) + + var rerr *ResponseError + if !errors.As(err, &rerr) || rerr.StatusCode != code || string(rerr.Body) != body { + t.Fatalf("%d: error %v", code, err) + } + + if m, ok := rerr.Model.(*ErrorBody); !ok || *m.Message != "nope" { + t.Errorf("%d: model = %#v", code, rerr.Model) + } + } + + status, body = 204, "" + if err := api.DeleteTask(ctx, DeleteTaskParams{ID: "1"}); err != nil { + t.Errorf("204: %v", err) + } + + status, body = 201, `{"id":"1"}` + if task, err := api.CreateTask(ctx, CreateTaskParams{ID: "1"}); err != nil || task == nil || *task.ID != "1" { + t.Errorf("201: %+v, %v", task, err) + } + + status, body = 202, "" + if task, err := api.CreateTask(ctx, CreateTaskParams{ID: "1"}); err != nil || task != nil { + t.Errorf("202 without a body: %+v, %v", task, err) + } + + status, body = 409, "conflict" + if err := api.DeleteTask(ctx, DeleteTaskParams{ID: "1"}); err == nil || err.Error() != "409 Conflict" { + t.Errorf("409: %v", err) + } +} diff --git a/testdata/gofetch/security-apikey/auth_test.go b/testdata/gofetch/security-apikey/auth_test.go new file mode 100644 index 0000000..3dc4b9c --- /dev/null +++ b/testdata/gofetch/security-apikey/auth_test.go @@ -0,0 +1,52 @@ +package securityapikey + +import ( + "context" + "testing" +) + +func TestAPIKey(t *testing.T) { + var calls []string + + api := NewDefaultAPI(&Configuration{ + APIKey: func(_ context.Context, op, name string) (string, error) { + calls = append(calls, op+" "+name) + + return "k-" + name, nil + }, + }) + ctx := context.Background() + + req, err := api.GetWithHeaderKeyRequest(ctx) + if err != nil || req.Header.Get("X-Api-Key") != "k-X-Api-Key" { + t.Errorf("header key: %v, %v", req.Header, err) + } + + req, err = api.GetWithQueryKeyRequest(ctx) + if err != nil || req.URL.Query().Get("api_key") != "k-api_key" { + t.Errorf("query key: %v, %v", req.URL, err) + } + + req, err = api.GetWithCookieKeyRequest(ctx) + if c, cerr := req.Cookie("session"); err != nil || cerr != nil || c.Value != "k-session" { + t.Errorf("cookie key: %v, %v, %v", c, err, cerr) + } + + want := []string{"getWithHeaderKey X-Api-Key", "getWithQueryKey api_key", "getWithCookieKey session"} + if len(calls) != len(want) { + t.Fatalf("calls = %q, want %q", calls, want) + } + + for i := range want { + if calls[i] != want[i] { + t.Errorf("call %d = %q, want %q", i, calls[i], want[i]) + } + } +} + +func TestNoKeyConfigured(t *testing.T) { + req, err := NewDefaultAPI(nil).GetWithHeaderKeyRequest(context.Background()) + if err != nil || req.Header.Get("X-Api-Key") != "" { + t.Errorf("header = %v, err %v, want no key", req.Header, err) + } +} diff --git a/testdata/gofetch/security-http/auth_test.go b/testdata/gofetch/security-http/auth_test.go new file mode 100644 index 0000000..c295645 --- /dev/null +++ b/testdata/gofetch/security-http/auth_test.go @@ -0,0 +1,35 @@ +package securityhttp + +import ( + "context" + "testing" +) + +func TestHTTPAuth(t *testing.T) { + var scheme string + + api := NewDefaultAPI(&Configuration{ + Username: "u", + Password: "p", + AccessToken: func(_ context.Context, op, name string, scopes []string) (string, error) { + scheme = op + " " + name + + return "tok", nil + }, + }) + ctx := context.Background() + + req, err := api.GetWithBasicAuthRequest(ctx) + if user, pass, ok := req.BasicAuth(); err != nil || !ok || user != "u" || pass != "p" { + t.Errorf("basic auth = %q %q %v, %v", user, pass, ok, err) + } + + req, err = api.GetWithBearerAuthRequest(ctx) + if err != nil || req.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("bearer: %v, %v", req.Header, err) + } + + if scheme != "getWithBearerAuth BearerAuth" { + t.Errorf("AccessToken called with %q", scheme) + } +} diff --git a/testdata/gofetch/security-oauth2/auth_test.go b/testdata/gofetch/security-oauth2/auth_test.go new file mode 100644 index 0000000..c14209d --- /dev/null +++ b/testdata/gofetch/security-oauth2/auth_test.go @@ -0,0 +1,28 @@ +package securityoauth2 + +import ( + "context" + "reflect" + "testing" +) + +func TestOAuth2Scopes(t *testing.T) { + var got []string + + api := NewDefaultAPI(&Configuration{ + AccessToken: func(_ context.Context, op, name string, scopes []string) (string, error) { + got = append([]string{op, name}, scopes...) + + return "tok", nil + }, + }) + + req, err := api.GetWithOAuthCodeRequest(context.Background()) + if err != nil || req.Header.Get("Authorization") != "Bearer tok" { + t.Errorf("oauth2: %v, %v", req.Header, err) + } + + if want := []string{"getWithOAuthCode", "OAuth2Code", "read", "write"}; !reflect.DeepEqual(got, want) { + t.Errorf("AccessToken called with %q, want %q", got, want) + } +} diff --git a/testdata/specs/feature/composition-oneof-inline.yaml b/testdata/specs/feature/composition-oneof-inline.yaml new file mode 100644 index 0000000..bf6d51a --- /dev/null +++ b/testdata/specs/feature/composition-oneof-inline.yaml @@ -0,0 +1,80 @@ +openapi: 3.0.3 +info: + title: Inline oneOf + version: 1.0.0 + description: oneOf and anyOf written in place, in a property, an array item, a request body and a response, not as a schema of their own; the members tell themselves apart by a kind of one value, or are objects of their own. +paths: + /shelters/{id}/pets: + post: + operationId: admit + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + responses: + "200": + description: the shelter after admission + content: + application/json: + schema: { $ref: "#/components/schemas/Shelter" } + /pets/random: + get: + operationId: randomPet + responses: + "200": + description: a cat or a dog + content: + application/json: + schema: + anyOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" +components: + schemas: + Cat: + type: object + required: [kind, lives] + properties: + kind: { type: string, enum: [cat] } + lives: { type: integer } + Dog: + type: object + required: [kind, barkVolume] + properties: + kind: { type: string, enum: [dog] } + barkVolume: { type: number } + Shelter: + type: object + required: [favourite] + properties: + favourite: + description: The pet everyone likes. + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + location: + oneOf: + - type: object + required: [street] + properties: + street: { type: string } + - type: object + required: [lat, lon] + properties: + lat: { type: number } + lon: { type: number } + residents: + type: array + items: + anyOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" diff --git a/testdata/specs/feature/discriminator-oneof.yaml b/testdata/specs/feature/discriminator-oneof.yaml new file mode 100644 index 0000000..6f12fb3 --- /dev/null +++ b/testdata/specs/feature/discriminator-oneof.yaml @@ -0,0 +1,44 @@ +openapi: "3.0.0" +info: + title: OneOf Discriminator Feature + version: 1.0.0 +paths: + /pets: + get: + operationId: getPet + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" +components: + schemas: + # two mapping values name the same model + Pet: + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + discriminator: + propertyName: petType + mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" + canine: "#/components/schemas/Dog" + Cat: + type: object + required: [petType] + properties: + petType: + type: string + huntingSkill: + type: string + Dog: + type: object + required: [petType] + properties: + petType: + type: string + packSize: + type: integer diff --git a/testdata/specs/feature/request-body-form-encoding.yaml b/testdata/specs/feature/request-body-form-encoding.yaml new file mode 100644 index 0000000..2fbdbf5 --- /dev/null +++ b/testdata/specs/feature/request-body-form-encoding.yaml @@ -0,0 +1,58 @@ +openapi: "3.0.0" +info: + title: Form Body Encoding Feature + version: 1.0.0 +paths: + /customers: + post: + operationId: createCustomer + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [name] + properties: + name: + type: string + address: + type: object + properties: + city: + type: string + lines: + type: array + items: + type: string + metadata: + type: object + additionalProperties: + type: string + expand: + type: array + items: + type: string + tags: + type: array + items: + type: string + ids: + type: array + items: + type: integer + encoding: + address: + style: deepObject + explode: true + metadata: + style: deepObject + explode: true + expand: + style: deepObject + explode: true + tags: + explode: false + responses: + "204": + description: created From 08e7446238d676e9f790525c62ffe4c5d3b4259d Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:49 +0100 Subject: [PATCH 04/18] fix(parser): read OpenAPI 3.1 null, const and binary schemas as upstream A 3.1 spec (FastAPI and pydantic write one) spells nullable as a null member of anyOf/oneOf or a type list with null, a literal as const, and a file as contentMediaType: application/octet-stream. The parser knew none of them: every such property typed as a union with null or any, and a file went out as a text field. A normalize step after loading ports the upstream OpenAPINormalizer defaults: SIMPLIFY_ONEOF_ANYOF (a null member makes the schema nullable; one member left is the schema, a $ref as `allOf: [$ref]`), SIMPLIFY_ONEOF_ANYOF_ENUM (a union of enums is one enum) and the NORMALIZE_31SPEC parts above. Also as upstream: a wrapper of an enum model (`allOf: [$ref]`, nullable) is typed as that model, not as a copy of its values (typescript-fetch `Status | null`, dart-fetch `Status.fromJson`), and a nullable parameter or form field is nullable (`q?: string | null`). --- README.md | 6 +- internal/parser/load.go | 3 + internal/parser/normalize.go | 267 ++++++++++++++++++ internal/parser/normalize_test.go | 78 +++++ internal/parser/operation.go | 3 +- internal/parser/parameter.go | 1 + internal/parser/property.go | 17 +- .../dart-fetch/modelPropertyFromJson.mustache | 2 +- .../gofetch/nullable-openapi31/pet_test.go | 86 ++++++ .../specs/feature/nullable-openapi31.yaml | 90 ++++++ 10 files changed, 548 insertions(+), 5 deletions(-) create mode 100644 internal/parser/normalize.go create mode 100644 internal/parser/normalize_test.go create mode 100644 testdata/gofetch/nullable-openapi31/pet_test.go create mode 100644 testdata/specs/feature/nullable-openapi31.yaml diff --git a/README.md b/README.md index fe274eb..a9bd4c7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ minimal dependencies. - Fast generation times without JVM overhead - Load specs from local files or remote URLs - Command-line interface compatible with the original OpenAPI Generator -- Supports OpenAPI 3.x and Swagger 2.0 specifications (auto-converts 2.0 -> 3.x) +- Supports OpenAPI 3.x and Swagger 2.0 specifications (auto-converts 2.0 -> 3.x); + a 3.1 `null` member of `anyOf`/`oneOf`, a `type` list with `null`, a `const` + and a `contentMediaType` binary string read as their 3.0 forms, as the + upstream normalizer's defaults do ## Installation @@ -164,6 +167,7 @@ sequenceDiagram CLI->>Parser: Load Spec (YAML/JSON) Parser-->>Parser: Convert Swagger 2.0 to OpenAPI 3.x + Parser-->>Parser: Normalize 3.1 null and binary schemas Parser-->>CLI: Parsed Spec CLI->>Config: Merge CLI + File Config diff --git a/internal/parser/load.go b/internal/parser/load.go index eb2271c..a70102e 100644 --- a/internal/parser/load.go +++ b/internal/parser/load.go @@ -47,6 +47,7 @@ func (p *Parser) LoadFromFile(path string) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() @@ -92,6 +93,7 @@ func (p *Parser) LoadFromURL(urlStr string) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() @@ -114,6 +116,7 @@ func (p *Parser) LoadFromData(data []byte) error { p.Doc = doc p.buildPropertyOrder(data) + p.normalize() // Validate the spec (unless skipped) return p.validateSpec() diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go new file mode 100644 index 0000000..82e3173 --- /dev/null +++ b/internal/parser/normalize.go @@ -0,0 +1,267 @@ +package parser + +import ( + "fmt" + "mime" + "slices" + + "github.com/getkin/kin-openapi/openapi3" +) + +// normalize applies two of the upstream OpenAPINormalizer's default rules to +// every schema of the loaded document, so an OpenAPI 3.1 spec reads as the +// 3.0 shapes the parser knows: +// +// - NORMALIZE_31SPEC: `type: [T, "null"]` is T with nullable, `const: v` +// an enum of v, and a string of contentMediaType +// application/octet-stream a binary string. +// - SIMPLIFY_ONEOF_ANYOF: a `{type: "null"}` member of oneOf/anyOf makes +// the schema nullable instead; when one member is left after that, it is +// the schema (a $ref one as `allOf: [$ref]`, the 3.0 spelling of a +// nullable $ref). Upstream also unwraps a lone member without a null. +// - SIMPLIFY_ONEOF_ANYOF_ENUM: a oneOf/anyOf of enums of one type is one +// enum of all their values (a union of pydantic Literals). +// +// It runs after the property order is recorded and keeps the schema pointers +// that order is keyed on. +func (p *Parser) normalize() { + seen := make(map[*openapi3.Schema]bool) + + walk := func(ref *openapi3.SchemaRef) { p.normalizeSchemaRef(ref, seen) } + + if c := p.Doc.Components; c != nil { + for _, ref := range c.Schemas { + walk(ref) + } + + for _, param := range c.Parameters { + normalizeParameter(param, walk) + } + + for _, body := range c.RequestBodies { + if body.Value != nil { + normalizeContent(body.Value.Content, walk) + } + } + + for _, resp := range c.Responses { + normalizeResponse(resp, walk) + } + } + + if p.Doc.Paths == nil { + return + } + + for _, item := range p.Doc.Paths.Map() { + for _, param := range item.Parameters { + normalizeParameter(param, walk) + } + + for _, op := range item.Operations() { + for _, param := range op.Parameters { + normalizeParameter(param, walk) + } + + if op.RequestBody != nil && op.RequestBody.Value != nil { + normalizeContent(op.RequestBody.Value.Content, walk) + } + + if op.Responses != nil { + for _, resp := range op.Responses.Map() { + normalizeResponse(resp, walk) + } + } + } + } +} + +func normalizeParameter(param *openapi3.ParameterRef, walk func(*openapi3.SchemaRef)) { + if param == nil || param.Value == nil { + return + } + + if param.Value.Schema != nil { + walk(param.Value.Schema) + } + + normalizeContent(param.Value.Content, walk) +} + +func normalizeResponse(resp *openapi3.ResponseRef, walk func(*openapi3.SchemaRef)) { + if resp == nil || resp.Value == nil { + return + } + + normalizeContent(resp.Value.Content, walk) + + for _, h := range resp.Value.Headers { + if h.Value != nil && h.Value.Schema != nil { + walk(h.Value.Schema) + } + } +} + +func normalizeContent(content openapi3.Content, walk func(*openapi3.SchemaRef)) { + for _, mt := range content { + if mt.Schema != nil { + walk(mt.Schema) + } + } +} + +// normalizeSchemaRef normalizes ref in place and every schema below it, each +// once. A $ref is left as it is: its target is a component, walked as one. +func (p *Parser) normalizeSchemaRef(ref *openapi3.SchemaRef, seen map[*openapi3.Schema]bool) { + if ref == nil || ref.Ref != "" || ref.Value == nil || seen[ref.Value] { + return + } + + s := ref.Value + seen[s] = true + + if s.Type != nil && s.Type.IsMultiple() && s.Type.IncludesNull() { + s.Type.Without(openapi3.TypeNull) + s.Nullable = true + } + + if s.Const != nil && len(s.Enum) == 0 { + s.Enum, s.Const = []any{s.Const}, nil + } + + if (s.Type.Is(openapi3.TypeString) || s.Type.IsEmpty()) && s.Format == "" && s.ContentEncoding == "" { + if mt, _, _ := mime.ParseMediaType(s.ContentMediaType); mt == "application/octet-stream" { + s.Type, s.Format = &openapi3.Types{openapi3.TypeString}, "binary" + } + } + + s.OneOf = withoutNull(s, s.OneOf) + s.AnyOf = withoutNull(s, s.AnyOf) + + for _, members := range []openapi3.SchemaRefs{s.AllOf, s.OneOf, s.AnyOf} { + for _, m := range members { + p.normalizeSchemaRef(m, seen) + } + } + + for _, prop := range s.Properties { + p.normalizeSchemaRef(prop, seen) + } + + p.normalizeSchemaRef(s.Items, seen) + p.normalizeSchemaRef(s.AdditionalProperties.Schema, seen) + p.normalizeSchemaRef(s.Not, seen) + + mergeEnums(s) + + // The schema is rewritten where it is, so a $ref to it (a component) + // sees the result too; an inline member keeps its property order. + if one := collapse(s); one != nil { + if order, ok := p.propOrder[one]; ok { + p.propOrder[s] = order + } + + *s = *one + } +} + +// withoutNull drops the `type: "null"` members of a oneOf/anyOf and makes s +// nullable when there was one. +func withoutNull(s *openapi3.Schema, members openapi3.SchemaRefs) openapi3.SchemaRefs { + kept := members[:0:0] + + for _, m := range members { + if m.Ref == "" && m.Value != nil && m.Value.Type.Is(openapi3.TypeNull) { + s.Nullable = true + continue + } + + kept = append(kept, m) + } + + if len(kept) == len(members) { + return members + } + + return kept +} + +// collapse returns the one member a nullable oneOf/anyOf has left, as the +// schema s stands for, or nil when s is anything more. +func collapse(s *openapi3.Schema) *openapi3.Schema { + members := s.OneOf + if len(s.AnyOf) > 0 { + members = s.AnyOf + } + + if !s.Nullable || len(members) != 1 || len(s.OneOf)+len(s.AnyOf) != 1 || + len(s.AllOf) > 0 || len(s.Properties) > 0 || !s.Type.IsEmpty() || s.Discriminator != nil { + return nil + } + + m := members[0] + if m.Ref != "" { + return &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{m}, Nullable: true, + Title: s.Title, Description: s.Description, Default: s.Default, + ReadOnly: s.ReadOnly, WriteOnly: s.WriteOnly, Deprecated: s.Deprecated, + } + } + + if m.Value == nil { + return nil + } + + v := m.Value + v.Nullable = true + v.ReadOnly = v.ReadOnly || s.ReadOnly + v.WriteOnly = v.WriteOnly || s.WriteOnly + + if v.Description == "" { + v.Description = s.Description + } + + if v.Title == "" { + v.Title = s.Title + } + + if v.Default == nil { + v.Default = s.Default + } + + return v +} + +// mergeEnums turns a oneOf/anyOf whose members are all enums of the same +// type into one enum of their values. +func mergeEnums(s *openapi3.Schema) { + members := s.OneOf + if len(s.AnyOf) > 0 { + members = s.AnyOf + } + + if len(members) < 2 || len(s.OneOf)+len(s.AnyOf) != len(members) || len(s.AllOf) > 0 || s.Not != nil { + return + } + + var values []any + + seen := make(map[string]bool) // by %#v: an enum value may be a map, which == panics on + + for _, m := range members { + if m.Value == nil || len(m.Value.Enum) == 0 || !slices.Equal(m.Value.Type.Slice(), members[0].Value.Type.Slice()) { + return + } + + for _, v := range m.Value.Enum { + if key := fmt.Sprintf("%#v", v); !seen[key] { + seen[key] = true + + values = append(values, v) + } + } + } + + t := members[0].Value.Type.Clone() + s.Type, s.Enum, s.OneOf, s.AnyOf = &t, values, nil, nil +} diff --git a/internal/parser/normalize_test.go b/internal/parser/normalize_test.go new file mode 100644 index 0000000..78b8f4f --- /dev/null +++ b/internal/parser/normalize_test.go @@ -0,0 +1,78 @@ +package parser + +import ( + "fmt" + "path/filepath" + "testing" +) + +// TestNormalize31 checks that the 3.1 spellings of null and of a binary +// string read as their 3.0 shapes: the member's type, nullable. +func TestNormalize31(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromFile(filepath.Join("..", "..", "testdata", "specs", "feature", "nullable-openapi31.yaml")); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + var got map[string]string + + for _, m := range models { + if m.Classname != "Pet" { + continue + } + + got = make(map[string]string) + for _, v := range m.Vars { + got[v.BaseName] = fmt.Sprintf("%s nullable=%t ref=%s %s", v.DataType, v.IsNullable, v.Ref, v.Description) + } + + // A const is an enum of it; a choice of consts one enum of them all. + for _, v := range m.Vars { + want := map[string]string{"kind": "[pet]", "mood": "[happy sad]"}[v.BaseName] + if got := fmt.Sprint(v.AllowableValues["values"]); want != "" && (!v.IsEnum || got != want) { + t.Errorf("%s = enum %t of %s, want %s", v.BaseName, v.IsEnum, got, want) + } + } + } + + want := map[string]string{ + "kind": "string nullable=false ref= ", + "name": "string nullable=false ref= ", + "size": "Size nullable=true ref=#/components/schemas/Size ", + "owner": "Owner nullable=true ref=#/components/schemas/Owner Who keeps the pet.", + "born": "Date nullable=true ref= ", + "nickname": "string nullable=true ref= ", + "tags": "Array nullable=true ref= ", + "alias": "string nullable=true ref=#/components/schemas/MaybeName ", + "mood": "string nullable=false ref= ", + } + for name, w := range want { + if got[name] != w { + t.Errorf("%s = %q, want %q", name, got[name], w) + } + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + for _, op := range ops["default"] { + for _, param := range op.AllParams { + if param.BaseName == "q" && (param.DataType != "string" || !param.IsNullable) { + t.Errorf("q = %s nullable=%t, want a nullable string", param.DataType, param.IsNullable) + } + + if param.BaseName == "file" && !param.IsFile { + t.Errorf("file = %s, want a file", param.DataType) + } + } + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 3db084e..d81c7be 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -307,6 +307,7 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc IsDateTime: prop.IsDateTime, IsDateTimeType: prop.IsDateTimeType, IsEnum: prop.IsEnum, + IsNullable: prop.IsNullable, Items: prop.Items, AllowableValues: prop.AllowableValues, EnumName: prop.EnumName, @@ -315,7 +316,7 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc } // The property's encoding with its defaults filled in: form style, - // exploded. Only an application/x-www-form-urlencoded body uses it. + // exploded. go-fetch reads it for urlencoded and multipart bodies. sm := encoding[name].SerializationMethod() fp.Style = sm.Style fp.IsExplode = sm.Explode diff --git a/internal/parser/parameter.go b/internal/parser/parameter.go index e701c88..7d71927 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -85,6 +85,7 @@ func (p *Parser) parameterToCodegen(param *openapi3.Parameter) *codegen.CodegenP cp.AllowableValues = prop.AllowableValues cp.EnumName = prop.EnumName cp.DatatypeWithEnum = prop.DatatypeWithEnum + cp.IsNullable = prop.IsNullable // Collection format if cp.IsArray { diff --git a/internal/parser/property.go b/internal/parser/property.go index e96fd04..e0e56aa 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -66,8 +66,11 @@ func (p *Parser) schemaRefToProperty(name string, ref *openapi3.SchemaRef, requi p.inlineMembers(prop, ref.Value) } - prop.Ref = ref.Ref - prop.IsEnumRef = ref.Ref != "" && prop.IsEnum + // A $ref sets them; an `allOf: [$ref]` wrapper has them from its member. + if ref.Ref != "" { + prop.Ref = ref.Ref + prop.IsEnumRef = prop.IsEnum + } // Only object-like targets need the override: arrays, enums and primitives are // already typed correctly by schemaToProperty, and clobbering them with the @@ -153,6 +156,16 @@ func (p *Parser) applyMemberType(prop *codegen.CodegenProperty, ref *openapi3.Sc prop.Items = member.Items prop.AllowableValues = member.AllowableValues prop.EnumName = member.EnumName + prop.Ref = member.Ref + prop.IsEnumRef = member.IsEnumRef + + // A wrapper of an enum model (`allOf: [$ref]`, a nullable 3.1 $ref) is + // typed as that model, as upstream types it, not as a copy of its values. + if member.IsEnumRef { + name := p.toModelName(extractRefName(member.Ref)) + prop.DataType, prop.BaseType, prop.ComplexType, prop.DatatypeWithEnum = name, name, name, name + prop.IsEnum, prop.IsModel, prop.IsPrimitiveType = false, true, false + } return true } diff --git a/templates/dart-fetch/modelPropertyFromJson.mustache b/templates/dart-fetch/modelPropertyFromJson.mustache index 7798f13..54942ed 100644 --- a/templates/dart-fetch/modelPropertyFromJson.mustache +++ b/templates/dart-fetch/modelPropertyFromJson.mustache @@ -1 +1 @@ -{{#isArray}}{{#items.isModel}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e as Map)).toList(){{/items.isModel}}{{^items.isModel}}{{#items.isEnum}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e)).toList(){{/items.isEnum}}{{^items.isEnum}}{{#items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => DateTime.parse(e as String)).toList(){{/items.isDateTime}}{{^items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast<{{{items.dataType}}}>().toList(){{/items.isDateTime}}{{/items.isEnum}}{{/items.isModel}}{{/isArray}}{{^isArray}}{{#isMap}}{{#additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((k, v) => MapEntry(k, {{additionalProperties.complexType}}.fromJson(v as Map))){{/additionalProperties.isModel}}{{^additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast(){{/additionalProperties.isModel}}{{/isMap}}{{^isMap}}{{#isModel}}{{#required}}{{^isNullable}}{{complexType}}.fromJson(json['{{baseName}}'] as Map){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}'] as Map){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}'] as Map){{/required}}{{/isModel}}{{^isModel}}{{#isEnum}}{{#required}}{{^isNullable}}{{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/required}}{{/isEnum}}{{^isEnum}}{{#isDateTime}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDateTime}}{{^isDateTime}}{{#isDate}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDate}}{{^isDate}}json['{{baseName}}'] as {{{dataType}}}{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}{{/isDate}}{{/isDateTime}}{{/isEnum}}{{/isModel}}{{/isMap}}{{/isArray}} \ No newline at end of file +{{#isArray}}{{#items.isModel}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e as Map)).toList(){{/items.isModel}}{{^items.isModel}}{{#items.isEnum}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => {{items.complexType}}.fromJson(e)).toList(){{/items.isEnum}}{{^items.isEnum}}{{#items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((e) => DateTime.parse(e as String)).toList(){{/items.isDateTime}}{{^items.isDateTime}}(json['{{baseName}}'] as List{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast<{{{items.dataType}}}>().toList(){{/items.isDateTime}}{{/items.isEnum}}{{/items.isModel}}{{/isArray}}{{^isArray}}{{#isMap}}{{#additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.map((k, v) => MapEntry(k, {{additionalProperties.complexType}}.fromJson(v as Map))){{/additionalProperties.isModel}}{{^additionalProperties.isModel}}(json['{{baseName}}'] as Map{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}){{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}.cast(){{/additionalProperties.isModel}}{{/isMap}}{{^isMap}}{{#isModel}}{{#required}}{{^isNullable}}{{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{complexType}}.fromJson(json['{{baseName}}']{{^isEnumRef}} as Map{{/isEnumRef}}){{/required}}{{/isModel}}{{^isModel}}{{#isEnum}}{{#required}}{{^isNullable}}{{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : {{{datatypeWithEnum}}}.fromJson(json['{{baseName}}']){{/required}}{{/isEnum}}{{^isEnum}}{{#isDateTime}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDateTime}}{{^isDateTime}}{{#isDate}}{{#required}}{{^isNullable}}DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{#isNullable}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/isNullable}}{{/required}}{{^required}}json['{{baseName}}'] == null ? null : DateTime.parse(json['{{baseName}}'] as String){{/required}}{{/isDate}}{{^isDate}}json['{{baseName}}'] as {{{dataType}}}{{^required}}?{{/required}}{{#isNullable}}?{{/isNullable}}{{/isDate}}{{/isDateTime}}{{/isEnum}}{{/isModel}}{{/isMap}}{{/isArray}} \ No newline at end of file diff --git a/testdata/gofetch/nullable-openapi31/pet_test.go b/testdata/gofetch/nullable-openapi31/pet_test.go new file mode 100644 index 0000000..4a3b600 --- /dev/null +++ b/testdata/gofetch/nullable-openapi31/pet_test.go @@ -0,0 +1,86 @@ +package nullableopenapi31 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// TestNullMembersTyped decodes the 3.1 spellings of null into the member's +// type and encodes them back unchanged. +func TestNullMembersTyped(t *testing.T) { + const in = `{"kind":"pet","name":"Mia","size":null,"owner":{"name":"Ann"},"born":"2020-05-17","nickname":null,"tags":["a"]}` + + var p Pet + if err := json.Unmarshal([]byte(in), &p); err != nil { + t.Fatal(err) + } + + // A component that is a nullable string is one wherever it is referred to. + var alias Opt[string] = p.Alias + + if alias.Set || p.Kind != PetKindEnumPet || p.Size != nil || p.Owner.Value.Name != "Ann" || p.Born.Value.Day() != 17 || !p.Nickname.Null || p.Tags.Value[0] != "a" { + t.Errorf("decoded %+v", p) + } + + p.Size = Ptr(Size2) + + out, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + + if want := strings.Replace(in, `"size":null`, `"size":2`, 1); string(out) != want { + t.Errorf("encoded %s\nwant %s", out, want) + } +} + +type recorder struct{ req *http.Request } + +func (r *recorder) Do(req *http.Request) (*http.Response, error) { + r.req = req + + return &http.Response{StatusCode: 204, Body: http.NoBody, Header: http.Header{}}, nil +} + +// TestContentMediaTypeIsFile sends a contentMediaType string as a file part. +func TestContentMediaTypeIsFile(t *testing.T) { + rec := &recorder{} + api := NewDefaultAPI(&Configuration{BasePath: "http://x", HTTPClient: rec}) + + err := api.UploadPhoto(t.Context(), UploadPhotoParams{ + ID: "1", File: File{Name: "a.png", Body: strings.NewReader("png")}, + Tags: []string{"a", "b"}, Labels: []string{"x", "y"}, + }) + if err != nil { + t.Fatal(err) + } + + mr, err := rec.req.MultipartReader() + if err != nil { + t.Fatal(err) + } + + part, err := mr.NextPart() + if err != nil { + t.Fatal(err) + } + + body, _ := io.ReadAll(part) + if part.FormName() != "file" || part.FileName() != "a.png" || string(body) != "png" { + t.Errorf("part %s %q = %q", part.FormName(), part.FileName(), body) + } + + // An array is a part per item, one comma-joined part with explode: false. + form, err := mr.ReadForm(1 << 20) + if err != nil { + t.Fatal(err) + } + + if got := fmt.Sprint(form.Value); got != "map[labels:[x,y] tags:[a b]]" { + t.Errorf("fields = %s", got) + } +} diff --git a/testdata/specs/feature/nullable-openapi31.yaml b/testdata/specs/feature/nullable-openapi31.yaml new file mode 100644 index 0000000..43895d2 --- /dev/null +++ b/testdata/specs/feature/nullable-openapi31.yaml @@ -0,0 +1,90 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 nullability + version: 1.0.0 + description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, as FastAPI and pydantic write them; multipart arrays, exploded by default. +paths: + /pets: + get: + operationId: listPets + parameters: + - name: q + in: query + schema: + anyOf: [{ type: string }, { type: "null" }] + - name: size + in: query + schema: + anyOf: [{ type: array, items: { $ref: "#/components/schemas/Size" } }, { type: "null" }] + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Pet" } + /pets/{id}/photo: + post: + operationId: uploadPhoto + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, contentMediaType: application/octet-stream } + caption: + anyOf: [{ type: string }, { type: "null" }] + tags: + type: array + items: { type: string } + labels: + type: array + items: { type: string } + encoding: + labels: + explode: false + responses: + "204": + description: stored +components: + schemas: + Size: + type: integer + enum: [1, 2, 3] + Owner: + type: object + required: [name] + properties: + name: { type: string } + Pet: + type: object + required: [kind, name, size] + properties: + kind: { type: string, const: pet } + name: { type: string } + size: + anyOf: [{ $ref: "#/components/schemas/Size" }, { type: "null" }] + owner: + description: Who keeps the pet. + anyOf: [{ $ref: "#/components/schemas/Owner" }, { type: "null" }] + born: + anyOf: [{ type: string, format: date }, { type: "null" }] + nickname: + type: [string, "null"] + tags: + anyOf: [{ type: array, items: { type: string } }, { type: "null" }] + alias: + $ref: "#/components/schemas/MaybeName" + mood: + anyOf: [{ type: string, const: happy }, { type: string, const: sad }] + MaybeName: + anyOf: [{ type: string }, { type: "null" }] From e1bb0b6d11817eb3a2ca6a0e96934b376bcca4b7 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:52 +0100 Subject: [PATCH 05/18] feat(cli): take comma-separated pairs in -p, as upstream does `-p packageName=petshop,withGoMod=true` set packageName to the whole string. One -p may now hold several key=value pairs, as the upstream CLI takes them; a piece without "=" belongs to the value before it, so a value with a comma (`npmDescription=cats, dogs`) still works. --- README.md | 20 ++++++++++---------- internal/gen/helpers.go | 16 +++++++++++++++- internal/gen/helpers_test.go | 10 ++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a9bd4c7..efc4428 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,16 @@ openapi-generator validate -i openapi.yaml --recommend ### `generate` -| Option | Short | Description | -| --------------------------- | ----- | ------------------------------------------------ | -| `--input-spec` | `-i` | Location of the OpenAPI spec (file or URL) | -| `--generator-name` | `-g` | typescript-fetch, dart-fetch, go-fetch | -| `--output` | `-o` | Output directory | -| `--config` | `-c` | Configuration file (JSON/YAML) | -| `--template-dir` | `-t` | Custom template directory | -| `--additional-properties` | `-p` | Key=value pairs for generator options | -| `--skip-validate-spec` | | Skip OpenAPI spec validation | -| `--verbose` | `-v` | Enable verbose output | +| Option | Short | Description | +| ------------------------- | ----- | ---------------------------------------------------- | +| `--input-spec` | `-i` | Location of the OpenAPI spec (file or URL) | +| `--generator-name` | `-g` | typescript-fetch, dart-fetch, go-fetch | +| `--output` | `-o` | Output directory | +| `--config` | `-c` | Configuration file (JSON/YAML) | +| `--template-dir` | `-t` | Custom template directory | +| `--additional-properties` | `-p` | `key=value` generator options, `a=1,b=2` or repeated | +| `--skip-validate-spec` | | Skip OpenAPI spec validation | +| `--verbose` | `-v` | Enable verbose output | ### `validate` diff --git a/internal/gen/helpers.go b/internal/gen/helpers.go index f7b0ee3..ae4584b 100644 --- a/internal/gen/helpers.go +++ b/internal/gen/helpers.go @@ -11,11 +11,25 @@ import ( // parseAdditionalProperties converts a list of "key=value" strings (as // accepted via the CLI's --additional-properties flag) into a map, coercing // "true"/"false" values to booleans so downstream config binding can type -// assert on bool. +// assert on bool. One string may hold several pairs, "a=1,b=2", as upstream +// takes them; a piece without "=" belongs to the value before it. func parseAdditionalProperties(props []string) map[string]any { result := make(map[string]any) + var pairs []string + for _, prop := range props { + for i, piece := range strings.Split(prop, ",") { + if i > 0 && !strings.Contains(piece, "=") { + pairs[len(pairs)-1] += "," + piece + continue + } + + pairs = append(pairs, piece) + } + } + + for _, prop := range pairs { parts := strings.SplitN(prop, "=", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) diff --git a/internal/gen/helpers_test.go b/internal/gen/helpers_test.go index fc56ce4..a4600ab 100644 --- a/internal/gen/helpers_test.go +++ b/internal/gen/helpers_test.go @@ -41,6 +41,16 @@ func TestParseAdditionalProperties(t *testing.T) { props: []string{"query=a=b"}, want: map[string]any{"query": "a=b"}, }, + { + name: "one entry may hold several comma-separated pairs", + props: []string{"packageName=petshop,withGoMod=true", "npmDescription=cats, dogs,x=1"}, + want: map[string]any{ + "packageName": "petshop", + "withGoMod": true, + "npmDescription": "cats, dogs", + "x": "1", + }, + }, { name: "entries without '=' are silently ignored", props: []string{"noEquals", "valid=1"}, From 056b7abf7badc9a278cc6354da7bf3c820a7aca7 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 16:41:36 +0100 Subject: [PATCH 06/18] feat(go-fetch): extra keys, other 2xx bodies and nullable results Found by driving a client generated from a FastAPI backend with every common shape against the live server: - A model with properties and additionalProperties keeps the other keys in an AdditionalProperties map, read and written with the fields. - A 2xx whose body is of another type than the result (a 202 Problem next to a 201 Order) is a *ResponseError with that body in Model; the typed method used to return an empty result without an error. - A nullable result is a pointer, nil for null. - An any result is the text when the response is not JSON (FastAPI writes an empty JSON schema next to application/x-ndjson). - A parameter that refers to an enum model has that type; the parser hands parameters their $ref. --- internal/codegen/parameter.go | 1 + internal/generator/golang/base.go | 2 +- internal/generator/golang/fetch.go | 122 +++++++++++++++--- internal/generator/golang/fetch_test.go | 41 ++++++ internal/parser/parameter.go | 1 + templates/go-fetch/README.md | 68 +++++----- templates/go-fetch/api.mustache | 17 +++ templates/go-fetch/model.mustache | 28 ++++ templates/go-fetch/runtime.mustache | 109 ++++++++++++++-- .../extra_test.go | 46 +++++++ .../responses-mixed-2xx/responses_test.go | 61 +++++++++ ...additional-properties-with-properties.yaml | 49 +++++++ .../specs/feature/responses-mixed-2xx.yaml | 88 +++++++++++++ 13 files changed, 576 insertions(+), 57 deletions(-) create mode 100644 testdata/gofetch/additional-properties-with-properties/extra_test.go create mode 100644 testdata/gofetch/responses-mixed-2xx/responses_test.go create mode 100644 testdata/specs/feature/additional-properties-with-properties.yaml create mode 100644 testdata/specs/feature/responses-mixed-2xx.yaml diff --git a/internal/codegen/parameter.go b/internal/codegen/parameter.go index e0b600b..6ec2045 100644 --- a/internal/codegen/parameter.go +++ b/internal/codegen/parameter.go @@ -100,6 +100,7 @@ type CodegenParameter struct { RequiredVars []*CodegenProperty `json:"requiredVars"` MostInnerItems *CodegenProperty `json:"mostInnerItems"` ComposedSchemas *CodegenComposedSchemas `json:"composedSchemas"` + Ref string `json:"ref"` // the $ref the parameter's schema is, as the spec writes it // Documentation Description string `json:"description"` diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go index e30610a..4a1163a 100644 --- a/internal/generator/golang/base.go +++ b/internal/generator/golang/base.go @@ -253,7 +253,7 @@ func GoParamType(p *codegen.CodegenParameter, named string) string { return "[]File" case p.IsArray: return "[]" + itemType(p.Items, named) - case (len(p.Vars) > 0 || p.ComposedSchemas != nil) && named != "": + case (len(p.Vars) > 0 || p.ComposedSchemas != nil || p.Ref != "") && named != "": return named case p.IsMap: return "map[string]" + itemType(p.Items, named) diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index 1d6fe7b..cb1af67 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -37,6 +37,11 @@ type FetchGenerator struct { // that refers to one: the parser types it as the enum or union inline. refs map[string]string + // enums are the enum models by class: a parameter that refers to one + // takes its type; one that refers to a union stays raw JSON, which the + // runtime sends as a scalar. + enums map[string]bool + // required are the quoted required properties of each struct model, which // a union's As method checks a value for; pinned its properties an enum // of one value fixes (see pinned). @@ -46,7 +51,7 @@ type FetchGenerator struct { // NewFetchGenerator creates a new go-fetch generator. func NewFetchGenerator() *FetchGenerator { - g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} + g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, enums: map[string]bool{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} g.ApiTemplateFiles["api.mustache"] = ".go" g.ModelTemplateFiles["model.mustache"] = ".go" @@ -144,6 +149,8 @@ func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*co g.refs[m.SchemaName] = m.Classname } + g.enums[m.Classname] = kind == "enum" + g.required[m.Classname] = requiredKeys(m.AllVars) g.pinned[m.Classname] = pinned(m.Classname, m.AllVars) @@ -165,6 +172,7 @@ func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*co switch kinds[m.Classname] { case "struct": fields[m.Classname] = g.structFields(m, ext, g.modelTypeName) + fields[m.Classname] = extraField(m, fields[m.Classname], ext) ext["x-go-fields"] = fields[m.Classname] inline, _ := ext["x-go-inline"].([]map[string]any) @@ -198,11 +206,9 @@ func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*co // modelKind is how a model is declared: union (oneOf/anyOf), enum, struct // (properties or allOf) or alias of a slice, map or scalar type. // -// ponytail: a struct drops additionalProperties next to its properties, so -// extra keys are lost on decode; the upgrade is an AdditionalProperties map -// field with its own (Un)MarshalJSON. A discriminator without oneOf (allOf -// inheritance) makes no union either: the parent stays a plain struct; the -// upgrade is a union wrapper for the parent too. +// ponytail: a discriminator without oneOf (allOf inheritance) makes no +// union: the parent stays a plain struct; the upgrade is a union wrapper for +// the parent too. func modelKind(m *codegen.CodegenModel) string { switch { case len(m.OneOf) > 0 || len(m.AnyOf) > 0: @@ -238,6 +244,41 @@ func aliasType(m *codegen.CodegenModel) string { return "any" } +// extraField adds to a struct that takes additionalProperties next to its +// properties a map field for the keys it does not declare, and describes in +// ext the (Un)MarshalJSON that moves them in and out of it. +func extraField(m *codegen.CodegenModel, fields []map[string]any, ext map[string]any) []map[string]any { + if !m.IsAdditionalPropertiesTrue && m.AdditionalProperties == nil { + return fields + } + + value := "any" + if m.AdditionalProperties != nil { + value = itemType(m.AdditionalProperties, "") + } + + taken := make(map[string]int) + + declared := make([]string, 0, len(m.AllVars)) + for _, v := range m.AllVars { + declared = append(declared, strconv.Quote(v.BaseName)) + } + + for _, f := range fields { + name, _ := f["name"].(string) + taken[name]++ + } + + name := uniqueName(taken, "AdditionalProperties") + fields = append(fields, map[string]any{ + "name": name, "type": "map[string]" + value, "tag": "`json:\"-\"`", + "doc": []string{"// " + name + " holds the keys the schema does not declare."}, + }) + ext["x-go-extra"] = map[string]any{"field": name, "value": value, "declared": strings.Join(declared, ", ")} + + return fields +} + // isContainer reports whether a type is nil-able as it is (slice, map, any, // raw JSON), so an optional field of it needs no pointer. func isContainer(t string) bool { @@ -328,7 +369,7 @@ func (g *FetchGenerator) structFields(m *codegen.CodegenModel, ext map[string]an enums = append(enums, nested...) } - if ref := g.refs[v.Ref[strings.LastIndexByte(v.Ref, '/')+1:]]; v.Ref != "" && ref != "" { + if ref := g.refModel(v.Ref); ref != "" { named = ref } else if v.IsEnum { base := m.Classname + exported(v.EnumName, "") @@ -408,6 +449,15 @@ func inlineObject(p *codegen.CodegenProperty) *codegen.CodegenProperty { return p } +// refModel is the enum or union model a $ref names, "" for none. +func (g *FetchGenerator) refModel(ref string) string { + if ref == "" { + return "" + } + + return g.refs[ref[strings.LastIndexByte(ref, '/')+1:]] +} + // inlineUnion is the inline oneOf/anyOf a property holds, itself or as the // element of its slices and maps; nil when there is none. func inlineUnion(p *codegen.CodegenProperty) *codegen.CodegenProperty { @@ -429,7 +479,7 @@ func (g *FetchGenerator) unionDecl(name, what, description string, members *code var types []string for i, member := range slices.Concat(members.OneOf, members.AnyOf) { - named := g.refs[member.Ref[strings.LastIndexByte(member.Ref, '/')+1:]] + named := g.refModel(member.Ref) // An inline object member is a struct of its own, named as upstream // names it: OneOf, OneOf1, ... @@ -869,13 +919,16 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s body := "" for _, p := range op.AllParams { - named := "" - vars, description := p.Vars, p.Description if obj := inlineObject(p.Items); len(vars) == 0 && obj != nil && (p.IsArray || p.IsMap) { vars, description = obj.Vars, obj.Description } + named := "" + if ref := g.refModel(p.Ref); g.enums[ref] { + named = ref + } + typeName := g.opTypeNamer(op.OperationId) what := "the " + p.BaseName + " parameter of " + base @@ -1041,9 +1094,12 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma } t = GoType(op.ReturnProperty, named) + if op.ReturnProperty.IsNullable && t != "string" && !isContainer(t) && !g.containers[t] { + t = "*" + t + } } - if t != "" && t != "string" && !isContainer(t) && !g.containers[t] { + if t != "" && t != "string" && !strings.HasPrefix(t, "*") && !isContainer(t) && !g.containers[t] { var empty []string for _, r := range op.Responses { @@ -1062,6 +1118,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma ext["x-go-type"] = t if t != "" && !op.IsResponseBinary { ext["x-go-decode-json"] = t + g.other2xx(op, ext) } var codes, ranges []map[string]any @@ -1074,11 +1131,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma continue } - // A body without the model's required properties is not that model. - keys := "nil" - if required := g.required[rt]; required != "" { - keys = "[]string{" + required + "}" - } + keys := g.modelKeys(rt) switch _, err := strconv.Atoi(r.Code); { case r.IsDefault: @@ -1099,6 +1152,43 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma } } +// modelKeys is the Go []string of the required properties of model t, the +// keys a body must hold to be decoded as one; "nil" for none. +func (g *FetchGenerator) modelKeys(t string) string { + if required := g.required[t]; required != "" { + return "[]string{" + required + "}" + } + + return "nil" +} + +// other2xx lists the 2xx statuses whose body is of another type than the +// result (a 201 Order next to a 202 Problem): the typed method returns +// them as a *ResponseError holding that body, which the result cannot. +func (g *FetchGenerator) other2xx(op *codegen.CodegenOperation, ext map[string]any) { + if ext["x-go-type"] == "any" { + return // any holds every body + } + + var codes []map[string]any + + for _, r := range op.Responses { + rt := responseType(r) + if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx || r.DataType == "" || r.DataType == op.ReturnType || rt == "" || rt == "any" || rt == "[]byte" { + continue + } + + codes = append(codes, map[string]any{"code": r.Code, "type": rt, "keys": g.modelKeys(rt)}) + + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// A "+r.Code+" response is a *ResponseError with its "+rt+" body in Model.") + } + + if len(codes) > 0 { + ext["x-go-other-2xx"] = map[string]any{"codes": codes} + } +} + // authEntries lists how each security scheme of the operation is sent. func authEntries(op *codegen.CodegenOperation) []map[string]any { opName := op.OperationIdOriginal diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go index bfc54a5..b7689f1 100644 --- a/internal/generator/golang/fetch_test.go +++ b/internal/generator/golang/fetch_test.go @@ -277,3 +277,44 @@ components: t.Errorf("mappings = %s", got) } } + +// TestEnumRefParam types a parameter that refers to an enum model as that +// model, not as its base type. +func TestEnumRefParam(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /paint/{color}: + get: + operationId: paint + parameters: + - {name: color, in: path, required: true, schema: {$ref: "#/components/schemas/Color"}} + - {name: also, in: query, schema: {type: array, items: {$ref: "#/components/schemas/Color"}}} + - {name: either, in: query, schema: {$ref: "#/components/schemas/Either"}} + responses: {"204": {description: ok}} +components: + schemas: + Color: {type: string, enum: [red, light green]} + Either: {oneOf: [{type: string}, {type: integer}]} +`) + + ms, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + g.PostProcessModels(ms) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + op := g.PostProcessOperations(byTag["default"])[0] + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + + // A union stays raw JSON, which the runtime sends as the scalar it holds. + if got := fmt.Sprint(fs[0]["type"], " ", fs[1]["type"], " ", fs[2]["type"]); got != "Color []Color json.RawMessage" { + t.Errorf("param types = %s, want Color []Color json.RawMessage", got) + } +} diff --git a/internal/parser/parameter.go b/internal/parser/parameter.go index 7d71927..c68b347 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -86,6 +86,7 @@ func (p *Parser) parameterToCodegen(param *openapi3.Parameter) *codegen.CodegenP cp.EnumName = prop.EnumName cp.DatatypeWithEnum = prop.DatatypeWithEnum cp.IsNullable = prop.IsNullable + cp.Ref = prop.Ref // Collection format if cp.IsArray { diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index 1265802..7664746 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -45,19 +45,22 @@ if errors.As(err, &rerr) { } ``` -| Method | Does | -| ------------ | ------------------------------------------------------------------------- | -| `Xxx` | sends and decodes the body (JSON, text for a string, `[]byte` for binary) | -| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | -| `XxxRequest` | builds the `*http.Request` without sending it | - -When a 2xx status has no body next to one that has (a 204 beside a 200), the -result is a pointer, nil for the bodiless status, unless the type is nil-able -already or a string. Each method takes trailing -`RequestEditorFn`s, which change the request after it is built. A status -outside 2xx is a `*ResponseError`; its `Model` is the body decoded into the -type the spec gives for that status (the exact code, then `4XX`/`5XX`, then -`default`), nil when the body lacks a property that type requires. +| Method | Does | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `Xxx` | sends and decodes the body (JSON, text for a string or for an `any` the server does not send as JSON, `[]byte` for binary) | +| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | +| `XxxRequest` | builds the `*http.Request` without sending it | + +When a 2xx status has no body next to one that has (a 204 beside a 200), or +the result is nullable, the result is a pointer, nil for the bodiless status +or `null`, unless the type is nil-able already or a string. Each method +takes trailing `RequestEditorFn`s, which change the request after it is +built. A status outside 2xx is a `*ResponseError`; its `Model` is the body +decoded into the type the spec gives for that status (the exact code, then +`4XX`/`5XX`, then `default`), nil when the body lacks a property that type +requires. So is a 2xx whose body is of another type than the result (a 202 +`Problem` next to a 201 `Order`): `Xxx` returns it as a `*ResponseError` +with that body in `Model`, `XxxRaw` as the response. | `Configuration` | Use | | --------------- | ---------------------------------------------------------------------- | @@ -70,25 +73,26 @@ type the spec gives for that status (the exact code, then `4XX`/`5XX`, then ## Types -| Schema | Go | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| required property | `T` | -| optional (or read-only) | `*T`; slices, maps and `any` stay as they are: nil is absent, empty is sent | -| required and nullable | `*T`, sent as `null` when nil | -| optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | -| `date` / `date-time` | `Date` / `time.Time` | -| enum | a named type with a constant per value | -| inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | -| nullable item | `[]*T`, `map[string]*T` | -| `allOf` | one struct with the members' properties | -| `oneOf` / `anyOf` | a wrapper over the raw JSON, `AsX` / `FromX`; `AsX` refuses a value without X's required properties; inline, named after its owner as an object is | -| ... with a discriminator | also `Discriminator()` and `ValueByDiscriminator()` | -| ... pinning a property | a variant property of one value (`enum: [cat]`, 3.1 `const`) is set by `FromX` and checked by `AsX`; one that tells every variant apart is the discriminator, inline too | -| file in a multipart body | `File{Name, Body}`; an `octet-stream` body is `io.Reader` | -| other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | -| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`) | +| Schema | Go | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| required property | `T` | +| optional (or read-only) | `*T`; slices, maps and `any` stay as they are: nil is absent, empty is sent | +| required and nullable | `*T`, sent as `null` when nil | +| optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | +| `date` / `date-time` | `Date` / `time.Time` | +| enum | a named type with a constant per value | +| inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | +| nullable item | `[]*T`, `map[string]*T` | +| `allOf` | one struct with the members' properties | +| `additionalProperties` next to properties | an `AdditionalProperties` map (numbered on a clash) beside the fields, read and written with them | +| `oneOf` / `anyOf` | a wrapper over the raw JSON, `AsX` / `FromX`; `AsX` refuses a value without X's required properties; inline, named after its owner as an object is | +| ... with a discriminator | also `Discriminator()` and `ValueByDiscriminator()` | +| ... pinning a property | a variant property of one value (`enum: [cat]`, 3.1 `const`) is set by `FromX` and checked by `AsX`; one that tells every variant apart is the discriminator, inline too | +| file in a multipart body | `File{Name, Body}`; an `octet-stream` body is `io.Reader` | +| other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | +| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`) | Not covered: server code, XML, server variables, validation, a discriminator without `oneOf` (the parent stays a plain struct), `additionalProperties` -next to properties (a model drops the extra keys on decode; an inline object -stays a map). +next to the properties of an inline object (it stays a map) or inherited +through `allOf` (the extra keys are dropped). diff --git a/templates/go-fetch/api.mustache b/templates/go-fetch/api.mustache index ccee57e..04dfe6c 100644 --- a/templates/go-fetch/api.mustache +++ b/templates/go-fetch/api.mustache @@ -237,7 +237,24 @@ func (a *{{classname}}) {{x-go-name}}Raw(ctx context.Context{{#x-go-has-params}} {{/x-go-doc}} {{#x-go-decode-json}} func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) ({{{.}}}, error) { +{{#x-go-other-2xx}} + resp, err := a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...) + resp, err = other2xx(resp, err, func(status int) (any, []string) { + switch status { +{{#codes}} + case {{code}}: + return new({{{type}}}), {{{keys}}} +{{/codes}} + } + + return nil, nil + }) + + return decodeJSON[{{{x-go-type}}}](resp, err) +{{/x-go-other-2xx}} +{{^x-go-other-2xx}} return decodeJSON[{{{.}}}](a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +{{/x-go-other-2xx}} } {{/x-go-decode-json}} {{#x-go-decode-bytes}} diff --git a/templates/go-fetch/model.mustache b/templates/go-fetch/model.mustache index a582b35..637db08 100644 --- a/templates/go-fetch/model.mustache +++ b/templates/go-fetch/model.mustache @@ -35,6 +35,34 @@ type {{classname}} struct { {{name}} {{{type}}} {{{tag}}} {{/vendorExtensions.x-go-fields}} } +{{#vendorExtensions.x-go-extra}} + +// MarshalJSON writes the declared fields and the keys of {{field}}. +func (m {{classname}}) MarshalJSON() ([]byte, error) { + type plain {{classname}} + + b, err := json.Marshal(plain(m)) + if err != nil { + return nil, err + } + + return withExtra(b, m.{{field}}, {{{declared}}}) +} + +// UnmarshalJSON reads the declared fields, and the other keys into {{field}}. +func (m *{{classname}}) UnmarshalJSON(b []byte) error { + type plain {{classname}} + + if err := json.Unmarshal(b, (*plain)(m)); err != nil { + return err + } + + extra, err := extraKeys[{{{value}}}](b, {{{declared}}}) + m.{{field}} = extra + + return err +} +{{/vendorExtensions.x-go-extra}} {{#vendorExtensions.x-go-inline}} {{#union}} diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 90c6917..2ac31bc 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -54,7 +54,8 @@ type Configuration struct { AccessToken func(ctx context.Context, op, name string, scopes []string) (string, error) } -// ResponseError is the error an operation returns for a status outside 2xx. +// ResponseError is the error an operation returns for a status outside 2xx, +// and the typed method for a 2xx whose body is not the result's type. type ResponseError struct { StatusCode int Header http.Header @@ -242,11 +243,18 @@ func (c *Configuration) send(req *http.Request, model func(status int) (any, []s return resp, nil } + return nil, responseError(resp, model) +} + +// responseError reads and closes resp's body into a *ResponseError, the body +// decoded into model(status) when model gives one and the body holds the +// keys it names. +func responseError(resp *http.Response, model func(status int) (any, []string)) error { defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return err } rerr := &ResponseError{StatusCode: resp.StatusCode, Header: resp.Header, Body: body} @@ -257,14 +265,90 @@ func (c *Configuration) send(req *http.Request, model func(status int) (any, []s } } - return nil, rerr + return rerr +} + +// withExtra adds the keys of extra that are not declared to the JSON +// object b, in key order. +func withExtra[V any](b []byte, extra map[string]V, declared ...string) ([]byte, error) { + for _, k := range slices.Sorted(maps.Keys(extra)) { + if slices.Contains(declared, k) { + continue + } + + key, err := json.Marshal(k) + if err != nil { + return nil, err + } + + value, err := json.Marshal(extra[k]) + if err != nil { + return nil, err + } + + if len(b) > 2 { + b = append(b[:len(b)-1], ',') + } else { + b = b[:len(b)-1] + } + + b = append(append(append(append(b, key...), ':'), value...), '}') + } + + return b, nil +} + +// extraKeys decodes the keys of the JSON object b that are not declared, +// nil when there are none. +func extraKeys[V any](b []byte, declared ...string) (map[string]V, error) { + var all map[string]json.RawMessage + if err := json.Unmarshal(b, &all); err != nil { + return nil, err + } + + var extra map[string]V + + for k, raw := range all { + if slices.Contains(declared, k) { + continue + } + + var v V + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + + if extra == nil { + extra = make(map[string]V) + } + + extra[k] = v + } + + return extra, nil +} + +// other2xx passes resp on, unless model gives a type for its 2xx status: +// then resp is a *ResponseError holding that body, which the typed result +// cannot. +func other2xx(resp *http.Response, err error, model func(status int) (any, []string)) (*http.Response, error) { + if err != nil { + return nil, err + } + + if m, _ := model(resp.StatusCode); m == nil { + return resp, nil + } + + return nil, responseError(resp, model) } var jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) // decodeJSON reads and closes resp's body into a T: JSON, or the text itself -// for a string the server does not send as JSON. An empty body is T's zero, -// nil for a pointer. +// for a string, or an any, the server does not send as JSON (an any typed +// only by an empty schema next to a text media type). An empty body is T's +// zero, nil for a pointer. func decodeJSON[T any](resp *http.Response, err error) (T, error) { var out T if err != nil { @@ -278,10 +362,19 @@ func decodeJSON[T any](resp *http.Response, err error) (T, error) { return out, err } - if s, ok := any(&out).(*string); ok && !jsonMime.MatchString(resp.Header.Get("Content-Type")) { - *s = string(body) + if ct := resp.Header.Get("Content-Type"); !jsonMime.MatchString(ct) { + switch p := any(&out).(type) { + case *string: + *p = string(body) + + return out, nil + case *any: + if ct != "" { + *p = string(body) - return out, nil + return out, nil + } + } } err = json.Unmarshal(body, &out) diff --git a/testdata/gofetch/additional-properties-with-properties/extra_test.go b/testdata/gofetch/additional-properties-with-properties/extra_test.go new file mode 100644 index 0000000..1090ded --- /dev/null +++ b/testdata/gofetch/additional-properties-with-properties/extra_test.go @@ -0,0 +1,46 @@ +package additionalpropertieswithproperties + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestExtraKeys reads undeclared keys into AdditionalProperties and writes +// them back after the declared fields, typed by the schema. +func TestExtraKeys(t *testing.T) { + const in = `{"kind":"click","x":1,"tags":["a"]}` + + var e Event + if err := json.Unmarshal([]byte(in), &e); err != nil { + t.Fatal(err) + } + + if want := map[string]any{"x": 1.0, "tags": []any{"a"}}; e.Kind != "click" || !reflect.DeepEqual(e.AdditionalProperties, want) { + t.Errorf("decoded %#v", e) + } + + out, err := json.Marshal(e) + if err != nil || string(out) != `{"kind":"click","tags":["a"],"x":1}` { + t.Errorf("encoded %s, %v", out, err) + } + + // A declared key in the map does not shadow the field. + s := Scores{Player: "p", AdditionalProperties: map[string]int{"player": 9, "round1": 3}} + if out, _ := json.Marshal(s); string(out) != `{"player":"p","round1":3}` { + t.Errorf("encoded %s", out) + } + + if err := json.Unmarshal([]byte(`{"player":"p","bad":"x"}`), &s); err == nil { + t.Error("a string decoded as an int score") + } + + if out, _ := json.Marshal(Event{Kind: "k"}); string(out) != `{"kind":"k"}` { + t.Errorf("no extras: %s", out) + } + + // Extras only, the declared fields all left out. + if out, _ := json.Marshal(Notes{AdditionalProperties: map[string]string{"b": "2", "a": "1"}}); string(out) != `{"a":"1","b":"2"}` { + t.Errorf("extras only: %s", out) + } +} diff --git a/testdata/gofetch/responses-mixed-2xx/responses_test.go b/testdata/gofetch/responses-mixed-2xx/responses_test.go new file mode 100644 index 0000000..b6fa3e4 --- /dev/null +++ b/testdata/gofetch/responses-mixed-2xx/responses_test.go @@ -0,0 +1,61 @@ +package responsesmixed2xx + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMixed2xx(t *testing.T) { + status, ctype, body := 0, "", "" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", ctype) + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) + + status, ctype, body = 201, "application/json", `{"id":7}` + if o, err := api.PlaceOrder(t.Context()); err != nil || o.ID != 7 { + t.Errorf("201: %+v, %v", o, err) + } + + // The 202 body is a Problem, which an Order cannot hold. + status, body = 202, `{"title":"queued","status":202}` + _, err := api.PlaceOrder(t.Context()) + + var rerr *ResponseError + if !errors.As(err, &rerr) || rerr.StatusCode != 202 { + t.Fatalf("202: %v", err) + } + + if p, ok := rerr.Model.(*Problem); !ok || p.Title != "queued" { + t.Errorf("202 model = %#v", rerr.Model) + } + + status, ctype, body = 200, "application/x-ndjson", "{\"i\":0}\n{\"i\":1}\n" + if s, err := api.StreamLines(t.Context()); err != nil || s != body { + t.Errorf("ndjson: %#v, %v", s, err) + } + + status, ctype, body = 200, "application/json", "null" + if o, err := api.LatestOrder(t.Context()); err != nil || o != nil { + t.Errorf("null: %+v, %v", o, err) + } + + // A nullable string sent as text stays a string. + status, ctype, body = 200, "text/plain", "hello" + if s, err := api.Greeting(t.Context()); err != nil || s != "hello" { + t.Errorf("text: %#v, %v", s, err) + } + + // An any result holds every 2xx body. + status, ctype, body = 201, "application/json", `{"id":1}` + if v, err := api.Anything(t.Context()); err != nil || v == nil { + t.Errorf("any 201: %#v, %v", v, err) + } +} diff --git a/testdata/specs/feature/additional-properties-with-properties.yaml b/testdata/specs/feature/additional-properties-with-properties.yaml new file mode 100644 index 0000000..5e87739 --- /dev/null +++ b/testdata/specs/feature/additional-properties-with-properties.yaml @@ -0,0 +1,49 @@ +openapi: "3.0.0" +info: + title: Additional properties next to properties + version: 1.0.0 + description: Objects that declare some properties and take any other key too (pydantic extra="allow"), or only keys of one type. +paths: + /events: + post: + operationId: recordEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Event" + responses: + "200": + description: the event as stored + content: + application/json: + schema: + $ref: "#/components/schemas/Scores" +components: + schemas: + Event: + type: object + required: [kind] + properties: + kind: + type: string + at: + type: string + format: date-time + additionalProperties: true + Notes: + type: object + properties: + title: + type: string + additionalProperties: + type: string + Scores: + type: object + required: [player] + properties: + player: + type: string + additionalProperties: + type: integer diff --git a/testdata/specs/feature/responses-mixed-2xx.yaml b/testdata/specs/feature/responses-mixed-2xx.yaml new file mode 100644 index 0000000..bee4170 --- /dev/null +++ b/testdata/specs/feature/responses-mixed-2xx.yaml @@ -0,0 +1,88 @@ +openapi: "3.0.0" +info: + title: Mixed success responses + version: 1.0.0 + description: Success responses as frameworks write them, FastAPI among them; a 202 with another body than the 201, an empty JSON schema next to a text stream, a nullable result. +paths: + /orders: + post: + operationId: placeOrder + responses: + "201": + description: placed + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "202": + description: queued for review + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + /stream: + get: + operationId: streamLines + responses: + "200": + description: one JSON object per line + content: + application/json: + schema: {} + application/x-ndjson: + schema: + type: string + /orders/latest: + get: + operationId: latestOrder + responses: + "200": + description: the latest order, null when there is none + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/Order" + /greeting: + get: + operationId: greeting + responses: + "200": + description: a greeting, as text + content: + text/plain: + schema: + type: string + nullable: true + /anything: + get: + operationId: anything + responses: + "200": + description: any JSON + content: + application/json: + schema: {} + "201": + description: an order + content: + application/json: + schema: + $ref: "#/components/schemas/Order" +components: + schemas: + Order: + type: object + required: [id] + properties: + id: + type: integer + Problem: + type: object + required: [title, status] + properties: + title: + type: string + status: + type: integer From 74478010aec6840532b03b589740ccf02dc1dc41 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 17:09:58 +0100 Subject: [PATCH 07/18] test(go-fetch): cover parameter styles, other bodies and model shapes Feature specs for what the go-fetch tests left out: objects and arrays in every path, query, header and cookie style of the OpenAPI serialization table; text/plain, octet-stream and multipart object bodies with the Configuration options; the text of scalar query parameters. Unit tests for alias models, nullable items, bool and float enums, union variants, FooRaw naming and an operation under two tags. Generator coverage (with the internal/gen tests) 92% -> 96%, the generated runtime under the feature tests 79% -> 92%. --- internal/generator/golang/fetch_test.go | 137 ++++++++++++++++++ testdata/gofetch/dates/null_test.go | 21 +++ .../parameters-object-styles/params_test.go | 38 +++++ .../parameters-scalar-formats/params_test.go | 26 ++++ .../request-body-other-media/body_test.go | 127 ++++++++++++++++ .../gofetch/security-apikey/empty_test.go | 24 +++ .../feature/parameters-object-styles.yaml | 34 +++++ .../feature/parameters-scalar-formats.yaml | 21 +++ .../feature/request-body-other-media.yaml | 80 ++++++++++ 9 files changed, 508 insertions(+) create mode 100644 testdata/gofetch/dates/null_test.go create mode 100644 testdata/gofetch/parameters-object-styles/params_test.go create mode 100644 testdata/gofetch/parameters-scalar-formats/params_test.go create mode 100644 testdata/gofetch/request-body-other-media/body_test.go create mode 100644 testdata/gofetch/security-apikey/empty_test.go create mode 100644 testdata/specs/feature/parameters-object-styles.yaml create mode 100644 testdata/specs/feature/parameters-scalar-formats.yaml create mode 100644 testdata/specs/feature/request-body-other-media.yaml diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go index b7689f1..2105559 100644 --- a/internal/generator/golang/fetch_test.go +++ b/internal/generator/golang/fetch_test.go @@ -318,3 +318,140 @@ components: t.Errorf("param types = %s, want Color []Color json.RawMessage", got) } } + +// TestModelShapes covers the model kinds the feature specs leave out: +// aliases of each type, nullable items, bool and float enums, union +// variants of arrays and maps, implied discriminators that do not hold, and +// a property name that needs a quoted tag. +func TestModelShapes(t *testing.T) { + ms := models(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: {} +components: + schemas: + Pet: {type: object, required: [kind], properties: {kind: {type: string, enum: [pet]}}} + Pets: {type: array, items: {$ref: "#/components/schemas/Pet"}} + Counts: {type: object, additionalProperties: {type: integer}} + Name: {type: string} + Age: {type: integer} + Weight: {type: number} + Flag: {type: boolean} + Free: {type: object} + Sparse: + type: object + properties: + list: {type: array, items: {type: integer, nullable: true}} + map: {type: object, additionalProperties: {type: string, nullable: true}} + "a`+"`"+`b": {type: string} + Toggle: {type: boolean, enum: [true, false]} + Ratio: {type: number, enum: [0.5, 1, 1.5]} + Mixed: + oneOf: + - $ref: "#/components/schemas/Pet" + - {type: array, items: {$ref: "#/components/schemas/Pet"}} + - {type: object, additionalProperties: {type: string}} + Same: + oneOf: + - $ref: "#/components/schemas/Pet" + - $ref: "#/components/schemas/Other" + Other: {type: object, required: [kind], properties: {kind: {type: string, enum: [pet]}}} +`) + + for model, want := range map[string]string{ + "Pets": "[]Pet", "Counts": "map[string]int", "Name": "string", "Age": "int", + "Weight": "float64", "Flag": "bool", "Free": "map[string]any", + } { + if got := ms[model].VendorExtensions["x-go-alias-type"]; got != want { + t.Errorf("%s = %v, want %s", model, got, want) + } + } + + if got, want := fields(ms["Sparse"]), "List []*int `json:\"list,omitzero\"`\nMap map[string]*string `json:\"map,omitzero\"`\n"+ + "AB *string \"json:\\\"a`b,omitempty\\\"\""; got != want { + t.Errorf("Sparse fields:\n%s\nwant:\n%s", got, want) + } + + for model, want := range map[string]string{"Toggle": "bool", "Ratio": "float64"} { + enums, _ := ms[model].VendorExtensions["x-go-enums"].([]map[string]any) + if len(enums) != 1 || enums[0]["base"] != want { + t.Errorf("%s enum = %v, want base %s", model, enums, want) + } + } + + variants, _ := ms["Mixed"].VendorExtensions["x-go-variants"].([]map[string]any) + + var methods []string + for _, v := range variants { + methods = append(methods, fmt.Sprint(v["method"])) + } + + if got := strings.Join(methods, " "); got != "Pet PetList StringMap" { + t.Errorf("Mixed variants = %s", got) + } + + // Pet and Other both pin kind to "pet": kind tells them apart from no other. + if d := ms["Same"].VendorExtensions["x-go-discriminator"]; d != false { + t.Errorf("Same discriminator = %v, want none", d) + } +} + +// TestOperationShapes covers operation naming the feature specs leave out: +// a FooRaw next to Foo, an operation under two tags naming its inline types +// once, and an array of inline objects as a parameter. +func TestOperationShapes(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /foo: + get: + operationId: foo + responses: {"204": {description: ok}} + /foo/raw: + get: + operationId: fooRaw + responses: {"204": {description: ok}} + /shared: + post: + operationId: shared + tags: [a, b] + parameters: + - name: points + in: query + content: + application/json: + schema: {type: array, items: {type: object, properties: {x: {type: integer}}}} + responses: + "200": + description: ok + content: + application/json: + schema: {type: object, properties: {id: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + names := map[string]string{} + for _, op := range g.PostProcessOperations(byTag["default"]) { + names[op.OperationId] = fmt.Sprint(op.VendorExtensions["x-go-name"]) + } + + if names["foo"] != "Foo" || names["fooRaw"] != "FooRawMethod" { + t.Errorf("names = %v, want Foo and FooRawMethod", names) + } + + for _, tag := range []string{"a", "b"} { + op := g.PostProcessOperations(byTag[tag])[0] + + if got := op.VendorExtensions["x-go-type"]; got != "SharedResponse" { + t.Errorf("tag %s: result %v, want SharedResponse each time", tag, got) + } + + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + if got := fs[0]["type"]; got != "[]SharedPoints" { + t.Errorf("tag %s: points %v, want []SharedPoints", tag, got) + } + } +} diff --git a/testdata/gofetch/dates/null_test.go b/testdata/gofetch/dates/null_test.go new file mode 100644 index 0000000..51f4d5c --- /dev/null +++ b/testdata/gofetch/dates/null_test.go @@ -0,0 +1,21 @@ +package dates + +import ( + "encoding/json" + "testing" +) + +// TestDateNullAndInvalid reads null as the zero Date and refuses a date +// that is not one. +func TestDateNullAndInvalid(t *testing.T) { + d := Date{} + if err := json.Unmarshal([]byte("null"), &d); err != nil || !d.IsZero() { + t.Errorf("null: %v, %v", d, err) + } + + for _, in := range []string{`"2024-13-01"`, `20240101`} { + if err := json.Unmarshal([]byte(in), &d); err == nil { + t.Errorf("%s decoded as %v", in, d) + } + } +} diff --git a/testdata/gofetch/parameters-object-styles/params_test.go b/testdata/gofetch/parameters-object-styles/params_test.go new file mode 100644 index 0000000..f7153d1 --- /dev/null +++ b/testdata/gofetch/parameters-object-styles/params_test.go @@ -0,0 +1,38 @@ +package parametersobjectstyles + +import "testing" + +// TestObjectStyles checks each style against the OpenAPI serialization +// table, for color = {R: 100, G: 200, B: 150} and the array [blue, black]. +func TestObjectStyles(t *testing.T) { + color := Color{R: 100, G: 200, B: 150} + + req, err := NewDefaultAPI(nil).PaintRequest(t.Context(), PaintParams{ + A: color, B: color, C: color, D: color, E: color, F: color, G: []string{"blue", "black"}, + Q: &color, XColor: &color, XColorExploded: &color, XTags: []string{"x", "y"}, Prefs: []string{"p", "q"}, + }) + if err != nil { + t.Fatal(err) + } + + want := "/simple/B,150,G,200,R,100/B=150,G=200,R=100" + + "/label/.B,150,G,200,R,100/.B=150.G=200.R=100" + + "/matrix/;e=B,150,G,200,R,100/;B=150;G=200;R=100/;g=blue;g=black" + if got := req.URL.EscapedPath(); got != want { + t.Errorf("path = %s\nwant %s", got, want) + } + + if got := req.URL.RawQuery; got != "q=B%2C150%2CG%2C200%2CR%2C100" { + t.Errorf("query = %s", got) + } + + // net/http quotes a cookie value with a comma, as RFC 6265 has no comma + // in a bare cookie value. + for name, want := range map[string]string{ + "X-Color": "B,150,G,200,R,100", "X-Color-Exploded": "B=150,G=200,R=100", "X-Tags": "x,y", "Cookie": `prefs="p,q"`, + } { + if got := req.Header.Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } +} diff --git a/testdata/gofetch/parameters-scalar-formats/params_test.go b/testdata/gofetch/parameters-scalar-formats/params_test.go new file mode 100644 index 0000000..298a3f3 --- /dev/null +++ b/testdata/gofetch/parameters-scalar-formats/params_test.go @@ -0,0 +1,26 @@ +package parametersscalarformats + +import ( + "testing" + "time" +) + +// TestScalarText sends each scalar as its shortest exact text: no float32 +// noise, no exponent, int64 above 2^53, RFC 3339 times, base64 text escaped. +func TestScalarText(t *testing.T) { + at := time.Date(2024, 2, 29, 23, 59, 58, 500, time.FixedZone("", -90*60)) + + req, err := NewDefaultAPI(nil).SearchRequest(t.Context(), SearchParams{ + F32: Ptr(float32(0.1)), F64: Ptr(1e21), N: Ptr(0.30000000000000004), I64: Ptr(int64(1<<62 + 1)), Ok: Ptr(false), + Day: &Date{Time: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)}, At: &at, Raw: Ptr("+/8="), + }) + if err != nil { + t.Fatal(err) + } + + want := "at=2024-02-29T23%3A59%3A58.0000005-01%3A30&day=2024-01-02&f32=0.1&f64=1000000000000000000000" + + "&i64=4611686018427387905&n=0.30000000000000004&ok=false&raw=%2B%2F8%3D" + if got := req.URL.RawQuery; got != want { + t.Errorf("query = %s\nwant %s", got, want) + } +} diff --git a/testdata/gofetch/request-body-other-media/body_test.go b/testdata/gofetch/request-body-other-media/body_test.go new file mode 100644 index 0000000..ded50d2 --- /dev/null +++ b/testdata/gofetch/request-body-other-media/body_test.go @@ -0,0 +1,127 @@ +package requestbodyothermedia + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// echo answers with the request's body and records the request. +type echo struct{ req *http.Request } + +func (e *echo) Do(req *http.Request) (*http.Response, error) { + e.req = req + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + h := http.Header{"Content-Type": {req.Header.Get("Content-Type")}} + + return &http.Response{StatusCode: 200, Header: h, Body: io.NopCloser(bytes.NewReader(body))}, nil +} + +func TestTextAndRawBodies(t *testing.T) { + e := &echo{} + api := NewDefaultAPI(&Configuration{HTTPClient: e}) + + note, err := api.PostNote(t.Context(), PostNoteParams{Body: "line 1\nčo"}) + if err != nil || note != "line 1\nčo" || e.req.Header.Get("Content-Type") != "text/plain" { + t.Errorf("text: %q, %v, %s", note, err, e.req.Header.Get("Content-Type")) + } + + blob, err := api.PutBlob(t.Context(), PutBlobParams{Body: bytes.NewReader([]byte{0, 0xff})}) + if err != nil || !bytes.Equal(blob, []byte{0, 0xff}) || e.req.Header.Get("Content-Type") != "application/octet-stream" { + t.Errorf("raw: %v, %v", blob, err) + } +} + +func TestMultipartObjectPart(t *testing.T) { + e := &echo{} + api := NewDefaultAPI(&Configuration{HTTPClient: e}) + + err := api.PostReport(t.Context(), PostReportParams{ + Title: "q3", + Meta: &Meta{Author: Ptr("ann"), Pages: Ptr(3)}, + Attachments: []File{{Name: "a.txt", Body: strings.NewReader("a")}, {Body: strings.NewReader("b")}}, + }) + if err != nil { + t.Fatal(err) + } + + req, _ := http.NewRequest(http.MethodPost, "/", bytes.NewReader(e.body(t))) + req.Header = e.req.Header + + if err := req.ParseMultipartForm(1 << 20); err != nil { + t.Fatal(err) + } + + // A File without a Name is named after its field. + f := req.MultipartForm + if f.Value["title"][0] != "q3" || f.Value["meta"][0] != `{"author":"ann","pages":3}` || + len(f.File["attachments"]) != 2 || f.File["attachments"][1].Filename != "attachments" { + t.Errorf("form = %v, files %v", f.Value, f.File["attachments"]) + } +} + +// body is the body the last request sent, read again. +func (e *echo) body(t *testing.T) []byte { + t.Helper() + + b, err := e.req.GetBody() + if err != nil { + t.Fatal(err) + } + + data, _ := io.ReadAll(b) + + return data +} + +// TestConfiguration checks BasePath, the default headers, the bearer token +// and that an editor's error stops the request. +func TestConfiguration(t *testing.T) { + var got *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + cfg := &Configuration{ + BasePath: srv.URL + "/v2", + Headers: http.Header{"X-Trace": {"t1"}}, + AccessToken: func(_ context.Context, op, name string, _ []string) (string, error) { + return op + ":" + name, nil + }, + } + + if _, err := NewDefaultAPI(cfg).PostNote(t.Context(), PostNoteParams{Body: "x"}); err != nil { + t.Fatal(err) + } + + if got.URL.Path != "/v2/notes" || got.Header.Get("X-Trace") != "t1" || got.Header.Get("Authorization") != "Bearer postNote:bearer" { + t.Errorf("request %s %v", got.URL.Path, got.Header) + } + + stop := errors.New("stop") + _, err := NewDefaultAPI(cfg).PostNote(t.Context(), PostNoteParams{Body: "x"}, func(context.Context, *http.Request) error { return stop }) + + if !errors.Is(err, stop) { + t.Errorf("editor error = %v", err) + } + + // The spec's server, when BasePath is not set. + req, err := NewDefaultAPI(nil).PutBlobRequest(t.Context(), PutBlobParams{Body: strings.NewReader("")}) + if err != nil || req.URL.String() != "http://api.example.com/v1/blobs" { + t.Errorf("default base: %v, %v", req.URL, err) + } +} diff --git a/testdata/gofetch/security-apikey/empty_test.go b/testdata/gofetch/security-apikey/empty_test.go new file mode 100644 index 0000000..5f6cec9 --- /dev/null +++ b/testdata/gofetch/security-apikey/empty_test.go @@ -0,0 +1,24 @@ +package securityapikey + +import ( + "context" + "errors" + "testing" +) + +// TestEmptyOrFailingKey sends no key for an empty one and stops on an error. +func TestEmptyOrFailingKey(t *testing.T) { + empty := NewDefaultAPI(&Configuration{APIKey: func(context.Context, string, string) (string, error) { return "", nil }}) + + req, err := empty.GetWithQueryKeyRequest(t.Context()) + if err != nil || req.URL.RawQuery != "" { + t.Errorf("empty key: %q, %v", req.URL.RawQuery, err) + } + + boom := errors.New("vault down") + failing := NewDefaultAPI(&Configuration{APIKey: func(context.Context, string, string) (string, error) { return "", boom }}) + + if _, err := failing.GetWithHeaderKeyRequest(t.Context()); !errors.Is(err, boom) { + t.Errorf("failing key: %v", err) + } +} diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml new file mode 100644 index 0000000..25480e4 --- /dev/null +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -0,0 +1,34 @@ +openapi: "3.0.0" +info: + title: Object and array parameters in every style + version: 1.0.0 + description: An object (and an array) as a path, header, query and cookie parameter, in each style the OpenAPI serialization table lists, exploded and not. +paths: + /simple/{a}/{b}/label/{c}/{d}/matrix/{e}/{f}/{g}: + get: + operationId: paint + parameters: + - { name: a, in: path, required: true, style: simple, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: b, in: path, required: true, style: simple, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: c, in: path, required: true, style: label, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: d, in: path, required: true, style: label, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: e, in: path, required: true, style: matrix, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: f, in: path, required: true, style: matrix, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: g, in: path, required: true, style: matrix, explode: true, schema: { type: array, items: { type: string } } } + - { name: q, in: query, style: form, explode: false, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Color, in: header, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Color-Exploded, in: header, explode: true, schema: { $ref: "#/components/schemas/Color" } } + - { name: X-Tags, in: header, schema: { type: array, items: { type: string } } } + - { name: prefs, in: cookie, schema: { type: array, items: { type: string } } } + responses: + "204": + description: painted +components: + schemas: + Color: + type: object + required: [R, G, B] + properties: + R: { type: integer } + G: { type: integer } + B: { type: integer } diff --git a/testdata/specs/feature/parameters-scalar-formats.yaml b/testdata/specs/feature/parameters-scalar-formats.yaml new file mode 100644 index 0000000..425a12c --- /dev/null +++ b/testdata/specs/feature/parameters-scalar-formats.yaml @@ -0,0 +1,21 @@ +openapi: "3.0.0" +info: + title: Scalar parameter formats + version: 1.0.0 + description: Query parameters of each scalar type and format, as their text on the wire. +paths: + /search: + get: + operationId: search + parameters: + - { name: f32, in: query, schema: { type: number, format: float } } + - { name: f64, in: query, schema: { type: number, format: double } } + - { name: n, in: query, schema: { type: number } } + - { name: i64, in: query, schema: { type: integer, format: int64 } } + - { name: ok, in: query, schema: { type: boolean } } + - { name: day, in: query, schema: { type: string, format: date } } + - { name: at, in: query, schema: { type: string, format: date-time } } + - { name: raw, in: query, schema: { type: string, format: byte } } + responses: + "204": + description: ok diff --git a/testdata/specs/feature/request-body-other-media.yaml b/testdata/specs/feature/request-body-other-media.yaml new file mode 100644 index 0000000..3c814ee --- /dev/null +++ b/testdata/specs/feature/request-body-other-media.yaml @@ -0,0 +1,80 @@ +openapi: "3.0.0" +info: + title: Request bodies that are not JSON + version: 1.0.0 + description: A text/plain body, a raw octet-stream body and a multipart body with an object part. +servers: + - url: http://api.example.com/v1 +paths: + /notes: + post: + operationId: postNote + security: + - bearer: [] + requestBody: + required: true + content: + text/plain: + schema: + type: string + responses: + "200": + description: the note as stored + content: + text/plain: + schema: + type: string + /blobs: + put: + operationId: putBlob + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: the blob back + content: + application/octet-stream: + schema: + type: string + format: binary + /reports: + post: + operationId: postReport + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [title] + properties: + title: + type: string + meta: + $ref: "#/components/schemas/Meta" + attachments: + type: array + items: + type: string + format: binary + responses: + "204": + description: stored +components: + securitySchemes: + bearer: + type: http + scheme: bearer + schemas: + Meta: + type: object + properties: + author: + type: string + pages: + type: integer From b4c83ea97708b0412994be91b750cf780dbb72cc Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:40:37 +0100 Subject: [PATCH 08/18] fix(parser): keep parameters that share a name once camelCased Parameters were deduplicated by their camelCase name, so two different parameters became one and the other was never sent: a path item_id and a header Item-Id, a query sort_by and sortBy, a query and a form field, or Kubernetes' proxy path parameter and its path query parameter. A parameter now replaces only one of the same location and wire name (an operation's over its path item's), and a second parameter of one name is renamed itemId2, as upstream's ensureUniqueParams does. typescript-fetch gets path and path2 on those Kubernetes operations, as the upstream generator does. --- internal/parser/dedupe_test.go | 47 ++++++++++++++++++++++ internal/parser/operation.go | 73 ++++++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 internal/parser/dedupe_test.go diff --git a/internal/parser/dedupe_test.go b/internal/parser/dedupe_test.go new file mode 100644 index 0000000..3816836 --- /dev/null +++ b/internal/parser/dedupe_test.go @@ -0,0 +1,47 @@ +package parser + +import ( + "strings" + "testing" +) + +// TestDeduplicateParams keeps parameters that share a name only once +// camelCased, each under its own name, and lets an operation's parameter +// replace its path item's of the same location and name. +func TestDeduplicateParams(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /things/{item_id}: + parameters: + - {name: item_id, in: path, required: true, schema: {type: string}} + get: + operationId: getThing + parameters: + - {name: item_id, in: path, required: true, schema: {type: integer}} + - {name: Item-Id, in: header, schema: {type: string}} + - {name: sort_by, in: query, schema: {type: string}} + - {name: sortBy, in: query, schema: {type: string}} + responses: {"204": {description: ok}} +`)); err != nil { + t.Fatal(err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + var got []string + for _, param := range ops["default"][0].AllParams { + got = append(got, param.ParamName+"="+param.BaseName+":"+param.DataType) + } + + if want := "itemId=item_id:number itemId2=Item-Id:string sortBy=sort_by:string sortBy2=sortBy:string"; strings.Join(got, " ") != want { + t.Errorf("params = %s\nwant %s", strings.Join(got, " "), want) + } +} diff --git a/internal/parser/operation.go b/internal/parser/operation.go index d81c7be..d8ec423 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -2,6 +2,7 @@ package parser import ( "sort" + "strconv" "strings" "github.com/getkin/kin-openapi/openapi3" @@ -353,39 +354,79 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc } } -// deduplicateParams removes duplicate parameters by name, keeping the last occurrence. -// This is useful when path-level and operation-level parameters have the same name. +// deduplicateParams drops a parameter that a later one of the same location +// and name replaces (an operation's over its path item's). A parameter whose +// ParamName another one already has (path item_id next to header Item-Id, +// query sort_by next to sortBy) is renamed itemId2, as upstream's +// ensureUniqueParams does, so that both stay and are sent. func deduplicateParams(params []*codegen.CodegenParameter) []*codegen.CodegenParameter { - if len(params) == 0 { - return params - } - seen := make(map[string]int) var result []*codegen.CodegenParameter - // Iterate through parameters and track positions - for i, param := range params { + for _, param := range params { if param == nil { continue } - key := param.ParamName - if idx, exists := seen[key]; exists { - // Replace previous occurrence + key := paramLocation(param) + " " + param.BaseName + if idx, ok := seen[key]; ok { result[idx] = param - } else { - // First occurrence - seen[key] = len(result) - result = append(result, param) + + continue } - _ = i // unused + seen[key] = len(result) + result = append(result, param) + } + + names := make(map[string]bool, len(result)) + + for _, param := range result { + for names[param.ParamName] { + param.ParamName = nextName(param.ParamName) + } + + names[param.ParamName] = true } return result } +// paramLocation is where a parameter goes: path, query, header, cookie, +// form or body. +func paramLocation(p *codegen.CodegenParameter) string { + switch { + case p.IsPathParam: + return "path" + case p.IsQueryParam: + return "query" + case p.IsHeaderParam: + return "header" + case p.IsCookieParam: + return "cookie" + case p.IsFormParam: + return "form" + } + + return "body" +} + +// nextName counts a name up: id becomes id2, id2 becomes id3. +func nextName(name string) string { + i := len(name) + for i > 0 && name[i-1] >= '0' && name[i-1] <= '9' { + i-- + } + + n, err := strconv.Atoi(name[i:]) + if err != nil { + return name + "2" + } + + return name[:i] + strconv.Itoa(n+1) +} + // requestBody turns the first content type of a request body, in sorted // order, into the operation's body parameter; form bodies expand into one // parameter per property instead. From 45a77de2b165eae58dbc81ba8ad0adc46e52ddb8 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:40:37 +0100 Subject: [PATCH 09/18] fix(go-fetch): read naive date-times and enums of mixed or null values Found by the e2e FastAPI backend: - A date-time is a DateTime, a time.Time that also reads a time without an offset, as FastAPI sends a naive datetime (a database column without a zone). Such a time stays Naive and goes back without an offset; before, one such value failed the whole call. - An enum whose values are of more than one JSON type (pydantic's Literal["auto", 0, 1], an Enum of 0 and "always") is any: no Go constant type holds them, and a string type sent 0 as "0". - A null among an enum's values (Literal["x", None]) makes the schema nullable, so the field can hold and send null. --- internal/generator/golang/base.go | 20 ++++-- internal/generator/golang/base_test.go | 2 +- internal/generator/golang/fetch.go | 4 +- internal/parser/normalize.go | 10 ++- templates/go-fetch/README.md | 4 +- templates/go-fetch/runtime.mustache | 67 ++++++++++++++++++- testdata/gofetch/dates/dates_test.go | 29 +++++++- .../parameters-scalar-formats/params_test.go | 2 +- 8 files changed, 121 insertions(+), 17 deletions(-) diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go index 4a1163a..363eddb 100644 --- a/internal/generator/golang/base.go +++ b/internal/generator/golang/base.go @@ -3,6 +3,7 @@ package golang import ( + "fmt" "maps" "strings" "unicode" @@ -30,7 +31,7 @@ var TypeMapping = map[string]string{ var RuntimeNames = map[string]bool{ "BasePath": true, "Doer": true, "RequestEditorFn": true, "Configuration": true, "ResponseError": true, "Opt": true, "Some": true, "Null": true, "Ptr": true, - "Date": true, "File": true, + "Date": true, "DateTime": true, "File": true, } // keywords are Go's reserved words, for the package name. @@ -276,7 +277,7 @@ func scalarType(isString, isDate, isDateTime, isBytes, isInteger, isLong, isNumb case isDate: return "Date" case isDateTime: - return "time.Time" + return "DateTime" case isBytes: return "[]byte" case isString: @@ -300,8 +301,20 @@ func scalarType(isString, isDate, isDateTime, isBytes, isInteger, isLong, isNumb // EnumBaseType is the Go type an enum's values have: string when they are // strings (isString on the enumVars), else bool, int when every value is -// whole, float64 otherwise. +// whole, float64 otherwise; any when they are of more than one JSON type +// (pydantic's Literal["auto", 0]), which no Go constant type holds. func EnumBaseType(allowable map[string]any, isBoolean bool) string { + values, _ := allowable["values"].([]any) + + kinds := map[string]bool{} + for _, v := range values { + kinds[fmt.Sprintf("%T", v)] = true + } + + if kinds["string"] && len(kinds) > 1 { + return "any" + } + vars, _ := allowable["enumVars"].([]map[string]any) if len(vars) > 0 && vars[0]["isString"] == true { return "string" @@ -311,7 +324,6 @@ func EnumBaseType(allowable map[string]any, isBoolean bool) string { return "bool" } - values, _ := allowable["values"].([]any) for _, v := range values { if f, ok := v.(float64); ok && f != float64(int64(f)) { return "float64" diff --git a/internal/generator/golang/base_test.go b/internal/generator/golang/base_test.go index a194b5a..331a14b 100644 --- a/internal/generator/golang/base_test.go +++ b/internal/generator/golang/base_test.go @@ -61,7 +61,7 @@ func TestGoType(t *testing.T) { {str, "string"}, {&codegen.CodegenProperty{IsString: true, IsUuid: true}, "string"}, {&codegen.CodegenProperty{IsString: true, IsDate: true}, "Date"}, - {&codegen.CodegenProperty{IsString: true, IsDateTime: true}, "time.Time"}, + {&codegen.CodegenProperty{IsString: true, IsDateTime: true}, "DateTime"}, {&codegen.CodegenProperty{IsString: true, IsByteArray: true}, "[]byte"}, {&codegen.CodegenProperty{IsString: true, IsBinary: true}, "[]byte"}, {&codegen.CodegenProperty{IsInteger: true}, "int"}, diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index cb1af67..605d190 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -213,7 +213,7 @@ func modelKind(m *codegen.CodegenModel) string { switch { case len(m.OneOf) > 0 || len(m.AnyOf) > 0: return "union" - case m.IsEnum: + case m.IsEnum && EnumBaseType(m.AllowableValues, m.IsBoolean) != "any": return "enum" case len(m.AllVars) > 0 || len(m.AllOf) > 0: return "struct" @@ -371,7 +371,7 @@ func (g *FetchGenerator) structFields(m *codegen.CodegenModel, ext map[string]an if ref := g.refModel(v.Ref); ref != "" { named = ref - } else if v.IsEnum { + } else if v.IsEnum && EnumBaseType(v.AllowableValues, v.IsBoolean) != "any" { base := m.Classname + exported(v.EnumName, "") named = enumNames[base] diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go index 82e3173..d714cb6 100644 --- a/internal/parser/normalize.go +++ b/internal/parser/normalize.go @@ -13,8 +13,8 @@ import ( // 3.0 shapes the parser knows: // // - NORMALIZE_31SPEC: `type: [T, "null"]` is T with nullable, `const: v` -// an enum of v, and a string of contentMediaType -// application/octet-stream a binary string. +// an enum of v, a null among enum values nullable, and a string of +// contentMediaType application/octet-stream a binary string. // - SIMPLIFY_ONEOF_ANYOF: a `{type: "null"}` member of oneOf/anyOf makes // the schema nullable instead; when one member is left after that, it is // the schema (a $ref one as `allOf: [$ref]`, the 3.0 spelling of a @@ -129,6 +129,12 @@ func (p *Parser) normalizeSchemaRef(ref *openapi3.SchemaRef, seen map[*openapi3. s.Enum, s.Const = []any{s.Const}, nil } + // A null among the values (pydantic's Literal["x", None]) makes the + // schema nullable; the value stays, as a 3.0 nullable enum lists it. + if slices.Contains(s.Enum, nil) { + s.Nullable = true + } + if (s.Type.Is(openapi3.TypeString) || s.Type.IsEmpty()) && s.Format == "" && s.ContentEncoding == "" { if mt, _, _ := mime.ParseMediaType(s.ContentMediaType); mt == "application/octet-stream" { s.Type, s.Format = &openapi3.Types{openapi3.TypeString}, "binary" diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index 7664746..36b328e 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -79,8 +79,8 @@ with that body in `Model`, `XxxRaw` as the response. | optional (or read-only) | `*T`; slices, maps and `any` stay as they are: nil is absent, empty is sent | | required and nullable | `*T`, sent as `null` when nil | | optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | -| `date` / `date-time` | `Date` / `time.Time` | -| enum | a named type with a constant per value | +| `date` / `date-time` | `Date` / `DateTime` (a `time.Time`; one without an offset, a naive FastAPI datetime, is `Naive` and goes back without one) | +| enum | a named type with a constant per value; `any` when the values are of more than one JSON type | | inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | | nullable item | `[]*T`, `map[string]*T` | | `allOf` | one struct with the members' properties | diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 2ac31bc..6100fd7 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -113,6 +113,64 @@ func (o *Opt[T]) UnmarshalJSON(b []byte) error { // Ptr returns a pointer to v, for optional fields. func Ptr[T any](v T) *T { return &v } +// DateTime is a date-time, RFC 3339 in JSON and parameters. It also reads a +// time without an offset, which servers send for a naive datetime (FastAPI +// over a database column without a zone); that one stays Naive, its clock +// in UTC, and goes back without an offset, as received. +type DateTime struct { + time.Time + Naive bool +} + +// naiveLayout is a date-time without an offset, to the microsecond as +// Python writes it. +const naiveLayout = "2006-01-02T15:04:05.000000" + +// String returns d in RFC 3339, or without an offset when d is Naive. +func (d DateTime) String() string { + switch { + case !d.Naive: + return d.Format(time.RFC3339Nano) + case d.Nanosecond() == 0: + return d.Format("2006-01-02T15:04:05") + case d.Nanosecond()%1000 == 0: + return d.Format(naiveLayout) + } + + return d.Format("2006-01-02T15:04:05.999999999") +} + +// MarshalJSON encodes d as String does. +func (d DateTime) MarshalJSON() ([]byte, error) { return []byte(`"` + d.String() + `"`), nil } + +// UnmarshalJSON decodes an RFC 3339 string, or one without an offset as a +// Naive time; null leaves d as it is. +func (d *DateTime) UnmarshalJSON(b []byte) error { + if string(b) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + *d = DateTime{Time: t} + + return nil + } + + t, err := time.Parse("2006-01-02T15:04:05.999999999", s) + if err != nil { + return err + } + + *d = DateTime{Time: t, Naive: true} + + return nil +} + // Date is a calendar date, "2006-01-02" in JSON and parameters. type Date struct{ time.Time } @@ -446,12 +504,15 @@ func value(v any) (any, bool) { return rv.Interface(), true } -// paramString formats a scalar for a parameter: RFC 3339 for a time, -// 2006-01-02 for a Date, base64 for bytes, the shortest decimal for a float. +// paramString formats a scalar for a parameter: RFC 3339 for a time (as +// DateTime has it), 2006-01-02 for a Date, base64 for bytes, the shortest +// decimal for a float. func paramString(v any) string { switch v := v.(type) { case time.Time: return v.Format(time.RFC3339Nano) + case DateTime: + return v.String() case Date: return v.String() case json.RawMessage: @@ -482,7 +543,7 @@ func paramString(v any) string { // an object. func isScalar(v any) bool { switch v.(type) { - case time.Time, Date, []byte, json.RawMessage: + case time.Time, DateTime, Date, []byte, json.RawMessage: return true } diff --git a/testdata/gofetch/dates/dates_test.go b/testdata/gofetch/dates/dates_test.go index cbf6be0..95108c6 100644 --- a/testdata/gofetch/dates/dates_test.go +++ b/testdata/gofetch/dates/dates_test.go @@ -24,7 +24,7 @@ func TestDates(t *testing.T) { at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) api := NewDefaultAPI(&Configuration{BasePath: srv.URL}) - events, err := api.ListEvents(context.Background(), ListEventsParams{Since: &at, OnDay: Date{at}}) + events, err := api.ListEvents(context.Background(), ListEventsParams{Since: &DateTime{Time: at}, OnDay: Date{at}}) if err != nil { t.Fatal(err) } @@ -38,7 +38,7 @@ func TestDates(t *testing.T) { t.Errorf("event = %+v", e) } - out, err := json.Marshal(Event{StartDate: e.StartDate, CreatedAt: at, EndDate: Some(Date{at})}) + out, err := json.Marshal(Event{StartDate: e.StartDate, CreatedAt: DateTime{Time: at}, EndDate: Some(Date{at})}) if err != nil { t.Fatal(err) } @@ -47,3 +47,28 @@ func TestDates(t *testing.T) { t.Errorf("Marshal = %s, want %s", out, want) } } + +// TestNaiveDateTime reads a date-time without an offset, as FastAPI sends a +// naive datetime, and writes it back without one. +func TestNaiveDateTime(t *testing.T) { + for in, want := range map[string]string{ + `"2024-03-01T10:00:00.500000"`: `"2024-03-01T10:00:00.500000"`, + `"2024-03-01T10:00:00"`: `"2024-03-01T10:00:00"`, + `"2024-03-01T10:00:00.5"`: `"2024-03-01T10:00:00.500000"`, + `"2024-03-01T10:00:00+02:00"`: `"2024-03-01T10:00:00+02:00"`, + } { + var d DateTime + if err := json.Unmarshal([]byte(in), &d); err != nil { + t.Fatalf("%s: %v", in, err) + } + + if out, _ := json.Marshal(d); string(out) != want { + t.Errorf("%s -> %s, want %s", in, out, want) + } + } + + var d DateTime + if err := json.Unmarshal([]byte(`"yesterday"`), &d); err == nil { + t.Error("yesterday decoded") + } +} diff --git a/testdata/gofetch/parameters-scalar-formats/params_test.go b/testdata/gofetch/parameters-scalar-formats/params_test.go index 298a3f3..56cf7cd 100644 --- a/testdata/gofetch/parameters-scalar-formats/params_test.go +++ b/testdata/gofetch/parameters-scalar-formats/params_test.go @@ -12,7 +12,7 @@ func TestScalarText(t *testing.T) { req, err := NewDefaultAPI(nil).SearchRequest(t.Context(), SearchParams{ F32: Ptr(float32(0.1)), F64: Ptr(1e21), N: Ptr(0.30000000000000004), I64: Ptr(int64(1<<62 + 1)), Ok: Ptr(false), - Day: &Date{Time: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)}, At: &at, Raw: Ptr("+/8="), + Day: &Date{Time: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)}, At: &DateTime{Time: at}, Raw: Ptr("+/8="), }) if err != nil { t.Fatal(err) From df160ff3b932da444770e1ca61b43d7207d26d34 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:40:37 +0100 Subject: [PATCH 10/18] fix(go-fetch): send a form that holds a file as multipart A form declared application/x-www-form-urlencoded with a binary field (FastAPI writes a form model with an UploadFile so) sent the File as its fmt text. Only multipart carries a file, so such a form now goes as multipart, as upstream's Go client does. --- internal/generator/golang/fetch.go | 9 +++-- templates/go-fetch/README.md | 2 +- .../request-body-form-urlencoded/body_test.go | 33 +++++++++++++++++++ .../feature/request-body-form-urlencoded.yaml | 20 +++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index 605d190..2dfe123 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -916,6 +916,7 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s taken := make(map[string]int) fields := []map[string]any{} byLocation := map[string][]map[string]any{} + files := false body := "" for _, p := range op.AllParams { @@ -968,6 +969,7 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s case p.IsFormParam: byLocation["form"] = append(byLocation["form"], entry) + files = files || strings.HasSuffix(t, "File") case p.IsPathParam: entry["placeholder"] = strconv.Quote("{" + p.BaseName + "}") @@ -990,10 +992,13 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s // ponytail: a multipart body is built in a bytes.Buffer, files included, // so a request holds its uploads in memory; the upgrade is an io.Pipe // that a goroutine writes once the request is sent. + // A form with a file goes as multipart whatever media type it declares, + // as upstream's Go client does: only multipart carries a file. form := len(byLocation["form"]) > 0 + multipart := op.IsMultipart || files ext["x-go-has-body"] = body != "" || form - ext["x-go-body-multipart"] = form && op.IsMultipart - ext["x-go-body-form"] = form && !op.IsMultipart + ext["x-go-body-multipart"] = form && multipart + ext["x-go-body-form"] = form && !multipart } // bodyKind sets how a body parameter is encoded: as it is for a reader, as diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index 36b328e..f37c382 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -90,7 +90,7 @@ with that body in `Model`, `XxxRaw` as the response. | ... pinning a property | a variant property of one value (`enum: [cat]`, 3.1 `const`) is set by `FromX` and checked by `AsX`; one that tells every variant apart is the discriminator, inline too | | file in a multipart body | `File{Name, Body}`; an `octet-stream` body is `io.Reader` | | other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | -| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`) | +| urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`); a form with a file goes as multipart | Not covered: server code, XML, server variables, validation, a discriminator without `oneOf` (the parent stays a plain struct), `additionalProperties` diff --git a/testdata/gofetch/request-body-form-urlencoded/body_test.go b/testdata/gofetch/request-body-form-urlencoded/body_test.go index 40d4328..a1f74f1 100644 --- a/testdata/gofetch/request-body-form-urlencoded/body_test.go +++ b/testdata/gofetch/request-body-form-urlencoded/body_test.go @@ -3,7 +3,10 @@ package requestbodyformurlencoded import ( "context" "io" + "mime" + "mime/multipart" "net/url" + "strings" "testing" ) @@ -40,3 +43,33 @@ func TestFormBody(t *testing.T) { t.Errorf("tags = %q, want [a b]", got) } } + +// TestFormWithFile: only multipart carries a file, so a form declared +// url-encoded goes as multipart once it holds one. +func TestFormWithFile(t *testing.T) { + req, err := NewDefaultAPI(nil).UploadAvatarRequest(context.Background(), UploadAvatarParams{ + User: "ann", + Image: File{Name: "a.png", Body: strings.NewReader("PNG")}, + }) + if err != nil { + t.Fatal(err) + } + + _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) + if err != nil { + t.Fatal(err) + } + + form, err := multipart.NewReader(req.Body, params["boundary"]).ReadForm(1 << 20) + if err != nil { + t.Fatalf("Content-Type %q: %v", req.Header.Get("Content-Type"), err) + } + + if got := form.Value["user"]; len(got) != 1 || got[0] != "ann" { + t.Errorf("user = %q", got) + } + + if f := form.File["image"]; len(f) != 1 || f[0].Filename != "a.png" || f[0].Size != 3 { + t.Errorf("image = %+v", f) + } +} diff --git a/testdata/specs/feature/request-body-form-urlencoded.yaml b/testdata/specs/feature/request-body-form-urlencoded.yaml index 4c5e82f..49cfa3f 100644 --- a/testdata/specs/feature/request-body-form-urlencoded.yaml +++ b/testdata/specs/feature/request-body-form-urlencoded.yaml @@ -27,3 +27,23 @@ paths: responses: "200": description: ok + /avatar: + post: + operationId: uploadAvatar + description: A form declared url-encoded that holds a file, as FastAPI writes a form model with an UploadFile. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [user, image] + properties: + user: + type: string + image: + type: string + format: binary + responses: + "204": + description: stored From 0b33a2cbba37c0ea34fd94ba0c944eda907e77ef Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:40:37 +0100 Subject: [PATCH 11/18] fix(go-fetch): keep the type of an enum that lists null EnumBaseType counted null as a JSON type of its own, so a nullable enum (enum: [a, null]) became any and lost its constants. null now only makes it nullable; values of two JSON types (1 and true) are any, as a string mixed with another already was. --- internal/generator/golang/base.go | 14 +++++++++--- internal/generator/golang/base_test.go | 22 +++++++++++++++++++ internal/parser/normalize_test.go | 1 + .../specs/feature/nullable-openapi31.yaml | 5 ++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go index 363eddb..bdd0cd0 100644 --- a/internal/generator/golang/base.go +++ b/internal/generator/golang/base.go @@ -302,16 +302,24 @@ func scalarType(isString, isDate, isDateTime, isBytes, isInteger, isLong, isNumb // EnumBaseType is the Go type an enum's values have: string when they are // strings (isString on the enumVars), else bool, int when every value is // whole, float64 otherwise; any when they are of more than one JSON type -// (pydantic's Literal["auto", 0]), which no Go constant type holds. +// (pydantic's Literal["auto", 0]), which no Go constant type holds. A null +// among them makes the enum nullable, not mixed. func EnumBaseType(allowable map[string]any, isBoolean bool) string { values, _ := allowable["values"].([]any) kinds := map[string]bool{} + for _, v := range values { - kinds[fmt.Sprintf("%T", v)] = true + switch v.(type) { + case nil: + case string, bool: + kinds[fmt.Sprintf("%T", v)] = true + default: + kinds["number"] = true + } } - if kinds["string"] && len(kinds) > 1 { + if len(kinds) > 1 { return "any" } diff --git a/internal/generator/golang/base_test.go b/internal/generator/golang/base_test.go index 331a14b..2fded9b 100644 --- a/internal/generator/golang/base_test.go +++ b/internal/generator/golang/base_test.go @@ -93,3 +93,25 @@ func TestGoType(t *testing.T) { t.Errorf("GoType(array of enum) = %q, want []PetTagsEnum", got) } } + +func TestEnumBaseType(t *testing.T) { + str := []map[string]any{{"isString": true}} + + tests := []struct { + values []any + vars []map[string]any + want string + }{ + {[]any{"a", "b"}, str, "string"}, + {[]any{"a", nil}, str, "string"}, + {[]any{1, 2, nil}, nil, "int"}, + {[]any{"auto", 0}, str, "any"}, + {[]any{1, true}, nil, "any"}, + } + + for _, tt := range tests { + if got := EnumBaseType(map[string]any{"values": tt.values, "enumVars": tt.vars}, false); got != tt.want { + t.Errorf("EnumBaseType(%v) = %q, want %q", tt.values, got, tt.want) + } + } +} diff --git a/internal/parser/normalize_test.go b/internal/parser/normalize_test.go index 78b8f4f..1385118 100644 --- a/internal/parser/normalize_test.go +++ b/internal/parser/normalize_test.go @@ -52,6 +52,7 @@ func TestNormalize31(t *testing.T) { "tags": "Array nullable=true ref= ", "alias": "string nullable=true ref=#/components/schemas/MaybeName ", "mood": "string nullable=false ref= ", + "tag": "string nullable=true ref= ", } for name, w := range want { if got[name] != w { diff --git a/testdata/specs/feature/nullable-openapi31.yaml b/testdata/specs/feature/nullable-openapi31.yaml index 43895d2..df010c1 100644 --- a/testdata/specs/feature/nullable-openapi31.yaml +++ b/testdata/specs/feature/nullable-openapi31.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: OpenAPI 3.1 nullability version: 1.0.0 - description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, as FastAPI and pydantic write them; multipart arrays, exploded by default. + description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, a null among enum values, as FastAPI and pydantic write them; multipart arrays, exploded by default. paths: /pets: get: @@ -86,5 +86,8 @@ components: $ref: "#/components/schemas/MaybeName" mood: anyOf: [{ type: string, const: happy }, { type: string, const: sad }] + tag: + type: string + enum: [a, null] MaybeName: anyOf: [{ type: string }, { type: "null" }] From b8bca647cdbcb1d45c5f105bcdb6b9d081444703 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:12:12 +0100 Subject: [PATCH 12/18] fix(parser): read a typeless Swagger 2.0 definition as any openapi2conv leaves an empty properties map on every schema, so a definition without a type (Kubernetes' JSON, JSONSchemaPropsOrArray) was read as an object: go-fetch typed it map[string]any and failed to decode any other JSON value, every CRD with an enum among them. Only properties that are there make a typeless schema an object now, as in 3.0. --- internal/parser/model.go | 2 +- internal/parser/typeless_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 internal/parser/typeless_test.go diff --git a/internal/parser/model.go b/internal/parser/model.go index d9bc799..caa23ab 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -76,7 +76,7 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co // Try to infer type if len(schema.Enum) > 0 { model.IsEnum = true - } else if schema.Properties != nil { + } else if len(schema.Properties) > 0 { schemaType = &openapi3.Types{"object"} } } diff --git a/internal/parser/typeless_test.go b/internal/parser/typeless_test.go new file mode 100644 index 0000000..cf7a877 --- /dev/null +++ b/internal/parser/typeless_test.go @@ -0,0 +1,32 @@ +package parser + +import "testing" + +// TestSwagger2TypelessDefinition: openapi2conv leaves an empty properties +// map on every schema, which must not make a typeless one an object (a map +// in go-fetch, which fails on any other JSON value). +func TestSwagger2TypelessDefinition(t *testing.T) { + spec := []byte(`{ + "swagger": "2.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + "definitions": {"JSON": {"description": "any valid JSON value"}} + }`) + + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData(spec); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + // As the same schema in 3.0: no type, not an object's "any". + if m := findModel(t, models, "JSON"); m.DataType != "" || m.HasVars { + t.Errorf("JSON = %q, vars %t; want no type, as in 3.0", m.DataType, m.HasVars) + } +} From f461b43553623e4368f484601149498f817a8bdf Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:12:12 +0100 Subject: [PATCH 13/18] fix(go-fetch): send a binary property of a JSON body as its text A format: binary property (3.1 contentMediaType, what pydantic writes for bytes) was a []byte, so it went out base64 and failed to decode any value that is not base64. A JSON string carries such bytes as they are: it is a string now. format: byte stays []byte, base64 on the wire. --- internal/generator/golang/base.go | 2 +- internal/generator/golang/base_test.go | 2 +- templates/go-fetch/README.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go index bdd0cd0..e25078b 100644 --- a/internal/generator/golang/base.go +++ b/internal/generator/golang/base.go @@ -231,7 +231,7 @@ func GoType(p *codegen.CodegenProperty, named string) string { return p.DataType } - return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsBinary || p.IsByteArray, p.IsInteger, p.IsLong, + return scalarType(p.IsString, p.IsDate, p.IsDateTime, p.IsByteArray, p.IsInteger, p.IsLong, p.IsNumber, p.IsFloat, p.IsBoolean, p.OpenApiType == "object") } diff --git a/internal/generator/golang/base_test.go b/internal/generator/golang/base_test.go index 2fded9b..00e4a47 100644 --- a/internal/generator/golang/base_test.go +++ b/internal/generator/golang/base_test.go @@ -63,7 +63,7 @@ func TestGoType(t *testing.T) { {&codegen.CodegenProperty{IsString: true, IsDate: true}, "Date"}, {&codegen.CodegenProperty{IsString: true, IsDateTime: true}, "DateTime"}, {&codegen.CodegenProperty{IsString: true, IsByteArray: true}, "[]byte"}, - {&codegen.CodegenProperty{IsString: true, IsBinary: true}, "[]byte"}, + {&codegen.CodegenProperty{IsString: true, IsBinary: true}, "string"}, {&codegen.CodegenProperty{IsInteger: true}, "int"}, {&codegen.CodegenProperty{IsInteger: true, IsLong: true}, "int64"}, {&codegen.CodegenProperty{IsNumber: true}, "float64"}, diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index f37c382..00e4aca 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -81,6 +81,7 @@ with that body in `Model`, `XxxRaw` as the response. | optional and nullable | `Opt[T]`: absent, `Null[T]()` or `Some(v)` | | `date` / `date-time` | `Date` / `DateTime` (a `time.Time`; one without an offset, a naive FastAPI datetime, is `Naive` and goes back without one) | | enum | a named type with a constant per value; `any` when the values are of more than one JSON type | +| `byte` / `binary` property | `[]byte` (base64 on the wire) / `string` (the raw text, as pydantic sends `bytes`) | | inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | | nullable item | `[]*T`, `map[string]*T` | | `allOf` | one struct with the members' properties | From 7ec1a2a096955fe23b6824c2fbc3dd960eff5d5f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:12:12 +0100 Subject: [PATCH 14/18] fix(go-fetch): keep null items and results, other 2xx inline bodies Found by schema-driven values through the client: - An array item that refers to a nullable schema is a pointer ([]*App), as a map value already was, and so is a result of a nullable model (*App): null became a zero value. - Another 2xx with an inline object body gets a type of its own (SetRecords207Response) and comes back as a *ResponseError: it was decoded as the result, its other fields dropped. - A model named Blob, the parser's name for binary, is a JSON body, not an io.Reader. --- internal/generator/golang/fetch.go | 43 ++++++++--- internal/generator/golang/fetch_test.go | 71 ++++++++++++++++++- internal/parser/property.go | 7 +- .../{typeless_test.go => swagger2_test.go} | 0 templates/go-fetch/README.md | 2 +- 5 files changed, 109 insertions(+), 14 deletions(-) rename internal/parser/{typeless_test.go => swagger2_test.go} (100%) diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index 2dfe123..c9b213f 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -3,6 +3,7 @@ package golang import ( "fmt" "maps" + "reflect" "slices" "strconv" "strings" @@ -42,6 +43,10 @@ type FetchGenerator struct { // runtime sends as a scalar. enums map[string]bool + // nullable are the nullable models by class: a result that refers to + // one is a pointer, nil for a null body. + nullable map[string]bool + // required are the quoted required properties of each struct model, which // a union's As method checks a value for; pinned its properties an enum // of one value fixes (see pinned). @@ -51,7 +56,7 @@ type FetchGenerator struct { // NewFetchGenerator creates a new go-fetch generator. func NewFetchGenerator() *FetchGenerator { - g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, enums: map[string]bool{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} + g := &FetchGenerator{BaseGenerator: NewBaseGenerator(), containers: map[string]bool{}, types: maps.Clone(RuntimeNames), opTypes: map[string]string{}, refs: map[string]string{}, enums: map[string]bool{}, nullable: map[string]bool{}, required: map[string]string{}, pinned: map[string][]map[string]any{}} g.ApiTemplateFiles["api.mustache"] = ".go" g.ModelTemplateFiles["model.mustache"] = ".go" @@ -150,6 +155,7 @@ func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*co } g.enums[m.Classname] = kind == "enum" + g.nullable[m.Classname] = m.IsNullable g.required[m.Classname] = requiredKeys(m.AllVars) g.pinned[m.Classname] = pinned(m.Classname, m.AllVars) @@ -944,7 +950,7 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s } t := GoParamType(p, named) - if p.IsBodyParam && p.DataType == "Blob" { + if p.IsBodyParam && p.DataType == "Blob" && !p.IsModel { t = "io.Reader" } @@ -1083,6 +1089,10 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma ext["x-go-discard"] = true case op.ReturnProperty == nil: t = op.ReturnType + if g.nullable[t] && !g.containers[t] { + t = "*" + t + } + default: named := "" @@ -1104,7 +1114,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma } } - if t != "" && t != "string" && !strings.HasPrefix(t, "*") && !isContainer(t) && !g.containers[t] { + if t != "" && t != "string" && !isContainer(t) && !g.containers[t] { var empty []string for _, r := range op.Responses { @@ -1114,7 +1124,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma } if len(empty) > 0 { - t = "*" + t + t = "*" + strings.TrimPrefix(t, "*") doc, _ := ext["x-go-doc"].([]string) ext["x-go-doc"] = append(doc, "//", "// The result is nil for a "+strings.Join(empty, " or ")+" response, which has no body.") } @@ -1123,7 +1133,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma ext["x-go-type"] = t if t != "" && !op.IsResponseBinary { ext["x-go-decode-json"] = t - g.other2xx(op, ext) + g.other2xx(op, ext, base) } var codes, ranges []map[string]any @@ -1169,17 +1179,32 @@ func (g *FetchGenerator) modelKeys(t string) string { // other2xx lists the 2xx statuses whose body is of another type than the // result (a 201 Order next to a 202 Problem): the typed method returns -// them as a *ResponseError holding that body, which the result cannot. -func (g *FetchGenerator) other2xx(op *codegen.CodegenOperation, ext map[string]any) { - if ext["x-go-type"] == "any" { +// them as a *ResponseError holding that body, which the result cannot. An +// inline object body gets a type named after the operation and status. +func (g *FetchGenerator) other2xx(op *codegen.CodegenOperation, ext map[string]any, base string) { + result, _ := ext["x-go-type"].(string) + if result == "any" { return // any holds every body } var codes []map[string]any for _, r := range op.Responses { + // An inline body of the result's shape is the result (a 200 and 201 + // of one schema); the parser gives each its own property. + if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx || r.DataType == "" || r.ReturnProperty != nil && reflect.DeepEqual(r.ReturnProperty, op.ReturnProperty) { + continue + } + rt := responseType(r) - if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx || r.DataType == "" || r.DataType == op.ReturnType || rt == "" || rt == "any" || rt == "[]byte" { + if obj := inlineObject(r.ReturnProperty); obj != nil { + typeName := g.opTypeNamer(op.OperationId) + named := typeName(base + r.Code + "Response") + g.opInline(ext)(g.inlineStructs(named, "the "+r.Code+" response of "+base, obj.Description, obj.Vars, typeName)) + rt = GoType(r.ReturnProperty, named) + } + + if rt == "" || rt == "any" || rt == "[]byte" || rt == strings.TrimPrefix(result, "*") { continue } diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go index 2105559..110078c 100644 --- a/internal/generator/golang/fetch_test.go +++ b/internal/generator/golang/fetch_test.go @@ -215,10 +215,29 @@ paths: responses: "200": {description: ok, content: {application/json: {schema: {type: array, items: {type: string}}}}} "204": {description: none} + /records: + post: + operationId: setRecords + responses: + "200": {description: ok, content: {application/json: {schema: {type: object, properties: {total: {type: integer}}}}}} + "201": {description: new, content: {application/json: {schema: {type: object, properties: {total: {type: integer}}}}}} + "206": {description: some, content: {application/json: {schema: {type: array, items: {type: object, properties: {id: {type: string}}}}}}} + "207": {description: partly, content: {application/json: {schema: {type: object, properties: {errors: {type: array, items: {type: string}}}}}}} + /maybe: + get: + operationId: getMaybe + responses: + "200": {description: ok, content: {application/json: {schema: {$ref: "#/components/schemas/Maybe"}}}} + "204": {description: none} + put: + operationId: listMaybe + responses: + "200": {description: ok, content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Maybe"}}}}} components: schemas: Pet: {type: object, properties: {name: {type: string}}} GetPetParams: {type: object, properties: {id: {type: string}}} + Maybe: {type: object, nullable: true, properties: {name: {type: string}}} `) byTag, err := p.GetOperations() @@ -228,7 +247,8 @@ components: want := map[string]string{ "getPet": "Pet", "listPets": "[]Pet", "getPhoto": "[]byte", "ping": "", - "startJob": "*Pet", "listJobs": "[]string", + "startJob": "*Pet", "listJobs": "[]string", "setRecords": "SetRecordsResponse", + "getMaybe": "*Maybe", "listMaybe": "[]*Maybe", } // The GetPetParams schema takes the name of getPet's Params type. @@ -248,6 +268,19 @@ components: t.Errorf("getPet params = %v, want GetPetParams2 next to the GetPetParams model", params) } + // Another inline object of a 2xx is a type of its own, not the result; + // one of the result's shape is the result. + if other, _ := op.VendorExtensions["x-go-other-2xx"].(map[string]any); op.OperationId == "setRecords" { + codes, _ := other["codes"].([]map[string]any) + if got := fmt.Sprint(codes); len(codes) != 2 || codes[0]["type"] != "[]SetRecords206Response" || codes[1]["type"] != "SetRecords207Response" { + t.Errorf("setRecords other 2xx = %s, want 206 of []SetRecords206Response and 207 of SetRecords207Response", got) + } + } + + if doc := fmt.Sprint(op.VendorExtensions["x-go-doc"]); op.OperationId == "getMaybe" && !strings.Contains(doc, "nil for a 204") { + t.Errorf("getMaybe doc = %s, want the 204 that leaves it nil", doc) + } + if op.OperationId == "getPet" { errs, _ := op.VendorExtensions["x-go-errors"].(map[string]any) if ranges, _ := errs["ranges"].([]map[string]any); len(ranges) != 1 || ranges[0]["digit"] != "4" || ranges[0]["type"] != "[]string" { @@ -455,3 +488,39 @@ paths: } } } + +// TestBodyNamedBlob: a model named Blob, the parser's name for binary, is +// still a JSON body; only a binary body is an io.Reader. +func TestBodyNamedBlob(t *testing.T) { + g, p := parse(t, `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /blob: + post: + operationId: putBlob + requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/Blob"}}}} + responses: {"204": {description: ok}} + /raw: + post: + operationId: putRaw + requestBody: {required: true, content: {application/octet-stream: {schema: {type: string, format: binary}}}} + responses: {"204": {description: ok}} +components: + schemas: + Blob: {type: object, properties: {data: {type: string}}} +`) + + byTag, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{"putBlob": "Blob", "putRaw": "io.Reader"} + + for _, op := range g.PostProcessOperations(byTag["default"]) { + fs, _ := op.VendorExtensions["x-go-fields"].([]map[string]any) + if got := fs[0]["type"]; got != want[op.OperationId] { + t.Errorf("%s body = %v, want %s", op.OperationId, got, want[op.OperationId]) + } + } +} diff --git a/internal/parser/property.go b/internal/parser/property.go index e0e56aa..2a96096 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -315,9 +315,10 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required case schema.Items != nil && schema.Items.Ref != "": modelName := p.toModelName(extractRefName(schema.Items.Ref)) prop.Items = &codegen.CodegenProperty{ - DataType: modelName, - Datatype: modelName, - IsModel: true, + DataType: modelName, + Datatype: modelName, + IsModel: true, + IsNullable: schema.Items.Value != nil && schema.Items.Value.Nullable && !schema.Items.Value.Type.Is(openapi3.TypeArray), } prop.DataType = "Array<" + modelName + ">" prop.Datatype = "Array<" + modelName + ">" diff --git a/internal/parser/typeless_test.go b/internal/parser/swagger2_test.go similarity index 100% rename from internal/parser/typeless_test.go rename to internal/parser/swagger2_test.go diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index 00e4aca..be7fa60 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -83,7 +83,7 @@ with that body in `Model`, `XxxRaw` as the response. | enum | a named type with a constant per value; `any` when the values are of more than one JSON type | | `byte` / `binary` property | `[]byte` (base64 on the wire) / `string` (the raw text, as pydantic sends `bytes`) | | inline object | a struct named after its owner: `ProfileUser`, `SearchReposResponse`, `CreateCustomerAddress` | -| nullable item | `[]*T`, `map[string]*T` | +| nullable item or result | `[]*T`, `map[string]*T`; a result of a nullable model is `*T` | | `allOf` | one struct with the members' properties | | `additionalProperties` next to properties | an `AdditionalProperties` map (numbered on a clash) beside the fields, read and written with them | | `oneOf` / `anyOf` | a wrapper over the raw JSON, `AsX` / `FromX`; `AsX` refuses a value without X's required properties; inline, named after its owner as an object is | From dbbd37f0679bcb5e8a755c370ac96b0fe2d441ab Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:28:48 +0100 Subject: [PATCH 15/18] fix(parser): load a Swagger 2.0 spec written in YAML Only a JSON document was recognised as Swagger 2.0, so a YAML one went to the OpenAPI 3 loader and failed with "value of openapi must be a non-empty string". A top-level swagger: "2.x" key marks one in YAML too; it is converted to JSON for kin's Swagger 2.0 model, and the property order is read from the source as before. --- go.mod | 2 +- internal/parser/load.go | 23 +++++++++++++++---- internal/parser/swagger2_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index c5eaa6e..75fefe9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/cbroglie/mustache v1.4.2 github.com/getkin/kin-openapi v0.152.0 + github.com/oasdiff/yaml v0.1.1 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 ) @@ -14,7 +15,6 @@ require ( github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/oasdiff/yaml v0.1.1 // indirect github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/internal/parser/load.go b/internal/parser/load.go index a70102e..55d21f9 100644 --- a/internal/parser/load.go +++ b/internal/parser/load.go @@ -10,12 +10,14 @@ import ( "net/url" "os" "path/filepath" + "regexp" "sort" "strings" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi2conv" "github.com/getkin/kin-openapi/openapi3" + "github.com/oasdiff/yaml" ) // LoadFromFile loads an OpenAPI spec from a file. @@ -124,24 +126,35 @@ func (p *Parser) LoadFromData(data []byte) error { // isSwagger2 checks if the data represents a Swagger 2.0 specification. func isSwagger2(data []byte) bool { - // Simple check: look for "swagger": "2.0" in the JSON/YAML var temp struct { - Swagger string `json:"swagger" yaml:"swagger"` + Swagger string `json:"swagger"` } - // Try JSON first if err := json.Unmarshal(data, &temp); err == nil { return strings.HasPrefix(temp.Swagger, "2.") } - return false + return swagger2YAML.Match(data) } +// swagger2YAML finds a top-level swagger: "2.x" key in a YAML document, +// without converting a large OpenAPI 3 one to JSON to look. +var swagger2YAML = regexp.MustCompile(`(?m)^["']?swagger["']?[ \t]*:[ \t]*["']?2\.`) + // loadSwagger2FromData loads a Swagger 2.0 spec and converts it to OpenAPI 3. func (p *Parser) loadSwagger2FromData(data []byte) error { var doc2 openapi2.T - if err := json.Unmarshal(data, &doc2); err != nil { + // YAMLToJSON sorts the keys: the property order reads data itself. + js := data + if !json.Valid(data) { + var err error + if js, err = yaml.YAMLToJSON(data); err != nil { + return fmt.Errorf("failed to parse Swagger 2.0 spec: %w", err) + } + } + + if err := json.Unmarshal(js, &doc2); err != nil { return fmt.Errorf("failed to parse Swagger 2.0 spec: %w", err) } diff --git a/internal/parser/swagger2_test.go b/internal/parser/swagger2_test.go index cf7a877..2968a75 100644 --- a/internal/parser/swagger2_test.go +++ b/internal/parser/swagger2_test.go @@ -30,3 +30,42 @@ func TestSwagger2TypelessDefinition(t *testing.T) { t.Errorf("JSON = %q, vars %t; want no type, as in 3.0", m.DataType, m.HasVars) } } + +// TestSwagger2YAML: a Swagger 2.0 document in YAML is one too, unquoted +// status codes included; it was read as an OpenAPI 3 one without openapi. +func TestSwagger2YAML(t *testing.T) { + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData([]byte(`swagger: "2.0" +info: {title: t, version: "1"} +paths: + /pets: + get: + operationId: listPets + responses: + 200: {description: ok, schema: {$ref: "#/definitions/Pet"}} +definitions: + Pet: {type: object, properties: {zebra: {type: string}, apple: {type: string}}} +`)); err != nil { + t.Fatal(err) + } + + models, err := p.GetModels() + if err != nil { + t.Fatal(err) + } + + if got := varNames(findModel(t, models, "Pet").Vars); len(got) != 2 || got[0] != "zebra" { + t.Errorf("Pet vars = %v, want [zebra apple]", got) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + if op := ops["default"]; len(op) != 1 || op[0].ReturnType != "Pet" { + t.Errorf("operations = %v, want listPets returning Pet", op) + } +} From 4d1e048d2e498bf10678280280f422477199c838 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:35:34 +0100 Subject: [PATCH 16/18] feat(go-fetch): stream JSON Lines and server-sent events FastAPI describes a yield endpoint with 3.2's itemSchema in a 3.1 document: the spec failed validation and go-fetch dropped every item. - The parser accepts 3.2 fields in a 3.1 document, normalizes and marks as used what an itemSchema refers to, and records a streamed response's media type and item as x-stream and x-stream-item; it keeps no return type, so typescript-fetch and dart-fetch are unchanged. - go-fetch returns an iter.Seq2[T, error] of the items as they arrive: JSON values for JSON Lines, a ServerSentEvent[T] per event for text/event-stream, T the contentSchema of its data or the text itself. An inline item gets a type named Item. --- README.md | 9 +- internal/generator/golang/base.go | 2 +- internal/generator/golang/fetch.go | 84 ++++++++++--- internal/parser/load.go | 24 +++- internal/parser/normalize.go | 5 + internal/parser/response.go | 31 +++++ internal/parser/stream_test.go | 68 ++++++++++ templates/go-fetch/README.md | 26 ++++ templates/go-fetch/api.mustache | 6 + templates/go-fetch/runtime.mustache | 113 +++++++++++++++++ .../gofetch/responses-stream/stream_test.go | 110 ++++++++++++++++ testdata/specs/feature/responses-stream.yaml | 118 ++++++++++++++++++ 12 files changed, 568 insertions(+), 28 deletions(-) create mode 100644 internal/parser/stream_test.go create mode 100644 testdata/gofetch/responses-stream/stream_test.go create mode 100644 testdata/specs/feature/responses-stream.yaml diff --git a/README.md b/README.md index efc4428..92f5d68 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,11 @@ minimal dependencies. - Fast generation times without JVM overhead - Load specs from local files or remote URLs - Command-line interface compatible with the original OpenAPI Generator -- Supports OpenAPI 3.x and Swagger 2.0 specifications (auto-converts 2.0 -> 3.x); - a 3.1 `null` member of `anyOf`/`oneOf`, a `type` list with `null`, a `const` - and a `contentMediaType` binary string read as their 3.0 forms, as the - upstream normalizer's defaults do +- Supports OpenAPI 3.x and Swagger 2.0 specifications, JSON or YAML + (auto-converts 2.0 -> 3.x); a 3.1 `null` member of `anyOf`/`oneOf`, a `type` + list with `null`, a `const` and a `contentMediaType` binary string read as + their 3.0 forms, as the upstream normalizer's defaults do; 3.2's `itemSchema` + of a streamed response is accepted in a 3.1 document, as FastAPI writes it ## Installation diff --git a/internal/generator/golang/base.go b/internal/generator/golang/base.go index e25078b..002d66d 100644 --- a/internal/generator/golang/base.go +++ b/internal/generator/golang/base.go @@ -31,7 +31,7 @@ var TypeMapping = map[string]string{ var RuntimeNames = map[string]bool{ "BasePath": true, "Doer": true, "RequestEditorFn": true, "Configuration": true, "ResponseError": true, "Opt": true, "Some": true, "Null": true, "Ptr": true, - "Date": true, "DateTime": true, "File": true, + "Date": true, "DateTime": true, "File": true, "ServerSentEvent": true, } // keywords are Go's reserved words, for the package name. diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index c9b213f..363fc16 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -1086,7 +1086,10 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma ext["x-go-decode-bytes"] = true case op.ReturnType == "": - ext["x-go-discard"] = true + if !g.streamExtensions(op, ext, base) { + ext["x-go-discard"] = true + } + case op.ReturnProperty == nil: t = op.ReturnType if g.nullable[t] && !g.containers[t] { @@ -1094,24 +1097,7 @@ func (g *FetchGenerator) responseExtensions(op *codegen.CodegenOperation, ext ma } default: - named := "" - - typeName := g.opTypeNamer(op.OperationId) - - if obj := inlineObject(op.ReturnProperty); obj != nil { - named = typeName(base + "Response") - g.opInline(ext)(g.inlineStructs(named, "the response of "+base, obj.Description, obj.Vars, typeName)) - } - - if u := inlineUnion(op.ReturnProperty); u != nil { - named = typeName(base + "Response") - g.opInline(ext)(g.unionDecl(named, "the response of "+base, u.Description, u.ComposedSchemas, typeName)) - } - - t = GoType(op.ReturnProperty, named) - if op.ReturnProperty.IsNullable && t != "string" && !isContainer(t) && !g.containers[t] { - t = "*" + t - } + t = g.resultType(op, op.ReturnProperty, base+"Response", "the response of "+base, ext) } if t != "" && t != "string" && !isContainer(t) && !g.containers[t] { @@ -1177,6 +1163,66 @@ func (g *FetchGenerator) modelKeys(t string) string { return "nil" } +// resultType is the Go type of a result or a streamed item, prop: an +// inline object or union is a type named name, declared for the operation; +// a nullable one is a pointer. +func (g *FetchGenerator) resultType(op *codegen.CodegenOperation, prop *codegen.CodegenProperty, name, what string, ext map[string]any) string { + named := "" + + typeName := g.opTypeNamer(op.OperationId) + + if obj := inlineObject(prop); obj != nil { + named = typeName(name) + g.opInline(ext)(g.inlineStructs(named, what, obj.Description, obj.Vars, typeName)) + } + + if u := inlineUnion(prop); u != nil { + named = typeName(name) + g.opInline(ext)(g.unionDecl(named, what, u.Description, u.ComposedSchemas, typeName)) + } + + t := GoType(prop, named) + if prop.IsNullable && t != "string" && !isContainer(t) && !g.containers[t] { + t = "*" + t + } + + return t +} + +// streamExtensions types an operation whose 2xx response streams its items +// (x-stream, see the parser's streamItem) and reports whether it does: JSON +// Lines as an iter.Seq2 of the item type, server-sent events as one of +// ServerSentEvent of it. Another streamed media type (json-seq, multipart) +// has only its Raw method. +func (g *FetchGenerator) streamExtensions(op *codegen.CodegenOperation, ext map[string]any, base string) bool { + for _, r := range op.Responses { + item, _ := r.VendorExtensions["x-stream-item"].(*codegen.CodegenProperty) + mediaType, _ := r.VendorExtensions["x-stream"].(string) + mediaType, _, _ = strings.Cut(mediaType, ";") + + decode := map[string]string{"application/jsonl": "decodeStream", "application/x-ndjson": "decodeStream", "text/event-stream": "decodeEvents"}[mediaType] + if !r.Is2xx || item == nil || decode == "" { + continue + } + + t := g.resultType(op, item, base+"Item", "an item of "+base, ext) + + yields := t + if decode == "decodeEvents" { + yields = "ServerSentEvent[" + t + "]" + } + + ext["x-go-stream"] = map[string]any{"item": yields, "decode": decode + "[" + t + "]"} + + doc, _ := ext["x-go-doc"].([]string) + ext["x-go-doc"] = append(doc, "//", "// Range the result to its end, break or cancel ctx: until then the response stays open.") + + return true + } + + return false +} + // other2xx lists the 2xx statuses whose body is of another type than the // result (a 201 Order next to a 202 Problem): the typed method returns // them as a *ResponseError holding that body, which the result cannot. An diff --git a/internal/parser/load.go b/internal/parser/load.go index 55d21f9..343dd96 100644 --- a/internal/parser/load.go +++ b/internal/parser/load.go @@ -272,7 +272,22 @@ func (p *Parser) validateSpec() error { return errors.New("no document loaded") } - if err := p.Doc.Validate(context.Background()); err != nil { + // FastAPI writes 3.2's itemSchema (a streamed response) into a 3.1 + // document: a 3.1 one may use what 3.2 adds. + // The other fields 3.2 adds hold operations, which no generator reads: + // those stay an error. + var opts []openapi3.ValidationOption + if strings.HasPrefix(p.Doc.OpenAPI, "3.1") { + opts = append(opts, openapi3.IsOpenAPI32OrLater()) + + for _, path := range p.Doc.Paths.InMatchingOrder() { + if item := p.Doc.Paths.Value(path); item.Query != nil || len(item.AdditionalOperations) > 0 { + p.ValidationErrors = append(p.ValidationErrors, "path "+path+": query and additionalOperations are for OpenAPI >=3.2") + } + } + } + + if err := p.Doc.Validate(context.Background(), opts...); err != nil { p.ValidationErrors = append(p.ValidationErrors, err.Error()) } @@ -381,9 +396,8 @@ func (p *Parser) markSchemasInPathItem(pathItem *openapi3.PathItem, usedSchemas for _, response := range op.Responses.Map() { if response != nil && response.Value != nil { for _, content := range response.Value.Content { - if content.Schema != nil { - p.markSchemaAsUsed(content.Schema, usedSchemas) - } + p.markSchemaAsUsed(content.Schema, usedSchemas) + p.markSchemaAsUsed(content.ItemSchema, usedSchemas) } } } @@ -458,4 +472,6 @@ func (p *Parser) markSchemaPropertiesAsUsed(schema *openapi3.Schema, usedSchemas for _, s := range schema.OneOf { p.markSchemaAsUsed(s, usedSchemas) } + + p.markSchemaAsUsed(schema.ContentSchema, usedSchemas) } diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go index d714cb6..52128aa 100644 --- a/internal/parser/normalize.go +++ b/internal/parser/normalize.go @@ -107,6 +107,10 @@ func normalizeContent(content openapi3.Content, walk func(*openapi3.SchemaRef)) if mt.Schema != nil { walk(mt.Schema) } + + if mt.ItemSchema != nil { + walk(mt.ItemSchema) + } } } @@ -157,6 +161,7 @@ func (p *Parser) normalizeSchemaRef(ref *openapi3.SchemaRef, seen map[*openapi3. p.normalizeSchemaRef(s.Items, seen) p.normalizeSchemaRef(s.AdditionalProperties.Schema, seen) p.normalizeSchemaRef(s.Not, seen) + p.normalizeSchemaRef(s.ContentSchema, seen) mergeEnums(s) diff --git a/internal/parser/response.go b/internal/parser/response.go index fca5ea8..dcddf64 100644 --- a/internal/parser/response.go +++ b/internal/parser/response.go @@ -1,6 +1,7 @@ package parser import ( + "maps" "sort" "strings" @@ -48,6 +49,10 @@ func (p *Parser) responseToCodegen(code string, resp *openapi3.Response) *codege for _, ct := range respContentTypes { mediaType := resp.Content[ct] if mediaType.Schema == nil { + if mediaType.ItemSchema != nil && cr.VendorExtensions["x-stream"] == nil { + p.streamItem(cr, ct, mediaType.ItemSchema) + } + continue } @@ -163,3 +168,29 @@ func ptrString(s *string) string { return *s } + +// streamItem records a response that streams its items (3.2's itemSchema, +// which FastAPI writes for a yield endpoint): x-stream is its media type, +// x-stream-item the property of one item, of a server-sent event its data's +// contentSchema, or else the data itself, a string when the event schema +// does not say. The response keeps no DataType, so a generator that does not +// read them sees one without a body. +func (p *Parser) streamItem(cr *codegen.CodegenResponse, mediaType string, item *openapi3.SchemaRef) { + if strings.HasPrefix(mediaType, "text/event-stream") && item.Value != nil { + item = item.Value.Properties["data"] + if item == nil { + item = openapi3.NewStringSchema().NewRef() + } else if item.Value != nil && item.Value.ContentSchema != nil { + item = item.Value.ContentSchema + } + } + + // convertExtensions hands out the document's own map: copy it. + cr.VendorExtensions = maps.Clone(cr.VendorExtensions) + if cr.VendorExtensions == nil { + cr.VendorExtensions = map[string]any{} + } + + cr.VendorExtensions["x-stream"] = mediaType + cr.VendorExtensions["x-stream-item"] = p.schemaRefToProperty("item", item, true) +} diff --git a/internal/parser/stream_test.go b/internal/parser/stream_test.go new file mode 100644 index 0000000..293cb77 --- /dev/null +++ b/internal/parser/stream_test.go @@ -0,0 +1,68 @@ +package parser + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +// TestStreamItems: a response with only an itemSchema validates in a 3.1 +// document, as FastAPI writes one, and records its item: of a server-sent +// event, its data's contentSchema. +func TestStreamItems(t *testing.T) { + p := NewParser() + if err := p.LoadFromFile(filepath.Join("..", "..", "testdata", "specs", "feature", "responses-stream.yaml")); err != nil { + t.Fatal(err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "streamTicks": "application/jsonl Tick", "streamWords": "application/jsonl string", "chat": "text/event-stream union", "rawEvents": "text/event-stream string", + "pings": "text/event-stream string", "records": "application/json-seq Tick", + "maybeTicks": "text/event-stream Tick nullable", + } + + for _, op := range ops["default"] { + r := op.Responses[0] + mediaType, _ := r.VendorExtensions["x-stream"].(string) + item, _ := r.VendorExtensions["x-stream-item"].(*codegen.CodegenProperty) + + got := "none" + if item != nil { + got = mediaType + " " + item.DataType + if item.IsNullable { + got += " nullable" + } + + if item.ComposedSchemas != nil { + got = mediaType + " union" + } + } + + if got != want[op.OperationId] || op.ReturnType != "" { + t.Errorf("%s = %s, returns %q; want %s and no return type", op.OperationId, got, op.ReturnType, want[op.OperationId]) + } + } +} + +// TestOpenAPI32Operations: a 3.1 document may hold 3.2's itemSchema, but not +// the operations 3.2 adds, which no generator reads. +func TestOpenAPI32Operations(t *testing.T) { + err := NewParser().LoadFromData([]byte(`openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /search: + query: + operationId: search + responses: {"200": {description: ok}} +`)) + if err == nil || !strings.Contains(err.Error(), "path /search: query and additionalOperations are for OpenAPI >=3.2") { + t.Errorf("err = %v, want the query operation refused", err) + } +} diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index be7fa60..b536dd9 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -62,6 +62,32 @@ requires. So is a 2xx whose body is of another type than the result (a 202 `Problem` next to a 201 `Order`): `Xxx` returns it as a `*ResponseError` with that body in `Model`, `XxxRaw` as the response. +A response that streams its items, described by 3.2's `itemSchema` (FastAPI +writes it for a `yield` endpoint, in a 3.1 document), makes `Xxx` return an +`iter.Seq2[T, error]` that yields each item as it arrives: a JSON value per +line for JSON Lines, a `ServerSentEvent[T]` (`Data`, `Event`, `ID`, `Retry`) +per event for `text/event-stream`, `T` being the `contentSchema` of its +`data`, or the text itself. A range stops at the first error. The response +stays open until the range ends, a `break` or `ctx` is cancelled: range the +result, or cancel `ctx`. JSON Lines (`application/jsonl`, +`application/x-ndjson`) and `text/event-stream` are read; another streamed +media type has only `XxxRaw`. + +```go +events, err := api.Chat(ctx, petstore.ChatParams{Body: petstore.ChatBody{Prompt: "hi"}}) +if err != nil { + return err +} + +for e, err := range events { + if err != nil { + return err + } + + fmt.Println(e.Event, e.Data) +} +``` + | `Configuration` | Use | | --------------- | ---------------------------------------------------------------------- | | `BasePath` | replaces the spec's first server URL | diff --git a/templates/go-fetch/api.mustache b/templates/go-fetch/api.mustache index 04dfe6c..a6ad0ef 100644 --- a/templates/go-fetch/api.mustache +++ b/templates/go-fetch/api.mustache @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "iter" "mime/multipart" "net/http" "net/url" @@ -262,6 +263,11 @@ func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p return decodeBytes(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) } {{/x-go-decode-bytes}} +{{#x-go-stream}} +func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) (iter.Seq2[{{{item}}}, error], error) { + return {{{decode}}}(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) +} +{{/x-go-stream}} {{#x-go-discard}} func (a *{{classname}}) {{x-go-name}}(ctx context.Context{{#x-go-has-params}}, p {{x-go-params}}{{/x-go-has-params}}, editors ...RequestEditorFn) error { return discard(a.{{x-go-name}}Raw(ctx{{#x-go-has-params}}, p{{/x-go-has-params}}, editors...)) diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 6100fd7..0490101 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -3,12 +3,15 @@ package {{packageName}} import ( + "bufio" "bytes" "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "iter" "maps" "mime/multipart" "net/http" @@ -467,6 +470,116 @@ func decodeBytes(resp *http.Response, err error) ([]byte, error) { return io.ReadAll(resp.Body) } +// ServerSentEvent is a server-sent event: its data as a T (the text itself for a +// string), and its name, id and retry (milliseconds) when the server sets them. +type ServerSentEvent[T any] struct { + Data T `json:"data"` + Event string `json:"event,omitempty"` + ID string `json:"id,omitempty"` + Retry int `json:"retry,omitempty"` +} + +// decodeStream yields the JSON values of resp's body (JSON Lines) as they +// arrive, then closes it. A range over the result ends at the first error; +// one that stops early closes the body too. +func decodeStream[T any](resp *http.Response, err error) (iter.Seq2[T, error], error) { + if err != nil { + return nil, err + } + + return func(yield func(T, error) bool) { + defer resp.Body.Close() + + dec := json.NewDecoder(resp.Body) + + for { + var v T + + err := dec.Decode(&v) + if errors.Is(err, io.EOF) || !yield(v, err) || err != nil { + return + } + } + }, nil +} + +// decodeEvents yields the server-sent events of resp's body as they arrive, +// then closes it, as decodeStream does. An event ends at a blank line, one +// the body ends before is dropped; several data lines are its data joined by +// newlines, a comment line (a keep-alive ": ping") is skipped, and an id +// holds until the next one. +func decodeEvents[T any](resp *http.Response, err error) (iter.Seq2[ServerSentEvent[T], error], error) { + if err != nil { + return nil, err + } + + return func(yield func(ServerSentEvent[T], error) bool) { + defer resp.Body.Close() + + sc := bufio.NewScanner(resp.Body) + sc.Buffer(nil, 1<<24) + + var ( + e ServerSentEvent[T] + data []string + ) + + // emit yields the event read so far, if it has data, and starts the next. + emit := func() bool { + defer func() { e, data = ServerSentEvent[T]{ID: e.ID}, nil }() + + if data == nil { + return true + } + + err := eventData(strings.Join(data, "\n"), &e.Data) + + return yield(e, err) && err == nil + } + + for first := true; sc.Scan(); first = false { + line := sc.Text() + if first { + line = strings.TrimPrefix(line, "\ufeff") + } + + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + + switch field { + case "": + if line == "" && !emit() { + return + } + case "data": + data = append(data, value) + case "event": + e.Event = value + case "id": + e.ID = value + case "retry": + e.Retry, _ = strconv.Atoi(value) + } + } + + if err := sc.Err(); err != nil { + yield(ServerSentEvent[T]{}, err) + } + }, nil +} + +// eventData reads an event's data into out: the text itself for a string, +// else JSON. +func eventData[T any](data string, out *T) error { + if p, ok := any(out).(*string); ok { + *p = data + + return nil + } + + return json.Unmarshal([]byte(data), out) +} + // discard drains and closes resp's body. func discard(resp *http.Response, err error) error { if err != nil { diff --git a/testdata/gofetch/responses-stream/stream_test.go b/testdata/gofetch/responses-stream/stream_test.go new file mode 100644 index 0000000..1176185 --- /dev/null +++ b/testdata/gofetch/responses-stream/stream_test.go @@ -0,0 +1,110 @@ +package responsesstream + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func serve(t *testing.T, contentType, body string) *DefaultAPI { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", contentType) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + return NewDefaultAPI(&Configuration{BasePath: srv.URL}) +} + +func TestJSONLines(t *testing.T) { + ticks, err := serve(t, "application/jsonl", "{\"i\":0,\"label\":\"a\"}\n{\"i\":1}\n").StreamTicks(context.Background()) + if err != nil { + t.Fatal(err) + } + + var got []Tick + + for tick, err := range ticks { + if err != nil { + t.Fatal(err) + } + + got = append(got, tick) + } + + if len(got) != 2 || got[0].I != 0 || *got[0].Label != "a" || got[1].I != 1 { + t.Errorf("ticks = %+v", got) + } + + // A bad line ends the range with its error; a break stops it early. + words, _ := serve(t, "application/jsonl", "\"a\"\n\"b\"\nnot json\n\"c\"\n").StreamWords(context.Background()) + + var n int + + for _, err := range words { + if n++; err != nil { + break + } + } + + if n != 3 { + t.Errorf("words read until the error = %d, want 3", n) + } + + for range words { + break + } +} + +func TestServerSentEvents(t *testing.T) { + body := ": ping\n\nevent: delta\nid: 1\ndata: {\"kind\":\"delta\",\n" + + "data: \"text\":\"hi\"}\n\nretry: 500\ndata: {\"kind\":\"done\",\"words\":1}\n\n" + + events, err := serve(t, "text/event-stream", body).Chat(context.Background(), ChatParams{Body: ChatBody{Prompt: "hi"}}) + if err != nil { + t.Fatal(err) + } + + var got []ServerSentEvent[ChatItem] + + for e, err := range events { + if err != nil { + t.Fatal(err) + } + + got = append(got, e) + } + + if len(got) != 2 || got[0].Event != "delta" || got[0].ID != "1" || got[1].Retry != 500 { + t.Fatalf("events = %+v", got) + } + + if d, err := got[0].Data.AsDelta(); err != nil || d.Text != "hi" { + t.Errorf("first data = %+v, %v", d, err) + } + + if d, err := got[1].Data.AsDone(); err != nil || d.Words != 1 { + t.Errorf("last data = %+v, %v", d, err) + } + + // Data of a string schema is the text itself; an id holds until the next + // one; a BOM is skipped; an event the body ends before is dropped. + raw, _ := serve(t, "text/event-stream", "\ufeffid: 7\ndata: {\"i\":1}\n\nevent: end\ndata: bye\n\ndata: cut").RawEvents(context.Background()) + + var texts []string + + for e, err := range raw { + if err != nil { + t.Fatal(err) + } + + texts = append(texts, e.ID+e.Event+"="+e.Data) + } + + if len(texts) != 2 || texts[0] != `7={"i":1}` || texts[1] != "7end=bye" { + t.Errorf("raw events = %q", texts) + } +} diff --git a/testdata/specs/feature/responses-stream.yaml b/testdata/specs/feature/responses-stream.yaml new file mode 100644 index 0000000..7bf9529 --- /dev/null +++ b/testdata/specs/feature/responses-stream.yaml @@ -0,0 +1,118 @@ +openapi: 3.1.0 +info: + title: Streamed responses + version: 1.0.0 + description: Responses that stream their items, as FastAPI describes a yield endpoint with 3.2's itemSchema in a 3.1 document; JSON Lines of a model and of strings, server-sent events with JSON data of a union, with raw data and without a data schema; a JSON text sequence, which only its Raw method reads. +paths: + /ticks: + get: + operationId: streamTicks + responses: + "200": + description: one tick per line + content: + application/jsonl: + itemSchema: { $ref: "#/components/schemas/Tick" } + /words: + get: + operationId: streamWords + responses: + "200": + description: one word per line + content: + application/jsonl: + itemSchema: { type: string } + /chat: + post: + operationId: chat + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [prompt], properties: { prompt: { type: string } } } + responses: + "200": + description: a delta per word, then done + content: + text/event-stream: + itemSchema: + type: object + required: [data] + properties: + data: + type: string + contentMediaType: application/json + contentSchema: + oneOf: [{ $ref: "#/components/schemas/Delta" }, { $ref: "#/components/schemas/Done" }] + discriminator: { propertyName: kind } + event: { type: string } + id: { type: string } + retry: { type: integer, minimum: 0 } + /events: + get: + operationId: rawEvents + responses: + "200": + description: events of any data + content: + text/event-stream: + itemSchema: + type: object + properties: + data: { type: string } + event: { type: string } + id: { type: string } + retry: { type: integer, minimum: 0 } + /pings: + get: + operationId: pings + responses: + "200": + description: events whose schema leaves out data + content: + text/event-stream: + itemSchema: { type: object, properties: { event: { type: string } } } + /maybe: + get: + operationId: maybeTicks + responses: + "200": + description: events of a tick or null, as pydantic writes Tick | None + content: + text/event-stream: + itemSchema: + type: object + properties: + data: + type: string + contentMediaType: application/json + contentSchema: { anyOf: [{ $ref: "#/components/schemas/Tick" }, { type: "null" }] } + /records: + get: + operationId: records + responses: + "200": + description: a JSON text sequence, not read + content: + application/json-seq: + itemSchema: { $ref: "#/components/schemas/Tick" } +components: + schemas: + Tick: + type: object + required: [i] + properties: + i: { type: integer } + label: { type: string } + Delta: + type: object + required: [kind, text] + properties: + kind: { type: string, const: delta } + text: { type: string } + Done: + type: object + required: [kind, words] + properties: + kind: { type: string, const: done } + words: { type: integer } From 5b930d79100190d6065294bc806bc7eeda43188a Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 22:04:46 +0100 Subject: [PATCH 17/18] fix(go-fetch): send null for a required nullable body A required body that may be null (GitHub's gists/update) was a value, so null went out as {}. It is a pointer now, and nil sends null; an optional body still sends none for nil. --- internal/generator/golang/fetch.go | 5 ++-- templates/go-fetch/api.mustache | 2 +- templates/go-fetch/runtime.mustache | 19 +++++++------ .../gofetch/request-body-json/body_test.go | 22 +++++++++++++++ testdata/specs/feature/request-body-json.yaml | 28 +++++++++++++++++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index 363fc16..d9dea6c 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -954,7 +954,8 @@ func (g *FetchGenerator) paramExtensions(op *codegen.CodegenOperation, ext map[s t = "io.Reader" } - if !p.Required && !isContainer(t) && !g.containers[t] { + // A required nullable body is a pointer too: nil sends null. + if (!p.Required || p.IsBodyParam && p.IsNullable) && !isContainer(t) && !g.containers[t] { t = "*" + t } @@ -1018,7 +1019,7 @@ func (g *FetchGenerator) bodyKind(p *codegen.CodegenParameter, t, field string, case strings.TrimPrefix(t, "*") == "string" && !isJSONMime(p.ContentType): ext["x-go-body-text"] = field default: - ext["x-go-body-json"] = field + ext["x-go-body-json"] = map[string]any{"field": field, "null": p.Required && p.IsNullable} } } diff --git a/templates/go-fetch/api.mustache b/templates/go-fetch/api.mustache index a6ad0ef..6900081 100644 --- a/templates/go-fetch/api.mustache +++ b/templates/go-fetch/api.mustache @@ -100,7 +100,7 @@ func (a *{{classname}}) {{x-go-name}}Request(ctx context.Context{{#x-go-has-para {{/x-go-in-query}} {{#x-go-body-json}} - body, err := jsonBody(p.{{.}}) + body, err := jsonBody(p.{{field}}, {{null}}) if err != nil { return nil, err } diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 0490101..7467573 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -942,15 +942,18 @@ func addCookie(req *http.Request, name string, v any, explode bool) error { return err } -// jsonBody encodes v as a JSON body; nil (pointer, slice, map) sends none. -func jsonBody(v any) (io.Reader, error) { - switch rv := reflect.ValueOf(v); rv.Kind() { - case reflect.Invalid: - return nil, nil - case reflect.Pointer, reflect.Slice, reflect.Map: - if rv.IsNil() { - return nil, nil +// jsonBody encodes v as a JSON body; nil (pointer, slice, map) sends none, +// or null for a required nullable body. +func jsonBody(v any, null bool) (io.Reader, error) { + rv := reflect.ValueOf(v) + + switch k := rv.Kind(); { + case k == reflect.Invalid, (k == reflect.Pointer || k == reflect.Slice || k == reflect.Map) && rv.IsNil(): + if null { + return strings.NewReader("null"), nil } + + return nil, nil } b, err := json.Marshal(v) diff --git a/testdata/gofetch/request-body-json/body_test.go b/testdata/gofetch/request-body-json/body_test.go index b072f5d..94eb01c 100644 --- a/testdata/gofetch/request-body-json/body_test.go +++ b/testdata/gofetch/request-body-json/body_test.go @@ -34,6 +34,28 @@ func TestJSONBodies(t *testing.T) { t.Errorf("CreateItems without items: body %v, %v", req.Body, err) } + // A required nullable body is a pointer: nil sends null. + for body, want := range map[*ReplaceItemBody]string{nil: "null", {Name: Ptr("b")}: `{"name":"b"}`} { + req, err = api.ReplaceItemRequest(ctx, ReplaceItemParams{ID: "1", Body: body}) + if err != nil { + t.Fatal(err) + } + + if got, _ := io.ReadAll(req.Body); string(got) != want { + t.Errorf("ReplaceItem body %s, want %s", got, want) + } + } + + // So is an any body: nil is null, not none. + req, err = api.SetNoteRequest(ctx, SetNoteParams{ID: "1"}) + if err != nil { + t.Fatal(err) + } + + if got, _ := io.ReadAll(req.Body); string(got) != "null" { + t.Errorf("SetNote body %s, want null", got) + } + req, err = api.SetRawCountRequest(ctx, SetRawCountParams{Body: 3}) if err != nil { t.Fatal(err) diff --git a/testdata/specs/feature/request-body-json.yaml b/testdata/specs/feature/request-body-json.yaml index 9813fe0..eb0fd14 100644 --- a/testdata/specs/feature/request-body-json.yaml +++ b/testdata/specs/feature/request-body-json.yaml @@ -45,6 +45,34 @@ paths: responses: "200": description: ok + /items/{id}/patch: + put: + operationId: replaceItem + description: A required body that may be null, as GitHub's gists/update has it. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { type: object, nullable: true, properties: { name: { type: string } } } + responses: + "204": + description: replaced + /items/{id}/note: + put: + operationId: setNote + description: A required body of any JSON that may be null. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { nullable: true } + responses: + "204": + description: set components: schemas: Item: From ae46c0e0c90e9e57c1d7e532ec0bc92dd9c85371 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 22:04:46 +0100 Subject: [PATCH 18/18] fix(parser): make a union nullable when a member takes null A oneOf/anyOf whose member is nullable (GitHub's expires_at: a nullable date or 0) takes null too, but the union was not nullable: go-fetch dropped an explicit null and typescript-fetch typed it without null. --- internal/parser/normalize.go | 7 +++++++ internal/parser/normalize_test.go | 1 + testdata/specs/feature/nullable-openapi31.yaml | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go index 52128aa..96bbf89 100644 --- a/internal/parser/normalize.go +++ b/internal/parser/normalize.go @@ -154,6 +154,13 @@ func (p *Parser) normalizeSchemaRef(ref *openapi3.SchemaRef, seen map[*openapi3. } } + // A member that takes null makes its union take it too. + for _, m := range slices.Concat(s.OneOf, s.AnyOf) { + if m.Value != nil && m.Value.Nullable { + s.Nullable = true + } + } + for _, prop := range s.Properties { p.normalizeSchemaRef(prop, seen) } diff --git a/internal/parser/normalize_test.go b/internal/parser/normalize_test.go index 1385118..bb249f0 100644 --- a/internal/parser/normalize_test.go +++ b/internal/parser/normalize_test.go @@ -53,6 +53,7 @@ func TestNormalize31(t *testing.T) { "alias": "string nullable=true ref=#/components/schemas/MaybeName ", "mood": "string nullable=false ref= ", "tag": "string nullable=true ref= ", + "expires": "Date | number nullable=true ref= ", } for name, w := range want { if got[name] != w { diff --git a/testdata/specs/feature/nullable-openapi31.yaml b/testdata/specs/feature/nullable-openapi31.yaml index df010c1..fd09535 100644 --- a/testdata/specs/feature/nullable-openapi31.yaml +++ b/testdata/specs/feature/nullable-openapi31.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: OpenAPI 3.1 nullability version: 1.0.0 - description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, a null among enum values, as FastAPI and pydantic write them; multipart arrays, exploded by default. + description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, a null among enum values, a union with a member that takes null, as FastAPI and pydantic write them; multipart arrays, exploded by default. paths: /pets: get: @@ -89,5 +89,7 @@ components: tag: type: string enum: [a, null] + expires: + oneOf: [{ type: [string, "null"], format: date }, { type: integer }] MaybeName: anyOf: [{ type: string }, { type: "null" }]