diff --git a/CLAUDE.md b/CLAUDE.md index 9327530..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 ``` @@ -32,7 +33,8 @@ 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/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 c36e7b5..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 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 ./... @@ -72,6 +80,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..efc4428 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 @@ -47,6 +50,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 @@ -75,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` | Generator to use: typescript-fetch, dart-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` @@ -163,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/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..6ec2045 100644 --- a/internal/codegen/parameter.go +++ b/internal/codegen/parameter.go @@ -93,12 +93,14 @@ 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"` + 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/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/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..e647148 --- /dev/null +++ b/internal/e2e/gofetch_test.go @@ -0,0 +1,198 @@ +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 + + // 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() + 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..c0401cd --- /dev/null +++ b/internal/e2e/scenario_test.go @@ -0,0 +1,168 @@ +// 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" + "maps" + "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, 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). 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{} + + 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 { + names = append(names, slices.Sorted(maps.Keys(mt.Schema.Value.Properties))...) + } + } + } + + 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/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/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"}, 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 abbb86e..4df5206 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)) @@ -74,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 @@ -105,6 +111,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/generator/golang/base.go b/internal/generator/golang/base.go new file mode 100644 index 0000000..bdd0cd0 --- /dev/null +++ b/internal/generator/golang/base.go @@ -0,0 +1,436 @@ +// Package golang provides the Go code generator; the name is golang because +// go is a keyword. +package golang + +import ( + "fmt" + "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, "DateTime": 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 || p.Ref != "") && 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 "DateTime" + 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; any when they are of more than one JSON type +// (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 { + switch v.(type) { + case nil: + case string, bool: + kinds[fmt.Sprintf("%T", v)] = true + default: + kinds["number"] = true + } + } + + if len(kinds) > 1 { + return "any" + } + + vars, _ := allowable["enumVars"].([]map[string]any) + if len(vars) > 0 && vars[0]["isString"] == true { + return "string" + } + + if isBoolean { + return "bool" + } + + 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..2fded9b --- /dev/null +++ b/internal/generator/golang/base_test.go @@ -0,0 +1,117 @@ +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}, "DateTime"}, + {&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) + } +} + +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/generator/golang/fetch.go b/internal/generator/golang/fetch.go new file mode 100644 index 0000000..2dfe123 --- /dev/null +++ b/internal/generator/golang/fetch.go @@ -0,0 +1,1247 @@ +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 + + // 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). + 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{}, enums: map[string]bool{}, 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.enums[m.Classname] = kind == "enum" + + 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) + fields[m.Classname] = extraField(m, fields[m.Classname], ext) + 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 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: + return "union" + case m.IsEnum && EnumBaseType(m.AllowableValues, m.IsBoolean) != "any": + 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" +} + +// 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 { + 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.refModel(v.Ref); ref != "" { + named = ref + } else if v.IsEnum && EnumBaseType(v.AllowableValues, v.IsBoolean) != "any" { + 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 +} + +// 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 { + 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.refModel(member.Ref) + + // 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{} + files := false + body := "" + + for _, p := range op.AllParams { + 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 + + 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) + files = files || strings.HasSuffix(t, "File") + + 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. + // 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 && multipart + ext["x-go-body-form"] = form && !multipart +} + +// 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 op.ReturnProperty.IsNullable && t != "string" && !isContainer(t) && !g.containers[t] { + t = "*" + t + } + } + + if t != "" && t != "string" && !strings.HasPrefix(t, "*") && !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 + g.other2xx(op, ext) + } + + 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 + } + + keys := g.modelKeys(rt) + + 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, + } + } +} + +// 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 + 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..2105559 --- /dev/null +++ b/internal/generator/golang/fetch_test.go @@ -0,0 +1,457 @@ +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) + } +} + +// 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) + } +} + +// 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/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/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/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/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 } diff --git a/internal/parser/normalize.go b/internal/parser/normalize.go new file mode 100644 index 0000000..d714cb6 --- /dev/null +++ b/internal/parser/normalize.go @@ -0,0 +1,273 @@ +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, 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 +// 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 + } + + // 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" + } + } + + 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..1385118 --- /dev/null +++ b/internal/parser/normalize_test.go @@ -0,0 +1,79 @@ +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= ", + "tag": "string nullable=true 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 9872696..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" @@ -224,8 +225,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) @@ -265,7 +267,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 @@ -306,11 +308,21 @@ 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, UniqueItems: prop.UniqueItems, + Vars: prop.Vars, } + + // The property's encoding with its defaults filled in: form style, + // exploded. go-fetch reads it for urlencoded and multipart bodies. + 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 @@ -342,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 + } + + 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) } - _ = i // unused + 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. @@ -403,7 +455,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 @@ -469,6 +521,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..c68b347 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -81,9 +81,12 @@ 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 + cp.IsNullable = prop.IsNullable + cp.Ref = prop.Ref // Collection format if cp.IsArray { diff --git a/internal/parser/property.go b/internal/parser/property.go index c55fc56..e0e56aa 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -61,6 +61,16 @@ 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) + } + + // 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 @@ -83,6 +93,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. @@ -106,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 } @@ -266,6 +326,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/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/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/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..f37c382 --- /dev/null +++ b/templates/go-fetch/README.md @@ -0,0 +1,98 @@ +# 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 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 | +| --------------- | ---------------------------------------------------------------------- | +| `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` / `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 | +| `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`); 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` +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/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..04dfe6c --- /dev/null +++ b/templates/go-fetch/api.mustache @@ -0,0 +1,271 @@ +// 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) { +{{#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}} +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..637db08 --- /dev/null +++ b/templates/go-fetch/model.mustache @@ -0,0 +1,94 @@ +// 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-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}} +{{>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..6100fd7 --- /dev/null +++ b/templates/go-fetch/runtime.mustache @@ -0,0 +1,935 @@ +// 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, +// and the typed method for a 2xx whose body is not the result's type. +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 } + +// 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 } + +// 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 + } + + 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 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 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, 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 { + return out, err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil || len(body) == 0 { + return out, err + } + + 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 + } + } + } + + 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 (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: + 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, DateTime, 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/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[] = [ diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md new file mode 100644 index 0000000..6a7dc98 --- /dev/null +++ b/testdata/e2e/README.md @@ -0,0 +1,143 @@ +# 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, 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 + +`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 | + +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 +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..bd8bbdd --- /dev/null +++ b/testdata/e2e/backends/fastapi/app.py @@ -0,0 +1,998 @@ +"""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 ( + APIRouter, + 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, + AwareDatetime, + BaseModel, + ConfigDict, + Discriminator, + EmailStr, + Field, + HttpUrl, + NaiveDatetime, + SecretStr, + Tag, + 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" + + +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.""" + + 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 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.""" + + 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 + + +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 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 + 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 + +# 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] = {} + +# ---------------------------------------------------------------- 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/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/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 + + +@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/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} + + +# ---------------------------------------------------------------- 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, + } + + +@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 + + +@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("/forms/typed", tags=["files"], operation_id="submitForm") +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} + + +@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} + + +# ---------------------------------------------------------------- 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/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..999d3ad --- /dev/null +++ b/testdata/e2e/runners/dart/runner.dart @@ -0,0 +1,225 @@ +// 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; + // 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 = {}; + + 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..edea0ec --- /dev/null +++ b/testdata/e2e/runners/go/runner.go @@ -0,0 +1,511 @@ +// 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[numbered(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) + } + } +} + +// 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 { + 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..41d0b96 --- /dev/null +++ b/testdata/e2e/runners/ts/runner.ts @@ -0,0 +1,275 @@ +// 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 = {}; + // The second parameter of one identifier is numbered, as generators do. + for (const p of c.params ?? []) { + let key = norm(p); + for (let n = 2; key in byNorm; n++) { + key = norm(p) + n; + } + + byNorm[key] = 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..a9a8a1b --- /dev/null +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -0,0 +1,559 @@ +# 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: 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 } + - 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 } + - 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)" } + - 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" } + - 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: 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 } + 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]" } + # 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 } + - 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" } + - 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 + 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 }] + 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 } + 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: 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: 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" } } } + 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 } + + # ------------------------------------------------------------ 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 } + - 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 } + - 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 } + - 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 + 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..3260735 --- /dev/null +++ b/testdata/e2e/specs/fastapi.json @@ -0,0 +1,5719 @@ +{ + "components": { + "schemas": { + "Body_cancelOrder": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "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": { + "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" + }, + "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": { + "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" + }, + "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": { + "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" + }, + "Literals": { + "additionalProperties": false, + "description": "Enums whose values are not one JSON type, null among them.", + "properties": { + "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": [ + "retry", + "pin", + "order" + ], + "title": "Literals", + "type": "object" + }, + "Nulls": { + "additionalProperties": false, + "description": "Null as a required value, and null that differs from a default.", + "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-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": { + "$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" + }, + "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.", + "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" + }, + "Stamps": { + "additionalProperties": false, + "description": "Times as ORM-backed services send them: naive, aware, in containers.", + "properties": { + "aware": { + "format": "date-time", + "title": "Aware", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "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" + } + ], + "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" + }, + "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": { + "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" + }, + "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" + }, + "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": { + "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/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", + "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/nulls": { + "post": { + "operationId": "echoNulls", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nulls" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullsEcho" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Nulls", + "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/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", + "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/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", + "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" + ] + } + }, + "/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" + ] + } + }, + "/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", + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Export Csv", + "tags": [ + "files" + ] + } + }, + "/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", + "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/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", + "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" + ] + } + }, + "/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", + "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/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", + "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" + ] + } + }, + "/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", + "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" + ] + } + }, + "/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", + "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/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/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..95108c6 --- /dev/null +++ b/testdata/gofetch/dates/dates_test.go @@ -0,0 +1,74 @@ +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: &DateTime{Time: 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: DateTime{Time: 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) + } +} + +// 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/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/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-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/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-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-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/parameters-scalar-formats/params_test.go b/testdata/gofetch/parameters-scalar-formats/params_test.go new file mode 100644 index 0000000..56cf7cd --- /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: &DateTime{Time: 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-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..a1f74f1 --- /dev/null +++ b/testdata/gofetch/request-body-form-urlencoded/body_test.go @@ -0,0 +1,75 @@ +package requestbodyformurlencoded + +import ( + "context" + "io" + "mime" + "mime/multipart" + "net/url" + "strings" + "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) + } +} + +// 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/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/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/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-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/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-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/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/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/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/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/nullable-openapi31.yaml b/testdata/specs/feature/nullable-openapi31.yaml new file mode 100644 index 0000000..df010c1 --- /dev/null +++ b/testdata/specs/feature/nullable-openapi31.yaml @@ -0,0 +1,93 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 nullability + version: 1.0.0 + description: The 3.1 spellings of null (a null member of anyOf/oneOf, a type list with null) and of a binary string (contentMediaType), and of a single value (const) or a choice of them, a null among enum values, as FastAPI and pydantic write them; multipart arrays, exploded by default. +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 }] + tag: + type: string + enum: [a, null] + MaybeName: + anyOf: [{ type: string }, { type: "null" }] diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml new file mode 100644 index 0000000..9679d0a --- /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, explode: false, 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-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 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 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 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