From 75f0225151094c727750904bd3ff968e8c710811 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 15:31:20 +0100 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 c2c3ec65a039bae6d033c5c99a42e4ba31947e06 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 18:14:53 +0100 Subject: [PATCH 08/14] test(e2e): run generated clients against a mock and a FastAPI backend `make e2e` checks what a generated client puts on the wire and how it decodes the answer, for go-fetch, typescript-fetch and dart-fetch alike. The cases are language-neutral scenarios (testdata/e2e/scenarios) in the spec's own names and wire values: - a mock layer: a server built on kin-openapi validates each request and each scenario response against the feature spec and records the request, so a case checks the exact path, query, headers, cookies and body; - a backend layer: a strict FastAPI app (pydantic, uv-locked) whose served OpenAPI 3.1 spec is snapshotted, called through a client generated from it, echoing what it parsed. A runner per generator turns the scenarios into calls of its client the way its users write them (Go by reflection, TypeScript from the compiled sources, Dart with mirrors) and prints one JSON line per case. A gap of a generator is a skip with its reason on the case; a skip that starts passing fails. go-fetch passes every case; typescript-fetch skips 29 and dart-fetch 103, mostly clients that do not compile. The cookie array of parameters-object-styles is explode: false: the OpenAPI serialization table defines no exploded form for a cookie. --- CLAUDE.md | 2 + Makefile | 10 +- internal/e2e/dart_test.go | 178 + internal/e2e/e2e_test.go | 382 ++ internal/e2e/gofetch_test.go | 196 + internal/e2e/match_test.go | 157 + internal/e2e/mock_test.go | 286 ++ internal/e2e/scenario_test.go | 166 + internal/e2e/typescript_test.go | 84 + testdata/e2e/README.md | 138 + testdata/e2e/backends/fastapi/.gitignore | 2 + testdata/e2e/backends/fastapi/app.py | 752 ++++ testdata/e2e/backends/fastapi/pyproject.toml | 14 + testdata/e2e/backends/fastapi/uv.lock | 313 ++ testdata/e2e/runners/dart/runner.dart | 217 + testdata/e2e/runners/go/runner.go | 497 ++ testdata/e2e/runners/ts/runner.ts | 269 ++ .../e2e/scenarios/additional-properties.yaml | 9 + testdata/e2e/scenarios/discriminator.yaml | 4 + testdata/e2e/scenarios/fastapi.yaml | 395 ++ .../e2e/scenarios/nullable-openapi31.yaml | 16 + testdata/e2e/scenarios/nullable.yaml | 9 + .../e2e/scenarios/parameters-deepobject.yaml | 8 + .../e2e/scenarios/parameters-locations.yaml | 20 + .../scenarios/parameters-object-styles.yaml | 43 + .../parameters-objects-in-query.yaml | 8 + .../e2e/scenarios/parameters-path-style.yaml | 5 + .../scenarios/parameters-scalar-formats.yaml | 9 + testdata/e2e/scenarios/parameters.yaml | 13 + testdata/e2e/scenarios/request-bodies.yaml | 18 + .../e2e/scenarios/request-body-forms.yaml | 26 + .../e2e/scenarios/request-body-login.yaml | 7 + .../e2e/scenarios/request-body-multipart.yaml | 15 + .../scenarios/request-body-other-media.yaml | 22 + testdata/e2e/scenarios/responses-binary.yaml | 5 + .../e2e/scenarios/responses-mixed-2xx.yaml | 13 + testdata/e2e/scenarios/responses.yaml | 21 + testdata/e2e/scenarios/security-http.yaml | 6 + testdata/e2e/scenarios/security-oauth2.yaml | 6 + testdata/e2e/scenarios/security.yaml | 7 + testdata/e2e/scenarios/unions-anyof.yaml | 8 + testdata/e2e/scenarios/unions-inline.yaml | 10 + testdata/e2e/scenarios/unions.yaml | 15 + testdata/e2e/specs/fastapi.json | 3995 +++++++++++++++++ testdata/specs/feature/README.md | 4 + .../feature/parameters-object-styles.yaml | 2 +- 46 files changed, 8380 insertions(+), 2 deletions(-) create mode 100644 internal/e2e/dart_test.go create mode 100644 internal/e2e/e2e_test.go create mode 100644 internal/e2e/gofetch_test.go create mode 100644 internal/e2e/match_test.go create mode 100644 internal/e2e/mock_test.go create mode 100644 internal/e2e/scenario_test.go create mode 100644 internal/e2e/typescript_test.go create mode 100644 testdata/e2e/README.md create mode 100644 testdata/e2e/backends/fastapi/.gitignore create mode 100644 testdata/e2e/backends/fastapi/app.py create mode 100644 testdata/e2e/backends/fastapi/pyproject.toml create mode 100644 testdata/e2e/backends/fastapi/uv.lock create mode 100644 testdata/e2e/runners/dart/runner.dart create mode 100644 testdata/e2e/runners/go/runner.go create mode 100644 testdata/e2e/runners/ts/runner.ts create mode 100644 testdata/e2e/scenarios/additional-properties.yaml create mode 100644 testdata/e2e/scenarios/discriminator.yaml create mode 100644 testdata/e2e/scenarios/fastapi.yaml create mode 100644 testdata/e2e/scenarios/nullable-openapi31.yaml create mode 100644 testdata/e2e/scenarios/nullable.yaml create mode 100644 testdata/e2e/scenarios/parameters-deepobject.yaml create mode 100644 testdata/e2e/scenarios/parameters-locations.yaml create mode 100644 testdata/e2e/scenarios/parameters-object-styles.yaml create mode 100644 testdata/e2e/scenarios/parameters-objects-in-query.yaml create mode 100644 testdata/e2e/scenarios/parameters-path-style.yaml create mode 100644 testdata/e2e/scenarios/parameters-scalar-formats.yaml create mode 100644 testdata/e2e/scenarios/parameters.yaml create mode 100644 testdata/e2e/scenarios/request-bodies.yaml create mode 100644 testdata/e2e/scenarios/request-body-forms.yaml create mode 100644 testdata/e2e/scenarios/request-body-login.yaml create mode 100644 testdata/e2e/scenarios/request-body-multipart.yaml create mode 100644 testdata/e2e/scenarios/request-body-other-media.yaml create mode 100644 testdata/e2e/scenarios/responses-binary.yaml create mode 100644 testdata/e2e/scenarios/responses-mixed-2xx.yaml create mode 100644 testdata/e2e/scenarios/responses.yaml create mode 100644 testdata/e2e/scenarios/security-http.yaml create mode 100644 testdata/e2e/scenarios/security-oauth2.yaml create mode 100644 testdata/e2e/scenarios/security.yaml create mode 100644 testdata/e2e/scenarios/unions-anyof.yaml create mode 100644 testdata/e2e/scenarios/unions-inline.yaml create mode 100644 testdata/e2e/scenarios/unions.yaml create mode 100644 testdata/e2e/specs/fastapi.json diff --git a/CLAUDE.md b/CLAUDE.md index d9e66f0..fe1e3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ make lint # golangci-lint, config in .golangci.yml — must be cle make fmt # gofumpt -w . make cover # coverage.out + per-function summary make regress REF=master # generated output, working tree vs a git ref +make e2e # generated clients against a mock and FastAPI (testdata/e2e) go test ./internal/parser -run TestName ``` @@ -33,6 +34,7 @@ go.yaml.in/yaml/v3. No cgo. | `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/`, `dart/`, `golang/` | +| `internal/e2e` | end-to-end tests: mock server, runners, scenarios in `testdata/e2e` | | `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 fabe9ac..1c1dede 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 samples-go-fetch regress all +.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch samples-go-fetch regress e2e e2e-update 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 @@ -19,6 +19,14 @@ test: go vet ./... go test -race ./... +## e2e: generated clients against a validating mock and the FastAPI backend (needs uv), testdata/e2e +e2e: + E2E=1 go test -count=1 -v ./internal/e2e + +## e2e-update: e2e, rewriting the snapshot of the backend's spec +e2e-update: + E2E=1 E2E_UPDATE=1 go test -count=1 -v ./internal/e2e + ## cover: Run tests with coverage cover: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... diff --git a/internal/e2e/dart_test.go b/internal/e2e/dart_test.go new file mode 100644 index 0000000..4719945 --- /dev/null +++ b/internal/e2e/dart_test.go @@ -0,0 +1,178 @@ +package e2e + +import ( + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// dartVersion reads "Dart SDK version: 3.13.7 ..." as 3 and 13. +var dartVersion = regexp.MustCompile(`version: (\d+)\.(\d+)`) + +// runDart generates a dart-fetch package per suite, depends on them from a +// package holding testdata/e2e/runners/dart and runs the suites on the VM. +// E2E_DART names the dart binary; the clients need Dart 3.13 or later. +func runDart(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + dart := os.Getenv("E2E_DART") + if dart == "" { + dart = "dart" + } + + ver, err := exec.Command(dart, "--version").CombinedOutput() + if m := dartVersion.FindStringSubmatch(string(ver)); err != nil || m == nil || m[1] == "3" && atoi(m[2]) < 13 { + t.Skipf("dart-fetch clients need Dart 3.13 or later (set E2E_DART): %s %v", strings.TrimSpace(string(ver)), err) + } + + dir := t.TempDir() + + runner, err := os.ReadFile("../../testdata/e2e/runners/dart/runner.dart") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.dart"), string(runner)) + + for i, s := range suites { + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, "c"+strconv.Itoa(i)), + GeneratorName: "dart-fetch", + TemplateDir: "../../templates/dart-fetch", + AdditionalProperties: []string{"pubName=c" + strconv.Itoa(i)}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + } + + // A client that does not compile would stop the whole program: it + // leaves the run, and its cases fail with its first error. + broken := dartErrors(t, dart, dir) + failed := map[string]map[int]outcome{} + + var imports, clients, deps strings.Builder + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + if msg, ok := broken[pkg]; ok { + failed[s.Name] = map[int]outcome{} + for j := range s.Cases { + failed[s.Name][j] = outcome{Suite: s.Name, Case: j, Fail: "the client does not compile: " + msg} + } + + continue + } + + fmt.Fprintf(&deps, " %s:\n path: %s\n", pkg, pkg) + fmt.Fprintf(&imports, "import 'package:%s/%s.dart' as %s;\n", pkg, pkg, pkg) + fmt.Fprintf(&clients, " %q: Client(\n (base, a) => %s.Configuration(basePath: base, username: a.username, password: a.password, "+ + "apiKey: (name) => a.apiKey ?? '', accessToken: () => a.bearer ?? ''),\n (cfg) => [%s],\n %q),\n", + s.Name, pkg, dartAPIs(t, filepath.Join(dir, pkg), pkg), pkg) + } + + var running []suite + + for _, s := range suites { + if _, ok := failed[s.Name]; !ok { + running = append(running, s) + } + } + + if len(running) == 0 { + return failed + } + + write(t, filepath.Join(dir, "pubspec.yaml"), "name: e2e_runner\npublish_to: none\nenvironment:\n sdk: '>=3.13.0 <4.0.0'\ndependencies:\n"+deps.String()) + write(t, filepath.Join(dir, "main.dart"), "import 'runner.dart';\n"+imports.String()+ + "\nvoid main(List args) => run(args[0], {\n"+clients.String()+"});\n") + + get := exec.Command(dart, "pub", "get", "--offline") + get.Dir = dir + + if out, err := get.CombinedOutput(); err != nil { + t.Fatalf("dart pub get: %v\n%s", err, out) + } + + writeInput(t, dir, running) + + cmd := exec.Command(dart, "run", "main.dart", filepath.Join(dir, "input.json")) + cmd.Dir = dir + + out := execRunner(t, cmd) + maps.Copy(out, failed) + + return out +} + +// dartErrors analyzes the generated packages under dir and returns the +// first compile error of each package that has one. +func dartErrors(t *testing.T, dart, dir string) map[string]string { + t.Helper() + + cmd := exec.Command(dart, "analyze", "--format=machine", ".") + cmd.Dir = dir + + out, _ := cmd.CombinedOutput() + + errs := map[string]string{} + + for _, line := range strings.Split(string(out), "\n") { + f := strings.Split(line, "|") + if len(f) < 8 || f[0] != "ERROR" { + continue + } + + rel, err := filepath.Rel(dir, f[3]) + if err != nil { + continue + } + + pkg, _, _ := strings.Cut(rel, string(filepath.Separator)) + if _, seen := errs[pkg]; !seen { + errs[pkg] = fmt.Sprintf("%s:%s: %s", rel, f[4], f[7]) + } + } + + return errs +} + +// dartAPIs lists the constructors of a package's API classes for cfg. +func dartAPIs(t *testing.T, dir, pkg string) string { + t.Helper() + + files, _ := filepath.Glob(filepath.Join(dir, "lib", "src", "apis", "*.dart")) + class := regexp.MustCompile(`(?m)^class (\w+) extends BaseApi`) + + var calls []string + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + + for _, m := range class.FindAllStringSubmatch(string(data), -1) { + calls = append(calls, pkg+"."+m[1]+"(cfg as "+pkg+".Configuration)") + } + } + + return strings.Join(calls, ", ") +} + +func atoi(s string) int { + n, _ := strconv.Atoi(s) + + return n +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go new file mode 100644 index 0000000..73bf716 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,382 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "net" + "net/http" + "os" + "os/exec" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" +) + +// runner runs suites through the clients of one generator, named as +// scenarios name it in skip, and returns the outcomes by suite and case. +type runner struct { + generator string + run func(t *testing.T, suites []suite) map[string]map[int]outcome +} + +var runners = []runner{ + {"go-fetch", runGoFetch}, + {"typescript-fetch", runTypeScript}, + {"dart-fetch", runDart}, +} + +// forEachRunner runs fn per generator; E2E_GENERATOR= picks one. +func forEachRunner(t *testing.T, fn func(t *testing.T, r runner)) { + t.Helper() + + for _, r := range runners { + if only := os.Getenv("E2E_GENERATOR"); only != "" && only != r.generator { + continue + } + + t.Run(r.generator, func(t *testing.T) { fn(t, r) }) + } +} + +// requireE2E skips unless the run asks for the e2e tests: they need uv, +// build clients and start servers. +func requireE2E(t *testing.T) { + t.Helper() + + if os.Getenv("E2E") == "" { + t.Skip("set E2E=1 (make e2e) to run the e2e tests") + } +} + +// TestMock runs the scenarios of the feature specs against the mock: each +// request must pass the spec's validation and look as the case says, and +// the client must return what the case says from the mock's response. +func TestMock(t *testing.T) { + requireE2E(t) + + scenarios := loadScenarios(t) + + forEachRunner(t, func(t *testing.T, r runner) { + t.Helper() + + var ( + suites []suite + mocks = map[string]*mock{} + byName = map[string]*scenario{} + ) + + for _, s := range scenarios { + if s.Spec == "" { + continue + } + + m := newMock(t, s.Spec, s.Cases) + mocks[s.Name], byName[s.Name] = m, s + suites = append(suites, toSuite(t, s, s.Spec, m.srv.URL, true)) + } + + outcomes := r.run(t, suites) + tally := map[string]*counts{} + + for _, s := range suites { + t.Run(s.Name, func(t *testing.T) { + tally[s.Name] = checkCases(t, r.generator, byName[s.Name], outcomes[s.Name], mocks[s.Name]) + }) + } + + report(t, r.generator, tally) + }) +} + +// counts are a suite's cases by how they ended. +type counts struct{ pass, fail, skip int } + +// report logs a PASS/FAIL/SKIP table of the suites for the generator. +func report(t *testing.T, generator string, tally map[string]*counts) { + t.Helper() + + var b strings.Builder + + fmt.Fprintf(&b, "\n%-32s %5s %5s %5s (%s)\n", "scenario", "pass", "fail", "skip", generator) + + for _, name := range slices.Sorted(maps.Keys(tally)) { + c := tally[name] + fmt.Fprintf(&b, "%-32s %5d %5d %5d\n", name, c.pass, c.fail, c.skip) + } + + t.Log(b.String()) +} + +// TestFastAPI starts the FastAPI backend, checks its spec against the +// snapshot (E2E_UPDATE=1 rewrites it), generates the client from the spec +// the server serves and runs the backend's scenario against it. +func TestFastAPI(t *testing.T) { + requireE2E(t) + + uv, err := exec.LookPath("uv") + if err != nil { + t.Fatal("the FastAPI backend runs with uv: https://docs.astral.sh/uv/") + } + + scenarios := loadScenarios(t) + + // A fresh backend per generator: the cases share its state. + forEachRunner(t, func(t *testing.T, r runner) { + t.Helper() + + base := startBackend(t, uv, "../../testdata/e2e/backends/fastapi") + + resp, err := http.Get(base + "/openapi.json") + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + var served any + if err := json.NewDecoder(resp.Body).Decode(&served); err != nil { + t.Fatal(err) + } + + spec := checkSnapshot(t, served, "../../testdata/e2e/specs/fastapi.json") + + for _, s := range scenarios { + if s.Backend != "fastapi" { + continue + } + + outcomes := r.run(t, []suite{toSuite(t, s, spec, base, false)}) + + var c *counts + + t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r.generator, s, outcomes[s.Name], nil) }) + report(t, r.generator, map[string]*counts{s.Name: c}) + } + }) +} + +func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suite { + t.Helper() + + doc, err := openapi3.NewLoader().LoadFromFile(specPath) + if err != nil { + t.Fatal(err) + } + + params := opParams(doc) + + out := suite{Name: s.Name, Base: base, Mock: isMock, Auth: s.Auth, Spec: specPath, Schemes: map[string]string{}} + + if doc.Components != nil { + for name, ref := range doc.Components.SecuritySchemes { + if ref.Value != nil { + out.Schemes[name] = ref.Value.Type + } + } + } + + for _, k := range s.Cases { + if _, ok := params[k.Op]; !ok { + t.Fatalf("%s: no operation %s in %s", s.Name, k.Op, specPath) + } + + if !isMock && (k.Request != nil || k.Response != nil) { + t.Fatalf("%s %s: request and response are for the mock; a backend answers itself", s.Name, k.Op) + } + + out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save}) + } + + return out +} + +// checkCases compares each case's outcome, and with a mock the request it +// received, with the scenario. A case skipped for this generator must still +// fail: one that passes has its skip left over. +func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]outcome, m *mock) *counts { + t.Helper() + + c := &counts{} + + for i, k := range s.Cases { + name := k.Name + if name == "" { + name = k.Op + } + + t.Run(fmt.Sprintf("%02d %s", i, name), func(t *testing.T) { + var diffs []string + + if o, ok := outcomes[i]; ok { + diffs = caseDiffs(k, o, m, i) + } else { + diffs = []string{"the runner reported nothing for this case"} + } + + reason, skipped := k.Skip[generator] + if !skipped { + reason, skipped = s.Skip[generator] + } + + switch { + case skipped && len(diffs) == 0: + c.fail++ + + t.Errorf("passes now: drop its skip (%s)", reason) + + case skipped: + c.skip++ + + t.Skipf("%s (%s)", reason, strings.Join(diffs, "; ")) + + case len(diffs) > 0: + c.fail++ + + t.Error("\n" + strings.Join(diffs, "\n")) + + default: + c.pass++ + } + }) + } + + return c +} + +func caseDiffs(k kase, o outcome, m *mock, i int) []string { + var diffs []string + + // With a mock every case's request must be valid for the spec, and the + // scenario's response too; "request" checks more of it. + if m != nil { + m.mu.Lock() + got, ok := m.received[i] + m.mu.Unlock() + + switch { + case !ok: + diffs = append(diffs, "request: none reached the server") + case got["invalid"] != nil: + diffs = append(diffs, fmt.Sprintf("request: invalid for the spec: %v", got["invalid"])) + case got["responseInvalid"] != nil: + diffs = append(diffs, fmt.Sprintf("response: the scenario's is invalid for the spec: %v", got["responseInvalid"])) + case k.Request != nil: + diffs = append(diffs, match("request", decodeJSON(k.Request), got)...) + } + } + + switch { + case o.Fail != "": + diffs = append(diffs, "call failed: "+o.Fail) + case k.Error != nil: + if o.Error == nil { + diffs = append(diffs, "error: want one, got result "+string(o.Result)) + + break + } + + got := map[string]any{"status": json.Number(strconv.Itoa(o.Error.Status)), "body": o.Error.Body, "model": nil} + if len(o.Error.Model) > 0 { + got["model"] = decodeJSON(o.Error.Model) + } + + diffs = append(diffs, match("error", decodeJSON(k.Error), got)...) + + case o.Error != nil: + diffs = append(diffs, fmt.Sprintf("error: %d %s", o.Error.Status, o.Error.Body)) + case k.Result != nil: + diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result))...) + } + + return diffs +} + +// startBackend runs `uv run uvicorn app:app` in dir on a free port and +// returns its URL once it answers; the test stops it. +func startBackend(t *testing.T, uv, dir string) string { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + addr, _ := l.Addr().(*net.TCPAddr) + port := strconv.Itoa(addr.Port) + _ = l.Close() + + cmd := exec.Command(uv, "run", "--quiet", "--locked", "uvicorn", "app:app", "--port", port, "--log-level", "warning") + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + exited := make(chan error, 1) + + go func() { exited <- cmd.Wait() }() + + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + + <-exited + }) + + base := "http://127.0.0.1:" + port + + for deadline := time.Now().Add(2 * time.Minute); time.Now().Before(deadline); { + select { + case err := <-exited: + exited <- err + + t.Fatalf("the backend exited: %v", err) + + case <-time.After(200 * time.Millisecond): + } + + if resp, err := http.Get(base + "/openapi.json"); err == nil { + _ = resp.Body.Close() + + return base + } + } + + t.Fatal("the backend did not start in 2 minutes") + + return "" +} + +// checkSnapshot compares the served spec with the committed one and returns +// the path of the served spec, written next to the test's temp files. +func checkSnapshot(t *testing.T, served any, snapshot string) string { + t.Helper() + + data, err := json.MarshalIndent(served, "", " ") + if err != nil { + t.Fatal(err) + } + + data = append(data, '\n') + + if os.Getenv("E2E_UPDATE") != "" { + if err := os.WriteFile(snapshot, data, 0o644); err != nil { //nolint:gosec // a file in the repository + t.Fatal(err) + } + } else if old, err := os.ReadFile(snapshot); err != nil || !bytes.Equal(old, data) { + t.Errorf("the backend's spec differs from %s; rerun with `make e2e-update` and review the diff", snapshot) + } + + path := t.TempDir() + "/openapi.json" + write(t, path, string(data)) + + return path +} diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go new file mode 100644 index 0000000..0b25ac6 --- /dev/null +++ b/internal/e2e/gofetch_test.go @@ -0,0 +1,196 @@ +package e2e + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// suite is one scenario as a runner runs it: where to send, the cases, and +// for each the names its args use. +type suite struct { + Name string `json:"name"` + Base string `json:"base"` + Mock bool `json:"mock"` + Auth *auth `json:"auth,omitempty"` + Cases []suiteCase `json:"cases"` + Spec string `json:"-"` + + // Schemes are the spec's security schemes by name and type (apiKey, + // http, oauth2, openIdConnect), for a client whose token function + // depends on it. + Schemes map[string]string `json:"schemes"` +} + +type suiteCase struct { + Op string `json:"op"` + Params []string `json:"params"` + Args json.RawMessage `json:"args,omitempty"` + Auth *auth `json:"auth,omitempty"` + Save map[string]string `json:"save,omitempty"` +} + +// outcome is what a runner reports for a case: the result as wire JSON, +// the *ResponseError, or why the call itself failed. +type outcome struct { + Suite string `json:"suite"` + Case int `json:"case"` + Result json.RawMessage `json:"result"` + Error *struct { + Status int `json:"status"` + Model json.RawMessage `json:"model"` + Body string `json:"body"` + } `json:"error"` + Fail string `json:"fail"` +} + +// runGoFetch generates a go-fetch client per suite into one module, builds +// testdata/e2e/runners/go with a main that hands it those clients, runs the +// suites and returns the outcomes by suite name and case. +func runGoFetch(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + goBin, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + write(t, filepath.Join(dir, "go.mod"), "module e2e\n\ngo 1.24\n") + + runner, err := os.ReadFile("../../testdata/e2e/runners/go/runner.go") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.go"), string(runner)) + + var imports, setups strings.Builder + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "go-fetch", + TemplateDir: "../../templates/go-fetch", + AdditionalProperties: []string{"packageName=" + pkg}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&imports, "\t%s \"e2e/%s\"\n", pkg, pkg) + fmt.Fprintf(&setups, "\t\t%q: func(base string, a *Auth) ([]any, any) {\n", s.Name) + fmt.Fprintf(&setups, "\t\t\tcfg := &%s.Configuration{BasePath: base, APIKey: func(context.Context, string, string) (string, error) { return a.APIKey, nil }, "+ + "AccessToken: func(context.Context, string, string, []string) (string, error) { return a.Bearer, nil }}\n\n", pkg) + fmt.Fprintf(&setups, "\t\t\treturn []any{%s}, cfg\n\t\t},\n", constructors(t, filepath.Join(dir, pkg), pkg)) + } + + write(t, filepath.Join(dir, "main.go"), "package main\n\nimport (\n\t\"context\"\n\n"+imports.String()+ + ")\n\nfunc main() {\n\trun(map[string]setup{\n"+setups.String()+"\t})\n}\n") + + build := exec.Command(goBin, "build", "-o", "runner", ".") + build.Dir = dir + + build.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build runner: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(filepath.Join(dir, "runner"), filepath.Join(dir, "input.json"))) +} + +// execRunner runs a built runner and reads its outcome lines. A runner that +// dies leaves the cases after it without an outcome; the ones before still +// count. +func execRunner(t *testing.T, cmd *exec.Cmd) map[string]map[int]outcome { + t.Helper() + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + + stdout, err := cmd.Output() + if err != nil { + t.Errorf("runner: %v\n%s", err, stderr.String()) + } + + out := map[string]map[int]outcome{} + + sc := bufio.NewScanner(bytes.NewReader(stdout)) + sc.Buffer(nil, 1<<26) + + for sc.Scan() { + var o outcome + if err := json.Unmarshal(sc.Bytes(), &o); err != nil { + t.Fatalf("runner output %q: %v", sc.Text(), err) + } + + if out[o.Suite] == nil { + out[o.Suite] = map[int]outcome{} + } + + out[o.Suite][o.Case] = o + } + + return out +} + +// constructors lists the NewAPI(cfg) calls of a generated package. +func constructors(t *testing.T, dir, pkg string) string { + t.Helper() + + files, _ := filepath.Glob(filepath.Join(dir, "api_*.go")) + + var calls []string + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + + for _, line := range strings.Split(string(data), "\n") { + if name, ok := strings.CutPrefix(line, "func New"); ok { + calls = append(calls, pkg+".New"+name[:strings.IndexByte(name, '(')]+"(cfg)") + } + } + } + + return strings.Join(calls, ", ") +} + +// writeInput writes the suites as the runner's input.json in dir. +func writeInput(t *testing.T, dir string, suites []suite) { + t.Helper() + + in, err := json.Marshal(map[string]any{"suites": suites}) + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "input.json"), string(in)) +} + +func write(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/e2e/match_test.go b/internal/e2e/match_test.go new file mode 100644 index 0000000..6d662df --- /dev/null +++ b/internal/e2e/match_test.go @@ -0,0 +1,157 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "maps" + "math/big" + "slices" + "strings" + "testing" +) + +// match compares a scenario's expectation with what arrived, the way the +// scenarios read: an object lists the keys it checks (others may be there), +// an array has exactly its items, "$any" is any value, "$absent" is a key +// that must be missing. In a request, where a query, header or form value is +// a list, a string stands for a list of just that string, and header names +// match in any case. It returns one line per difference. +func match(path string, want, got any) []string { + switch w := want.(type) { + case string: + switch w { + case "$any": + return nil + case "$absent": + return []string{path + ": want absent, got " + show(got)} + } + + if g, ok := got.([]any); ok && len(g) == 1 && strings.HasPrefix(path, "request.") { + got = g[0] + } + + if g, ok := got.(string); !ok || g != w { + return []string{path + ": want " + show(w) + ", got " + show(got)} + } + + return nil + + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + return []string{path + ": want an object, got " + show(got)} + } + + var diffs []string + + for _, k := range slices.Sorted(maps.Keys(w)) { + gv, present := g[k] + if !present && path == "request.header" { + gv, present = headerValue(g, k) + } + + switch { + case w[k] == "$absent": + if present { + diffs = append(diffs, path+"."+k+": want absent, got "+show(gv)) + } + + case !present: + diffs = append(diffs, path+"."+k+": missing, want "+show(w[k])) + default: + diffs = append(diffs, match(path+"."+k, w[k], gv)...) + } + } + + return diffs + + case []any: + g, ok := got.([]any) + if !ok || len(g) != len(w) { + return []string{path + ": want " + show(w) + ", got " + show(got)} + } + + var diffs []string + for i := range w { + diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i])...) + } + + return diffs + + case json.Number: + if g, ok := got.(json.Number); !ok || !sameNumber(w, g) { + return []string{path + ": want " + w.String() + ", got " + show(got)} + } + + return nil + } + + if want != got { + return []string{path + ": want " + show(want) + ", got " + show(got)} + } + + return nil +} + +// headerValue finds header k in any case: names on the wire are canonical +// (X-Request-Id) where a scenario may write the spec's (X-Request-ID). +func headerValue(m map[string]any, k string) (any, bool) { + for mk, v := range m { + if strings.EqualFold(mk, k) { + return v, true + } + } + + return nil, false +} + +// sameNumber compares two JSON numbers exactly: 1 and 1.0 are the same, +// 2^62+1 and 2^62 are not, nor are two integers past int64 that differ. +func sameNumber(a, b json.Number) bool { + x, okA := new(big.Rat).SetString(a.String()) + y, okB := new(big.Rat).SetString(b.String()) + + return okA && okB && x.Cmp(y) == 0 +} + +func show(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + + return string(b) +} + +// TestMatch pins the matcher's rules; it runs without E2E. +func TestMatch(t *testing.T) { + tests := []struct { + name, path, want, got string + ok bool + }{ + {"extra keys allowed", "result", `{"a":1}`, `{"a":1,"b":2}`, true}, + {"missing key", "result", `{"a":1}`, `{"b":1}`, false}, + {"key case matters in a result", "result", `{"id":"1"}`, `{"ID":"1"}`, false}, + {"header name in any case", "request.header", `{"X-Request-ID":"r"}`, `{"X-Request-Id":["r"]}`, true}, + {"string for a one-value list in a request", "request.query", `{"q":"a"}`, `{"q":["a"]}`, true}, + {"but not in a result", "result", `"stored"`, `["stored"]`, false}, + {"array exact length", "result", `[1]`, `[1,2]`, false}, + {"$any present", "result", `{"a":"$any"}`, `{"a":null}`, true}, + {"$any missing", "result", `{"a":"$any"}`, `{}`, false}, + {"$absent missing", "result", `{"a":"$absent"}`, `{}`, true}, + {"$absent null is present", "result", `{"a":"$absent"}`, `{"a":null}`, false}, + {"null is null", "result", `null`, `null`, true}, + {"null is not an object", "result", `null`, `{}`, false}, + {"1 and 1.0", "result", `1`, `1.0`, true}, + {"int64 exact", "result", `4611686018427387905`, `4611686018427387904`, false}, + {"past int64 exact", "result", `12345678901234567890`, `12345678901234567891`, false}, + {"number is not a string", "result", `1`, `"1"`, false}, + } + + for _, tt := range tests { + diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got))) + if (len(diffs) == 0) != tt.ok { + t.Errorf("%s: match(%s, %s) = %v, want ok %t", tt.name, tt.want, tt.got, diffs, tt.ok) + } + } +} diff --git a/internal/e2e/mock_test.go b/internal/e2e/mock_test.go new file mode 100644 index 0000000..25033e9 --- /dev/null +++ b/internal/e2e/mock_test.go @@ -0,0 +1,286 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "maps" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/legacy" +) + +// mock answers each case with its scenario response, after validating the +// request against the spec and recording it as the wire carried it. +type mock struct { + srv *httptest.Server + router routers.Router + cases []kase + + mu sync.Mutex + current int + received map[int]map[string]any +} + +// A JSON-lines body is text to the validator, as to a client. +func init() { + openapi3filter.RegisterBodyDecoder("application/x-ndjson", openapi3filter.PlainBodyDecoder) +} + +// newMock serves the spec at a fresh local URL. +func newMock(t *testing.T, specPath string, cases []kase) *mock { + t.Helper() + + loader := openapi3.NewLoader() + + doc, err := loader.LoadFromFile(specPath) + if err != nil { + t.Fatal(err) + } + + m := &mock{cases: cases, current: -1, received: map[int]map[string]any{}} + m.srv = httptest.NewServer(m) + t.Cleanup(m.srv.Close) + + // A server-side request carries no host: match the path alone. + doc.Servers = openapi3.Servers{{URL: "/"}} + + if m.router, err = legacy.NewRouter(doc); err != nil { + t.Fatal(err) + } + + return m +} + +func (m *mock) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if n, ok := strings.CutPrefix(r.URL.Path, "/__e2e/case/"); ok { + m.mu.Lock() + m.current, _ = strconv.Atoi(n) + m.mu.Unlock() + + return + } + + body, _ := io.ReadAll(r.Body) + got := record(r, body) + + input, err := m.validate(r.Context(), r, body) + if err != nil { + got["invalid"] = err.Error() + } + + m.mu.Lock() + i := m.current + + if _, seen := m.received[i]; !seen { + m.received[i] = got + } + m.mu.Unlock() + + if i < 0 || i >= len(m.cases) || m.cases[i].Response == nil { + http.Error(w, "no response for this case", http.StatusNotImplemented) + + return + } + + status, header, out := m.cases[i].Response.encode() + + // The scenario's response must be one the spec allows, or a case could + // pass on a body no server would send. + if input != nil && !m.cases[i].Response.OffSpec { + err := openapi3filter.ValidateResponse(r.Context(), &openapi3filter.ResponseValidationInput{ + RequestValidationInput: input, Status: status, Header: header, Body: io.NopCloser(bytes.NewReader(out)), + Options: &openapi3filter.Options{IncludeResponseStatus: true, MultiError: true}, + }) + if err != nil { + m.mu.Lock() + got["responseInvalid"] = err.Error() + m.mu.Unlock() + } + } + + maps.Copy(w.Header(), header) + w.WriteHeader(status) + _, _ = w.Write(out) +} + +// validate checks the request against its operation in the spec: path, +// parameters by style and explode, and the body by its media type. It +// returns the input the response is validated with, nil without a route. +func (m *mock) validate(ctx context.Context, r *http.Request, body []byte) (*openapi3filter.RequestValidationInput, error) { + r = r.Clone(ctx) + r.Body = io.NopCloser(bytes.NewReader(body)) + + route, pathParams, err := m.router.FindRoute(r) + if err != nil { + return nil, err + } + + input := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true}, + } + + return input, openapi3filter.ValidateRequest(ctx, input) +} + +// encode is the response as sent: the status (200 by default), headers and +// the body, its Content-Type set from its kind unless the case gives one. +func (resp *mockResponse) encode() (int, http.Header, []byte) { + status := resp.Status + if status == 0 { + status = http.StatusOK + } + + header := http.Header{} + for k, v := range resp.Header { + header.Set(k, v) + } + + var ( + body []byte + contentType string + ) + + switch { + case len(resp.JSON) > 0: + body, contentType = resp.JSON, "application/json" + case resp.Text != nil: + body, contentType = []byte(*resp.Text), "text/plain" + case resp.Base64 != "": + body, _ = base64.StdEncoding.DecodeString(resp.Base64) + contentType = "application/octet-stream" + } + + if contentType != "" && header.Get("Content-Type") == "" { + header.Set("Content-Type", contentType) + } + + return status, header, body +} + +// ignoredHeaders are set by the HTTP client itself, not by the generated +// code a scenario checks. +var ignoredHeaders = map[string]bool{"Accept-Encoding": true, "Content-Length": true, "User-Agent": true, "Cookie": true} + +// record is the request as a scenario's "request" reads it: the method, +// the decoded path, query values, headers, cookies and the body by its +// media type (JSON as JSON, a form as lists of values, multipart as lists +// of parts, text as a string, anything else as base64). +func record(r *http.Request, body []byte) map[string]any { + query := map[string]any{} + for k, vs := range r.URL.Query() { + query[k] = strs(vs) + } + + header := map[string]any{} + + for k, vs := range r.Header { + if !ignoredHeaders[k] { + header[k] = strs(vs) + } + } + + cookie := map[string]any{} + for _, c := range r.Cookies() { + cookie[c.Name] = c.Value + } + + got := map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "rawPath": r.URL.EscapedPath(), + "query": query, + "header": header, + "cookie": cookie, + } + + if len(body) > 0 { + got["body"] = recordBody(r.Header.Get("Content-Type"), body) + } + + return got +} + +func recordBody(contentType string, body []byte) any { + mt, params, _ := mime.ParseMediaType(contentType) + + switch { + case mt == "application/json" || strings.HasSuffix(mt, "+json"): + return decodeJSON(body) + case mt == "application/x-www-form-urlencoded": + values, _ := url.ParseQuery(string(body)) + + form := map[string]any{} + for k, vs := range values { + form[k] = strs(vs) + } + + return form + + case mt == "multipart/form-data": + return recordParts(body, params["boundary"]) + case strings.HasPrefix(mt, "text/"): + return string(body) + } + + return map[string]any{"$base64": base64.StdEncoding.EncodeToString(body)} +} + +// recordParts lists each multipart field's parts: a plain part as its +// text, a file as {filename, contentType, text or base64}. +func recordParts(body []byte, boundary string) any { + parts := map[string]any{} + mr := multipart.NewReader(bytes.NewReader(body), boundary) + + for { + p, err := mr.NextPart() + if err != nil { + return parts + } + + data, _ := io.ReadAll(p) + + var v any = string(data) + + if p.FileName() != "" { + f := map[string]any{"filename": p.FileName(), "contentType": p.Header.Get("Content-Type")} + if utf8.Valid(data) { + f["text"] = string(data) + } else { + f["base64"] = base64.StdEncoding.EncodeToString(data) + } + + v = f + } else if mt, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")); mt == "application/json" || strings.HasSuffix(mt, "+json") { + v = map[string]any{"json": decodeJSON(data)} + } + + list, _ := parts[p.FormName()].([]any) + parts[p.FormName()] = append(list, v) + } +} + +func strs(vs []string) []any { + out := make([]any, len(vs)) + for i, v := range vs { + out[i] = v + } + + return out +} diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go new file mode 100644 index 0000000..0ab7853 --- /dev/null +++ b/internal/e2e/scenario_test.go @@ -0,0 +1,166 @@ +// Package e2e runs generated clients against servers: a mock that validates +// every request against the spec and records it, and real backends. The +// cases are the language-neutral scenarios in testdata/e2e/scenarios; each +// generator has a runner that turns them into calls of its client. Run with +// `make e2e`. +package e2e + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "go.yaml.in/yaml/v3" +) + +// scenario is one testdata/e2e/scenarios file: the cases for one spec, sent +// to the mock (spec) or to a running backend (backend). +type scenario struct { + Name string `json:"-"` + Spec string `json:"spec"` + Backend string `json:"backend"` + Auth *auth `json:"auth"` + Cases []kase `json:"cases"` + + // Skip skips every case for a generator, one whose client for the spec + // does not even compile; a case's own skip says more. + Skip map[string]string `json:"skip"` +} + +type auth struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Bearer string `json:"bearer,omitempty"` +} + +// kase is one call: the operation and its arguments by the spec's names, +// what the mock must receive and answer, and what the client must return. +type kase struct { + Name string `json:"name"` + Op string `json:"op"` + Args json.RawMessage `json:"args"` + Auth *auth `json:"auth"` + Save map[string]string `json:"save"` + Request json.RawMessage `json:"request"` + Response *mockResponse `json:"response"` + Result json.RawMessage `json:"result"` // null is a null result, absent is unchecked + Error json.RawMessage `json:"error"` + Skip map[string]string `json:"skip"` +} + +// mockResponse is what the mock answers a case with: a status, headers and +// one of a JSON, text or base64 body. OffSpec marks one the spec does not +// allow on purpose (an undeclared status), which is otherwise an error. +type mockResponse struct { + OffSpec bool `json:"offSpec"` + Status int `json:"status"` + Header map[string]string `json:"header"` + JSON json.RawMessage `json:"json"` + Text *string `json:"text"` + Base64 string `json:"base64"` +} + +const scenarioDir = "../../testdata/e2e/scenarios" + +// loadScenarios reads every scenario file, YAML turned into JSON so the +// values compare as the wire's (numbers as json.Number). +func loadScenarios(t *testing.T) []*scenario { + t.Helper() + + files, err := filepath.Glob(filepath.Join(scenarioDir, "*.yaml")) + if err != nil || len(files) == 0 { + t.Fatalf("no scenarios in %s: %v", scenarioDir, err) + } + + var out []*scenario + + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + var y any + if err := yaml.Unmarshal(data, &y); err != nil { + t.Fatalf("%s: %v", file, err) + } + + j, err := json.Marshal(y) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + + s := &scenario{Name: strings.TrimSuffix(filepath.Base(file), ".yaml")} + + dec := json.NewDecoder(bytes.NewReader(j)) + dec.UseNumber() + dec.DisallowUnknownFields() + + if err := dec.Decode(s); err != nil { + t.Fatalf("%s: %v", file, err) + } + + if s.Spec != "" { + s.Spec = filepath.Join(scenarioDir, s.Spec) + } + + out = append(out, s) + } + + return out +} + +// opParams lists, per operationId, the names a case's args may use: the +// parameters and, for a form body, its properties. A runner uses them to +// tell the body's field from the parameters'. +func opParams(doc *openapi3.T) map[string][]string { + out := map[string][]string{} + + for _, item := range doc.Paths.Map() { + for _, op := range item.Operations() { + var names []string + + for _, p := range slices.Concat(item.Parameters, op.Parameters) { + if p.Value != nil { + names = append(names, p.Value.Name) + } + } + + if op.RequestBody != nil && op.RequestBody.Value != nil { + for ct, mt := range op.RequestBody.Value.Content { + form := strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" + if form && mt.Schema != nil && mt.Schema.Value != nil { + for name := range mt.Schema.Value.Properties { + names = append(names, name) + } + } + } + } + + slices.Sort(names) + out[op.OperationID] = names + } + } + + return out +} + +// decodeJSON decodes JSON keeping numbers exact. +func decodeJSON(b []byte) any { + var v any + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + if err := dec.Decode(&v); err != nil { + return string(b) + } + + return v +} diff --git a/internal/e2e/typescript_test.go b/internal/e2e/typescript_test.go new file mode 100644 index 0000000..6b0bdd5 --- /dev/null +++ b/internal/e2e/typescript_test.go @@ -0,0 +1,84 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// runTypeScript generates a typescript-fetch client per suite, compiles +// them with testdata/e2e/runners/ts to CommonJS and runs the suites with +// node. +func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + tsc, err := exec.LookPath("tsc") + if err != nil { + t.Fatal("the typescript-fetch runner compiles with tsc (npm i -g typescript)") + } + + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("the typescript-fetch runner runs with node 22 or later") + } + + dir := t.TempDir() + + runner, err := os.ReadFile("../../testdata/e2e/runners/ts/runner.ts") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.ts"), string(runner)) + + var main strings.Builder + + main.WriteString("import { run } from \"./runner\";\n") + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "typescript-fetch", + TemplateDir: "../../templates/typescript-fetch", + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&main, "import * as %s from \"./%s/index\";\n", pkg, pkg) + } + + main.WriteString("\nrun({\n") + + for i, s := range suites { + fmt.Fprintf(&main, " %q: { module: c%d, dir: %q },\n", s.Name, i, filepath.Join(dir, "c"+strconv.Itoa(i))) + } + + main.WriteString("});\n") + write(t, filepath.Join(dir, "main.ts"), main.String()) + write(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions": {"target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], + "outDir": "dist", "strict": false, "skipLibCheck": true, "noEmitOnError": false}, "files": ["main.ts"]}`) + + // Type errors in a client are its generator's; the JavaScript is + // emitted anyway and the cases show what they break. + build := exec.Command(tsc, "-p", ".") + build.Dir = dir + + if out, err := build.CombinedOutput(); err != nil { + t.Logf("tsc: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(node, filepath.Join(dir, "dist", "main.js"), filepath.Join(dir, "input.json"))) +} diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md new file mode 100644 index 0000000..46fec84 --- /dev/null +++ b/testdata/e2e/README.md @@ -0,0 +1,138 @@ +# End-to-end tests + +Generated clients make real calls. Each call passes through the client's +request building, the HTTP wire and its response decoding, and the test +checks what arrived, not only the status code. + +```sh +make e2e # every generator, both layers +E2E_GENERATOR=go-fetch make e2e # one generator +E2E_DART=~/dart-sdk/bin/dart make e2e # a Dart other than the one on PATH +make e2e-update # rewrites specs/fastapi.json from the backend +``` + +`go test ./...` skips these tests; `make e2e` sets `E2E=1`. Tools: + +| Generator | Needs | +| ------------------ | --------------------------------------------------------------------------- | +| all | [uv](https://docs.astral.sh/uv/) for the FastAPI backend | +| `go-fetch` | Go | +| `typescript-fetch` | `tsc` (`npm i -g typescript`) and Node 22 or later (its `fetch`, `File`) | +| `dart-fetch` | Dart 3.13 or later, which the generated pubspec asks for; skipped with less | + +A run takes about 7 s with warm caches. + +## Layers + +| Layer | Server | Spec | Checks | +| ------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Mock | `internal/e2e`: validates each request against the spec (kin-openapi), records it, answers with the case's `response` | a feature spec, `testdata/specs/feature` | the request is valid and arrives as `request` says; the client returns `result` or `error` from the response | +| Backend | `backends/fastapi`: a strict FastAPI app (pydantic rejects extra keys) that echoes what it parsed | the one the server serves, compared with `specs/fastapi.json` | the client returns `result` or `error`; the server's validation and echo show what it received | + +The mock layer covers every parameter style and body encoding the feature +specs declare. The backend shows the shapes a framework writes into its +spec: pydantic's separate input and output schemas, OpenAPI 3.1 null unions, +`const`, Decimal as number or string, and FastAPI's empty JSON schema next +to another media type. + +## Scenarios + +`scenarios/.yaml` holds one spec's cases. Every value in it is in +wire terms, with names, JSON and media types as the spec has them. The cases +therefore do not depend on a generator's naming or language. + +```yaml +spec: ../../specs/feature/parameters-locations.yaml # mock layer; or: backend: fastapi +auth: { username: u, password: p, apiKey: k, bearer: t } +cases: + - name: path, query, header and cookie + op: getItem # operationId + args: { orgId: acme/eu, itemId: 42, X-Request-Id: r-1, body: {...} } + request: # mock only: what the server must receive + path: /orgs/acme/eu/items/42 # unescaped + query: { verbose: "false" } # a string stands for a one-value list + header: { X-Request-Id: r-1 } + cookie: { sessionId: s-1 } + body: {...} # JSON; a form as lists; multipart parts + response: { status: 200, json: { id: "42" } } # mock only; or text, base64, header + result: { id: "42" } # the client's result as wire JSON; null is null + error: { status: 404, model: {...}, body: "..." } + save: { token: access_token } # ${token} in later args and auth + skip: { go-fetch: why it fails } # a known gap; fails once it passes +``` + +The body argument is `body`; a form's fields are arguments of their own. +A file is `{ $file: { name, text | base64 } }` and a raw body is +`{ $bytes: { base64 } }`. + +**Matching.** An expected object lists the keys it checks, and other keys +may be present. An array must hold exactly the listed items. `$any` matches +any value, `$absent` requires a missing key, and numbers compare exactly +(int64 and beyond). In a `request`, a string also matches a one-value list, +and header names match in any case. + +**What the mock records.** +- The path both unescaped (`path`) and as sent (`rawPath`). +- A body only when one was sent: JSON as JSON, a form as lists of values, a + multipart field as a list of parts, text as a string, anything else as + `{ $base64 }`. A file part arrives as + `{ filename, contentType, text | base64 }` (text when its bytes are UTF-8), + a JSON part as `{ json: ... }`. + +Every request must pass the spec's validation, and so must the scenario's +`response`, unless it sets `offSpec: true` (an undeclared status on purpose). +A backend scenario has no `request` or `response`: the server answers and +validates. + +**Portable values.** A case states what every generator should do. A value +that a language cannot represent the same way belongs in a case of its own, +which that generator's runner skips: an int64 above 2^53 in JavaScript, a +time with microseconds in a JavaScript `Date`, an error body a client does +not decode. + +## Runners + +A runner turns the scenarios into calls of one generator's client and uses +the client the way its users do. `internal/e2e` generates every suite's +client, builds one program that holds them all and runs it, then compares +what the program prints with the scenarios. + +| Runner | Client | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runners/go` | go-fetch: finds methods, `Params` fields and results by reflection; standard library only | +| `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | +| `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | + +typescript-fetch sends an OAuth2 token as the whole `Authorization` header, +so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the +input). Its error has no decoded model: the runner reports the error body +parsed as JSON. A case one generator cannot pass has +`skip: { : why }`. A spec whose client does not compile at all +has the same `skip` at the top of its file. + +A runner for another generator follows the same protocol: + +1. **Input** is a JSON file given as the first argument: + `{ suites: [{ name, base, mock, auth, schemes, cases: [{ op, params, args, auth, save }] }] }`. + - `params` lists the names the operation's args may use (parameters and + form fields); any other arg is `body`. + - A case's `auth` fields replace those before it for the rest of the + suite. +2. **Before each case** of a `mock` suite, the runner sends + `POST {base}/__e2e/case/{index}` so that the mock knows which case the + next request belongs to. +3. **Arguments.** `${name}` in `args` and `auth` is a value an earlier + case saved: + - `save: { name: path }` takes a dotted path into the result + (`items.0.id`). + - A string is saved as it is, anything else as its JSON. + - A name never saved fails the case. +4. **Output** is one JSON line per case, `{ suite, case, result | error | fail }`: + - `result` is the client's result as wire JSON; a binary result is a + base64 string, and a call without one is `null`. + - `error` is `{ status, model, body }` from the client's error for a + status (`model` is the decoded error body, or absent). + - `fail` says why the call did not happen or failed otherwise (an + unknown arg, a transport error, a panic). + + A case the runner cannot handle fails that case only, not the run. diff --git a/testdata/e2e/backends/fastapi/.gitignore b/testdata/e2e/backends/fastapi/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/testdata/e2e/backends/fastapi/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py new file mode 100644 index 0000000..75ecc33 --- /dev/null +++ b/testdata/e2e/backends/fastapi/app.py @@ -0,0 +1,752 @@ +"""Warehouse: a strict FastAPI backend that puts every common OpenAPI shape +into one spec, for testing generated clients end to end (internal/e2e). + +Every request is validated by pydantic (extra keys rejected, 422 on any +mismatch). Most endpoints echo what they parsed, so a client can check the +exact values that arrived, not only the status code.""" + +import datetime as dt +import uuid +from decimal import Decimal +from enum import Enum, IntEnum +from ipaddress import IPv4Address +from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union + +from fastapi import ( + Body, + Cookie, + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Path, + Query, + Request, + Response, + Security, + UploadFile, +) +from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse +from fastapi.security import ( + APIKeyCookie, + APIKeyHeader, + APIKeyQuery, + HTTPAuthorizationCredentials, + HTTPBasic, + HTTPBasicCredentials, + HTTPBearer, + OAuth2PasswordBearer, + OAuth2PasswordRequestForm, + SecurityScopes, +) +from pydantic import ( + AliasGenerator, + BaseModel, + ConfigDict, + EmailStr, + Field, + HttpUrl, + SecretStr, + computed_field, +) +from pydantic.alias_generators import to_camel + +app = FastAPI( + title="Warehouse", + version="2.0.0", + description="Every common OpenAPI shape in one strict API.", +) + + +# ---------------------------------------------------------------- base models + + +class Strict(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Camel(BaseModel): + """camelCase on the wire, snake_case in Python, as most JSON APIs.""" + + model_config = ConfigDict(extra="forbid", alias_generator=AliasGenerator(to_camel), populate_by_name=True) + + +# ---------------------------------------------------------------- enums + + +class Color(str, Enum): + red = "red" + dark_blue = "dark-blue" + light_green = "light green" + n2 = "2nd" + type_ = "type" + empty = "" + + +class Priority(IntEnum): + low = 1 + mid = 5 + high = 10 + + +class Unit(str, Enum): + kg = "kg" + lb = "lb" + + +# ---------------------------------------------------------------- scalars + + +class Scalars(Strict): + """Every scalar format pydantic writes into a schema.""" + + i: int + i64: Annotated[int, Field(ge=-(2**63), le=2**63 - 1)] + f: float + d: Decimal + b: bool + s: Annotated[str, Field(min_length=1, max_length=40, pattern=r"^[^\x00]+$")] + when: dt.datetime + day: dt.date + clock: dt.time + span: dt.timedelta + id: uuid.UUID + email: EmailStr + url: HttpUrl + ip: IPv4Address + blob: bytes + anything: Any = None + color: Color + priority: Priority + unit: Literal["kg", "lb"] = "kg" + + +# ---------------------------------------------------------------- containers + + +class Dimensions(Camel): + width_cm: float + height_cm: float + depth_cm: Optional[float] = None + + +class Containers(Strict): + tags: list[str] + unique: set[str] = set() + pair: tuple[int, str] + matrix: list[list[int]] = [] + sparse: list[Optional[int]] = [] + counts: dict[str, int] = {} + groups: dict[str, list[str]] = {} + by_name: dict[str, Dimensions] = {} + maybe_list: Optional[list[str]] = None + maybe_map: Optional[dict[str, Optional[str]]] = None + + +# ---------------------------------------------------------------- items + + +class Supplier(Camel): + name: str + contact_email: Optional[EmailStr] = None + website: Optional[HttpUrl] = None + + +class ItemIn(Camel): + """An item as a client sends it.""" + + sku: Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$", examples=["ABC-1234"])] + name: str + description: Optional[str] = Field(default=None, description="Free text; null clears it.") + price: Decimal + weight: Optional[float] = None + unit: Unit = Unit.kg + color: Optional[Color] = None + priority: Priority = Priority.mid + tags: list[str] = [] + dimensions: Optional[Dimensions] = None + supplier: Optional[Supplier] = None + attributes: dict[str, str] = {} + discontinued: bool = Field(default=False, deprecated=True) + + +class Item(ItemIn): + """An item as the server returns it.""" + + id: uuid.UUID + created_at: dt.datetime + updated_at: Optional[dt.datetime] = None + version: int + + @computed_field + @property + def display_name(self) -> str: + return f"{self.name} ({self.sku})" + + +class ItemPatch(Camel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[Decimal] = None + color: Optional[Color] = None + tags: Optional[list[str]] = None + dimensions: Optional[Dimensions] = None + + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + next_cursor: Optional[str] = None + + +# ---------------------------------------------------------------- unions + + +class Card(Strict): + method: Literal["card"] + last4: Annotated[str, Field(pattern=r"^\d{4}$")] + expires: dt.date + + +class Transfer(Strict): + method: Literal["transfer"] + iban: str + reference: Optional[str] = None + + +class Voucher(Strict): + method: Literal["voucher"] + code: str + amount: Decimal + + +Payment = Annotated[Union[Card, Transfer, Voucher], Field(discriminator="method")] + + +class Circle(Strict): + radius: float + + +class Rect(Strict): + width: float + height: float + + +class OrderLine(Camel): + sku: str + quantity: Annotated[int, Field(gt=0)] + note: Optional[str] = None + + +class OrderIn(Camel): + lines: Annotated[list[OrderLine], Field(min_length=1)] + payment: Payment + shape: Optional[Union[Circle, Rect]] = None + gift: Union[bool, str] = False + code: Optional[Union[int, str]] = None + extra_payments: list[Payment] = [] + + +class OrderStatus(str, Enum): + placed = "placed" + paid = "paid" + shipped = "shipped" + + +class Order(OrderIn): + id: int + status: OrderStatus + placed_at: dt.datetime + + +# ---------------------------------------------------------------- recursion, open objects, keywords + + +class Category(Strict): + name: str + children: list["Category"] = [] + parent: Optional["Category"] = None + + +class Open(BaseModel): + """Takes any extra key and returns it.""" + + model_config = ConfigDict(extra="allow") + kind: str + + +class Keywords(BaseModel): + """Property names that are keywords or predeclared in Go.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + type: str + func: int + range: list[int] + map: dict[str, int] + interface: Optional[str] = None + package: str = "main" + default: bool = False + select: Optional[str] = None + string: str = Field(alias="string") + error_: str = Field(alias="error") + dash_name: str = Field(alias="x-dash-name") + dot_name: str = Field(alias="dot.name") + upper: str = Field(alias="UPPER") + digit: str = Field(alias="1st") + + +class Secret(Strict): + username: str + password: SecretStr + + +# ---------------------------------------------------------------- errors + + +class Problem(Strict): + type: str + title: str + status: int + detail: Optional[str] = None + + +class Conflict(Strict): + message: str + current_version: int + + +# ---------------------------------------------------------------- state + +ITEMS: dict[uuid.UUID, Item] = {} +ORDERS: dict[int, Order] = {} +FILES: dict[str, bytes] = {} + +# ---------------------------------------------------------------- security + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={"items:read": "Read items", "items:write": "Write items", "orders": "Place orders"}, +) +bearer = HTTPBearer(auto_error=False) +basic = HTTPBasic() +key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +key_query = APIKeyQuery(name="api_key", auto_error=False) +key_cookie = APIKeyCookie(name="session", auto_error=False) + +TOKENS = {"tok-rw": ["items:read", "items:write", "orders"], "tok-r": ["items:read"]} + + +def user(scopes: SecurityScopes, token: str = Depends(oauth2)) -> str: + granted = TOKENS.get(token) + if granted is None: + raise HTTPException(401, "bad token", headers={"WWW-Authenticate": "Bearer"}) + for s in scopes.scopes: + if s not in granted: + raise HTTPException(403, f"missing scope {s}") + return token + + +def any_key( + h: Optional[str] = Depends(key_header), + q: Optional[str] = Depends(key_query), + c: Optional[str] = Depends(key_cookie), +) -> str: + for where, v in (("header", h), ("query", q), ("cookie", c)): + if v == "key-1": + return where + raise HTTPException(401, "no api key") + + +def admin(c: HTTPBasicCredentials = Depends(basic)) -> str: + if (c.username, c.password) != ("admin", "p@ss:w0rd"): + raise HTTPException(401, "bad credentials") + return c.username + + +def maybe_bearer(c: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[str]: + return c.credentials if c else None + + +def problem(status: int, title: str, detail: str = "") -> JSONResponse: + return JSONResponse({"type": "about:blank", "title": title, "status": status, "detail": detail or None}, status) + + +not_found = {404: {"model": Problem, "description": "No such resource"}} + +# ---------------------------------------------------------------- auth + + +@app.post("/token", tags=["auth"], operation_id="issueToken") +def issue_token(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]: + if (form.username, form.password) == ("alice", "secret"): + return {"access_token": "tok-rw", "token_type": "bearer", "scope": " ".join(form.scopes)} + if (form.username, form.password) == ("bob", "secret"): + return {"access_token": "tok-r", "token_type": "bearer", "scope": " ".join(form.scopes)} + raise HTTPException(400, "bad login") + + +@app.get("/whoami", tags=["auth"], operation_id="whoami") +def whoami(where: str = Depends(any_key), token: Optional[str] = Depends(maybe_bearer)) -> dict[str, Optional[str]]: + return {"key_in": where, "bearer": token} + + +@app.get("/admin/stats", tags=["auth"], operation_id="adminStats") +def admin_stats(_: str = Depends(admin)) -> dict[str, int]: + return {"items": len(ITEMS), "orders": len(ORDERS)} + + +# ---------------------------------------------------------------- echo: scalars and containers + + +@app.post("/echo/scalars", tags=["echo"], operation_id="echoScalars") +def echo_scalars(body: Scalars) -> Scalars: + return body + + +@app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") +def echo_containers(body: Containers) -> Containers: + return body + + +@app.post("/echo/keywords", tags=["echo"], operation_id="echoKeywords") +def echo_keywords(body: Keywords) -> Keywords: + return body + + +@app.post("/echo/open", tags=["echo"], operation_id="echoOpen") +def echo_open(body: Open) -> Open: + return body + + +@app.post("/echo/tree", tags=["echo"], operation_id="echoTree") +def echo_tree(body: Category) -> Category: + return body + + +@app.post("/echo/secret", tags=["echo"], operation_id="echoSecret") +def echo_secret(body: Secret) -> dict[str, Union[str, int]]: + return {"username": body.username, "password_length": len(body.password.get_secret_value())} + + +@app.post("/echo/list", tags=["echo"], operation_id="echoList") +def echo_list(body: list[Dimensions]) -> list[Dimensions]: + return body + + +@app.post("/echo/map", tags=["echo"], operation_id="echoMap") +def echo_map(body: dict[str, list[int]]) -> dict[str, int]: + return {k: sum(v) for k, v in body.items()} + + +@app.post("/echo/number", tags=["echo"], operation_id="echoNumber") +def echo_number(body: Annotated[float, Body()]) -> float: + return body * 2 + + +@app.post("/echo/text", tags=["echo"], operation_id="echoText", response_class=PlainTextResponse, + openapi_extra={"requestBody": {"required": True, "content": {"text/plain": {"schema": {"type": "string"}}}}}) +async def echo_text(request: Request) -> str: + return (await request.body()).decode()[::-1] + + +@app.post("/echo/raw", tags=["echo"], operation_id="echoRaw", + openapi_extra={"requestBody": {"required": True, "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + response_class=Response) +async def echo_raw(request: Request) -> Response: + data = await request.body() + return Response(bytes(reversed(data)), media_type="application/octet-stream") + + +@app.post("/echo/union", tags=["echo"], operation_id="echoUnion") +def echo_union(body: Union[int, str, list[int]]) -> dict[str, str]: + return {"type": type(body).__name__, "value": str(body)} + + +@app.post("/echo/optional", tags=["echo"], operation_id="echoOptional") +def echo_optional(body: Annotated[Optional[Dimensions], Body()] = None) -> dict[str, bool]: + return {"got": body is not None} + + +# ---------------------------------------------------------------- parameters + + +@app.get("/params/path/{id}/{day}/{color}/{rest:path}", tags=["params"], operation_id="pathParams") +def path_params(id: int, day: dt.date, color: Color, rest: str) -> dict[str, str]: + return {"id": str(id), "day": day.isoformat(), "color": color.value, "rest": rest} + + +@app.get("/params/query", tags=["params"], operation_id="queryParams") +def query_params( + q: str, + page_size: Annotated[int, Query(alias="page-size", ge=1, le=100)] = 20, + tags: Annotated[list[str], Query()] = [], + ids: Annotated[Optional[list[int]], Query()] = None, + colors: Annotated[list[Color], Query()] = [], + since: Optional[dt.datetime] = None, + ratio: Optional[float] = None, + active: Optional[bool] = None, + old: Annotated[Optional[str], Query(deprecated=True)] = None, +) -> dict[str, Any]: + return { + "q": q, + "pageSize": page_size, + "tags": tags, + "ids": ids, + "colors": [c.value for c in colors], + "since": since.isoformat() if since else None, + "ratio": ratio, + "active": active, + "old": old, + } + + +@app.get("/params/headers", tags=["params"], operation_id="headerParams") +def header_params( + x_request_id: Annotated[uuid.UUID, Header()], + x_retry: Annotated[Optional[int], Header()] = None, + x_flags: Annotated[list[str], Header()] = [], + accept_language: Annotated[Optional[str], Header()] = None, + tenant: Annotated[Optional[str], Cookie()] = None, + theme: Annotated[Optional[str], Cookie()] = None, +) -> dict[str, Any]: + return { + "requestId": str(x_request_id), + "retry": x_retry, + "flags": x_flags, + "lang": accept_language, + "tenant": tenant, + "theme": theme, + } + + +# ---------------------------------------------------------------- items CRUD + + +@app.post( + "/items", + status_code=201, + tags=["items"], + operation_id="createItem", + responses={ + 201: {"headers": {"Location": {"schema": {"type": "string"}, "description": "URL of the item"}}}, + 409: {"model": Problem, "description": "SKU taken"}, + }, +) +def create_item(body: ItemIn, response: Response, _: str = Security(user, scopes=["items:write"])) -> Item: + if any(i.sku == body.sku for i in ITEMS.values()): + return problem(409, "SKU taken", body.sku) + item = Item(id=uuid.uuid4(), created_at=dt.datetime.now(dt.timezone.utc), version=1, **body.model_dump()) + ITEMS[item.id] = item + response.headers["Location"] = f"/items/{item.id}" + return item + + +@app.get("/items", tags=["items"], operation_id="listItems") +def list_items( + color: Optional[Color] = None, + min_price: Optional[Decimal] = None, + cursor: Optional[str] = None, + limit: Annotated[int, Query(ge=1, le=50)] = 10, + _: str = Security(user, scopes=["items:read"]), +) -> Page[Item]: + items = sorted(ITEMS.values(), key=lambda i: i.sku) + items = [i for i in items if (color is None or i.color == color) and (min_price is None or i.price >= min_price)] + start = int(cursor or 0) + chunk = items[start : start + limit] + nxt = str(start + limit) if start + limit < len(items) else None + return Page[Item](items=chunk, total=len(items), next_cursor=nxt) + + +@app.get("/items/{item_id}", tags=["items"], operation_id="getItem", responses=not_found) +def get_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + return ITEMS[item_id] + + +@app.patch( + "/items/{item_id}", + tags=["items"], + operation_id="patchItem", + responses={**not_found, 409: {"model": Conflict, "description": "Stale version"}}, +) +def patch_item( + item_id: uuid.UUID, + body: ItemPatch, + if_match: Annotated[int, Header()], + _: str = Security(user, scopes=["items:write"]), +) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + cur = ITEMS[item_id] + if if_match != cur.version: + return JSONResponse({"message": "stale", "current_version": cur.version}, 409) + data = cur.model_dump() + sent = body.model_dump(exclude_unset=True) + for k in ("name", "price"): + if k in sent and sent[k] is None: + raise HTTPException(400, f"{k} may not be null") + data.update(sent) + data.update(version=cur.version + 1, updated_at=dt.datetime.now(dt.timezone.utc)) + data.pop("display_name", None) + ITEMS[item_id] = Item(**data) + return ITEMS[item_id] + + +@app.delete("/items/{item_id}", status_code=204, tags=["items"], operation_id="deleteItem", responses=not_found) +def delete_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:write"])): + if ITEMS.pop(item_id, None) is None: + return problem(404, "Not found", str(item_id)) + return Response(status_code=204) + + +@app.head("/items/{item_id}", tags=["items"], operation_id="itemExists", responses={404: {"description": "absent"}}) +def item_exists(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])): + return Response(status_code=200 if item_id in ITEMS else 404) + + +# ---------------------------------------------------------------- files + + +@app.post("/files", status_code=201, tags=["files"], operation_id="uploadFiles") +def upload_files( + files: Annotated[list[UploadFile], File(description="One or more files")], + folder: Annotated[str, Form()], + labels: Annotated[list[str], Form()] = [], + overwrite: Annotated[bool, Form()] = False, + meta: Annotated[Optional[str], Form(description="JSON text")] = None, + _: str = Depends(any_key), +) -> list[dict[str, Union[str, int]]]: + out = [] + for f in files: + data = f.file.read() + name = f"{folder}/{f.filename}" + if name in FILES and not overwrite: + raise HTTPException(409, f"{name} exists") + FILES[name] = data + out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": ",".join(labels), "meta": meta or ""}) + return out + + +@app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") +def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: + return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} + + +@app.get( + "/files/{name:path}", + tags=["files"], + operation_id="downloadFile", + response_class=Response, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, **not_found}, +) +def download_file(name: str): + if name not in FILES: + return problem(404, "Not found", name) + return Response(FILES[name], media_type="application/octet-stream") + + +@app.get("/export.csv", tags=["files"], operation_id="exportCsv", response_class=PlainTextResponse, + responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +def export_csv() -> PlainTextResponse: + rows = ["sku,name"] + [f"{i.sku},{i.name}" for i in sorted(ITEMS.values(), key=lambda i: i.sku)] + return PlainTextResponse("\n".join(rows) + "\n", media_type="text/csv") + + +# ---------------------------------------------------------------- orders + + +@app.post( + "/orders", + status_code=201, + tags=["orders"], + operation_id="placeOrder", + responses={202: {"model": Problem, "description": "Queued for review"}, 422: {"description": "Invalid"}}, +) +def place_order( + body: OrderIn, + dry_run: bool = False, + idempotency_key: Annotated[Optional[str], Header()] = None, + _: str = Security(user, scopes=["orders"]), +) -> Order: + total = sum(line.quantity for line in body.lines) + if total > 100: + return problem(202, "Queued for review", f"{total} units") + order = Order(id=len(ORDERS) + 1, status=OrderStatus.placed, placed_at=dt.datetime.now(dt.timezone.utc), **body.model_dump()) + if not dry_run: + ORDERS[order.id] = order + return order + + +@app.get("/orders/{order_id}", tags=["orders"], operation_id="getOrder", responses=not_found) +def get_order(order_id: Annotated[int, Path(ge=1)], _: str = Security(user, scopes=["orders"])) -> Order: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id] + + +@app.get("/orders/{order_id}/status", tags=["orders"], operation_id="orderStatus", responses=not_found) +def order_status(order_id: int, _: str = Security(user, scopes=["orders"])) -> OrderStatus: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id].status + + +@app.post("/orders/{order_id}/cancel", status_code=202, tags=["orders"], operation_id="cancelOrder", + responses={202: {"description": "Cancellation accepted; no body"}, **not_found}) +def cancel_order(order_id: int, reason: Annotated[Optional[str], Body(embed=True)] = None, _: str = Security(user, scopes=["orders"])): + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return Response(status_code=202) + + +# ---------------------------------------------------------------- misc + + +@app.get("/categories", tags=["misc"], operation_id="categoryTree") +def category_tree() -> list[Category]: + root = Category(name="root", children=[Category(name="a", children=[Category(name="a1")]), Category(name="b")]) + return [root] + + +@app.get("/count", tags=["misc"], operation_id="countItems") +def count_items() -> int: + return len(ITEMS) + + +@app.get("/maybe", tags=["misc"], operation_id="maybeItem") +def maybe_item(present: bool = False) -> Optional[Dimensions]: + return Dimensions(width_cm=1, height_cm=2) if present else None + + +@app.get("/legacy", tags=["misc"], deprecated=True) +def legacy_endpoint(x: int = 1) -> dict[str, int]: + """A deprecated operation with FastAPI's generated operationId.""" + return {"x": x} + + +@app.get("/redirect", tags=["misc"], operation_id="redirectToCount", response_class=RedirectResponse, status_code=307) +def redirect() -> RedirectResponse: + return RedirectResponse("/count", status_code=307) + + +@app.get("/stream", tags=["misc"], operation_id="streamLines", + responses={200: {"content": {"application/x-ndjson": {"schema": {"type": "string"}}}}}) +def stream_lines(n: int = 3) -> StreamingResponse: + return StreamingResponse((f'{{"i":{i}}}\n' for i in range(n)), media_type="application/x-ndjson") + + +@app.get("/numbers/{a}/{b}", tags=["misc"], operation_id="add") +def add(a: int, b: float, round_: Annotated[bool, Query(alias="round")] = False) -> float: + return round(a + b) if round_ else a + b + + +@app.get("/untagged", operation_id="untagged") +def untagged() -> dict[str, bool]: + return {"ok": True} diff --git a/testdata/e2e/backends/fastapi/pyproject.toml b/testdata/e2e/backends/fastapi/pyproject.toml new file mode 100644 index 0000000..7d4d0f2 --- /dev/null +++ b/testdata/e2e/backends/fastapi/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "e2e-fastapi" +version = "0" +description = "The FastAPI backend of the e2e tests (internal/e2e)." +requires-python = ">=3.12" +dependencies = [ + "fastapi==0.162.0", + "pydantic[email]==2.14.1", + "python-multipart", + "uvicorn", +] + +[tool.uv] +package = false diff --git a/testdata/e2e/backends/fastapi/uv.lock b/testdata/e2e/backends/fastapi/uv.lock new file mode 100644 index 0000000..c800670 --- /dev/null +++ b/testdata/e2e/backends/fastapi/uv.lock @@ -0,0 +1,313 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + +[[package]] +name = "e2e-fastapi" +version = "0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic", extra = ["email"] }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.162.0" }, + { name = "pydantic", extras = ["email"], specifier = "==2.14.1" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.162.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] diff --git a/testdata/e2e/runners/dart/runner.dart b/testdata/e2e/runners/dart/runner.dart new file mode 100644 index 0000000..c73e74d --- /dev/null +++ b/testdata/e2e/runners/dart/runner.dart @@ -0,0 +1,217 @@ +// Runner of the e2e scenarios for dart-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.dart, written for each run, hands it a +// factory of each client's Configuration and APIs; dart:mirrors finds the +// methods, their named parameters and the models' fromJson/toJson, so it +// runs on the VM (`dart run`), not compiled ahead of time. +import 'dart:convert'; +import 'dart:io'; +import 'dart:mirrors'; + +class Auth { + String? username, password, apiKey, bearer; + + Auth.fromJson(Map? j) + : username = j?['username'], + password = j?['password'], + apiKey = j?['apiKey'], + bearer = j?['bearer']; + + void merge(Auth o) { + username = o.username?.isNotEmpty == true ? o.username : username; + password = o.password?.isNotEmpty == true ? o.password : password; + apiKey = o.apiKey?.isNotEmpty == true ? o.apiKey : apiKey; + bearer = o.bearer?.isNotEmpty == true ? o.bearer : bearer; + } +} + +/// Client is one generated package: its Configuration for a base URL and +/// credentials, and its API objects for a Configuration. +class Client { + Client(this.configuration, this.apis, this.package); + + final Object Function(String base, Auth auth) configuration; + final List Function(Object cfg) apis; + final String package; +} + +String norm(String s) => s.replaceAll(RegExp(r'[^\p{L}\p{N}]', unicode: true), '').toLowerCase(); + +String expand(String s, Map vars, bool json) => + s.replaceAllMapped(RegExp(r'\$\{(\w+)\}'), (m) { + final v = vars[m[1]]; + if (v == null) throw StateError('${m[0]} was never saved'); + if (!json) return v; + final e = jsonEncode(v); + return e.substring(1, e.length - 1); + }); + +/// fromWire turns a wire value into what a parameter of type t takes. +Object? fromWire(TypeMirror t, Object? v) { + if (v == null) return null; + + if (v is Map && (v.containsKey(r'$file') || v.containsKey(r'$bytes'))) { + final c = (v[r'$file'] ?? v[r'$bytes']) as Map; + return c['base64'] != null ? base64Decode(c['base64'] as String) : utf8.encode((c['text'] ?? '') as String); + } + + if (t is ClassMirror && t.typeArguments.isNotEmpty) { + if (t.isSubtypeOf(reflectType(List)) && v is List) { + final item = t.typeArguments.first; + return _typedList(item, v.map((x) => fromWire(item, x)).toList()); + } + + if (t.isSubtypeOf(reflectType(Map)) && v is Map) { + final value = t.typeArguments.last; + return _typedMap(value, {for (final e in v.entries) e.key as String: fromWire(value, e.value)}); + } + } + + if (t is ClassMirror) { + if (t.reflectedType == DateTime && v is String) return DateTime.parse(v); + if (t.reflectedType == double && v is num) return v.toDouble(); + // A model reads JSON with its fromJson constructor, an enum with a + // static fromJson. + if (t.declarations.keys.any((k) => MirrorSystem.getName(k) == '${MirrorSystem.getName(t.simpleName)}.fromJson')) { + return t.newInstance(#fromJson, [v]).reflectee; + } + + if (t.staticMembers.containsKey(#fromJson)) return t.invoke(#fromJson, [v]).reflectee; + } + + return v; +} + +/// _typedList and _typedMap build a List or Map of the +/// parameter's own T, which a plain List would not satisfy. +Object _typedList(TypeMirror item, List values) { + final list = (reflectType(List, [item.reflectedType]) as ClassMirror).newInstance(#empty, [], {#growable: true}).reflectee as List; + for (final x in values) { + list.add(x); + } + return list; +} + +Object _typedMap(TypeMirror value, Map values) { + final map = (reflectType(Map, [String, value.reflectedType]) as ClassMirror).newInstance(Symbol(''), []).reflectee as Map; + values.forEach((k, x) => map[k] = x); + return map; +} + +/// toWire turns a result back into wire JSON. +Object? toWire(Object? v) { + if (v == null || v is String || v is num || v is bool) return v; + if (v is DateTime) return v.toUtc().toIso8601String(); + if (v is List && v is! List) return base64Encode(v); + if (v is List) return v.map(toWire).toList(); + if (v is Map) return {for (final e in v.entries) '${e.key}': toWire(e.value)}; + + final m = reflect(v); + if (m.type.instanceMembers.containsKey(#toJson)) return toWire(m.invoke(#toJson, []).reflectee); + + return v.toString(); +} + +void save(Object? result, Map? paths, Map vars) { + paths?.forEach((name, p) { + Object? v = result; + for (final key in (p as String).split('.').where((k) => k.isNotEmpty)) { + v = v is List ? v[int.parse(key)] : (v is Map ? v[key] : null); + } + vars[name] = v is String ? v : jsonEncode(v); + }); +} + +Future> callCase(Client client, Map suite, Map c, Auth auth, + Map vars) async { + final cfg = client.configuration(suite['base'] as String, auth); + final op = norm(c['op'] as String); + + for (final api in client.apis(cfg)) { + final m = reflect(api); + final method = m.type.instanceMembers.values.where((x) => x.isRegularMethod && norm(MirrorSystem.getName(x.simpleName)) == op); + if (method.isEmpty) continue; + + final mm = method.first; + final args = c['args'] == null ? {} : jsonDecode(expand(jsonEncode(c['args']), vars, true)) as Map; + final byNorm = {for (final p in (c['params'] as List? ?? [])) norm(p as String): p}; + final named = {}; + final used = {}; + + for (final p in mm.parameters) { + final field = MirrorSystem.getName(p.simpleName); + final name = byNorm[norm(field)] ?? 'body'; + if (!args.containsKey(name)) continue; + if (!used.add(name)) return {'fail': 'arg $name: fits fields $field and another'}; + named[p.simpleName] = fromWire(p.type, args[name]); + } + + for (final name in args.keys) { + if (!used.contains(name)) return {'fail': 'arg $name: no parameter of ${MirrorSystem.getName(mm.simpleName)} takes it'}; + } + + try { + final result = await (m.invoke(mm.simpleName, [], named).reflectee as Future); + final wire = toWire(result); + save(wire, c['save'] as Map?, vars); + return {'result': wire}; + } catch (e) { + final em = reflect(e); + if (em.type.instanceMembers.containsKey(#statusCode) && em.type.instanceMembers.containsKey(#body)) { + final body = (em.getField(#body).reflectee as String?) ?? ''; + Object? model; + try { + model = jsonDecode(body); + } catch (_) {} + return { + 'error': {'status': em.getField(#statusCode).reflectee, 'body': body, if (model != null) 'model': model}, + }; + } + return {'fail': '$e'}; + } + } + + return {'fail': 'no method for operation ${c['op']}'}; +} + +Future run(String inputPath, Map clients) async { + final input = jsonDecode(File(inputPath).readAsStringSync()) as Map; + + for (final suite in (input['suites'] as List).cast>()) { + final client = clients[suite['name']]!; + final vars = {}; + final auth = Auth.fromJson(suite['auth'] as Map?); + final cases = (suite['cases'] as List).cast>(); + + for (var i = 0; i < cases.length; i++) { + final c = cases[i]; + auth.merge(Auth.fromJson(c['auth'] as Map?)); + + String ex(String? s) { + try { + return expand(s ?? '', vars, false); + } catch (_) { + return s ?? ''; + } + } + + final current = Auth.fromJson({'username': ex(auth.username), 'password': ex(auth.password), 'apiKey': ex(auth.apiKey), 'bearer': ex(auth.bearer)}); + + if (suite['mock'] == true) { + final http = HttpClient(); + final req = await http.postUrl(Uri.parse('${suite['base']}/__e2e/case/$i')); + await (await req.close()).drain(); + http.close(); + } + + Map out; + try { + out = await callCase(client, suite, c, current, vars); + } catch (e, st) { + out = {'fail': 'panic: $e\n$st'}; + } + + stdout.writeln(jsonEncode({'suite': suite['name'], 'case': i, ...out})); + } + } +} diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go new file mode 100644 index 0000000..d6770d4 --- /dev/null +++ b/testdata/e2e/runners/go/runner.go @@ -0,0 +1,497 @@ +// Command runner calls the operations of generated go-fetch clients as the +// e2e scenarios say and prints one JSON line per case. It knows nothing of +// a particular client: main.go, written next to it for each run, hands it +// the API values and their Configuration, and it finds methods, parameters +// and results by reflection. Standard library only. +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "reflect" + "regexp" + "strconv" + "strings" + "time" + "unicode" +) + +// Auth is what a case authenticates with; the Configuration funcs read it +// at call time. +type Auth struct { + Username string `json:"username"` + Password string `json:"password"` + APIKey string `json:"apiKey"` + Bearer string `json:"bearer"` +} + +type input struct { + Suites []suite `json:"suites"` +} + +type suite struct { + Name string `json:"name"` + Base string `json:"base"` + Mock bool `json:"mock"` + Auth Auth `json:"auth"` + Cases []cas `json:"cases"` +} + +type cas struct { + Op string `json:"op"` + Params []string `json:"params"` + Args json.RawMessage `json:"args"` + Auth *Auth `json:"auth"` + Save map[string]string `json:"save"` +} + +type outcome struct { + Suite string `json:"suite"` + Case int `json:"case"` + Result json.RawMessage `json:"result,omitempty"` + Error *respErr `json:"error,omitempty"` + Fail string `json:"fail,omitempty"` +} + +type respErr struct { + Status int `json:"status"` + Model json.RawMessage `json:"model,omitempty"` + Body string `json:"body"` +} + +// setup builds the API values of one suite's client, sending to base, and +// returns them with the *Configuration they share. +type setup func(base string, auth *Auth) (apis []any, cfg any) + +func run(setups map[string]setup) { + var in input + + data, err := os.ReadFile(os.Args[1]) + if err == nil { + err = json.Unmarshal(data, &in) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + out := json.NewEncoder(os.Stdout) + + for _, s := range in.Suites { + auth := s.Auth + apis, cfg := setups[s.Name](s.Base, &auth) + vars := map[string]string{} + + for i, c := range s.Cases { + o := outcome{Suite: s.Name, Case: i} + + if c.Auth != nil { + auth = merge(auth, *c.Auth) + } + + auth = expandAuth(auth, vars) + setBasic(cfg, auth) + + if s.Mock { + mark(s.Base, i) + } + + callCase(&o, apis, c, vars) + _ = out.Encode(o) + } + } +} + +// mark tells the mock server which case the next requests belong to. +func mark(base string, i int) { + resp, err := http.Post(base+"/__e2e/case/"+strconv.Itoa(i), "text/plain", nil) + if err == nil { + _ = resp.Body.Close() + } +} + +func callCase(o *outcome, apis []any, c cas, vars map[string]string) { + // A panic in reflection or in the client fails this case, not the run. + defer func() { + if r := recover(); r != nil { + o.Result, o.Error, o.Fail = nil, nil, fmt.Sprint("panic: ", r) + } + }() + + method, ok := findMethod(apis, c.Op) + if !ok { + o.Fail = "no method for operation " + c.Op + + return + } + + args := map[string]json.RawMessage{} + if len(c.Args) > 0 { + expanded, err := expand(string(c.Args), vars, true) + if err == nil { + err = json.Unmarshal([]byte(expanded), &args) + } + + if err != nil { + o.Fail = "args: " + err.Error() + + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + in := []reflect.Value{reflect.ValueOf(ctx)} + + if t := method.Type(); t.NumIn() > 1 && t.In(1).Kind() == reflect.Struct { + p, err := params(t.In(1), c.Params, args) + if err != nil { + o.Fail = err.Error() + + return + } + + in = append(in, p) + } + + res := method.Call(in) + + if err, _ := res[len(res)-1].Interface().(error); err != nil { + o.Error, o.Fail = responseError(err) + + return + } + + if len(res) == 1 { + o.Result = json.RawMessage("null") + + return + } + + b, err := json.Marshal(res[0].Interface()) + if err != nil { + o.Fail = "result: " + err.Error() + + return + } + + o.Result = b + save(b, c.Save, vars) +} + +// findMethod finds the typed method of an operation: its operationId with +// the punctuation dropped, with "Method" after it first, the name a +// generator gives a FooRaw next to Foo (whose own FooRaw would match too). +func findMethod(apis []any, op string) (reflect.Value, bool) { + for _, want := range []string{norm(op) + "method", norm(op)} { + for _, api := range apis { + v := reflect.ValueOf(api) + for i := range v.NumMethod() { + if norm(v.Type().Method(i).Name) == want { + return v.Method(i), true + } + } + } + } + + return reflect.Value{}, false +} + +// params fills an operation's Params struct from the args, keyed by the +// spec's names. The field no spec name matches is the body, args["body"]. +func params(t reflect.Type, names []string, args map[string]json.RawMessage) (reflect.Value, error) { + p := reflect.New(t).Elem() + + byField := map[string]string{} + for _, name := range names { + byField[norm(name)] = name + } + + used := map[string]bool{} + + for i := range t.NumField() { + f := t.Field(i) + + name, isParam := byField[norm(f.Name)] + if !isParam { + name = "body" + } + + raw, ok := args[name] + if !ok { + continue + } + + if used[name] { + return p, fmt.Errorf("arg %s: fits fields %s and another", name, f.Name) + } + + used[name] = true + + if err := set(p.Field(i), raw); err != nil { + return p, fmt.Errorf("arg %s: %w", name, err) + } + } + + for name := range args { + if !used[name] { + return p, fmt.Errorf("arg %s: no field of %s takes it", name, t.Name()) + } + } + + return p, nil +} + +// fileArg is a file or raw body in the args: {"$file": {...}} or +// {"$bytes": {...}}, its content as text or base64. +type fileArg struct { + File *content `json:"$file"` + Bytes *content `json:"$bytes"` +} + +type content struct { + Name string `json:"name"` + Text string `json:"text"` + Base64 string `json:"base64"` +} + +func (c content) data() []byte { + if c.Base64 != "" { + b, _ := base64.StdEncoding.DecodeString(c.Base64) + + return b + } + + return []byte(c.Text) +} + +var readerType = reflect.TypeFor[io.Reader]() + +// set decodes raw into the field: JSON for every type, files and readers +// from their fileArg. +func set(f reflect.Value, raw json.RawMessage) error { + var fa fileArg + _ = json.Unmarshal(raw, &fa) + + switch { + case f.Type() == readerType && fa.Bytes != nil: + f.Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.Bytes.data())))) + + return nil + case isFile(f.Type()): + return setFile(f, raw) + } + + return json.Unmarshal(raw, f.Addr().Interface()) +} + +// isFile reports whether t is a client's File, *File or []File: a struct +// of Name and Body. +func isFile(t reflect.Type) bool { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { + t = t.Elem() + } + + if t.Kind() != reflect.Struct || t.NumField() != 2 { + return false + } + + n, nok := t.FieldByName("Name") + b, bok := t.FieldByName("Body") + + return nok && bok && n.Type.Kind() == reflect.String && b.Type == readerType +} + +func setFile(f reflect.Value, raw json.RawMessage) error { + switch f.Kind() { + case reflect.Slice: + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + + s := reflect.MakeSlice(f.Type(), len(items), len(items)) + for i, item := range items { + if err := setFile(s.Index(i), item); err != nil { + return err + } + } + + f.Set(s) + + return nil + case reflect.Pointer: + v := reflect.New(f.Type().Elem()) + if err := setFile(v.Elem(), raw); err != nil { + return err + } + + f.Set(v) + + return nil + } + + var fa fileArg + if err := json.Unmarshal(raw, &fa); err != nil || fa.File == nil { + return fmt.Errorf("want {\"$file\": ...}, got %s", raw) + } + + f.FieldByName("Name").SetString(fa.File.Name) + f.FieldByName("Body").Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.File.data())))) + + return nil +} + +// responseError reads a client's *ResponseError by its fields; any other +// error is a failure of the call itself. +func responseError(err error) (*respErr, string) { + for e := err; e != nil; e = errors.Unwrap(e) { + v := reflect.ValueOf(e) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + continue + } + + s := v.Elem() + status, body, model := s.FieldByName("StatusCode"), s.FieldByName("Body"), s.FieldByName("Model") + + if !status.IsValid() || !body.IsValid() || !model.IsValid() { + continue + } + + r := &respErr{Status: int(status.Int()), Body: string(body.Bytes())} + if !model.IsNil() { + r.Model, _ = json.Marshal(model.Interface()) + } + + return r, "" + } + + return nil, err.Error() +} + +// setBasic sets the basic credentials on the *Configuration, which holds +// them as values. +func setBasic(cfg any, a Auth) { + v := reflect.ValueOf(cfg).Elem() + if f := v.FieldByName("Username"); f.IsValid() { + f.SetString(a.Username) + } + + if f := v.FieldByName("Password"); f.IsValid() { + f.SetString(a.Password) + } +} + +var varRef = regexp.MustCompile(`\$\{(\w+)\}`) + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when inJSON; a name never saved is an error. +func expand(s string, vars map[string]string, inJSON bool) (string, error) { + var missing string + + out := varRef.ReplaceAllStringFunc(s, func(m string) string { + v, ok := vars[m[2:len(m)-1]] + if !ok { + missing = m + + return m + } + + if inJSON { + b, _ := json.Marshal(v) + + return string(b[1 : len(b)-1]) + } + + return v + }) + + if missing != "" { + return "", fmt.Errorf("%s was never saved", missing) + } + + return out, nil +} + +// merge sets the fields a case gives over the ones before; they stay for +// the cases after it. +func merge(a, over Auth) Auth { + for _, f := range []struct{ to, from *string }{ + {&a.Username, &over.Username}, {&a.Password, &over.Password}, {&a.APIKey, &over.APIKey}, {&a.Bearer, &over.Bearer}, + } { + if *f.from != "" { + *f.to = *f.from + } + } + + return a +} + +// expandAuth fills ${name} in the credentials; one not saved yet stays as +// it is, which the server then refuses. +func expandAuth(a Auth, vars map[string]string) Auth { + for _, f := range []*string{&a.Username, &a.Password, &a.APIKey, &a.Bearer} { + if v, err := expand(*f, vars, false); err == nil { + *f = v + } + } + + return a +} + +// save keeps values of the result for later cases: name -> a dotted path +// into it ("items.0.id"), the whole result for "". +func save(result []byte, paths map[string]string, vars map[string]string) { + for name, path := range paths { + var v any + if json.Unmarshal(result, &v) != nil { + continue + } + + for _, key := range strings.Split(path, ".") { + if key == "" { + continue + } + + switch x := v.(type) { + case map[string]any: + v = x[key] + case []any: + i, _ := strconv.Atoi(key) + if i < len(x) { + v = x[i] + } + } + } + + if s, ok := v.(string); ok { + vars[name] = s + } else { + b, _ := json.Marshal(v) + vars[name] = string(b) + } + } +} + +// norm drops all but letters and digits and lowers them, so a spec name +// and the identifier a generator makes of it compare equal. +func norm(s string) string { + var b strings.Builder + + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + } + } + + return b.String() +} diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts new file mode 100644 index 0000000..037ebed --- /dev/null +++ b/testdata/e2e/runners/ts/runner.ts @@ -0,0 +1,269 @@ +// Runner of the e2e scenarios for typescript-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.ts, written for each run, hands it the +// clients and the directories of their sources. Node's own fetch, FormData +// and File carry the requests. Types come from the clients' sources: the +// Request interfaces and the method signatures say which model's +// FromJSON/ToJSON turns wire JSON into the client's objects and back. + +declare const require: any; +declare const process: any; +declare const Buffer: any; + +const fs = require("fs"); +const path = require("path"); + +type Auth = { username?: string; password?: string; apiKey?: string; bearer?: string }; + +type Case = { op: string; params: string[]; args?: Record; auth?: Auth; save?: Record }; + +type Suite = { name: string; base: string; mock: boolean; auth?: Auth; cases: Case[]; schemes?: Record }; + +type Client = { module: any; dir: string }; + +type Method = { name: string; api: string; request?: string; result: string }; + +const norm = (s: string): string => s.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase(); + +// methods reads the typed method of each operation from the API sources: +// its name, the API class, the Request interface it takes and what it +// returns. +function methods(dir: string): { methods: Method[]; requests: Record> } { + const out: Method[] = []; + const requests: Record> = {}; + + for (const file of fs.readdirSync(path.join(dir, "apis"))) { + const src: string = fs.readFileSync(path.join(dir, "apis", file), "utf8"); + const api = /export class (\w+) extends runtime\.BaseAPI/.exec(src)?.[1]; + + for (const m of src.matchAll(/export interface (\w+) \{([\s\S]*?)\n\}/g)) { + const fields: Record = {}; + for (const f of m[2].matchAll(/^\s+(\w+)\??: (.+);$/gm)) { + fields[f[1]] = f[2]; + } + + requests[m[1]] = fields; + } + + if (!api) { + continue; + } + + for (const m of src.matchAll(/^ async (\w+)\((?:requestParameters: (\w+)(?: = \{\})?, )?initOverrides\?: [^)]*\): Promise<(.*)> \{$/gm)) { + if (!m[1].endsWith("Raw")) { + out.push({ name: m[1], api, request: m[2], result: m[3] }); + } + } + } + + return { methods: out, requests }; +} + +const nullable = (t: string): string => t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); + +// fromWire turns a wire value into what the client takes for type t. +function fromWire(client: any, t: string, v: any): any { + t = nullable(t); + if (v === null || v === undefined) { + return v; + } + + if (v && typeof v === "object" && (v.$file || v.$bytes)) { + const c = v.$file ?? v.$bytes; + const data = c.base64 !== undefined ? Buffer.from(c.base64, "base64") : Buffer.from(c.text ?? "", "utf8"); + + return v.$file ? new File([data], c.name) : new Blob([data]); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return v.map((x) => fromWire(client, m![1], x)); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, m![1], x)])); + } + + if (t === "Date") { + return new Date(v); + } + + const from = client[t + "FromJSON"]; + + return typeof from === "function" ? from(v) : v; +} + +// toWire turns a client's result of type t back into wire JSON. +async function toWire(client: any, t: string, v: any): Promise { + t = nullable(t); + if (v === null || v === undefined) { + return v ?? null; + } + + if (v instanceof Blob) { + return Buffer.from(await v.arrayBuffer()).toString("base64"); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return Promise.all(v.map((x) => toWire(client, m![1], x))); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + const out: Record = {}; + for (const [k, x] of Object.entries(v)) { + out[k] = await toWire(client, m[1], x); + } + + return out; + } + + if (v instanceof Date) { + return v.toISOString(); + } + + const to = client[t + "ToJSON"]; + + return typeof to === "function" ? to(v) : v; +} + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when json; a name never saved is an error. +function expand(s: string, vars: Record, json: boolean): string { + return s.replace(/\$\{(\w+)\}/g, (ref, name) => { + if (!(name in vars)) { + throw new Error(ref + " was never saved"); + } + + return json ? JSON.stringify(vars[name]).slice(1, -1) : vars[name]; + }); +} + +function save(result: any, paths: Record | undefined, vars: Record) { + for (const [name, p] of Object.entries(paths ?? {})) { + let v = result; + for (const key of p.split(".").filter(Boolean)) { + v = v?.[Array.isArray(v) ? Number(key) : key]; + } + + vars[name] = typeof v === "string" ? v : JSON.stringify(v); + } +} + +async function callCase(client: Client, info: ReturnType, suite: Suite, c: Case, auth: Auth, vars: Record) { + const cfg = new client.module.Configuration({ + basePath: suite.base, + username: auth.username, + password: auth.password, + apiKey: () => auth.apiKey ?? "", + // typescript-fetch sends an OAuth2 token as the whole Authorization + // header and prefixes only an HTTP bearer one: the function returns + // "Bearer ..." for OAuth2, as its users write it. + accessToken: (name?: string) => { + const oauth = ["oauth2", "openIdConnect"].includes(suite.schemes?.[name ?? ""] ?? ""); + + return auth.bearer ? (oauth ? "Bearer " : "") + auth.bearer : ""; + }, + }); + + const want = [norm(c.op) + "operation", norm(c.op) + "method", norm(c.op)]; + const m = want.map((w) => info.methods.find((x) => norm(x.name) === w)).find(Boolean); + if (!m) { + return { fail: "no method for operation " + c.op }; + } + + const api = new client.module[m.api](cfg); + const args = c.args ? JSON.parse(expand(JSON.stringify(c.args), vars, true)) : {}; + + const fields = m.request ? (info.requests[m.request] ?? {}) : {}; + const byNorm: Record = {}; + for (const p of c.params ?? []) { + byNorm[norm(p)] = p; + } + + const params: Record = {}; + const used = new Set(); + + for (const [field, type] of Object.entries(fields)) { + const name = byNorm[norm(field)] ?? "body"; + if (!(name in args)) { + continue; + } + + if (used.has(name)) { + return { fail: `arg ${name}: fits fields ${field} and another` }; + } + + used.add(name); + params[field] = fromWire(client.module, type, args[name]); + } + + for (const name of Object.keys(args)) { + if (!used.has(name)) { + return { fail: `arg ${name}: no field of ${m.request ?? m.name} takes it` }; + } + } + + try { + const result = m.request ? await api[m.name](params) : await api[m.name](); + const wire = m.result === "void" ? null : await toWire(client.module, m.result, result); + save(wire, c.save, vars); + + return { result: wire ?? null }; + } catch (e: any) { + if (e?.response && typeof e.response.status === "number") { + const body = await e.response.text(); + let model: any; + try { + model = JSON.parse(body); + } catch { + model = undefined; + } + + return { error: { status: e.response.status, model, body } }; + } + + return { fail: String(e?.cause ?? e) }; + } +} + +export async function run(clients: Record) { + const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + + for (const suite of input.suites as Suite[]) { + const client = clients[suite.name]; + const info = methods(client.dir); + const vars: Record = {}; + let auth: Auth = { ...(suite.auth ?? {}) }; + + for (const [i, c] of suite.cases.entries()) { + for (const [k, v] of Object.entries(c.auth ?? {})) { + if (v) { + (auth as any)[k] = v; + } + } + + const current: Auth = {}; + for (const [k, v] of Object.entries(auth)) { + try { + (current as any)[k] = expand(v ?? "", vars, false); + } catch { + (current as any)[k] = v; + } + } + + if (suite.mock) { + await fetch(`${suite.base}/__e2e/case/${i}`, { method: "POST" }); + } + + let out: any; + try { + out = await callCase(client, info, suite, c, current, vars); + } catch (e: any) { + out = { fail: "panic: " + String(e?.stack ?? e) }; + } + + process.stdout.write(JSON.stringify({ suite: suite.name, case: i, ...out }) + "\n"); + } + } +} diff --git a/testdata/e2e/scenarios/additional-properties.yaml b/testdata/e2e/scenarios/additional-properties.yaml new file mode 100644 index 0000000..b74ef23 --- /dev/null +++ b/testdata/e2e/scenarios/additional-properties.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/additional-properties-with-properties.yaml +cases: + - name: extra keys both ways + op: recordEvent + args: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + request: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + response: { json: { player: p, round1: 3, round2: 5 } } + result: { player: p, round1: 3, round2: 5 } + skip: { dart-fetch: "drops the keys next to the declared properties (additionalProperties); a DateTime goes out with .000Z", typescript-fetch: "drops the keys next to the declared properties (additionalProperties); a Date goes out with .000Z" } diff --git a/testdata/e2e/scenarios/discriminator.yaml b/testdata/e2e/scenarios/discriminator.yaml new file mode 100644 index 0000000..ff97fb8 --- /dev/null +++ b/testdata/e2e/scenarios/discriminator.yaml @@ -0,0 +1,4 @@ +spec: ../../specs/feature/discriminator-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected an identifier." } +cases: + - { name: mapped value, op: getPet, response: { json: { petType: canine, packSize: 4 } }, result: { petType: canine, packSize: 4 } } diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml new file mode 100644 index 0000000..54aa8db --- /dev/null +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -0,0 +1,395 @@ +# The FastAPI backend (testdata/e2e/backends/fastapi), called through a +# client generated from the spec it serves. Most endpoints echo what they +# parsed, so a result shows what the server received. Cases run in order and +# share the server's state: save keeps a value for ${name} in later cases. +backend: fastapi +skip: { dart-fetch: "does not compile: lib/src/apis/auth_api.dart:24: Expected an identifier." } +auth: { username: admin, password: "p@ss:w0rd", apiKey: key-1 } +cases: + # ------------------------------------------------------------ auth + - name: password form, bad login + op: issueToken + args: { username: alice, password: nope } + error: { status: 400, model: null, body: '{"detail":"bad login"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: password form with a scope + op: issueToken + args: { username: bob, password: secret, scope: "items:read" } + result: { access_token: tok-r, scope: "items:read" } + save: { token: access_token } + - name: api key and optional bearer + op: whoami + auth: { bearer: "${token}" } + result: { key_in: header, bearer: tok-r } + - name: basic auth with a colon in the password + op: adminStats + result: { items: 0, orders: 0 } + + # ------------------------------------------------------------ scalars and containers + - name: every scalar format + op: echoScalars + args: + body: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + b: true + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + result: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + - name: containers, empty and null + op: echoContainers + args: + body: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + result: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + skip: { typescript-fetch: "a tuple (prefixItems) renders an undefined FromJSON" } + - name: Go keywords and odd property names + op: echoKeywords + args: + body: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + result: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + - name: extra keys both ways + op: echoOpen + args: { body: { kind: k, extra: 1, nested: { a: [true] } } } + result: { kind: k, extra: 1, nested: { a: [true] } } + skip: { typescript-fetch: "drops the keys next to the declared properties (additionalProperties)" } + - name: recursive tree + op: echoTree + args: { body: { name: root, children: [{ name: a, children: [{ name: a1 }] }], parent: { name: up } } } + result: { name: root, children: [{ name: a, children: [{ name: a1, children: [] }] }], parent: { name: up } } + - name: writeOnly secret + op: echoSecret + args: { body: { username: u, password: "12345" } } + result: { username: u, password_length: 5 } + + # ------------------------------------------------------------ bodies + - name: array body + op: echoList + args: { body: [{ widthCm: 1, heightCm: 2 }] } + result: [{ widthCm: 1, heightCm: 2 }] + - name: map body + op: echoMap + args: { body: { a: [1, 2], b: [] } } + result: { a: 3, b: 0 } + - name: number body + op: echoNumber + args: { body: 2.25 } + result: 4.5 + - name: text/plain body + op: echoText + args: { body: héllo } + result: olléh + - name: octet-stream body + op: echoRaw + args: { body: { $bytes: { base64: AAH/ } } } + result: /wEA + - name: union body, int + op: echoUnion + args: { body: 5 } + result: { type: int, value: "5" } + - name: union body, string + op: echoUnion + args: { body: x } + result: { type: str, value: x } + - name: union body, list + op: echoUnion + args: { body: [1, 2] } + result: { type: list, value: "[1, 2]" } + - name: optional body left out + op: echoOptional + result: { got: false } + - name: optional body given + op: echoOptional + args: { body: { widthCm: 1, heightCm: 1 } } + result: { got: true } + + # ------------------------------------------------------------ parameters + - name: path int, date, enum with a space, path with slashes + op: pathParams + args: { id: -3, day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + result: { id: "-3", day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + - name: query values that need escaping, arrays, offset time + op: queryParams + args: + q: "a&b=c+d ž" + page-size: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + result: + q: "a&b=c+d ž" + pageSize: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + old: null + skip: { typescript-fetch: "a Date comes back in UTC" } + - name: query, optional ones left out + op: queryParams + args: { q: only } + result: { q: only, pageSize: 20, tags: [], active: null } + - name: headers and two cookies + op: headerParams + args: + x-request-id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + x-retry: 3 + x-flags: [a] + accept-language: "sk-SK,en;q=0.5" + tenant: t1 + theme: dark + result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } + skip: { typescript-fetch: "no cookie parameters" } + + # ------------------------------------------------------------ items + - name: read-only token cannot write + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "19.99" } } + error: { status: 403 } + - name: token with write scopes + op: issueToken + args: { username: alice, password: secret, scope: "items:read items:write orders" } + save: { token: access_token } + - name: create an item + op: createItem + auth: { bearer: "${token}" } + args: + body: + sku: ABC-0001 + name: Box + price: "19.99" + description: small + color: dark-blue + tags: [a] + dimensions: { widthCm: 10, heightCm: 5 } + supplier: { name: ACME, website: https://acme.example/ } + attributes: { material: wood } + result: + sku: ABC-0001 + price: "19.99" + version: 1 + displayName: Box (ABC-0001) + supplier: { name: ACME, website: https://acme.example/ } + dimensions: { widthCm: 10, heightCm: 5 } + id: $any + createdAt: $any + save: { item: id } + skip: { typescript-fetch: "runner: ToJSON leaves out a result's readOnly properties" } + - name: create a second item, price as a number + op: createItem + args: { body: { sku: ABC-0002, name: Bag, price: 5 } } + result: { sku: ABC-0002, price: "5" } + - name: duplicate SKU is a 409 Problem + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "1" } } + error: { status: 409, model: { status: 409, title: SKU taken, detail: ABC-0001 } } + - name: page and cursor + op: listItems + args: { limit: 1 } + result: { items: [{ sku: ABC-0001 }], total: 2, next_cursor: "1" } + - name: enum and decimal filters + op: listItems + args: { color: dark-blue, min_price: "10.5" } + result: { items: [{ sku: ABC-0001 }], total: 1 } + - name: get the item + op: getItem + args: { item_id: "${item}" } + result: { id: $any, sku: ABC-0001, description: small, color: dark-blue, attributes: { material: wood } } + - name: unknown item is a 404 Problem + op: getItem + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404, model: { status: 404, title: Not found } } + - name: patch clears with null, sends an empty list, keeps the rest + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { description: null, tags: [] } } + result: { description: null, tags: [], name: Box, version: 2, updatedAt: $any } + - name: stale version is a 409 Conflict + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { name: x } } + error: { status: 409, model: { message: stale, current_version: 2 } } + - name: undeclared 400 keeps the body + op: patchItem + args: { item_id: "${item}", if-match: 2, body: { name: null } } + error: { status: 400, model: null, body: '{"detail":"name may not be null"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: HEAD of an item that exists + op: itemExists + args: { item_id: "${item}" } + skip: { typescript-fetch: "fails on the empty body of a HEAD" } + - name: HEAD of one that does not + op: itemExists + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404 } + + # ------------------------------------------------------------ files + - name: two files, an array field and JSON text + op: uploadFiles + args: + files: [{ $file: { name: a b.txt, text: first } }, { $file: { name: ž.bin, base64: AP8= } }] + folder: docs + labels: [x, y] + meta: '{"k":1}' + result: [{ name: docs/a b.txt, size: 5, labels: "x,y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + - name: duplicate file is a 409 + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } + error: { status: 409 } + - name: bool form field + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs, overwrite: true } + result: [{ name: docs/a b.txt, size: 5 }] + - name: optional file left out + op: uploadAvatar + args: { image: { $file: { name: i.png, text: "12345" } } } + result: { image: 5, thumbnail: -1 } + - name: download by a path with a slash and unicode + op: downloadFile + args: { name: docs/ž.bin } + result: AP8= + - name: text/csv response + op: exportCsv + result: "sku,name\nABC-0001,Box\nABC-0002,Bag\n" + + # ------------------------------------------------------------ orders + - name: unions round trip + op: placeOrder + args: + idempotency-key: k1 + body: + lines: [{ sku: ABC-0001, quantity: 2, note: fragile }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + result: + id: 1 + status: placed + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + - name: a 202 with another body than the 201 + op: placeOrder + args: + body: + lines: [{ sku: ABC-0001, quantity: 500 }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + error: { status: 202, model: { title: Queued for review, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - name: get the order + op: getOrder + args: { order_id: 1 } + result: { id: 1, extraPayments: [{ method: transfer }, { method: voucher }] } + - name: enum response + op: orderStatus + args: { order_id: 1 } + result: placed + - name: 202 without a body, embedded body field + op: cancelOrder + args: { order_id: 1, body: { reason: changed my mind } } + skip: { typescript-fetch: "fails on a 2xx without a body" } + - name: a bound on a path parameter is a 422 + op: getOrder + args: { order_id: 0 } + error: { status: 422, model: { detail: [{ loc: [path, order_id] }] } } + + # ------------------------------------------------------------ misc + - name: recursive response + op: categoryTree + result: [{ name: root, children: [{ name: a, children: [{ name: a1 }] }, { name: b }] }] + - name: integer response + op: countItems + result: 2 + - name: nullable response present + op: maybeItem + args: { present: true } + result: { widthCm: 1, heightCm: 2 } + - name: nullable response null + op: maybeItem + result: null + - name: deprecated operation with FastAPI's operationId + op: legacy_endpoint_legacy_get + args: { x: 9 } + result: { x: 9 } + - name: 307 redirect followed + op: redirectToCount + - name: ndjson stream next to an empty JSON schema + op: streamLines + args: { n: 2 } + result: "{\"i\":0}\n{\"i\":1}\n" + skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } + - name: float path parameter and an aliased query + op: add + args: { a: 2, b: 0.75, round: true } + result: 3 + - name: untagged operation + op: untagged + result: { ok: true } + + # ------------------------------------------------------------ cleanup + - name: delete is a 204 + op: deleteItem + args: { item_id: "${item}" } + - name: delete again is a 404 + op: deleteItem + args: { item_id: "${item}" } + error: { status: 404 } diff --git a/testdata/e2e/scenarios/nullable-openapi31.yaml b/testdata/e2e/scenarios/nullable-openapi31.yaml new file mode 100644 index 0000000..0ad15dc --- /dev/null +++ b/testdata/e2e/scenarios/nullable-openapi31.yaml @@ -0,0 +1,16 @@ +spec: ../../specs/feature/nullable-openapi31.yaml +skip: { dart-fetch: "does not compile: lib/src/models/maybe_name.dart:13: Expected an identifier." } +cases: + - name: 3.1 null members, a nullable query and an enum array + op: listPets + args: { q: mi, size: [1, 3] } + request: { query: { q: mi, size: ["1", "3"] } } + response: { json: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] } + result: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array" } + - name: contentMediaType file and multipart arrays + op: uploadPhoto + args: { id: "1", file: { $file: { name: p.png, base64: iVBORw== } }, caption: hi, tags: [a, b], labels: [x, y] } + request: { body: { file: [{ filename: p.png, base64: iVBORw== }], caption: [hi], tags: [a, b], labels: ["x,y"] } } + response: { status: 204 } + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } diff --git a/testdata/e2e/scenarios/nullable.yaml b/testdata/e2e/scenarios/nullable.yaml new file mode 100644 index 0000000..afc8bc4 --- /dev/null +++ b/testdata/e2e/scenarios/nullable.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/nullable-required.yaml +skip: { dart-fetch: "does not compile: lib/src/models/record.dart:26: Expected an identifier." } +cases: + - name: required null, optional null and absent + op: createRecord + args: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5 } } + request: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5, optionalNotNullable: $absent } } + response: { json: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null } } + result: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null, optionalNullable: $absent } diff --git a/testdata/e2e/scenarios/parameters-deepobject.yaml b/testdata/e2e/scenarios/parameters-deepobject.yaml new file mode 100644 index 0000000..db17ffa --- /dev/null +++ b/testdata/e2e/scenarios/parameters-deepobject.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-deepobject.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - op: deepSearch + args: { filter: { name: "a b&c", minPrice: 9.5 } } + request: { query: { "filter[name]": "a b&c", "filter[minPrice]": "9.5" } } + response: { json: [found] } + result: [found] diff --git a/testdata/e2e/scenarios/parameters-locations.yaml b/testdata/e2e/scenarios/parameters-locations.yaml new file mode 100644 index 0000000..90c63b1 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -0,0 +1,20 @@ +spec: ../../specs/feature/parameters-locations.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:76: Expected to find '>'." } +cases: + - name: path, query, header and cookie + op: getItem + args: { orgId: "acme/eu", itemId: 42, verbose: false, X-Request-Id: r-1, sessionId: s-1 } + request: + path: /orgs/acme/eu/items/42 + query: { verbose: "false" } + header: { X-Request-Id: r-1 } + cookie: { sessionId: s-1 } + response: { json: { id: "42" } } + result: { id: "42" } + skip: { typescript-fetch: "no cookie parameters" } + - name: optional ones left out + op: getItem + args: { orgId: o, itemId: 1 } + request: { query: { verbose: $absent }, header: { X-Request-Id: $absent }, cookie: { sessionId: $absent } } + response: { json: {} } + result: {} diff --git a/testdata/e2e/scenarios/parameters-object-styles.yaml b/testdata/e2e/scenarios/parameters-object-styles.yaml new file mode 100644 index 0000000..2a693c4 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-object-styles.yaml @@ -0,0 +1,43 @@ +# Objects and arrays in every parameter style, against the OpenAPI +# serialization table (color = {R: 100, G: 200, B: 150}). +spec: ../../specs/feature/parameters-object-styles.yaml +cases: + - name: every style + op: paint + args: + a: { R: 100, G: 200, B: 150 } + b: { R: 100, G: 200, B: 150 } + c: { R: 100, G: 200, B: 150 } + d: { R: 100, G: 200, B: 150 } + e: { R: 100, G: 200, B: 150 } + f: { R: 100, G: 200, B: 150 } + g: [blue, black] + q: { R: 100, G: 200, B: 150 } + X-Color: { R: 100, G: 200, B: 150 } + X-Color-Exploded: { R: 100, G: 200, B: 150 } + X-Tags: [x, y] + prefs: [p, q] + request: + method: GET + path: "/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" + query: { q: "B,150,G,200,R,100" } + header: { 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" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } + - name: optional ones left out + op: paint + args: + a: { R: 1, G: 2, B: 3 } + b: { R: 1, G: 2, B: 3 } + c: { R: 1, G: 2, B: 3 } + d: { R: 1, G: 2, B: 3 } + e: { R: 1, G: 2, B: 3 } + f: { R: 1, G: 2, B: 3 } + g: [a] + request: + query: { q: $absent } + header: { X-Color: $absent, X-Tags: $absent } + cookie: { prefs: $absent } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } diff --git a/testdata/e2e/scenarios/parameters-objects-in-query.yaml b/testdata/e2e/scenarios/parameters-objects-in-query.yaml new file mode 100644 index 0000000..9e18a24 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-objects-in-query.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-explode-object.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected to find '}'." } +cases: + - name: exploded map, free-form object, deepObject + op: listItems + args: { labels: { color: red, size: L }, extra: { a: "1" }, range: { min: 1, max: 5 } } + request: { query: { color: red, size: L, a: "1", "range[min]": "1", "range[max]": "5" } } + response: { status: 204 } diff --git a/testdata/e2e/scenarios/parameters-path-style.yaml b/testdata/e2e/scenarios/parameters-path-style.yaml new file mode 100644 index 0000000..a1b3eca --- /dev/null +++ b/testdata/e2e/scenarios/parameters-path-style.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/parameters-path-style.yaml +cases: + - { op: getSimplePath, args: { id: [a, b] }, request: { path: "/simple/a,b" }, response: { status: 200 }, skip: { dart-fetch: "request.path: want \"/simple/a,b\", got \"/simple/[a, b]\"" } } + - { op: getMatrixPath, args: { id: [a, b] }, request: { path: "/matrix/;id=a,b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \";id=\"", typescript-fetch: "no matrix path style" } } + - { op: getLabelPath, args: { id: [a, b] }, request: { path: "/label/.a.b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \".\"", typescript-fetch: "no label path style" } } diff --git a/testdata/e2e/scenarios/parameters-scalar-formats.yaml b/testdata/e2e/scenarios/parameters-scalar-formats.yaml new file mode 100644 index 0000000..0d56d27 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-scalar-formats.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/parameters-scalar-formats.yaml +cases: + - name: shortest exact text of each scalar + op: search + args: { f32: 0.1, f64: 1e21, n: 0.30000000000000004, i64: 4611686018427387905, ok: false, day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + request: + query: { f32: "0.1", f64: "1000000000000000000000", n: "0.30000000000000004", i64: "4611686018427387905", ok: "false", day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\": string doesn't match pattern \"^[0-9]{4}-(0[1-9]|10", typescript-fetch: "JavaScript numbers and Date: no int64, 1e21 as 1e+21, times in UTC with milliseconds" } diff --git a/testdata/e2e/scenarios/parameters.yaml b/testdata/e2e/scenarios/parameters.yaml new file mode 100644 index 0000000..4984ecd --- /dev/null +++ b/testdata/e2e/scenarios/parameters.yaml @@ -0,0 +1,13 @@ +# Parameters in each location and style; the request is as the mock decoded +# it (path and query values unescaped). +spec: ../../specs/feature/parameters-array-style-explode.yaml +cases: + - name: array styles in query and path + op: filterItems + args: { pathIds: [a, b], formExplodeTrue: [x, y], formExplodeFalse: [x, y], spaceDelimited: [1, 2], pipeDelimited: [3, 4] } + request: + path: "/filter/a,b" + query: { formExplodeTrue: [x, y], formExplodeFalse: "x,y", spaceDelimited: "1 2", pipeDelimited: "3|4" } + response: { json: [ok] } + result: [ok] + skip: { dart-fetch: "request.path: want \"/filter/a,b\", got \"/filter/[a, b]\"", typescript-fetch: "explode false, spaceDelimited and pipeDelimited go out as an exploded list" } diff --git a/testdata/e2e/scenarios/request-bodies.yaml b/testdata/e2e/scenarios/request-bodies.yaml new file mode 100644 index 0000000..c1b7aa5 --- /dev/null +++ b/testdata/e2e/scenarios/request-bodies.yaml @@ -0,0 +1,18 @@ +spec: ../../specs/feature/request-body-json.yaml +cases: + - name: model body and result + op: createItem + args: { body: { name: box, quantity: 2 } } + request: { method: POST, header: { Content-Type: application/json }, body: { name: box, quantity: 2 } } + response: { json: { name: box, quantity: 2 } } + result: { name: box, quantity: 2 } + - name: array body + op: createItems + args: { body: [{ name: a }, { name: b, quantity: 0 }] } + request: { body: [{ name: a }, { name: b, quantity: 0 }] } + response: { status: 200 } + - name: integer body + op: setRawCount + args: { body: 7 } + request: { body: 7 } + response: { status: 200 } diff --git a/testdata/e2e/scenarios/request-body-forms.yaml b/testdata/e2e/scenarios/request-body-forms.yaml new file mode 100644 index 0000000..76b045b --- /dev/null +++ b/testdata/e2e/scenarios/request-body-forms.yaml @@ -0,0 +1,26 @@ +spec: ../../specs/feature/request-body-form-encoding.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - name: urlencoded fields by their encoding + op: createCustomer + args: + name: Ann + address: { city: Oslo, lines: [a, b] } + metadata: { plan: gold } + expand: [x, y] + tags: [t1, t2] + ids: [1, 2] + request: + header: { Content-Type: application/x-www-form-urlencoded } + body: + name: Ann + "address[city]": Oslo + "address[lines][0]": a + "address[lines][1]": b + "metadata[plan]": gold + "expand[0]": x + "expand[1]": y + tags: "t1,t2" + ids: ["1", "2"] + response: { status: 204 } + skip: { typescript-fetch: "the form encoding (deepObject, explode) is ignored" } diff --git a/testdata/e2e/scenarios/request-body-login.yaml b/testdata/e2e/scenarios/request-body-login.yaml new file mode 100644 index 0000000..18d32d1 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-login.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/request-body-form-urlencoded.yaml +cases: + - op: login + args: { username: "a@b.c", password: "p&s=s w", rememberMe: true, tags: [x, y] } + request: { body: { username: "a@b.c", password: "p&s=s w", rememberMe: "true", tags: [x, y] } } + response: { status: 200 } + skip: { dart-fetch: "request.body.tags: want [\"x\",\"y\"], got [\"[x, y]\"]", typescript-fetch: "a form array goes comma-joined, not exploded" } diff --git a/testdata/e2e/scenarios/request-body-multipart.yaml b/testdata/e2e/scenarios/request-body-multipart.yaml new file mode 100644 index 0000000..60e1cce --- /dev/null +++ b/testdata/e2e/scenarios/request-body-multipart.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/request-body-multipart.yaml +cases: + - name: a file, an optional text and a list of files + op: uploadFile + args: + file: { $file: { name: a.txt, text: hello } } + description: two files + attachments: [{ $file: { name: b.bin, base64: AP8= } }, { $file: { name: c.txt, text: c } }] + request: + body: + file: [{ filename: a.txt, text: hello }] + description: [two files] + attachments: [{ filename: b.bin, base64: AP8= }, { filename: c.txt, text: c }] + response: { status: 200 } + skip: { dart-fetch: "request: invalid for the spec: request body has an error: header Content-Type has unexpected value \"application/x-www-form-urlencoded\"" } diff --git a/testdata/e2e/scenarios/request-body-other-media.yaml b/testdata/e2e/scenarios/request-body-other-media.yaml new file mode 100644 index 0000000..368b720 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -0,0 +1,22 @@ +spec: ../../specs/feature/request-body-other-media.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:196: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +auth: { bearer: tok } +cases: + - name: text/plain body and response, bearer + op: postNote + args: { body: "line 1\nčo" } + request: { header: { Content-Type: text/plain, Authorization: Bearer tok }, body: "line 1\nčo" } + response: { text: "stored" } + result: stored + - name: octet-stream body and response + op: putBlob + args: { body: { $bytes: { base64: AAH/ } } } + request: { header: { Content-Type: application/octet-stream }, body: { $base64: AAH/ } } + response: { base64: /wEA } + result: /wEA + - name: multipart with an object part + op: postReport + args: { title: q3, meta: { author: ann, pages: 3 }, attachments: [{ $file: { name: a.txt, text: a } }] } + request: { body: { title: [q3], meta: [{ json: { author: ann, pages: 3 } }], attachments: [{ filename: a.txt, text: a }] } } + response: { status: 204 } + skip: { typescript-fetch: "an object multipart part goes as text, not application/json" } diff --git a/testdata/e2e/scenarios/responses-binary.yaml b/testdata/e2e/scenarios/responses-binary.yaml new file mode 100644 index 0000000..eb8c11c --- /dev/null +++ b/testdata/e2e/scenarios/responses-binary.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/responses-binary.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:65: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +cases: + - { op: downloadFile, args: { id: "1" }, response: { base64: AAH/ }, result: AAH/ } + - { op: getThumbnail, args: { id: "1" }, response: { header: { Content-Type: image/png }, base64: iVBORw== }, result: iVBORw== } diff --git a/testdata/e2e/scenarios/responses-mixed-2xx.yaml b/testdata/e2e/scenarios/responses-mixed-2xx.yaml new file mode 100644 index 0000000..8954a5f --- /dev/null +++ b/testdata/e2e/scenarios/responses-mixed-2xx.yaml @@ -0,0 +1,13 @@ +spec: ../../specs/feature/responses-mixed-2xx.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:23: Expected an identifier." } +cases: + - { name: 201 result, op: placeOrder, response: { status: 201, json: { id: 7 } }, result: { id: 7 } } + - name: 202 with another body is an error with its model + op: placeOrder + response: { status: 202, json: { title: queued, status: 202 } } + error: { status: 202, model: { title: queued, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - { name: ndjson as text, op: streamLines, response: { header: { Content-Type: application/x-ndjson }, text: "{\"i\":0}\n" }, result: "{\"i\":0}\n", skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } } + - { name: nullable result null, op: latestOrder, response: { json: null }, result: null } + - { name: nullable text, op: greeting, response: { text: hello }, result: hello } + - { name: any holds another 2xx, op: anything, response: { status: 201, json: { id: 1 } }, result: { id: 1 } } diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml new file mode 100644 index 0000000..32bb404 --- /dev/null +++ b/testdata/e2e/scenarios/responses.yaml @@ -0,0 +1,21 @@ +spec: ../../specs/feature/responses-status-codes.yaml +cases: + - { name: 200 model, op: getTask, args: { id: "1" }, response: { json: { id: "1", title: t } }, result: { id: "1", title: t } } + - name: 404 error model + op: getTask + args: { id: "2" } + response: { status: 404, json: { message: nope, code: 4 } } + error: { status: 404, model: { message: nope, code: 4 } } + - name: default error model for a 500 + op: getTask + args: { id: "3" } + response: { status: 500, json: { message: boom } } + error: { status: 500, model: { message: boom } } + - { name: 204 no body, op: deleteTask, args: { id: "1" }, response: { status: 204 } } + - { name: 201 with a body, op: createTask, args: { id: "1" }, response: { status: 201, json: { id: "1" } }, result: { id: "1" } } + - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null, skip: { typescript-fetch: "fails on a 2xx without a body next to one with a body" } } + - name: undeclared status keeps the text body + op: deleteTask + args: { id: "1" } + response: { status: 409, text: conflict, offSpec: true } + error: { status: 409, model: null, body: conflict } diff --git a/testdata/e2e/scenarios/security-http.yaml b/testdata/e2e/scenarios/security-http.yaml new file mode 100644 index 0000000..d095029 --- /dev/null +++ b/testdata/e2e/scenarios/security-http.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-http.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { username: u, password: "p:w", bearer: jwt } +cases: + - { op: getWithBasicAuth, request: { header: { Authorization: "Basic dTpwOnc=" } }, response: { status: 200 } } + - { op: getWithBearerAuth, request: { header: { Authorization: Bearer jwt } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security-oauth2.yaml b/testdata/e2e/scenarios/security-oauth2.yaml new file mode 100644 index 0000000..a4011f5 --- /dev/null +++ b/testdata/e2e/scenarios/security-oauth2.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-oauth2.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { bearer: tok } +cases: + - { op: getWithOAuthCode, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } + - { op: getWithOAuthClientCredentials, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security.yaml b/testdata/e2e/scenarios/security.yaml new file mode 100644 index 0000000..b4a176c --- /dev/null +++ b/testdata/e2e/scenarios/security.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/security-apikey.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { apiKey: k1 } +cases: + - { op: getWithHeaderKey, request: { header: { X-Api-Key: k1 } }, response: { status: 200 } } + - { op: getWithQueryKey, request: { query: { api_key: k1 } }, response: { status: 200 } } + - { op: getWithCookieKey, request: { cookie: { session: k1 } }, response: { status: 200 }, skip: { typescript-fetch: "no API key in a cookie" } } diff --git a/testdata/e2e/scenarios/unions-anyof.yaml b/testdata/e2e/scenarios/unions-anyof.yaml new file mode 100644 index 0000000..cae292c --- /dev/null +++ b/testdata/e2e/scenarios/unions-anyof.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/composition-anyof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/contact_method.dart:13: Expected an identifier." } +cases: + - op: createContact + args: { body: { email: a@b.c } } + request: { body: { email: a@b.c } } + response: { json: { phone: "+421" } } + result: { phone: "+421" } diff --git a/testdata/e2e/scenarios/unions-inline.yaml b/testdata/e2e/scenarios/unions-inline.yaml new file mode 100644 index 0000000..c6835a8 --- /dev/null +++ b/testdata/e2e/scenarios/unions-inline.yaml @@ -0,0 +1,10 @@ +spec: ../../specs/feature/composition-oneof-inline.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:82: Expected an identifier." } +cases: + - name: inline union body, property and items + op: admit + args: { id: s1, body: { kind: dog, barkVolume: 1.5 } } + request: { path: /shelters/s1/pets, body: { kind: dog, barkVolume: 1.5 } } + response: { json: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } } + result: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } + - { name: inline union response, op: randomPet, response: { json: { kind: cat, lives: 9 } }, result: { kind: cat, lives: 9 } } diff --git a/testdata/e2e/scenarios/unions.yaml b/testdata/e2e/scenarios/unions.yaml new file mode 100644 index 0000000..8f86715 --- /dev/null +++ b/testdata/e2e/scenarios/unions.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/composition-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/search_result.dart:8: Target of URI doesn't exist: 'arraynumber.dart'." } +cases: + - name: model variant round trip + op: search + args: { body: { petType: cat, huntingSkill: lazy } } + request: { body: { petType: cat, huntingSkill: lazy } } + response: { json: { petType: dog, packSize: 3 } } + result: { petType: dog, packSize: 3 } + - name: string and array variants + op: search + args: { body: "just text" } + request: { body: "just text" } + response: { json: [1, 2] } + result: [1, 2] diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json new file mode 100644 index 0000000..c2bbd30 --- /dev/null +++ b/testdata/e2e/specs/fastapi.json @@ -0,0 +1,3995 @@ +{ + "components": { + "schemas": { + "Body_cancelOrder": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "title": "Body_cancelOrder", + "type": "object" + }, + "Body_issueToken": { + "properties": { + "client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Id" + }, + "client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "password", + "title": "Client Secret" + }, + "grant_type": { + "anyOf": [ + { + "pattern": "^password$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Grant Type" + }, + "password": { + "format": "password", + "title": "Password", + "type": "string" + }, + "scope": { + "default": "", + "title": "Scope", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Body_issueToken", + "type": "object" + }, + "Body_uploadAvatar": { + "properties": { + "image": { + "contentMediaType": "application/octet-stream", + "title": "Image", + "type": "string" + }, + "thumbnail": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail" + } + }, + "required": [ + "image" + ], + "title": "Body_uploadAvatar", + "type": "object" + }, + "Body_uploadFiles": { + "properties": { + "files": { + "description": "One or more files", + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "meta": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON text", + "title": "Meta" + }, + "overwrite": { + "default": false, + "title": "Overwrite", + "type": "boolean" + } + }, + "required": [ + "files", + "folder" + ], + "title": "Body_uploadFiles", + "type": "object" + }, + "Card": { + "additionalProperties": false, + "properties": { + "expires": { + "format": "date", + "title": "Expires", + "type": "string" + }, + "last4": { + "pattern": "^\\d{4}$", + "title": "Last4", + "type": "string" + }, + "method": { + "const": "card", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "last4", + "expires" + ], + "title": "Card", + "type": "object" + }, + "Category-Input": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Input" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Input" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Category-Output": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Output" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Circle": { + "additionalProperties": false, + "properties": { + "radius": { + "title": "Radius", + "type": "number" + } + }, + "required": [ + "radius" + ], + "title": "Circle", + "type": "object" + }, + "Color": { + "enum": [ + "red", + "dark-blue", + "light green", + "2nd", + "type", + "" + ], + "title": "Color", + "type": "string" + }, + "Conflict": { + "additionalProperties": false, + "properties": { + "current_version": { + "title": "Current Version", + "type": "integer" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message", + "current_version" + ], + "title": "Conflict", + "type": "object" + }, + "Containers": { + "additionalProperties": false, + "properties": { + "by_name": { + "additionalProperties": { + "$ref": "#/components/schemas/Dimensions" + }, + "default": {}, + "title": "By Name", + "type": "object" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "default": {}, + "title": "Counts", + "type": "object" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "title": "Groups", + "type": "object" + }, + "matrix": { + "default": [], + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Matrix", + "type": "array" + }, + "maybe_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Maybe List" + }, + "maybe_map": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Maybe Map" + }, + "pair": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "title": "Pair", + "type": "array" + }, + "sparse": { + "default": [], + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": "Sparse", + "type": "array" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unique": { + "default": [], + "items": { + "type": "string" + }, + "title": "Unique", + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "tags", + "pair" + ], + "title": "Containers", + "type": "object" + }, + "Dimensions": { + "additionalProperties": false, + "properties": { + "depthCm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Depthcm" + }, + "heightCm": { + "title": "Heightcm", + "type": "number" + }, + "widthCm": { + "title": "Widthcm", + "type": "number" + } + }, + "required": [ + "widthCm", + "heightCm" + ], + "title": "Dimensions", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Item": { + "additionalProperties": false, + "description": "An item as the server returns it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "title": "Createdat", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "displayName": { + "readOnly": true, + "title": "Displayname", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "title": "Price", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "updatedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "version": { + "title": "Version", + "type": "integer" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price", + "id", + "createdAt", + "version", + "displayName" + ], + "title": "Item", + "type": "object" + }, + "ItemIn": { + "additionalProperties": false, + "description": "An item as a client sends it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price" + ], + "title": "ItemIn", + "type": "object" + }, + "ItemPatch": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "title": "ItemPatch", + "type": "object" + }, + "Keywords": { + "additionalProperties": false, + "description": "Property names that are keywords or predeclared in Go.", + "properties": { + "1st": { + "title": "1St", + "type": "string" + }, + "UPPER": { + "title": "Upper", + "type": "string" + }, + "default": { + "default": false, + "title": "Default", + "type": "boolean" + }, + "dot.name": { + "title": "Dot.Name", + "type": "string" + }, + "error": { + "title": "Error", + "type": "string" + }, + "func": { + "title": "Func", + "type": "integer" + }, + "interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Interface" + }, + "map": { + "additionalProperties": { + "type": "integer" + }, + "title": "Map", + "type": "object" + }, + "package": { + "default": "main", + "title": "Package", + "type": "string" + }, + "range": { + "items": { + "type": "integer" + }, + "title": "Range", + "type": "array" + }, + "select": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Select" + }, + "string": { + "title": "String", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "x-dash-name": { + "title": "X-Dash-Name", + "type": "string" + } + }, + "required": [ + "type", + "func", + "range", + "map", + "string", + "error", + "x-dash-name", + "dot.name", + "UPPER", + "1st" + ], + "title": "Keywords", + "type": "object" + }, + "Open": { + "additionalProperties": true, + "description": "Takes any extra key and returns it.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "Open", + "type": "object" + }, + "Order": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ], + "title": "Payment" + }, + "placedAt": { + "format": "date-time", + "title": "Placedat", + "type": "string" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + }, + "status": { + "$ref": "#/components/schemas/OrderStatus" + } + }, + "required": [ + "lines", + "payment", + "id", + "status", + "placedAt" + ], + "title": "Order", + "type": "object" + }, + "OrderIn": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ], + "title": "Payment" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + } + }, + "required": [ + "lines", + "payment" + ], + "title": "OrderIn", + "type": "object" + }, + "OrderLine": { + "additionalProperties": false, + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "quantity": { + "exclusiveMinimum": 0, + "title": "Quantity", + "type": "integer" + }, + "sku": { + "title": "Sku", + "type": "string" + } + }, + "required": [ + "sku", + "quantity" + ], + "title": "OrderLine", + "type": "object" + }, + "OrderStatus": { + "enum": [ + "placed", + "paid", + "shipped" + ], + "title": "OrderStatus", + "type": "string" + }, + "Page_Item_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Item" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "Page[Item]", + "type": "object" + }, + "Priority": { + "enum": [ + 1, + 5, + 10 + ], + "title": "Priority", + "type": "integer" + }, + "Problem": { + "additionalProperties": false, + "properties": { + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status" + ], + "title": "Problem", + "type": "object" + }, + "Rect": { + "additionalProperties": false, + "properties": { + "height": { + "title": "Height", + "type": "number" + }, + "width": { + "title": "Width", + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "title": "Rect", + "type": "object" + }, + "Scalars-Input": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "D" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Scalars-Output": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "title": "D", + "type": "string" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Secret": { + "additionalProperties": false, + "properties": { + "password": { + "format": "password", + "title": "Password", + "type": "string", + "writeOnly": true + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Secret", + "type": "object" + }, + "Supplier": { + "additionalProperties": false, + "properties": { + "contactEmail": { + "anyOf": [ + { + "format": "email", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Contactemail" + }, + "name": { + "title": "Name", + "type": "string" + }, + "website": { + "anyOf": [ + { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Website" + } + }, + "required": [ + "name" + ], + "title": "Supplier", + "type": "object" + }, + "Transfer": { + "additionalProperties": false, + "properties": { + "iban": { + "title": "Iban", + "type": "string" + }, + "method": { + "const": "transfer", + "title": "Method", + "type": "string" + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reference" + } + }, + "required": [ + "method", + "iban" + ], + "title": "Transfer", + "type": "object" + }, + "Unit": { + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "Voucher-Input": { + "additionalProperties": false, + "properties": { + "amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Amount" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + }, + "Voucher-Output": { + "additionalProperties": false, + "properties": { + "amount": { + "title": "Amount", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + } + }, + "securitySchemes": { + "APIKeyCookie": { + "in": "cookie", + "name": "session", + "type": "apiKey" + }, + "APIKeyHeader": { + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "APIKeyQuery": { + "in": "query", + "name": "api_key", + "type": "apiKey" + }, + "HTTPBasic": { + "scheme": "basic", + "type": "http" + }, + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + }, + "OAuth2PasswordBearer": { + "flows": { + "password": { + "scopes": { + "items:read": "Read items", + "items:write": "Write items", + "orders": "Place orders" + }, + "tokenUrl": "token" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "description": "Every common OpenAPI shape in one strict API.", + "title": "Warehouse", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/admin/stats": { + "get": { + "operationId": "adminStats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Adminstats", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBasic": [] + } + ], + "summary": "Admin Stats", + "tags": [ + "auth" + ] + } + }, + "/categories": { + "get": { + "operationId": "categoryTree", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Response Categorytree", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Category Tree", + "tags": [ + "misc" + ] + } + }, + "/count": { + "get": { + "operationId": "countItems", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Countitems", + "type": "integer" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Count Items", + "tags": [ + "misc" + ] + } + }, + "/echo/containers": { + "post": { + "operationId": "echoContainers", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Containers", + "tags": [ + "echo" + ] + } + }, + "/echo/keywords": { + "post": { + "operationId": "echoKeywords", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Keywords", + "tags": [ + "echo" + ] + } + }, + "/echo/list": { + "post": { + "operationId": "echoList", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Body", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Response Echolist", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo List", + "tags": [ + "echo" + ] + } + }, + "/echo/map": { + "post": { + "operationId": "echoMap", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Body", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Echomap", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Map", + "tags": [ + "echo" + ] + } + }, + "/echo/number": { + "post": { + "operationId": "echoNumber", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Body", + "type": "number" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Echonumber", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Number", + "tags": [ + "echo" + ] + } + }, + "/echo/open": { + "post": { + "operationId": "echoOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Open", + "tags": [ + "echo" + ] + } + }, + "/echo/optional": { + "post": { + "operationId": "echoOptional", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Echooptional", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Optional", + "tags": [ + "echo" + ] + } + }, + "/echo/raw": { + "post": { + "operationId": "echoRaw", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Raw", + "tags": [ + "echo" + ] + } + }, + "/echo/scalars": { + "post": { + "operationId": "echoScalars", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Scalars", + "tags": [ + "echo" + ] + } + }, + "/echo/secret": { + "post": { + "operationId": "echoSecret", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Response Echosecret", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Secret", + "tags": [ + "echo" + ] + } + }, + "/echo/text": { + "post": { + "operationId": "echoText", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Text", + "tags": [ + "echo" + ] + } + }, + "/echo/tree": { + "post": { + "operationId": "echoTree", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Tree", + "tags": [ + "echo" + ] + } + }, + "/echo/union": { + "post": { + "operationId": "echoUnion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Echounion", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Union", + "tags": [ + "echo" + ] + } + }, + "/export.csv": { + "get": { + "operationId": "exportCsv", + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Export Csv", + "tags": [ + "files" + ] + } + }, + "/files": { + "post": { + "operationId": "uploadFiles", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadFiles" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "object" + }, + "title": "Response Uploadfiles", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "APIKeyQuery": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Upload Files", + "tags": [ + "files" + ] + } + }, + "/files/avatar": { + "post": { + "operationId": "uploadAvatar", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadAvatar" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Uploadavatar", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Avatar", + "tags": [ + "files" + ] + } + }, + "/files/{name}": { + "get": { + "operationId": "downloadFile", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download File", + "tags": [ + "files" + ] + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ + { + "in": "query", + "name": "color", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ], + "title": "Color" + } + }, + { + "in": "query", + "name": "min_price", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Min Price" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 10, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Item_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "List Items", + "tags": [ + "items" + ] + }, + "post": { + "operationId": "createItem", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response", + "headers": { + "Location": { + "description": "URL of the item", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "SKU taken" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Create Item", + "tags": [ + "items" + ] + } + }, + "/items/{item_id}": { + "delete": { + "operationId": "deleteItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Delete Item", + "tags": [ + "items" + ] + }, + "get": { + "operationId": "getItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Get Item", + "tags": [ + "items" + ] + }, + "head": { + "operationId": "itemExists", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "404": { + "description": "absent" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Item Exists", + "tags": [ + "items" + ] + }, + "patch": { + "operationId": "patchItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "if-match", + "required": true, + "schema": { + "title": "If-Match", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Conflict" + } + } + }, + "description": "Stale version" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Patch Item", + "tags": [ + "items" + ] + } + }, + "/legacy": { + "get": { + "deprecated": true, + "description": "A deprecated operation with FastAPI's generated operationId.", + "operationId": "legacy_endpoint_legacy_get", + "parameters": [ + { + "in": "query", + "name": "x", + "required": false, + "schema": { + "default": 1, + "title": "X", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Legacy Endpoint Legacy Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Legacy Endpoint", + "tags": [ + "misc" + ] + } + }, + "/maybe": { + "get": { + "operationId": "maybeItem", + "parameters": [ + { + "in": "query", + "name": "present", + "required": false, + "schema": { + "default": false, + "title": "Present", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Response Maybeitem" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Maybe Item", + "tags": [ + "misc" + ] + } + }, + "/numbers/{a}/{b}": { + "get": { + "operationId": "add", + "parameters": [ + { + "in": "path", + "name": "a", + "required": true, + "schema": { + "title": "A", + "type": "integer" + } + }, + { + "in": "path", + "name": "b", + "required": true, + "schema": { + "title": "B", + "type": "number" + } + }, + { + "in": "query", + "name": "round", + "required": false, + "schema": { + "default": false, + "title": "Round", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Add", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Add", + "tags": [ + "misc" + ] + } + }, + "/orders": { + "post": { + "operationId": "placeOrder", + "parameters": [ + { + "in": "query", + "name": "dry_run", + "required": false, + "schema": { + "default": false, + "title": "Dry Run", + "type": "boolean" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Queued for review" + }, + "422": { + "description": "Invalid" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Place Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}": { + "get": { + "operationId": "getOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "minimum": 1, + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Get Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/cancel": { + "post": { + "operationId": "cancelOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_cancelOrder" + } + } + } + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Cancellation accepted; no body" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Cancel Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/status": { + "get": { + "operationId": "orderStatus", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderStatus" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Order Status", + "tags": [ + "orders" + ] + } + }, + "/params/headers": { + "get": { + "operationId": "headerParams", + "parameters": [ + { + "in": "header", + "name": "x-request-id", + "required": true, + "schema": { + "format": "uuid", + "title": "X-Request-Id", + "type": "string" + } + }, + { + "in": "header", + "name": "x-retry", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X-Retry" + } + }, + { + "in": "header", + "name": "x-flags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Flags", + "type": "array" + } + }, + { + "in": "header", + "name": "accept-language", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accept-Language" + } + }, + { + "in": "cookie", + "name": "tenant", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant" + } + }, + { + "in": "cookie", + "name": "theme", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Theme" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Params", + "tags": [ + "params" + ] + } + }, + "/params/path/{id}/{day}/{color}/{rest}": { + "get": { + "operationId": "pathParams", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "day", + "required": true, + "schema": { + "format": "date", + "title": "Day", + "type": "string" + } + }, + { + "in": "path", + "name": "color", + "required": true, + "schema": { + "$ref": "#/components/schemas/Color" + } + }, + { + "in": "path", + "name": "rest", + "required": true, + "schema": { + "title": "Rest", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Pathparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Path Params", + "tags": [ + "params" + ] + } + }, + "/params/query": { + "get": { + "operationId": "queryParams", + "parameters": [ + { + "in": "query", + "name": "q", + "required": true, + "schema": { + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "page-size", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Page-Size", + "type": "integer" + } + }, + { + "in": "query", + "name": "tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + } + }, + { + "in": "query", + "name": "ids", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ids" + } + }, + { + "in": "query", + "name": "colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "in": "query", + "name": "ratio", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ratio" + } + }, + { + "in": "query", + "name": "active", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + } + }, + { + "deprecated": true, + "in": "query", + "name": "old", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Old" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Queryparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Query Params", + "tags": [ + "params" + ] + } + }, + "/redirect": { + "get": { + "operationId": "redirectToCount", + "responses": { + "307": { + "description": "Successful Response" + } + }, + "summary": "Redirect", + "tags": [ + "misc" + ] + } + }, + "/stream": { + "get": { + "operationId": "streamLines", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 3, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "application/x-ndjson": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Lines", + "tags": [ + "misc" + ] + } + }, + "/token": { + "post": { + "operationId": "issueToken", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_issueToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Issuetoken", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Issue Token", + "tags": [ + "auth" + ] + } + }, + "/untagged": { + "get": { + "operationId": "untagged", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Untagged", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Untagged" + } + }, + "/whoami": { + "get": { + "operationId": "whoami", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Whoami", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "APIKeyQuery": [] + }, + { + "APIKeyCookie": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Whoami", + "tags": [ + "auth" + ] + } + } + } +} diff --git a/testdata/specs/feature/README.md b/testdata/specs/feature/README.md index a3d58dc..6891f68 100644 --- a/testdata/specs/feature/README.md +++ b/testdata/specs/feature/README.md @@ -17,5 +17,9 @@ make regress REF=master # or any branch, tag or commit Run it before committing a template or parser change. No diff means the change reached nothing else; a diff shows exactly which generated files it changed. +They are also the input of the end-to-end tests: `testdata/e2e/scenarios` +calls their operations through each generated client against a validating +mock (`make e2e`). + A new spec covers one feature and passes `openapi-generator validate -i `. A new boolean generator option goes into `flipped` in `scripts/regress.sh`. diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml index 25480e4..9679d0a 100644 --- a/testdata/specs/feature/parameters-object-styles.yaml +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -19,7 +19,7 @@ paths: - { 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 } } } + - { name: prefs, in: cookie, explode: false, schema: { type: array, items: { type: string } } } responses: "204": description: painted From e37f175f0ec4b3be1a438f1caff96454ead3ec0f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 19:38:31 +0100 Subject: [PATCH 09/14] test(e2e): widen the FastAPI backend with the shapes that break clients Six rounds of looking for realistic FastAPI and pydantic shapes a generated client gets wrong on the wire, each added to the backend with calls that show it (61 -> 81 cases): - discriminated unions as the body, a nullable field, map values and list items, behind a camelCase key, and a callable discriminator; - naive and aware date-times, times with offsets, negative durations, dates in lists, maps and query arrays; runners now run in a non-UTC zone (TZ=Europe/Bratislava); - required nullables and null against a non-null default, with the keys the client sent echoed back; - a typed form model; multipart labels echo joined by "|" so one part "x,y" no longer passes for two; - parameters whose names collide once camelCased, under a router prefix and a shared dependency, and ones named like a client's locals; - enums and Literals of mixed JSON types and with null. The gaps they found are skips with their reasons: go-fetch cannot hold a naive date-time or an enum without a type; typescript-fetch sends a date query array as Date.toString(), a naive date-time in UTC, an explicit null of an optional nullable as absent, and does not compile a union form field; the parser merges parameters of one camelCase name. --- internal/e2e/gofetch_test.go | 2 + testdata/e2e/README.md | 9 +- testdata/e2e/backends/fastapi/app.py | 202 +++- testdata/e2e/scenarios/fastapi.yaml | 139 ++- testdata/e2e/specs/fastapi.json | 1653 ++++++++++++++++++++++++-- 5 files changed, 1918 insertions(+), 87 deletions(-) diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go index 0b25ac6..e647148 100644 --- a/internal/e2e/gofetch_test.go +++ b/internal/e2e/gofetch_test.go @@ -123,6 +123,8 @@ func execRunner(t *testing.T, cmd *exec.Cmd) map[string]map[int]outcome { var stderr bytes.Buffer + // Away from UTC, so a client that reads or writes local time shows it. + cmd.Env = append(os.Environ(), "TZ=Europe/Bratislava") cmd.Stderr = &stderr stdout, err := cmd.Output() diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 46fec84..6a7dc98 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -32,8 +32,10 @@ A run takes about 7 s with warm caches. The mock layer covers every parameter style and body encoding the feature specs declare. The backend shows the shapes a framework writes into its spec: pydantic's separate input and output schemas, OpenAPI 3.1 null unions, -`const`, Decimal as number or string, and FastAPI's empty JSON schema next -to another media type. +`const`, Decimal as number or string, FastAPI's empty JSON schema next to +another media type, discriminated unions in every position, naive and aware +date-times, required nullables, form models, enums of mixed JSON types and +parameters whose names collide once camelCased. ## Scenarios @@ -103,6 +105,9 @@ what the program prints with the scenarios. | `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | | `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | +Runners run with `TZ=Europe/Bratislava`, so a client that reads or writes +local time shows it. + typescript-fetch sends an OAuth2 token as the whole `Authorization` header, so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the input). Its error has no decoded model: the runner reports the error body diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 75ecc33..71b16ba 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -13,6 +13,7 @@ from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union from fastapi import ( + APIRouter, Body, Cookie, Depends, @@ -43,12 +44,16 @@ ) from pydantic import ( AliasGenerator, + AwareDatetime, BaseModel, ConfigDict, + Discriminator, EmailStr, Field, HttpUrl, + NaiveDatetime, SecretStr, + Tag, computed_field, ) from pydantic.alias_generators import to_camel @@ -123,6 +128,67 @@ class Scalars(Strict): unit: Literal["kg", "lb"] = "kg" +class Stamps(Strict): + """Times as ORM-backed services send them: naive, aware, in containers.""" + + naive: NaiveDatetime + aware: AwareDatetime + clock: dt.time + span: dt.timedelta + days: list[dt.date] = [] + seen: dict[str, dt.datetime] = {} + + +class Nulls(Strict): + """Null as a required value, and null that differs from a default.""" + + note: Optional[str] + due: Optional[dt.date] + color: Optional[Color] + dims: Optional["Dimensions"] + lines: Optional[list["Dimensions"]] + payment: Optional["Payment"] + retries: Optional[int] = 3 + extras: Optional[list["Dimensions"]] = None + + +class NullsEcho(Nulls): + sent: list[str] # the keys the client put on the wire + + +class FormIn(Strict): + """A form model: every value a client must turn into text itself.""" + + day: dt.date + when: AwareDatetime + price: Decimal + color: Color + tags: list[str] = [] + colors: list[Color] = [] + days: list[dt.date] = [] + qty: Optional[int] = None + agree: bool = False + + +class Retry(Enum): + """Members of two JSON types: the schema has an enum and no type.""" + + never = 0 + always = "always" + once = 1 + + +class Literals(Strict): + """Enums whose values are not one JSON type, null among them.""" + + retry: Retry + pin: Literal["x", None] + order: Optional[Literal["asc", "desc"]] + mode: Literal["auto", 0, 1] = "auto" + tag: Literal["a", None] = "a" + size: Literal[10, 25, 50] = 25 + + # ---------------------------------------------------------------- containers @@ -258,6 +324,42 @@ class OrderStatus(str, Enum): shipped = "shipped" +class Kind(str, Enum): + cat = "cat" + dog = "dog" + + +class Cat(Camel): + pet_kind: Literal[Kind.cat] + lives: int + + +class Dog(Camel): + pet_kind: Literal[Kind.dog] + good_boy: bool + + +# Tagged by a camelCase key whose values are enum members. +Pet = Annotated[Union[Cat, Dog], Field(discriminator="pet_kind")] + + +def _method(v: Any) -> Optional[str]: + return v.get("method") if isinstance(v, dict) else getattr(v, "method", None) + + +# A callable discriminator: the spec has a plain oneOf, no discriminator. +Tagged = Annotated[Union[Annotated[Card, Tag("card")], Annotated[Transfer, Tag("transfer")]], Discriminator(_method)] + + +class Wallet(Strict): + """The union in every place a model can hold it.""" + + primary: Optional[Payment] = None + by_label: dict[str, Payment] = {} + pets: list[Pet] = [] + tagged: Optional[Tagged] = None + + class Order(OrderIn): id: int status: OrderStatus @@ -322,6 +424,10 @@ class Conflict(Strict): # ---------------------------------------------------------------- state +# Nulls names Dimensions and Payment before they are defined. +Nulls.model_rebuild() +NullsEcho.model_rebuild() + ITEMS: dict[uuid.UUID, Item] = {} ORDERS: dict[int, Order] = {} FILES: dict[str, bytes] = {} @@ -408,6 +514,35 @@ def echo_scalars(body: Scalars) -> Scalars: return body +@app.post("/echo/literals", tags=["echo"], operation_id="echoLiterals") +def echo_literals(body: Literals) -> Literals: + return body + + +@app.post("/echo/nulls", tags=["echo"], operation_id="echoNulls") +def echo_nulls(body: Nulls) -> NullsEcho: + return NullsEcho(**body.model_dump(), sent=sorted(body.model_fields_set)) + + +@app.post("/echo/stamps", tags=["echo"], operation_id="echoStamps") +def echo_stamps(body: Stamps) -> Stamps: + return body + + +@app.get("/stamps", tags=["echo"], operation_id="stampsQuery") +def stamps_query( + days: Annotated[list[dt.date], Query()] = [], + since: Optional[NaiveDatetime] = None, +) -> Stamps: + return Stamps( + naive=since or dt.datetime(2024, 3, 1, 10, 0, 0, 500000), + aware=dt.datetime(2024, 3, 1, 10, tzinfo=dt.timezone.utc), + clock=dt.time(13, 45, 30, 250000), + span=dt.timedelta(seconds=-1.5), + days=days, + ) + + @app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") def echo_containers(body: Containers) -> Containers: return body @@ -468,6 +603,16 @@ def echo_union(body: Union[int, str, list[int]]) -> dict[str, str]: return {"type": type(body).__name__, "value": str(body)} +@app.post("/echo/pet", tags=["echo"], operation_id="echoPet") +def echo_pet(body: Annotated[Pet, Body()]) -> Pet: + return body + + +@app.post("/echo/wallet", tags=["echo"], operation_id="echoWallet") +def echo_wallet(body: Wallet) -> Wallet: + return body + + @app.post("/echo/optional", tags=["echo"], operation_id="echoOptional") def echo_optional(body: Annotated[Optional[Dimensions], Body()] = None) -> dict[str, bool]: return {"got": body is not None} @@ -630,10 +775,15 @@ def upload_files( if name in FILES and not overwrite: raise HTTPException(409, f"{name} exists") FILES[name] = data - out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": ",".join(labels), "meta": meta or ""}) + out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": "|".join(labels), "meta": meta or ""}) return out +@app.post("/forms/typed", tags=["files"], operation_id="submitForm") +def submit_form(body: Annotated[FormIn, Form()]) -> FormIn: + return body + + @app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} @@ -750,3 +900,53 @@ def add(a: int, b: float, round_: Annotated[bool, Query(alias="round")] = False) @app.get("/untagged", operation_id="untagged") def untagged() -> dict[str, bool]: return {"ok": True} + + +# ---------------------------------------------------------------- name clashes +# Parameters that differ on the wire but share a name once camelCased, and +# ones named like a client's own locals; a router prefix and a shared +# dependency bring them in as real services do. + + +def api_version(version: Annotated[str, Header(alias="Version")] = "1") -> str: + return version + + +clash = APIRouter(prefix="/tenants/{tenant}", tags=["clash"]) + + +@clash.get("/things/{item_id}", operation_id="clashLocations") +def clash_locations( + tenant: str, + item_id: int, + item_hdr: Annotated[Optional[str], Header(alias="Item-Id")] = None, + hv: str = Depends(api_version), + version: Optional[int] = None, +) -> dict[str, Any]: + return {"tenant": tenant, "path": item_id, "header": item_hdr, "hversion": hv, "qversion": version} + + +@clash.get("/sorted", operation_id="clashAliases") +def clash_aliases( + tenant: str, + old: Annotated[Optional[str], Query(alias="sort_by", deprecated=True)] = None, + new: Annotated[Optional[str], Query(alias="sortBy")] = None, +) -> dict[str, Optional[str]]: + return {"sort_by": old, "sortBy": new} + + +@clash.post("/notes", operation_id="clashForm") +def clash_form( + tenant: str, + q: Annotated[str, Query(alias="folder_name")], + f: Annotated[str, Form(alias="folderName")], +) -> dict[str, str]: + return {"query": q, "form": f} + + +@app.get("/fetch", tags=["clash"], operation_id="clashLocals") +def clash_locals(url: str, headers: Optional[str] = None, path: Optional[str] = None, query: Optional[str] = None) -> dict[str, Optional[str]]: + return {"url": url, "headers": headers, "path": path, "query": query} + + +app.include_router(clash) diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index 54aa8db..cab7b58 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -69,6 +69,76 @@ cases: priority: 10 unit: lb skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + - name: literals, string members and defaults + op: echoLiterals + args: { body: { retry: always, pin: x, order: asc } } + result: { retry: always, pin: x, order: asc, mode: auto, tag: a, size: 25 } + - name: literals, int members of a mixed enum + op: echoLiterals + args: { body: { retry: 0, pin: x, order: null, mode: 1, size: 50 } } + result: { retry: 0, pin: x, order: null, mode: 1, tag: a, size: 50 } + skip: { go-fetch: "an enum without a type (members of two JSON types) is a Go string: a number can be neither sent nor decoded" } + - name: literals, null as an enum member + op: echoLiterals + args: { body: { retry: 1, pin: null, order: desc, tag: null } } + result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } + skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default", go-fetch: "an enum without a type is a Go string (no number); enum [x, null] has no null: required sends \"\", optional drops it" } + - name: required nullables all null, optional nullables left out + op: echoNulls + args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } + result: + note: null + due: null + color: null + dims: null + lines: null + payment: null + retries: 3 + extras: null + sent: [color, dims, due, lines, note, payment] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: required nullables set, falsy values, explicit null over a default + op: echoNulls + args: + body: { note: "", due: "2024-02-29", color: "", dims: { widthCm: 1, heightCm: 2 }, lines: [], payment: { method: transfer, iban: SK31 }, retries: null, extras: null } + result: + note: "" + due: "2024-02-29" + color: "" + dims: { widthCm: 1, heightCm: 2 } + lines: [] + payment: { method: transfer, iban: SK31 } + retries: null + extras: null + sent: [color, dims, due, extras, lines, note, payment, retries] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: naive, aware and contained times round trip + op: echoStamps + args: + body: + naive: "2024-03-01T10:00:00.5" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.25+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + result: + naive: "2024-03-01T10:00:00.500000" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.250000+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + - name: an offset on a naive field is refused by the server + op: echoStamps + args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } + error: { status: 422, model: { detail: [{ type: timezone_naive }] } } + - name: a naive time in the response and the query, a date list in the query + op: stampsQuery + args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } + result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } + skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } - name: containers, empty and null op: echoContainers args: @@ -147,6 +217,41 @@ cases: op: echoUnion args: { body: [1, 2] } result: { type: list, value: "[1, 2]" } + # A discriminated union as the whole body, a nullable field, map values + # and list items, behind a camelCase alias, and a callable discriminator. + - name: union body, the second member + op: echoPet + args: { body: { petKind: dog, goodBoy: true } } + result: { petKind: dog, goodBoy: true } + - name: union body, the first member + op: echoPet + args: { body: { petKind: cat, lives: 9 } } + result: { petKind: cat, lives: 9 } + - name: union body of the wrong shape is refused + op: echoPet + args: { body: { petKind: cat, goodBoy: true } } + error: { status: 422 } + - name: unions as a nullable field, map values, list items and by a callable + op: echoWallet + args: + body: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + result: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + - name: an explicit null union + op: echoWallet + args: { body: { primary: null } } + result: { primary: null, by_label: {}, pets: [] } + - name: unset unions are left out + op: echoWallet + args: { body: {} } + result: { primary: null, tagged: null } - name: optional body left out op: echoOptional result: { got: false } @@ -286,7 +391,8 @@ cases: folder: docs labels: [x, y] meta: '{"k":1}' - result: [{ name: docs/a b.txt, size: 5, labels: "x,y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + result: [{ name: docs/a b.txt, size: 5, labels: "x|y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } - name: duplicate file is a 409 op: uploadFiles args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } @@ -295,6 +401,16 @@ cases: op: uploadFiles args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs, overwrite: true } result: [{ name: docs/a b.txt, size: 5 }] + - name: typed form, lists as repeated keys + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + result: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: typed form, defaults left out, a number for a Decimal + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } + result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } - name: optional file left out op: uploadAvatar args: { image: { $file: { name: i.png, text: "12345" } } } @@ -385,6 +501,27 @@ cases: op: untagged result: { ok: true } + # ------------------------------------------------------------ name clashes + - name: one camel name for a path parameter and a header, a query and a header + op: clashLocations + args: { tenant: acme, item_id: 7, Item-Id: h-7, Version: "2", version: 3 } + result: { tenant: acme, path: 7, header: h-7, hversion: "2", qversion: 3 } + skip: { typescript-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent", go-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent" } + - name: a snake and a camel alias of one query parameter + op: clashAliases + args: { tenant: acme, sort_by: a, sortBy: b } + result: { sort_by: a, sortBy: b } + skip: { typescript-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent", go-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent" } + - name: a query parameter and a form field of one camel name + op: clashForm + args: { tenant: acme, folder_name: q, folderName: f } + result: { query: q, form: f } + skip: { typescript-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)", go-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)" } + - name: query parameters named like a client's locals + op: clashLocals + args: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + result: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + # ------------------------------------------------------------ cleanup - name: delete is a 204 op: deleteItem diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index c2bbd30..56cb652 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -18,6 +18,19 @@ "title": "Body_cancelOrder", "type": "object" }, + "Body_clashForm": { + "properties": { + "folderName": { + "title": "Foldername", + "type": "string" + } + }, + "required": [ + "folderName" + ], + "title": "Body_clashForm", + "type": "object" + }, "Body_issueToken": { "properties": { "client_id": { @@ -178,6 +191,26 @@ "title": "Card", "type": "object" }, + "Cat": { + "additionalProperties": false, + "properties": { + "lives": { + "title": "Lives", + "type": "integer" + }, + "petKind": { + "const": "cat", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "lives" + ], + "title": "Cat", + "type": "object" + }, "Category-Input": { "additionalProperties": false, "properties": { @@ -446,6 +479,177 @@ "title": "Dimensions", "type": "object" }, + "Dog": { + "additionalProperties": false, + "properties": { + "goodBoy": { + "title": "Goodboy", + "type": "boolean" + }, + "petKind": { + "const": "dog", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "goodBoy" + ], + "title": "Dog", + "type": "object" + }, + "FormIn-Input": { + "additionalProperties": false, + "description": "A form model: every value a client must turn into text itself.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, + "FormIn-Output": { + "additionalProperties": false, + "description": "A form model: every value a client must turn into text itself.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "title": "Price", + "type": "string" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -900,79 +1104,407 @@ "title": "Keywords", "type": "object" }, - "Open": { - "additionalProperties": true, - "description": "Takes any extra key and returns it.", + "Literals": { + "additionalProperties": false, + "description": "Enums whose values are not one JSON type, null among them.", "properties": { - "kind": { - "title": "Kind", - "type": "string" + "mode": { + "default": "auto", + "enum": [ + "auto", + 0, + 1 + ], + "title": "Mode" + }, + "order": { + "anyOf": [ + { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order" + }, + "pin": { + "enum": [ + "x", + null + ], + "title": "Pin" + }, + "retry": { + "$ref": "#/components/schemas/Retry" + }, + "size": { + "default": 25, + "enum": [ + 10, + 25, + 50 + ], + "title": "Size", + "type": "integer" + }, + "tag": { + "default": "a", + "enum": [ + "a", + null + ], + "title": "Tag" } }, "required": [ - "kind" + "retry", + "pin", + "order" ], - "title": "Open", + "title": "Literals", "type": "object" }, - "Order": { + "Nulls": { "additionalProperties": false, + "description": "Null as a required value, and null that differs from a default.", "properties": { - "code": { + "color": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" }, { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", "type": "string" }, { "type": "null" } ], - "title": "Code" + "title": "Due" }, - "extraPayments": { - "default": [], - "items": { - "discriminator": { - "mapping": { - "card": "#/components/schemas/Card", - "transfer": "#/components/schemas/Transfer", - "voucher": "#/components/schemas/Voucher-Output" + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" }, - "propertyName": "method" + "type": "array" }, - "oneOf": [ - { - "$ref": "#/components/schemas/Card" - }, - { - "$ref": "#/components/schemas/Transfer" - }, - { - "$ref": "#/components/schemas/Voucher-Output" - } - ] - }, - "title": "Extrapayments", - "type": "array" + { + "type": "null" + } + ], + "title": "Extras" }, - "gift": { + "lines": { "anyOf": [ { - "type": "boolean" + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" }, { - "type": "string" + "type": "null" } ], - "default": false, - "title": "Gift" + "title": "Lines" }, - "id": { - "title": "Id", - "type": "integer" + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment" + ], + "title": "Nulls", + "type": "object" + }, + "NullsEcho": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Due" + }, + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Extras" + }, + "lines": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Lines" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + }, + "sent": { + "items": { + "type": "string" + }, + "title": "Sent", + "type": "array" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment", + "sent" + ], + "title": "NullsEcho", + "type": "object" + }, + "Open": { + "additionalProperties": true, + "description": "Takes any extra key and returns it.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "Open", + "type": "object" + }, + "Order": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "id": { + "title": "Id", + "type": "integer" }, "lines": { "items": { @@ -1279,6 +1811,15 @@ "title": "Rect", "type": "object" }, + "Retry": { + "description": "Members of two JSON types: the schema has an enum and no type.", + "enum": [ + 0, + "always", + 1 + ], + "title": "Retry" + }, "Scalars-Input": { "additionalProperties": false, "description": "Every scalar format pydantic writes into a schema.", @@ -1549,34 +2090,86 @@ "title": "Secret", "type": "object" }, - "Supplier": { + "Stamps": { "additionalProperties": false, + "description": "Times as ORM-backed services send them: naive, aware, in containers.", "properties": { - "contactEmail": { - "anyOf": [ - { - "format": "email", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Contactemail" + "aware": { + "format": "date-time", + "title": "Aware", + "type": "string" }, - "name": { - "title": "Name", + "clock": { + "format": "time", + "title": "Clock", "type": "string" }, - "website": { - "anyOf": [ - { - "format": "uri", - "maxLength": 2083, - "minLength": 1, - "type": "string" - }, - { + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "naive": { + "format": "date-time", + "title": "Naive", + "type": "string" + }, + "seen": { + "additionalProperties": { + "format": "date-time", + "type": "string" + }, + "default": {}, + "title": "Seen", + "type": "object" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + } + }, + "required": [ + "naive", + "aware", + "clock", + "span" + ], + "title": "Stamps", + "type": "object" + }, + "Supplier": { + "additionalProperties": false, + "properties": { + "contactEmail": { + "anyOf": [ + { + "format": "email", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Contactemail" + }, + "name": { + "title": "Name", + "type": "string" + }, + "website": { + "anyOf": [ + { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + { "type": "null" } ], @@ -1724,6 +2317,212 @@ ], "title": "Voucher", "type": "object" + }, + "Wallet-Input": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" + }, + "Wallet-Output": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" } }, "securitySchemes": { @@ -1980,6 +2779,47 @@ ] } }, + "/echo/literals": { + "post": { + "operationId": "echoLiterals", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Literals", + "tags": [ + "echo" + ] + } + }, "/echo/map": { "post": { "operationId": "echoMap", @@ -2032,15 +2872,14 @@ ] } }, - "/echo/number": { + "/echo/nulls": { "post": { - "operationId": "echoNumber", + "operationId": "echoNulls", "requestBody": { "content": { "application/json": { "schema": { - "title": "Body", - "type": "number" + "$ref": "#/components/schemas/Nulls" } } }, @@ -2051,8 +2890,7 @@ "content": { "application/json": { "schema": { - "title": "Response Echonumber", - "type": "number" + "$ref": "#/components/schemas/NullsEcho" } } }, @@ -2069,20 +2907,21 @@ "description": "Validation Error" } }, - "summary": "Echo Number", + "summary": "Echo Nulls", "tags": [ "echo" ] } }, - "/echo/open": { + "/echo/number": { "post": { - "operationId": "echoOpen", + "operationId": "echoNumber", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Open" + "title": "Body", + "type": "number" } } }, @@ -2093,7 +2932,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Open" + "title": "Response Echonumber", + "type": "number" } } }, @@ -2110,13 +2950,54 @@ "description": "Validation Error" } }, - "summary": "Echo Open", + "summary": "Echo Number", "tags": [ "echo" ] } }, - "/echo/optional": { + "/echo/open": { + "post": { + "operationId": "echoOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Open", + "tags": [ + "echo" + ] + } + }, + "/echo/optional": { "post": { "operationId": "echoOptional", "requestBody": { @@ -2168,6 +3049,77 @@ ] } }, + "/echo/pet": { + "post": { + "operationId": "echoPet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Response Echopet" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Pet", + "tags": [ + "echo" + ] + } + }, "/echo/raw": { "post": { "operationId": "echoRaw", @@ -2294,6 +3246,47 @@ ] } }, + "/echo/stamps": { + "post": { + "operationId": "echoStamps", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Stamps", + "tags": [ + "echo" + ] + } + }, "/echo/text": { "post": { "operationId": "echoText", @@ -2425,6 +3418,47 @@ ] } }, + "/echo/wallet": { + "post": { + "operationId": "echoWallet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wallet", + "tags": [ + "echo" + ] + } + }, "/export.csv": { "get": { "operationId": "exportCsv", @@ -2451,6 +3485,107 @@ ] } }, + "/fetch": { + "get": { + "operationId": "clashLocals", + "parameters": [ + { + "in": "query", + "name": "url", + "required": true, + "schema": { + "title": "Url", + "type": "string" + } + }, + { + "in": "query", + "name": "headers", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Headers" + } + }, + { + "in": "query", + "name": "path", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + { + "in": "query", + "name": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashlocals", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locals", + "tags": [ + "clash" + ] + } + }, "/files": { "post": { "operationId": "uploadFiles", @@ -2615,10 +3750,51 @@ ] } }, - "/items": { - "get": { - "operationId": "listItems", - "parameters": [ + "/forms/typed": { + "post": { + "operationId": "submitForm", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FormIn-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormIn-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Submit Form", + "tags": [ + "files" + ] + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ { "in": "query", "name": "color", @@ -3832,6 +5008,70 @@ ] } }, + "/stamps": { + "get": { + "operationId": "stampsQuery", + "parameters": [ + { + "in": "query", + "name": "days", + "required": false, + "schema": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stamps Query", + "tags": [ + "echo" + ] + } + }, "/stream": { "get": { "operationId": "streamLines", @@ -3878,6 +5118,253 @@ ] } }, + "/tenants/{tenant}/notes": { + "post": { + "operationId": "clashForm", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "query", + "name": "folder_name", + "required": true, + "schema": { + "title": "Folder Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_clashForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Clashform", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Form", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/sorted": { + "get": { + "operationId": "clashAliases", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "deprecated": true, + "in": "query", + "name": "sort_by", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Sort By" + } + }, + { + "in": "query", + "name": "sortBy", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sortby" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashaliases", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Aliases", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/things/{item_id}": { + "get": { + "operationId": "clashLocations", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "in": "header", + "name": "Item-Id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Item-Id" + } + }, + { + "in": "header", + "name": "Version", + "required": false, + "schema": { + "default": "1", + "title": "Version", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Clashlocations", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locations", + "tags": [ + "clash" + ] + } + }, "/token": { "post": { "operationId": "issueToken", From fa94e05b513b17d4edd2d6a881dff28b5669e993 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 19:52:46 +0100 Subject: [PATCH 10/14] 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. The e2e runners number a repeated identifier the same way, and the FastAPI name-clash cases now pass for go-fetch and typescript-fetch. --- internal/e2e/scenario_test.go | 14 ++--- internal/parser/dedupe_test.go | 47 +++++++++++++++++ internal/parser/operation.go | 73 +++++++++++++++++++++------ testdata/e2e/runners/dart/runner.dart | 10 +++- testdata/e2e/runners/go/runner.go | 16 +++++- testdata/e2e/runners/ts/runner.ts | 8 ++- testdata/e2e/scenarios/fastapi.yaml | 3 -- 7 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 internal/parser/dedupe_test.go diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index 0ab7853..c78437f 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -8,6 +8,7 @@ package e2e import ( "bytes" "encoding/json" + "maps" "os" "path/filepath" "slices" @@ -116,9 +117,11 @@ func loadScenarios(t *testing.T) []*scenario { return out } -// opParams lists, per operationId, the names a case's args may use: the -// parameters and, for a form body, its properties. A runner uses them to -// tell the body's field from the parameters'. +// opParams lists, per operationId, the names a case's args may use, in the +// order of the spec: the parameters and, for a form body, its properties +// (sorted). A runner uses them to tell the body's field from the +// parameters', and their order to tell apart two names that a generator +// gives one identifier, the second one numbered (itemId, itemId2). func opParams(doc *openapi3.T) map[string][]string { out := map[string][]string{} @@ -136,14 +139,11 @@ func opParams(doc *openapi3.T) map[string][]string { for ct, mt := range op.RequestBody.Value.Content { form := strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" if form && mt.Schema != nil && mt.Schema.Value != nil { - for name := range mt.Schema.Value.Properties { - names = append(names, name) - } + names = append(names, slices.Sorted(maps.Keys(mt.Schema.Value.Properties))...) } } } - slices.Sort(names) out[op.OperationID] = names } } 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. diff --git a/testdata/e2e/runners/dart/runner.dart b/testdata/e2e/runners/dart/runner.dart index c73e74d..999d3ad 100644 --- a/testdata/e2e/runners/dart/runner.dart +++ b/testdata/e2e/runners/dart/runner.dart @@ -134,7 +134,15 @@ Future> callCase(Client client, Map suite, final mm = method.first; final args = c['args'] == null ? {} : jsonDecode(expand(jsonEncode(c['args']), vars, true)) as Map; - final byNorm = {for (final p in (c['params'] as List? ?? [])) norm(p as String): p}; + // The second parameter of one identifier is numbered, as generators do. + final byNorm = {}; + for (final p in (c['params'] as List? ?? []).cast()) { + var key = norm(p); + for (var n = 2; byNorm.containsKey(key); n++) { + key = '${norm(p)}$n'; + } + byNorm[key] = p; + } final named = {}; final used = {}; diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go index d6770d4..edea0ec 100644 --- a/testdata/e2e/runners/go/runner.go +++ b/testdata/e2e/runners/go/runner.go @@ -213,7 +213,7 @@ func params(t reflect.Type, names []string, args map[string]json.RawMessage) (re byField := map[string]string{} for _, name := range names { - byField[norm(name)] = name + byField[numbered(byField, norm(name))] = name } used := map[string]bool{} @@ -482,6 +482,20 @@ func save(result []byte, paths map[string]string, vars map[string]string) { } } +// numbered is key, or key2, key3 when taken: a generator numbers the second +// parameter of one identifier the same way. +func numbered(taken map[string]string, key string) string { + if _, ok := taken[key]; !ok { + return key + } + + for n := 2; ; n++ { + if k := key + strconv.Itoa(n); taken[k] == "" { + return k + } + } +} + // norm drops all but letters and digits and lowers them, so a spec name // and the identifier a generator makes of it compare equal. func norm(s string) string { diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index 037ebed..41d0b96 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -177,8 +177,14 @@ async function callCase(client: Client, info: ReturnType, suite: const fields = m.request ? (info.requests[m.request] ?? {}) : {}; const byNorm: Record = {}; + // The second parameter of one identifier is numbered, as generators do. for (const p of c.params ?? []) { - byNorm[norm(p)] = p; + let key = norm(p); + for (let n = 2; key in byNorm; n++) { + key = norm(p) + n; + } + + byNorm[key] = p; } const params: Record = {}; diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index cab7b58..aa0d44c 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -506,17 +506,14 @@ cases: op: clashLocations args: { tenant: acme, item_id: 7, Item-Id: h-7, Version: "2", version: 3 } result: { tenant: acme, path: 7, header: h-7, hversion: "2", qversion: 3 } - skip: { typescript-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent", go-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent" } - name: a snake and a camel alias of one query parameter op: clashAliases args: { tenant: acme, sort_by: a, sortBy: b } result: { sort_by: a, sortBy: b } - skip: { typescript-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent", go-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent" } - name: a query parameter and a form field of one camel name op: clashForm args: { tenant: acme, folder_name: q, folderName: f } result: { query: q, form: f } - skip: { typescript-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)", go-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)" } - name: query parameters named like a client's locals op: clashLocals args: { url: "https://x.example/?a=1", headers: h, path: p, query: q } From 9feaec70b7c9d9bbfb6cc100acbe6d8a0cb0534f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 19:52:52 +0100 Subject: [PATCH 11/14] 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/e2e/scenarios/fastapi.yaml | 7 +- testdata/gofetch/dates/dates_test.go | 29 +++++++- .../parameters-scalar-formats/params_test.go | 2 +- 9 files changed, 124 insertions(+), 21 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/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index aa0d44c..d8b47ed 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -77,12 +77,11 @@ cases: op: echoLiterals args: { body: { retry: 0, pin: x, order: null, mode: 1, size: 50 } } result: { retry: 0, pin: x, order: null, mode: 1, tag: a, size: 50 } - skip: { go-fetch: "an enum without a type (members of two JSON types) is a Go string: a number can be neither sent nor decoded" } - name: literals, null as an enum member op: echoLiterals args: { body: { retry: 1, pin: null, order: desc, tag: null } } result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } - skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default", go-fetch: "an enum without a type is a Go string (no number); enum [x, null] has no null: required sends \"\", optional drops it" } + skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default" } - name: required nullables all null, optional nullables left out op: echoNulls args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } @@ -129,7 +128,7 @@ cases: span: "-PT1.5S" days: ["2024-01-31", "2024-12-31"] seen: { a: "2024-03-01T10:00:00Z" } - skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)" } - name: an offset on a naive field is refused by the server op: echoStamps args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } @@ -138,7 +137,7 @@ cases: op: stampsQuery args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } - skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z" } - name: containers, empty and null op: echoContainers args: 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 0f42909c4669238ff1f3f6471b3e158b43a7a3ff Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:34:19 +0100 Subject: [PATCH 12/14] 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 aedc0fac062675b516c8455e21da0af5410990c5 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:34:19 +0100 Subject: [PATCH 13/14] 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 8e4eca945ae6c0c28f805ef5a4ed49b2fedd42c4 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:34:19 +0100 Subject: [PATCH 14/14] test(e2e): cover numbers past int64, list headers and a form model with files --- internal/e2e/scenario_test.go | 4 +- testdata/e2e/backends/fastapi/app.py | 46 ++++++ testdata/e2e/scenarios/fastapi.yaml | 33 +++- testdata/e2e/specs/fastapi.json | 237 +++++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index c78437f..c0401cd 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -121,7 +121,9 @@ func loadScenarios(t *testing.T) []*scenario { // order of the spec: the parameters and, for a form body, its properties // (sorted). A runner uses them to tell the body's field from the // parameters', and their order to tell apart two names that a generator -// gives one identifier, the second one numbered (itemId, itemId2). +// gives one identifier, the second one numbered (itemId, itemId2). kin +// does not keep a schema's property order, so two form fields of one +// identifier must stand in the spec in sorted order too. func opParams(doc *openapi3.T) map[string][]string { out := map[string][]string{} diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 71b16ba..bd8bbdd 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -128,6 +128,16 @@ class Scalars(Strict): unit: Literal["kg", "lb"] = "kg" +class Wide(Strict): + """Numbers past int64 and float64's usual range: Python ints have no bound + and pydantic writes plain integer for them, a uint64 bound as a float.""" + + big: int + u64: Annotated[int, Field(ge=0, le=2**64 - 1)] + f: float + tiny: Annotated[float, Field(gt=0, multiple_of=1e-7)] + + class Stamps(Strict): """Times as ORM-backed services send them: naive, aware, in containers.""" @@ -156,6 +166,16 @@ class NullsEcho(Nulls): sent: list[str] # the keys the client put on the wire +class UploadIn(Strict): + """A form model holding files: FastAPI declares its body + application/x-www-form-urlencoded, yet only multipart carries a file.""" + + folder: str + files: list[UploadFile] + labels: list[str] = [] + cover: Optional[UploadFile] = None + + class FormIn(Strict): """A form model: every value a client must turn into text itself.""" @@ -543,6 +563,11 @@ def stamps_query( ) +@app.post("/echo/wide", tags=["echo"], operation_id="echoWide") +def echo_wide(body: Wide) -> Wide: + return body + + @app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") def echo_containers(body: Containers) -> Containers: return body @@ -670,6 +695,17 @@ def header_params( } +@app.get("/params/header-lists", tags=["params"], operation_id="headerLists") +def header_lists( + x_tags: Annotated[list[str], Header()] = [], + x_ids: Annotated[list[int], Header()] = [], + x_colors: Annotated[list[Color], Header()] = [], +) -> dict[str, Any]: + """FastAPI reads a list header from repeated lines; the spec's simple + style is one comma-joined line, which it takes as one item.""" + return {"tags": x_tags, "ids": x_ids, "colors": [c.value for c in x_colors]} + + # ---------------------------------------------------------------- items CRUD @@ -784,6 +820,16 @@ def submit_form(body: Annotated[FormIn, Form()]) -> FormIn: return body +@app.post("/files/model", tags=["files"], operation_id="uploadModel") +def upload_model(body: Annotated[UploadIn, Form()]) -> dict[str, Any]: + return { + "folder": body.folder, + "files": {f.filename: len(f.file.read()) for f in body.files}, + "labels": body.labels, + "cover": body.cover.filename if body.cover else None, + } + + @app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index d8b47ed..a9a8a1b 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -81,7 +81,6 @@ cases: op: echoLiterals args: { body: { retry: 1, pin: null, order: desc, tag: null } } result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } - skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default" } - name: required nullables all null, optional nullables left out op: echoNulls args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } @@ -196,6 +195,17 @@ cases: op: echoNumber args: { body: 2.25 } result: 4.5 + - name: integers past int64 + op: echoWide + args: { body: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } } + result: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } + skip: + go-fetch: a plain integer is an int, which holds no value past int64 + typescript-fetch: a JavaScript number loses integers past 2^53 + - name: exponent floats + op: echoWide + args: { body: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } } + result: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } - name: text/plain body op: echoText args: { body: héllo } @@ -301,6 +311,17 @@ cases: theme: dark result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } skip: { typescript-fetch: "no cookie parameters" } + - name: list headers, one item each + op: headerLists + args: { x-tags: [a], x-ids: [7], x-colors: [dark-blue] } + result: { tags: [a], ids: [7], colors: [dark-blue] } + - name: list headers, two items each + op: headerLists + args: { x-tags: [a, b], x-ids: [1, 2], x-colors: [red, light green] } + result: { tags: [a, b], ids: [1, 2], colors: [red, light green] } + skip: + go-fetch: &listHeader "a list header goes as one comma-joined line, as the spec's simple style says; FastAPI reads repeated lines only (422 on \"1,2\")" + typescript-fetch: *listHeader # ------------------------------------------------------------ items - name: read-only token cannot write @@ -410,6 +431,16 @@ cases: args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: a form model with files, declared url-encoded + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }, { $file: { name: b.txt, text: de } }], labels: [x, y] } + result: { folder: docs, files: { a.txt: 3, b.txt: 2 }, labels: [x, y], cover: null } + skip: { typescript-fetch: &urlFile "a form declared url-encoded goes as URLSearchParams even with a file, a Blob as text" } + - name: a form model's optional file given + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }], cover: { $file: { name: c.png, text: C } } } + result: { files: { a.txt: 3 }, cover: c.png } + skip: { typescript-fetch: *urlFile } - name: optional file left out op: uploadAvatar args: { image: { $file: { name: i.png, text: "12345" } } } diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index 56cb652..3260735 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -2221,6 +2221,50 @@ "title": "Unit", "type": "string" }, + "UploadIn": { + "additionalProperties": false, + "description": "A form model holding files: FastAPI declares its body\napplication/x-www-form-urlencoded, yet only multipart carries a file.", + "properties": { + "cover": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover" + }, + "files": { + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + } + }, + "required": [ + "folder", + "files" + ], + "title": "UploadIn", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -2523,6 +2567,40 @@ }, "title": "Wallet", "type": "object" + }, + "Wide": { + "additionalProperties": false, + "description": "Numbers past int64 and float64's usual range: Python ints have no bound\nand pydantic writes plain integer for them, a uint64 bound as a float.", + "properties": { + "big": { + "title": "Big", + "type": "integer" + }, + "f": { + "title": "F", + "type": "number" + }, + "tiny": { + "exclusiveMinimum": 0, + "multipleOf": 1e-7, + "title": "Tiny", + "type": "number" + }, + "u64": { + "maximum": 18446744073709552000, + "minimum": 0, + "title": "U64", + "type": "integer" + } + }, + "required": [ + "big", + "u64", + "f", + "tiny" + ], + "title": "Wide", + "type": "object" } }, "securitySchemes": { @@ -3459,6 +3537,47 @@ ] } }, + "/echo/wide": { + "post": { + "operationId": "echoWide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wide", + "tags": [ + "echo" + ] + } + }, "/export.csv": { "get": { "operationId": "exportCsv", @@ -3697,6 +3816,49 @@ ] } }, + "/files/model": { + "post": { + "operationId": "uploadModel", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UploadIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Uploadmodel", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Model", + "tags": [ + "files" + ] + } + }, "/files/{name}": { "get": { "operationId": "downloadFile", @@ -4631,6 +4793,81 @@ ] } }, + "/params/header-lists": { + "get": { + "description": "FastAPI reads a list header from repeated lines; the spec's simple\nstyle is one comma-joined line, which it takes as one item.", + "operationId": "headerLists", + "parameters": [ + { + "in": "header", + "name": "x-tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Tags", + "type": "array" + } + }, + { + "in": "header", + "name": "x-ids", + "required": false, + "schema": { + "default": [], + "items": { + "type": "integer" + }, + "title": "X-Ids", + "type": "array" + } + }, + { + "in": "header", + "name": "x-colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "X-Colors", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerlists", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Lists", + "tags": [ + "params" + ] + } + }, "/params/headers": { "get": { "operationId": "headerParams",