From fc738b365bbda74653c09a016b8a9bac29e6e6a0 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 18:14:53 +0100 Subject: [PATCH 1/6] test(e2e): run generated clients against a mock and a FastAPI backend `make e2e` checks what a generated client puts on the wire and how it decodes the answer, for go-fetch, typescript-fetch and dart-fetch alike. The cases are language-neutral scenarios (testdata/e2e/scenarios) in the spec's own names and wire values: - a mock layer: a server built on kin-openapi validates each request and each scenario response against the feature spec and records the request, so a case checks the exact path, query, headers, cookies and body; - a backend layer: a strict FastAPI app (pydantic, uv-locked) whose served OpenAPI 3.1 spec is snapshotted, called through a client generated from it, echoing what it parsed. A runner per generator turns the scenarios into calls of its client the way its users write them (Go by reflection, TypeScript from the compiled sources, Dart with mirrors) and prints one JSON line per case. A gap of a generator is a skip with its reason on the case; a skip that starts passing fails. go-fetch passes every case; typescript-fetch skips 29 and dart-fetch 103, mostly clients that do not compile. The cookie array of parameters-object-styles is explode: false: the OpenAPI serialization table defines no exploded form for a cookie. --- CLAUDE.md | 2 + Makefile | 10 +- go.sum | 2 + internal/e2e/dart_test.go | 178 + internal/e2e/e2e_test.go | 382 ++ internal/e2e/gofetch_test.go | 196 + internal/e2e/match_test.go | 157 + internal/e2e/mock_test.go | 286 ++ internal/e2e/scenario_test.go | 166 + internal/e2e/typescript_test.go | 84 + testdata/e2e/README.md | 138 + testdata/e2e/backends/fastapi/.gitignore | 2 + testdata/e2e/backends/fastapi/app.py | 752 ++++ testdata/e2e/backends/fastapi/pyproject.toml | 14 + testdata/e2e/backends/fastapi/uv.lock | 313 ++ testdata/e2e/runners/dart/runner.dart | 217 + testdata/e2e/runners/go/runner.go | 497 ++ testdata/e2e/runners/ts/runner.ts | 269 ++ .../e2e/scenarios/additional-properties.yaml | 9 + testdata/e2e/scenarios/discriminator.yaml | 4 + testdata/e2e/scenarios/fastapi.yaml | 395 ++ .../e2e/scenarios/nullable-openapi31.yaml | 16 + testdata/e2e/scenarios/nullable.yaml | 9 + .../e2e/scenarios/parameters-deepobject.yaml | 8 + .../e2e/scenarios/parameters-locations.yaml | 20 + .../scenarios/parameters-object-styles.yaml | 43 + .../parameters-objects-in-query.yaml | 8 + .../e2e/scenarios/parameters-path-style.yaml | 5 + .../scenarios/parameters-scalar-formats.yaml | 9 + testdata/e2e/scenarios/parameters.yaml | 13 + testdata/e2e/scenarios/request-bodies.yaml | 18 + .../e2e/scenarios/request-body-forms.yaml | 26 + .../e2e/scenarios/request-body-login.yaml | 7 + .../e2e/scenarios/request-body-multipart.yaml | 15 + .../scenarios/request-body-other-media.yaml | 22 + testdata/e2e/scenarios/responses-binary.yaml | 5 + .../e2e/scenarios/responses-mixed-2xx.yaml | 13 + testdata/e2e/scenarios/responses.yaml | 21 + testdata/e2e/scenarios/security-http.yaml | 6 + testdata/e2e/scenarios/security-oauth2.yaml | 6 + testdata/e2e/scenarios/security.yaml | 7 + testdata/e2e/scenarios/unions-anyof.yaml | 8 + testdata/e2e/scenarios/unions-inline.yaml | 10 + testdata/e2e/scenarios/unions.yaml | 15 + testdata/e2e/specs/fastapi.json | 3995 +++++++++++++++++ testdata/specs/feature/README.md | 4 + .../feature/parameters-object-styles.yaml | 2 +- 47 files changed, 8382 insertions(+), 2 deletions(-) create mode 100644 internal/e2e/dart_test.go create mode 100644 internal/e2e/e2e_test.go create mode 100644 internal/e2e/gofetch_test.go create mode 100644 internal/e2e/match_test.go create mode 100644 internal/e2e/mock_test.go create mode 100644 internal/e2e/scenario_test.go create mode 100644 internal/e2e/typescript_test.go create mode 100644 testdata/e2e/README.md create mode 100644 testdata/e2e/backends/fastapi/.gitignore create mode 100644 testdata/e2e/backends/fastapi/app.py create mode 100644 testdata/e2e/backends/fastapi/pyproject.toml create mode 100644 testdata/e2e/backends/fastapi/uv.lock create mode 100644 testdata/e2e/runners/dart/runner.dart create mode 100644 testdata/e2e/runners/go/runner.go create mode 100644 testdata/e2e/runners/ts/runner.ts create mode 100644 testdata/e2e/scenarios/additional-properties.yaml create mode 100644 testdata/e2e/scenarios/discriminator.yaml create mode 100644 testdata/e2e/scenarios/fastapi.yaml create mode 100644 testdata/e2e/scenarios/nullable-openapi31.yaml create mode 100644 testdata/e2e/scenarios/nullable.yaml create mode 100644 testdata/e2e/scenarios/parameters-deepobject.yaml create mode 100644 testdata/e2e/scenarios/parameters-locations.yaml create mode 100644 testdata/e2e/scenarios/parameters-object-styles.yaml create mode 100644 testdata/e2e/scenarios/parameters-objects-in-query.yaml create mode 100644 testdata/e2e/scenarios/parameters-path-style.yaml create mode 100644 testdata/e2e/scenarios/parameters-scalar-formats.yaml create mode 100644 testdata/e2e/scenarios/parameters.yaml create mode 100644 testdata/e2e/scenarios/request-bodies.yaml create mode 100644 testdata/e2e/scenarios/request-body-forms.yaml create mode 100644 testdata/e2e/scenarios/request-body-login.yaml create mode 100644 testdata/e2e/scenarios/request-body-multipart.yaml create mode 100644 testdata/e2e/scenarios/request-body-other-media.yaml create mode 100644 testdata/e2e/scenarios/responses-binary.yaml create mode 100644 testdata/e2e/scenarios/responses-mixed-2xx.yaml create mode 100644 testdata/e2e/scenarios/responses.yaml create mode 100644 testdata/e2e/scenarios/security-http.yaml create mode 100644 testdata/e2e/scenarios/security-oauth2.yaml create mode 100644 testdata/e2e/scenarios/security.yaml create mode 100644 testdata/e2e/scenarios/unions-anyof.yaml create mode 100644 testdata/e2e/scenarios/unions-inline.yaml create mode 100644 testdata/e2e/scenarios/unions.yaml create mode 100644 testdata/e2e/specs/fastapi.json diff --git a/CLAUDE.md b/CLAUDE.md index d9e66f0..fe1e3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ make lint # golangci-lint, config in .golangci.yml — must be cle make fmt # gofumpt -w . make cover # coverage.out + per-function summary make regress REF=master # generated output, working tree vs a git ref +make e2e # generated clients against a mock and FastAPI (testdata/e2e) go test ./internal/parser -run TestName ``` @@ -33,6 +34,7 @@ go.yaml.in/yaml/v3. No cgo. | `internal/codegen` | the `Codegen*` structs templates render; fields mirror the Java originals | | `internal/gen` | the pipeline: load spec → parse → render models, apis, supporting files | | `internal/generator` | `CodegenConfig` and the language configs, `typescript/`, `dart/`, `golang/` | +| `internal/e2e` | end-to-end tests: mock server, runners, scenarios in `testdata/e2e` | | `internal/template` | the Mustache engine: partials, lambdas, struct-to-map via json | | `internal/config` | generator options and their config-file keys | | `internal/update` | release check, verified download, self-update | diff --git a/Makefile b/Makefile index fabe9ac..1c1dede 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch samples-go-fetch regress all +.PHONY: help test cover cover-html lint fmt vet build install clean tidy update samples-dart-fetch samples-go-fetch regress e2e e2e-update all GOFUMPT ?= go run mvdan.cc/gofumpt@v0.12.0 GOLANGCI ?= go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.0 @@ -19,6 +19,14 @@ test: go vet ./... go test -race ./... +## e2e: generated clients against a validating mock and the FastAPI backend (needs uv), testdata/e2e +e2e: + E2E=1 go test -count=1 -v ./internal/e2e + +## e2e-update: e2e, rewriting the snapshot of the backend's spec +e2e-update: + E2E=1 E2E_UPDATE=1 go test -count=1 -v ./internal/e2e + ## cover: Run tests with coverage cover: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... diff --git a/go.sum b/go.sum index be9fe39..423f335 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3U github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/internal/e2e/dart_test.go b/internal/e2e/dart_test.go new file mode 100644 index 0000000..4719945 --- /dev/null +++ b/internal/e2e/dart_test.go @@ -0,0 +1,178 @@ +package e2e + +import ( + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// dartVersion reads "Dart SDK version: 3.13.7 ..." as 3 and 13. +var dartVersion = regexp.MustCompile(`version: (\d+)\.(\d+)`) + +// runDart generates a dart-fetch package per suite, depends on them from a +// package holding testdata/e2e/runners/dart and runs the suites on the VM. +// E2E_DART names the dart binary; the clients need Dart 3.13 or later. +func runDart(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + dart := os.Getenv("E2E_DART") + if dart == "" { + dart = "dart" + } + + ver, err := exec.Command(dart, "--version").CombinedOutput() + if m := dartVersion.FindStringSubmatch(string(ver)); err != nil || m == nil || m[1] == "3" && atoi(m[2]) < 13 { + t.Skipf("dart-fetch clients need Dart 3.13 or later (set E2E_DART): %s %v", strings.TrimSpace(string(ver)), err) + } + + dir := t.TempDir() + + runner, err := os.ReadFile("../../testdata/e2e/runners/dart/runner.dart") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.dart"), string(runner)) + + for i, s := range suites { + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, "c"+strconv.Itoa(i)), + GeneratorName: "dart-fetch", + TemplateDir: "../../templates/dart-fetch", + AdditionalProperties: []string{"pubName=c" + strconv.Itoa(i)}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + } + + // A client that does not compile would stop the whole program: it + // leaves the run, and its cases fail with its first error. + broken := dartErrors(t, dart, dir) + failed := map[string]map[int]outcome{} + + var imports, clients, deps strings.Builder + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + if msg, ok := broken[pkg]; ok { + failed[s.Name] = map[int]outcome{} + for j := range s.Cases { + failed[s.Name][j] = outcome{Suite: s.Name, Case: j, Fail: "the client does not compile: " + msg} + } + + continue + } + + fmt.Fprintf(&deps, " %s:\n path: %s\n", pkg, pkg) + fmt.Fprintf(&imports, "import 'package:%s/%s.dart' as %s;\n", pkg, pkg, pkg) + fmt.Fprintf(&clients, " %q: Client(\n (base, a) => %s.Configuration(basePath: base, username: a.username, password: a.password, "+ + "apiKey: (name) => a.apiKey ?? '', accessToken: () => a.bearer ?? ''),\n (cfg) => [%s],\n %q),\n", + s.Name, pkg, dartAPIs(t, filepath.Join(dir, pkg), pkg), pkg) + } + + var running []suite + + for _, s := range suites { + if _, ok := failed[s.Name]; !ok { + running = append(running, s) + } + } + + if len(running) == 0 { + return failed + } + + write(t, filepath.Join(dir, "pubspec.yaml"), "name: e2e_runner\npublish_to: none\nenvironment:\n sdk: '>=3.13.0 <4.0.0'\ndependencies:\n"+deps.String()) + write(t, filepath.Join(dir, "main.dart"), "import 'runner.dart';\n"+imports.String()+ + "\nvoid main(List args) => run(args[0], {\n"+clients.String()+"});\n") + + get := exec.Command(dart, "pub", "get", "--offline") + get.Dir = dir + + if out, err := get.CombinedOutput(); err != nil { + t.Fatalf("dart pub get: %v\n%s", err, out) + } + + writeInput(t, dir, running) + + cmd := exec.Command(dart, "run", "main.dart", filepath.Join(dir, "input.json")) + cmd.Dir = dir + + out := execRunner(t, cmd) + maps.Copy(out, failed) + + return out +} + +// dartErrors analyzes the generated packages under dir and returns the +// first compile error of each package that has one. +func dartErrors(t *testing.T, dart, dir string) map[string]string { + t.Helper() + + cmd := exec.Command(dart, "analyze", "--format=machine", ".") + cmd.Dir = dir + + out, _ := cmd.CombinedOutput() + + errs := map[string]string{} + + for _, line := range strings.Split(string(out), "\n") { + f := strings.Split(line, "|") + if len(f) < 8 || f[0] != "ERROR" { + continue + } + + rel, err := filepath.Rel(dir, f[3]) + if err != nil { + continue + } + + pkg, _, _ := strings.Cut(rel, string(filepath.Separator)) + if _, seen := errs[pkg]; !seen { + errs[pkg] = fmt.Sprintf("%s:%s: %s", rel, f[4], f[7]) + } + } + + return errs +} + +// dartAPIs lists the constructors of a package's API classes for cfg. +func dartAPIs(t *testing.T, dir, pkg string) string { + t.Helper() + + files, _ := filepath.Glob(filepath.Join(dir, "lib", "src", "apis", "*.dart")) + class := regexp.MustCompile(`(?m)^class (\w+) extends BaseApi`) + + var calls []string + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + + for _, m := range class.FindAllStringSubmatch(string(data), -1) { + calls = append(calls, pkg+"."+m[1]+"(cfg as "+pkg+".Configuration)") + } + } + + return strings.Join(calls, ", ") +} + +func atoi(s string) int { + n, _ := strconv.Atoi(s) + + return n +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go new file mode 100644 index 0000000..73bf716 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,382 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "net" + "net/http" + "os" + "os/exec" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" +) + +// runner runs suites through the clients of one generator, named as +// scenarios name it in skip, and returns the outcomes by suite and case. +type runner struct { + generator string + run func(t *testing.T, suites []suite) map[string]map[int]outcome +} + +var runners = []runner{ + {"go-fetch", runGoFetch}, + {"typescript-fetch", runTypeScript}, + {"dart-fetch", runDart}, +} + +// forEachRunner runs fn per generator; E2E_GENERATOR= picks one. +func forEachRunner(t *testing.T, fn func(t *testing.T, r runner)) { + t.Helper() + + for _, r := range runners { + if only := os.Getenv("E2E_GENERATOR"); only != "" && only != r.generator { + continue + } + + t.Run(r.generator, func(t *testing.T) { fn(t, r) }) + } +} + +// requireE2E skips unless the run asks for the e2e tests: they need uv, +// build clients and start servers. +func requireE2E(t *testing.T) { + t.Helper() + + if os.Getenv("E2E") == "" { + t.Skip("set E2E=1 (make e2e) to run the e2e tests") + } +} + +// TestMock runs the scenarios of the feature specs against the mock: each +// request must pass the spec's validation and look as the case says, and +// the client must return what the case says from the mock's response. +func TestMock(t *testing.T) { + requireE2E(t) + + scenarios := loadScenarios(t) + + forEachRunner(t, func(t *testing.T, r runner) { + t.Helper() + + var ( + suites []suite + mocks = map[string]*mock{} + byName = map[string]*scenario{} + ) + + for _, s := range scenarios { + if s.Spec == "" { + continue + } + + m := newMock(t, s.Spec, s.Cases) + mocks[s.Name], byName[s.Name] = m, s + suites = append(suites, toSuite(t, s, s.Spec, m.srv.URL, true)) + } + + outcomes := r.run(t, suites) + tally := map[string]*counts{} + + for _, s := range suites { + t.Run(s.Name, func(t *testing.T) { + tally[s.Name] = checkCases(t, r.generator, byName[s.Name], outcomes[s.Name], mocks[s.Name]) + }) + } + + report(t, r.generator, tally) + }) +} + +// counts are a suite's cases by how they ended. +type counts struct{ pass, fail, skip int } + +// report logs a PASS/FAIL/SKIP table of the suites for the generator. +func report(t *testing.T, generator string, tally map[string]*counts) { + t.Helper() + + var b strings.Builder + + fmt.Fprintf(&b, "\n%-32s %5s %5s %5s (%s)\n", "scenario", "pass", "fail", "skip", generator) + + for _, name := range slices.Sorted(maps.Keys(tally)) { + c := tally[name] + fmt.Fprintf(&b, "%-32s %5d %5d %5d\n", name, c.pass, c.fail, c.skip) + } + + t.Log(b.String()) +} + +// TestFastAPI starts the FastAPI backend, checks its spec against the +// snapshot (E2E_UPDATE=1 rewrites it), generates the client from the spec +// the server serves and runs the backend's scenario against it. +func TestFastAPI(t *testing.T) { + requireE2E(t) + + uv, err := exec.LookPath("uv") + if err != nil { + t.Fatal("the FastAPI backend runs with uv: https://docs.astral.sh/uv/") + } + + scenarios := loadScenarios(t) + + // A fresh backend per generator: the cases share its state. + forEachRunner(t, func(t *testing.T, r runner) { + t.Helper() + + base := startBackend(t, uv, "../../testdata/e2e/backends/fastapi") + + resp, err := http.Get(base + "/openapi.json") + if err != nil { + t.Fatal(err) + } + + defer func() { _ = resp.Body.Close() }() + + var served any + if err := json.NewDecoder(resp.Body).Decode(&served); err != nil { + t.Fatal(err) + } + + spec := checkSnapshot(t, served, "../../testdata/e2e/specs/fastapi.json") + + for _, s := range scenarios { + if s.Backend != "fastapi" { + continue + } + + outcomes := r.run(t, []suite{toSuite(t, s, spec, base, false)}) + + var c *counts + + t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r.generator, s, outcomes[s.Name], nil) }) + report(t, r.generator, map[string]*counts{s.Name: c}) + } + }) +} + +func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suite { + t.Helper() + + doc, err := openapi3.NewLoader().LoadFromFile(specPath) + if err != nil { + t.Fatal(err) + } + + params := opParams(doc) + + out := suite{Name: s.Name, Base: base, Mock: isMock, Auth: s.Auth, Spec: specPath, Schemes: map[string]string{}} + + if doc.Components != nil { + for name, ref := range doc.Components.SecuritySchemes { + if ref.Value != nil { + out.Schemes[name] = ref.Value.Type + } + } + } + + for _, k := range s.Cases { + if _, ok := params[k.Op]; !ok { + t.Fatalf("%s: no operation %s in %s", s.Name, k.Op, specPath) + } + + if !isMock && (k.Request != nil || k.Response != nil) { + t.Fatalf("%s %s: request and response are for the mock; a backend answers itself", s.Name, k.Op) + } + + out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save}) + } + + return out +} + +// checkCases compares each case's outcome, and with a mock the request it +// received, with the scenario. A case skipped for this generator must still +// fail: one that passes has its skip left over. +func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]outcome, m *mock) *counts { + t.Helper() + + c := &counts{} + + for i, k := range s.Cases { + name := k.Name + if name == "" { + name = k.Op + } + + t.Run(fmt.Sprintf("%02d %s", i, name), func(t *testing.T) { + var diffs []string + + if o, ok := outcomes[i]; ok { + diffs = caseDiffs(k, o, m, i) + } else { + diffs = []string{"the runner reported nothing for this case"} + } + + reason, skipped := k.Skip[generator] + if !skipped { + reason, skipped = s.Skip[generator] + } + + switch { + case skipped && len(diffs) == 0: + c.fail++ + + t.Errorf("passes now: drop its skip (%s)", reason) + + case skipped: + c.skip++ + + t.Skipf("%s (%s)", reason, strings.Join(diffs, "; ")) + + case len(diffs) > 0: + c.fail++ + + t.Error("\n" + strings.Join(diffs, "\n")) + + default: + c.pass++ + } + }) + } + + return c +} + +func caseDiffs(k kase, o outcome, m *mock, i int) []string { + var diffs []string + + // With a mock every case's request must be valid for the spec, and the + // scenario's response too; "request" checks more of it. + if m != nil { + m.mu.Lock() + got, ok := m.received[i] + m.mu.Unlock() + + switch { + case !ok: + diffs = append(diffs, "request: none reached the server") + case got["invalid"] != nil: + diffs = append(diffs, fmt.Sprintf("request: invalid for the spec: %v", got["invalid"])) + case got["responseInvalid"] != nil: + diffs = append(diffs, fmt.Sprintf("response: the scenario's is invalid for the spec: %v", got["responseInvalid"])) + case k.Request != nil: + diffs = append(diffs, match("request", decodeJSON(k.Request), got)...) + } + } + + switch { + case o.Fail != "": + diffs = append(diffs, "call failed: "+o.Fail) + case k.Error != nil: + if o.Error == nil { + diffs = append(diffs, "error: want one, got result "+string(o.Result)) + + break + } + + got := map[string]any{"status": json.Number(strconv.Itoa(o.Error.Status)), "body": o.Error.Body, "model": nil} + if len(o.Error.Model) > 0 { + got["model"] = decodeJSON(o.Error.Model) + } + + diffs = append(diffs, match("error", decodeJSON(k.Error), got)...) + + case o.Error != nil: + diffs = append(diffs, fmt.Sprintf("error: %d %s", o.Error.Status, o.Error.Body)) + case k.Result != nil: + diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result))...) + } + + return diffs +} + +// startBackend runs `uv run uvicorn app:app` in dir on a free port and +// returns its URL once it answers; the test stops it. +func startBackend(t *testing.T, uv, dir string) string { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + addr, _ := l.Addr().(*net.TCPAddr) + port := strconv.Itoa(addr.Port) + _ = l.Close() + + cmd := exec.Command(uv, "run", "--quiet", "--locked", "uvicorn", "app:app", "--port", port, "--log-level", "warning") + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + exited := make(chan error, 1) + + go func() { exited <- cmd.Wait() }() + + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + + <-exited + }) + + base := "http://127.0.0.1:" + port + + for deadline := time.Now().Add(2 * time.Minute); time.Now().Before(deadline); { + select { + case err := <-exited: + exited <- err + + t.Fatalf("the backend exited: %v", err) + + case <-time.After(200 * time.Millisecond): + } + + if resp, err := http.Get(base + "/openapi.json"); err == nil { + _ = resp.Body.Close() + + return base + } + } + + t.Fatal("the backend did not start in 2 minutes") + + return "" +} + +// checkSnapshot compares the served spec with the committed one and returns +// the path of the served spec, written next to the test's temp files. +func checkSnapshot(t *testing.T, served any, snapshot string) string { + t.Helper() + + data, err := json.MarshalIndent(served, "", " ") + if err != nil { + t.Fatal(err) + } + + data = append(data, '\n') + + if os.Getenv("E2E_UPDATE") != "" { + if err := os.WriteFile(snapshot, data, 0o644); err != nil { //nolint:gosec // a file in the repository + t.Fatal(err) + } + } else if old, err := os.ReadFile(snapshot); err != nil || !bytes.Equal(old, data) { + t.Errorf("the backend's spec differs from %s; rerun with `make e2e-update` and review the diff", snapshot) + } + + path := t.TempDir() + "/openapi.json" + write(t, path, string(data)) + + return path +} diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go new file mode 100644 index 0000000..0b25ac6 --- /dev/null +++ b/internal/e2e/gofetch_test.go @@ -0,0 +1,196 @@ +package e2e + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// suite is one scenario as a runner runs it: where to send, the cases, and +// for each the names its args use. +type suite struct { + Name string `json:"name"` + Base string `json:"base"` + Mock bool `json:"mock"` + Auth *auth `json:"auth,omitempty"` + Cases []suiteCase `json:"cases"` + Spec string `json:"-"` + + // Schemes are the spec's security schemes by name and type (apiKey, + // http, oauth2, openIdConnect), for a client whose token function + // depends on it. + Schemes map[string]string `json:"schemes"` +} + +type suiteCase struct { + Op string `json:"op"` + Params []string `json:"params"` + Args json.RawMessage `json:"args,omitempty"` + Auth *auth `json:"auth,omitempty"` + Save map[string]string `json:"save,omitempty"` +} + +// outcome is what a runner reports for a case: the result as wire JSON, +// the *ResponseError, or why the call itself failed. +type outcome struct { + Suite string `json:"suite"` + Case int `json:"case"` + Result json.RawMessage `json:"result"` + Error *struct { + Status int `json:"status"` + Model json.RawMessage `json:"model"` + Body string `json:"body"` + } `json:"error"` + Fail string `json:"fail"` +} + +// runGoFetch generates a go-fetch client per suite into one module, builds +// testdata/e2e/runners/go with a main that hands it those clients, runs the +// suites and returns the outcomes by suite name and case. +func runGoFetch(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + goBin, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + write(t, filepath.Join(dir, "go.mod"), "module e2e\n\ngo 1.24\n") + + runner, err := os.ReadFile("../../testdata/e2e/runners/go/runner.go") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.go"), string(runner)) + + var imports, setups strings.Builder + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "go-fetch", + TemplateDir: "../../templates/go-fetch", + AdditionalProperties: []string{"packageName=" + pkg}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&imports, "\t%s \"e2e/%s\"\n", pkg, pkg) + fmt.Fprintf(&setups, "\t\t%q: func(base string, a *Auth) ([]any, any) {\n", s.Name) + fmt.Fprintf(&setups, "\t\t\tcfg := &%s.Configuration{BasePath: base, APIKey: func(context.Context, string, string) (string, error) { return a.APIKey, nil }, "+ + "AccessToken: func(context.Context, string, string, []string) (string, error) { return a.Bearer, nil }}\n\n", pkg) + fmt.Fprintf(&setups, "\t\t\treturn []any{%s}, cfg\n\t\t},\n", constructors(t, filepath.Join(dir, pkg), pkg)) + } + + write(t, filepath.Join(dir, "main.go"), "package main\n\nimport (\n\t\"context\"\n\n"+imports.String()+ + ")\n\nfunc main() {\n\trun(map[string]setup{\n"+setups.String()+"\t})\n}\n") + + build := exec.Command(goBin, "build", "-o", "runner", ".") + build.Dir = dir + + build.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build runner: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(filepath.Join(dir, "runner"), filepath.Join(dir, "input.json"))) +} + +// execRunner runs a built runner and reads its outcome lines. A runner that +// dies leaves the cases after it without an outcome; the ones before still +// count. +func execRunner(t *testing.T, cmd *exec.Cmd) map[string]map[int]outcome { + t.Helper() + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + + stdout, err := cmd.Output() + if err != nil { + t.Errorf("runner: %v\n%s", err, stderr.String()) + } + + out := map[string]map[int]outcome{} + + sc := bufio.NewScanner(bytes.NewReader(stdout)) + sc.Buffer(nil, 1<<26) + + for sc.Scan() { + var o outcome + if err := json.Unmarshal(sc.Bytes(), &o); err != nil { + t.Fatalf("runner output %q: %v", sc.Text(), err) + } + + if out[o.Suite] == nil { + out[o.Suite] = map[int]outcome{} + } + + out[o.Suite][o.Case] = o + } + + return out +} + +// constructors lists the NewAPI(cfg) calls of a generated package. +func constructors(t *testing.T, dir, pkg string) string { + t.Helper() + + files, _ := filepath.Glob(filepath.Join(dir, "api_*.go")) + + var calls []string + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + + for _, line := range strings.Split(string(data), "\n") { + if name, ok := strings.CutPrefix(line, "func New"); ok { + calls = append(calls, pkg+".New"+name[:strings.IndexByte(name, '(')]+"(cfg)") + } + } + } + + return strings.Join(calls, ", ") +} + +// writeInput writes the suites as the runner's input.json in dir. +func writeInput(t *testing.T, dir string, suites []suite) { + t.Helper() + + in, err := json.Marshal(map[string]any{"suites": suites}) + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "input.json"), string(in)) +} + +func write(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/e2e/match_test.go b/internal/e2e/match_test.go new file mode 100644 index 0000000..6d662df --- /dev/null +++ b/internal/e2e/match_test.go @@ -0,0 +1,157 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "maps" + "math/big" + "slices" + "strings" + "testing" +) + +// match compares a scenario's expectation with what arrived, the way the +// scenarios read: an object lists the keys it checks (others may be there), +// an array has exactly its items, "$any" is any value, "$absent" is a key +// that must be missing. In a request, where a query, header or form value is +// a list, a string stands for a list of just that string, and header names +// match in any case. It returns one line per difference. +func match(path string, want, got any) []string { + switch w := want.(type) { + case string: + switch w { + case "$any": + return nil + case "$absent": + return []string{path + ": want absent, got " + show(got)} + } + + if g, ok := got.([]any); ok && len(g) == 1 && strings.HasPrefix(path, "request.") { + got = g[0] + } + + if g, ok := got.(string); !ok || g != w { + return []string{path + ": want " + show(w) + ", got " + show(got)} + } + + return nil + + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + return []string{path + ": want an object, got " + show(got)} + } + + var diffs []string + + for _, k := range slices.Sorted(maps.Keys(w)) { + gv, present := g[k] + if !present && path == "request.header" { + gv, present = headerValue(g, k) + } + + switch { + case w[k] == "$absent": + if present { + diffs = append(diffs, path+"."+k+": want absent, got "+show(gv)) + } + + case !present: + diffs = append(diffs, path+"."+k+": missing, want "+show(w[k])) + default: + diffs = append(diffs, match(path+"."+k, w[k], gv)...) + } + } + + return diffs + + case []any: + g, ok := got.([]any) + if !ok || len(g) != len(w) { + return []string{path + ": want " + show(w) + ", got " + show(got)} + } + + var diffs []string + for i := range w { + diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i])...) + } + + return diffs + + case json.Number: + if g, ok := got.(json.Number); !ok || !sameNumber(w, g) { + return []string{path + ": want " + w.String() + ", got " + show(got)} + } + + return nil + } + + if want != got { + return []string{path + ": want " + show(want) + ", got " + show(got)} + } + + return nil +} + +// headerValue finds header k in any case: names on the wire are canonical +// (X-Request-Id) where a scenario may write the spec's (X-Request-ID). +func headerValue(m map[string]any, k string) (any, bool) { + for mk, v := range m { + if strings.EqualFold(mk, k) { + return v, true + } + } + + return nil, false +} + +// sameNumber compares two JSON numbers exactly: 1 and 1.0 are the same, +// 2^62+1 and 2^62 are not, nor are two integers past int64 that differ. +func sameNumber(a, b json.Number) bool { + x, okA := new(big.Rat).SetString(a.String()) + y, okB := new(big.Rat).SetString(b.String()) + + return okA && okB && x.Cmp(y) == 0 +} + +func show(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + + return string(b) +} + +// TestMatch pins the matcher's rules; it runs without E2E. +func TestMatch(t *testing.T) { + tests := []struct { + name, path, want, got string + ok bool + }{ + {"extra keys allowed", "result", `{"a":1}`, `{"a":1,"b":2}`, true}, + {"missing key", "result", `{"a":1}`, `{"b":1}`, false}, + {"key case matters in a result", "result", `{"id":"1"}`, `{"ID":"1"}`, false}, + {"header name in any case", "request.header", `{"X-Request-ID":"r"}`, `{"X-Request-Id":["r"]}`, true}, + {"string for a one-value list in a request", "request.query", `{"q":"a"}`, `{"q":["a"]}`, true}, + {"but not in a result", "result", `"stored"`, `["stored"]`, false}, + {"array exact length", "result", `[1]`, `[1,2]`, false}, + {"$any present", "result", `{"a":"$any"}`, `{"a":null}`, true}, + {"$any missing", "result", `{"a":"$any"}`, `{}`, false}, + {"$absent missing", "result", `{"a":"$absent"}`, `{}`, true}, + {"$absent null is present", "result", `{"a":"$absent"}`, `{"a":null}`, false}, + {"null is null", "result", `null`, `null`, true}, + {"null is not an object", "result", `null`, `{}`, false}, + {"1 and 1.0", "result", `1`, `1.0`, true}, + {"int64 exact", "result", `4611686018427387905`, `4611686018427387904`, false}, + {"past int64 exact", "result", `12345678901234567890`, `12345678901234567891`, false}, + {"number is not a string", "result", `1`, `"1"`, false}, + } + + for _, tt := range tests { + diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got))) + if (len(diffs) == 0) != tt.ok { + t.Errorf("%s: match(%s, %s) = %v, want ok %t", tt.name, tt.want, tt.got, diffs, tt.ok) + } + } +} diff --git a/internal/e2e/mock_test.go b/internal/e2e/mock_test.go new file mode 100644 index 0000000..25033e9 --- /dev/null +++ b/internal/e2e/mock_test.go @@ -0,0 +1,286 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "maps" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "testing" + "unicode/utf8" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/legacy" +) + +// mock answers each case with its scenario response, after validating the +// request against the spec and recording it as the wire carried it. +type mock struct { + srv *httptest.Server + router routers.Router + cases []kase + + mu sync.Mutex + current int + received map[int]map[string]any +} + +// A JSON-lines body is text to the validator, as to a client. +func init() { + openapi3filter.RegisterBodyDecoder("application/x-ndjson", openapi3filter.PlainBodyDecoder) +} + +// newMock serves the spec at a fresh local URL. +func newMock(t *testing.T, specPath string, cases []kase) *mock { + t.Helper() + + loader := openapi3.NewLoader() + + doc, err := loader.LoadFromFile(specPath) + if err != nil { + t.Fatal(err) + } + + m := &mock{cases: cases, current: -1, received: map[int]map[string]any{}} + m.srv = httptest.NewServer(m) + t.Cleanup(m.srv.Close) + + // A server-side request carries no host: match the path alone. + doc.Servers = openapi3.Servers{{URL: "/"}} + + if m.router, err = legacy.NewRouter(doc); err != nil { + t.Fatal(err) + } + + return m +} + +func (m *mock) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if n, ok := strings.CutPrefix(r.URL.Path, "/__e2e/case/"); ok { + m.mu.Lock() + m.current, _ = strconv.Atoi(n) + m.mu.Unlock() + + return + } + + body, _ := io.ReadAll(r.Body) + got := record(r, body) + + input, err := m.validate(r.Context(), r, body) + if err != nil { + got["invalid"] = err.Error() + } + + m.mu.Lock() + i := m.current + + if _, seen := m.received[i]; !seen { + m.received[i] = got + } + m.mu.Unlock() + + if i < 0 || i >= len(m.cases) || m.cases[i].Response == nil { + http.Error(w, "no response for this case", http.StatusNotImplemented) + + return + } + + status, header, out := m.cases[i].Response.encode() + + // The scenario's response must be one the spec allows, or a case could + // pass on a body no server would send. + if input != nil && !m.cases[i].Response.OffSpec { + err := openapi3filter.ValidateResponse(r.Context(), &openapi3filter.ResponseValidationInput{ + RequestValidationInput: input, Status: status, Header: header, Body: io.NopCloser(bytes.NewReader(out)), + Options: &openapi3filter.Options{IncludeResponseStatus: true, MultiError: true}, + }) + if err != nil { + m.mu.Lock() + got["responseInvalid"] = err.Error() + m.mu.Unlock() + } + } + + maps.Copy(w.Header(), header) + w.WriteHeader(status) + _, _ = w.Write(out) +} + +// validate checks the request against its operation in the spec: path, +// parameters by style and explode, and the body by its media type. It +// returns the input the response is validated with, nil without a route. +func (m *mock) validate(ctx context.Context, r *http.Request, body []byte) (*openapi3filter.RequestValidationInput, error) { + r = r.Clone(ctx) + r.Body = io.NopCloser(bytes.NewReader(body)) + + route, pathParams, err := m.router.FindRoute(r) + if err != nil { + return nil, err + } + + input := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true}, + } + + return input, openapi3filter.ValidateRequest(ctx, input) +} + +// encode is the response as sent: the status (200 by default), headers and +// the body, its Content-Type set from its kind unless the case gives one. +func (resp *mockResponse) encode() (int, http.Header, []byte) { + status := resp.Status + if status == 0 { + status = http.StatusOK + } + + header := http.Header{} + for k, v := range resp.Header { + header.Set(k, v) + } + + var ( + body []byte + contentType string + ) + + switch { + case len(resp.JSON) > 0: + body, contentType = resp.JSON, "application/json" + case resp.Text != nil: + body, contentType = []byte(*resp.Text), "text/plain" + case resp.Base64 != "": + body, _ = base64.StdEncoding.DecodeString(resp.Base64) + contentType = "application/octet-stream" + } + + if contentType != "" && header.Get("Content-Type") == "" { + header.Set("Content-Type", contentType) + } + + return status, header, body +} + +// ignoredHeaders are set by the HTTP client itself, not by the generated +// code a scenario checks. +var ignoredHeaders = map[string]bool{"Accept-Encoding": true, "Content-Length": true, "User-Agent": true, "Cookie": true} + +// record is the request as a scenario's "request" reads it: the method, +// the decoded path, query values, headers, cookies and the body by its +// media type (JSON as JSON, a form as lists of values, multipart as lists +// of parts, text as a string, anything else as base64). +func record(r *http.Request, body []byte) map[string]any { + query := map[string]any{} + for k, vs := range r.URL.Query() { + query[k] = strs(vs) + } + + header := map[string]any{} + + for k, vs := range r.Header { + if !ignoredHeaders[k] { + header[k] = strs(vs) + } + } + + cookie := map[string]any{} + for _, c := range r.Cookies() { + cookie[c.Name] = c.Value + } + + got := map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "rawPath": r.URL.EscapedPath(), + "query": query, + "header": header, + "cookie": cookie, + } + + if len(body) > 0 { + got["body"] = recordBody(r.Header.Get("Content-Type"), body) + } + + return got +} + +func recordBody(contentType string, body []byte) any { + mt, params, _ := mime.ParseMediaType(contentType) + + switch { + case mt == "application/json" || strings.HasSuffix(mt, "+json"): + return decodeJSON(body) + case mt == "application/x-www-form-urlencoded": + values, _ := url.ParseQuery(string(body)) + + form := map[string]any{} + for k, vs := range values { + form[k] = strs(vs) + } + + return form + + case mt == "multipart/form-data": + return recordParts(body, params["boundary"]) + case strings.HasPrefix(mt, "text/"): + return string(body) + } + + return map[string]any{"$base64": base64.StdEncoding.EncodeToString(body)} +} + +// recordParts lists each multipart field's parts: a plain part as its +// text, a file as {filename, contentType, text or base64}. +func recordParts(body []byte, boundary string) any { + parts := map[string]any{} + mr := multipart.NewReader(bytes.NewReader(body), boundary) + + for { + p, err := mr.NextPart() + if err != nil { + return parts + } + + data, _ := io.ReadAll(p) + + var v any = string(data) + + if p.FileName() != "" { + f := map[string]any{"filename": p.FileName(), "contentType": p.Header.Get("Content-Type")} + if utf8.Valid(data) { + f["text"] = string(data) + } else { + f["base64"] = base64.StdEncoding.EncodeToString(data) + } + + v = f + } else if mt, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")); mt == "application/json" || strings.HasSuffix(mt, "+json") { + v = map[string]any{"json": decodeJSON(data)} + } + + list, _ := parts[p.FormName()].([]any) + parts[p.FormName()] = append(list, v) + } +} + +func strs(vs []string) []any { + out := make([]any, len(vs)) + for i, v := range vs { + out[i] = v + } + + return out +} diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go new file mode 100644 index 0000000..0ab7853 --- /dev/null +++ b/internal/e2e/scenario_test.go @@ -0,0 +1,166 @@ +// Package e2e runs generated clients against servers: a mock that validates +// every request against the spec and records it, and real backends. The +// cases are the language-neutral scenarios in testdata/e2e/scenarios; each +// generator has a runner that turns them into calls of its client. Run with +// `make e2e`. +package e2e + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "go.yaml.in/yaml/v3" +) + +// scenario is one testdata/e2e/scenarios file: the cases for one spec, sent +// to the mock (spec) or to a running backend (backend). +type scenario struct { + Name string `json:"-"` + Spec string `json:"spec"` + Backend string `json:"backend"` + Auth *auth `json:"auth"` + Cases []kase `json:"cases"` + + // Skip skips every case for a generator, one whose client for the spec + // does not even compile; a case's own skip says more. + Skip map[string]string `json:"skip"` +} + +type auth struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Bearer string `json:"bearer,omitempty"` +} + +// kase is one call: the operation and its arguments by the spec's names, +// what the mock must receive and answer, and what the client must return. +type kase struct { + Name string `json:"name"` + Op string `json:"op"` + Args json.RawMessage `json:"args"` + Auth *auth `json:"auth"` + Save map[string]string `json:"save"` + Request json.RawMessage `json:"request"` + Response *mockResponse `json:"response"` + Result json.RawMessage `json:"result"` // null is a null result, absent is unchecked + Error json.RawMessage `json:"error"` + Skip map[string]string `json:"skip"` +} + +// mockResponse is what the mock answers a case with: a status, headers and +// one of a JSON, text or base64 body. OffSpec marks one the spec does not +// allow on purpose (an undeclared status), which is otherwise an error. +type mockResponse struct { + OffSpec bool `json:"offSpec"` + Status int `json:"status"` + Header map[string]string `json:"header"` + JSON json.RawMessage `json:"json"` + Text *string `json:"text"` + Base64 string `json:"base64"` +} + +const scenarioDir = "../../testdata/e2e/scenarios" + +// loadScenarios reads every scenario file, YAML turned into JSON so the +// values compare as the wire's (numbers as json.Number). +func loadScenarios(t *testing.T) []*scenario { + t.Helper() + + files, err := filepath.Glob(filepath.Join(scenarioDir, "*.yaml")) + if err != nil || len(files) == 0 { + t.Fatalf("no scenarios in %s: %v", scenarioDir, err) + } + + var out []*scenario + + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + var y any + if err := yaml.Unmarshal(data, &y); err != nil { + t.Fatalf("%s: %v", file, err) + } + + j, err := json.Marshal(y) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + + s := &scenario{Name: strings.TrimSuffix(filepath.Base(file), ".yaml")} + + dec := json.NewDecoder(bytes.NewReader(j)) + dec.UseNumber() + dec.DisallowUnknownFields() + + if err := dec.Decode(s); err != nil { + t.Fatalf("%s: %v", file, err) + } + + if s.Spec != "" { + s.Spec = filepath.Join(scenarioDir, s.Spec) + } + + out = append(out, s) + } + + return out +} + +// opParams lists, per operationId, the names a case's args may use: the +// parameters and, for a form body, its properties. A runner uses them to +// tell the body's field from the parameters'. +func opParams(doc *openapi3.T) map[string][]string { + out := map[string][]string{} + + for _, item := range doc.Paths.Map() { + for _, op := range item.Operations() { + var names []string + + for _, p := range slices.Concat(item.Parameters, op.Parameters) { + if p.Value != nil { + names = append(names, p.Value.Name) + } + } + + if op.RequestBody != nil && op.RequestBody.Value != nil { + for ct, mt := range op.RequestBody.Value.Content { + form := strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" + if form && mt.Schema != nil && mt.Schema.Value != nil { + for name := range mt.Schema.Value.Properties { + names = append(names, name) + } + } + } + } + + slices.Sort(names) + out[op.OperationID] = names + } + } + + return out +} + +// decodeJSON decodes JSON keeping numbers exact. +func decodeJSON(b []byte) any { + var v any + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + if err := dec.Decode(&v); err != nil { + return string(b) + } + + return v +} diff --git a/internal/e2e/typescript_test.go b/internal/e2e/typescript_test.go new file mode 100644 index 0000000..6b0bdd5 --- /dev/null +++ b/internal/e2e/typescript_test.go @@ -0,0 +1,84 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// runTypeScript generates a typescript-fetch client per suite, compiles +// them with testdata/e2e/runners/ts to CommonJS and runs the suites with +// node. +func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + tsc, err := exec.LookPath("tsc") + if err != nil { + t.Fatal("the typescript-fetch runner compiles with tsc (npm i -g typescript)") + } + + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("the typescript-fetch runner runs with node 22 or later") + } + + dir := t.TempDir() + + runner, err := os.ReadFile("../../testdata/e2e/runners/ts/runner.ts") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.ts"), string(runner)) + + var main strings.Builder + + main.WriteString("import { run } from \"./runner\";\n") + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "typescript-fetch", + TemplateDir: "../../templates/typescript-fetch", + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&main, "import * as %s from \"./%s/index\";\n", pkg, pkg) + } + + main.WriteString("\nrun({\n") + + for i, s := range suites { + fmt.Fprintf(&main, " %q: { module: c%d, dir: %q },\n", s.Name, i, filepath.Join(dir, "c"+strconv.Itoa(i))) + } + + main.WriteString("});\n") + write(t, filepath.Join(dir, "main.ts"), main.String()) + write(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions": {"target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], + "outDir": "dist", "strict": false, "skipLibCheck": true, "noEmitOnError": false}, "files": ["main.ts"]}`) + + // Type errors in a client are its generator's; the JavaScript is + // emitted anyway and the cases show what they break. + build := exec.Command(tsc, "-p", ".") + build.Dir = dir + + if out, err := build.CombinedOutput(); err != nil { + t.Logf("tsc: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(node, filepath.Join(dir, "dist", "main.js"), filepath.Join(dir, "input.json"))) +} diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md new file mode 100644 index 0000000..46fec84 --- /dev/null +++ b/testdata/e2e/README.md @@ -0,0 +1,138 @@ +# End-to-end tests + +Generated clients make real calls. Each call passes through the client's +request building, the HTTP wire and its response decoding, and the test +checks what arrived, not only the status code. + +```sh +make e2e # every generator, both layers +E2E_GENERATOR=go-fetch make e2e # one generator +E2E_DART=~/dart-sdk/bin/dart make e2e # a Dart other than the one on PATH +make e2e-update # rewrites specs/fastapi.json from the backend +``` + +`go test ./...` skips these tests; `make e2e` sets `E2E=1`. Tools: + +| Generator | Needs | +| ------------------ | --------------------------------------------------------------------------- | +| all | [uv](https://docs.astral.sh/uv/) for the FastAPI backend | +| `go-fetch` | Go | +| `typescript-fetch` | `tsc` (`npm i -g typescript`) and Node 22 or later (its `fetch`, `File`) | +| `dart-fetch` | Dart 3.13 or later, which the generated pubspec asks for; skipped with less | + +A run takes about 7 s with warm caches. + +## Layers + +| Layer | Server | Spec | Checks | +| ------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Mock | `internal/e2e`: validates each request against the spec (kin-openapi), records it, answers with the case's `response` | a feature spec, `testdata/specs/feature` | the request is valid and arrives as `request` says; the client returns `result` or `error` from the response | +| Backend | `backends/fastapi`: a strict FastAPI app (pydantic rejects extra keys) that echoes what it parsed | the one the server serves, compared with `specs/fastapi.json` | the client returns `result` or `error`; the server's validation and echo show what it received | + +The mock layer covers every parameter style and body encoding the feature +specs declare. The backend shows the shapes a framework writes into its +spec: pydantic's separate input and output schemas, OpenAPI 3.1 null unions, +`const`, Decimal as number or string, and FastAPI's empty JSON schema next +to another media type. + +## Scenarios + +`scenarios/.yaml` holds one spec's cases. Every value in it is in +wire terms, with names, JSON and media types as the spec has them. The cases +therefore do not depend on a generator's naming or language. + +```yaml +spec: ../../specs/feature/parameters-locations.yaml # mock layer; or: backend: fastapi +auth: { username: u, password: p, apiKey: k, bearer: t } +cases: + - name: path, query, header and cookie + op: getItem # operationId + args: { orgId: acme/eu, itemId: 42, X-Request-Id: r-1, body: {...} } + request: # mock only: what the server must receive + path: /orgs/acme/eu/items/42 # unescaped + query: { verbose: "false" } # a string stands for a one-value list + header: { X-Request-Id: r-1 } + cookie: { sessionId: s-1 } + body: {...} # JSON; a form as lists; multipart parts + response: { status: 200, json: { id: "42" } } # mock only; or text, base64, header + result: { id: "42" } # the client's result as wire JSON; null is null + error: { status: 404, model: {...}, body: "..." } + save: { token: access_token } # ${token} in later args and auth + skip: { go-fetch: why it fails } # a known gap; fails once it passes +``` + +The body argument is `body`; a form's fields are arguments of their own. +A file is `{ $file: { name, text | base64 } }` and a raw body is +`{ $bytes: { base64 } }`. + +**Matching.** An expected object lists the keys it checks, and other keys +may be present. An array must hold exactly the listed items. `$any` matches +any value, `$absent` requires a missing key, and numbers compare exactly +(int64 and beyond). In a `request`, a string also matches a one-value list, +and header names match in any case. + +**What the mock records.** +- The path both unescaped (`path`) and as sent (`rawPath`). +- A body only when one was sent: JSON as JSON, a form as lists of values, a + multipart field as a list of parts, text as a string, anything else as + `{ $base64 }`. A file part arrives as + `{ filename, contentType, text | base64 }` (text when its bytes are UTF-8), + a JSON part as `{ json: ... }`. + +Every request must pass the spec's validation, and so must the scenario's +`response`, unless it sets `offSpec: true` (an undeclared status on purpose). +A backend scenario has no `request` or `response`: the server answers and +validates. + +**Portable values.** A case states what every generator should do. A value +that a language cannot represent the same way belongs in a case of its own, +which that generator's runner skips: an int64 above 2^53 in JavaScript, a +time with microseconds in a JavaScript `Date`, an error body a client does +not decode. + +## Runners + +A runner turns the scenarios into calls of one generator's client and uses +the client the way its users do. `internal/e2e` generates every suite's +client, builds one program that holds them all and runs it, then compares +what the program prints with the scenarios. + +| Runner | Client | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runners/go` | go-fetch: finds methods, `Params` fields and results by reflection; standard library only | +| `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | +| `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | + +typescript-fetch sends an OAuth2 token as the whole `Authorization` header, +so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the +input). Its error has no decoded model: the runner reports the error body +parsed as JSON. A case one generator cannot pass has +`skip: { : why }`. A spec whose client does not compile at all +has the same `skip` at the top of its file. + +A runner for another generator follows the same protocol: + +1. **Input** is a JSON file given as the first argument: + `{ suites: [{ name, base, mock, auth, schemes, cases: [{ op, params, args, auth, save }] }] }`. + - `params` lists the names the operation's args may use (parameters and + form fields); any other arg is `body`. + - A case's `auth` fields replace those before it for the rest of the + suite. +2. **Before each case** of a `mock` suite, the runner sends + `POST {base}/__e2e/case/{index}` so that the mock knows which case the + next request belongs to. +3. **Arguments.** `${name}` in `args` and `auth` is a value an earlier + case saved: + - `save: { name: path }` takes a dotted path into the result + (`items.0.id`). + - A string is saved as it is, anything else as its JSON. + - A name never saved fails the case. +4. **Output** is one JSON line per case, `{ suite, case, result | error | fail }`: + - `result` is the client's result as wire JSON; a binary result is a + base64 string, and a call without one is `null`. + - `error` is `{ status, model, body }` from the client's error for a + status (`model` is the decoded error body, or absent). + - `fail` says why the call did not happen or failed otherwise (an + unknown arg, a transport error, a panic). + + A case the runner cannot handle fails that case only, not the run. diff --git a/testdata/e2e/backends/fastapi/.gitignore b/testdata/e2e/backends/fastapi/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/testdata/e2e/backends/fastapi/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py new file mode 100644 index 0000000..75ecc33 --- /dev/null +++ b/testdata/e2e/backends/fastapi/app.py @@ -0,0 +1,752 @@ +"""Warehouse: a strict FastAPI backend that puts every common OpenAPI shape +into one spec, for testing generated clients end to end (internal/e2e). + +Every request is validated by pydantic (extra keys rejected, 422 on any +mismatch). Most endpoints echo what they parsed, so a client can check the +exact values that arrived, not only the status code.""" + +import datetime as dt +import uuid +from decimal import Decimal +from enum import Enum, IntEnum +from ipaddress import IPv4Address +from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union + +from fastapi import ( + Body, + Cookie, + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Path, + Query, + Request, + Response, + Security, + UploadFile, +) +from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse +from fastapi.security import ( + APIKeyCookie, + APIKeyHeader, + APIKeyQuery, + HTTPAuthorizationCredentials, + HTTPBasic, + HTTPBasicCredentials, + HTTPBearer, + OAuth2PasswordBearer, + OAuth2PasswordRequestForm, + SecurityScopes, +) +from pydantic import ( + AliasGenerator, + BaseModel, + ConfigDict, + EmailStr, + Field, + HttpUrl, + SecretStr, + computed_field, +) +from pydantic.alias_generators import to_camel + +app = FastAPI( + title="Warehouse", + version="2.0.0", + description="Every common OpenAPI shape in one strict API.", +) + + +# ---------------------------------------------------------------- base models + + +class Strict(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Camel(BaseModel): + """camelCase on the wire, snake_case in Python, as most JSON APIs.""" + + model_config = ConfigDict(extra="forbid", alias_generator=AliasGenerator(to_camel), populate_by_name=True) + + +# ---------------------------------------------------------------- enums + + +class Color(str, Enum): + red = "red" + dark_blue = "dark-blue" + light_green = "light green" + n2 = "2nd" + type_ = "type" + empty = "" + + +class Priority(IntEnum): + low = 1 + mid = 5 + high = 10 + + +class Unit(str, Enum): + kg = "kg" + lb = "lb" + + +# ---------------------------------------------------------------- scalars + + +class Scalars(Strict): + """Every scalar format pydantic writes into a schema.""" + + i: int + i64: Annotated[int, Field(ge=-(2**63), le=2**63 - 1)] + f: float + d: Decimal + b: bool + s: Annotated[str, Field(min_length=1, max_length=40, pattern=r"^[^\x00]+$")] + when: dt.datetime + day: dt.date + clock: dt.time + span: dt.timedelta + id: uuid.UUID + email: EmailStr + url: HttpUrl + ip: IPv4Address + blob: bytes + anything: Any = None + color: Color + priority: Priority + unit: Literal["kg", "lb"] = "kg" + + +# ---------------------------------------------------------------- containers + + +class Dimensions(Camel): + width_cm: float + height_cm: float + depth_cm: Optional[float] = None + + +class Containers(Strict): + tags: list[str] + unique: set[str] = set() + pair: tuple[int, str] + matrix: list[list[int]] = [] + sparse: list[Optional[int]] = [] + counts: dict[str, int] = {} + groups: dict[str, list[str]] = {} + by_name: dict[str, Dimensions] = {} + maybe_list: Optional[list[str]] = None + maybe_map: Optional[dict[str, Optional[str]]] = None + + +# ---------------------------------------------------------------- items + + +class Supplier(Camel): + name: str + contact_email: Optional[EmailStr] = None + website: Optional[HttpUrl] = None + + +class ItemIn(Camel): + """An item as a client sends it.""" + + sku: Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$", examples=["ABC-1234"])] + name: str + description: Optional[str] = Field(default=None, description="Free text; null clears it.") + price: Decimal + weight: Optional[float] = None + unit: Unit = Unit.kg + color: Optional[Color] = None + priority: Priority = Priority.mid + tags: list[str] = [] + dimensions: Optional[Dimensions] = None + supplier: Optional[Supplier] = None + attributes: dict[str, str] = {} + discontinued: bool = Field(default=False, deprecated=True) + + +class Item(ItemIn): + """An item as the server returns it.""" + + id: uuid.UUID + created_at: dt.datetime + updated_at: Optional[dt.datetime] = None + version: int + + @computed_field + @property + def display_name(self) -> str: + return f"{self.name} ({self.sku})" + + +class ItemPatch(Camel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[Decimal] = None + color: Optional[Color] = None + tags: Optional[list[str]] = None + dimensions: Optional[Dimensions] = None + + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + next_cursor: Optional[str] = None + + +# ---------------------------------------------------------------- unions + + +class Card(Strict): + method: Literal["card"] + last4: Annotated[str, Field(pattern=r"^\d{4}$")] + expires: dt.date + + +class Transfer(Strict): + method: Literal["transfer"] + iban: str + reference: Optional[str] = None + + +class Voucher(Strict): + method: Literal["voucher"] + code: str + amount: Decimal + + +Payment = Annotated[Union[Card, Transfer, Voucher], Field(discriminator="method")] + + +class Circle(Strict): + radius: float + + +class Rect(Strict): + width: float + height: float + + +class OrderLine(Camel): + sku: str + quantity: Annotated[int, Field(gt=0)] + note: Optional[str] = None + + +class OrderIn(Camel): + lines: Annotated[list[OrderLine], Field(min_length=1)] + payment: Payment + shape: Optional[Union[Circle, Rect]] = None + gift: Union[bool, str] = False + code: Optional[Union[int, str]] = None + extra_payments: list[Payment] = [] + + +class OrderStatus(str, Enum): + placed = "placed" + paid = "paid" + shipped = "shipped" + + +class Order(OrderIn): + id: int + status: OrderStatus + placed_at: dt.datetime + + +# ---------------------------------------------------------------- recursion, open objects, keywords + + +class Category(Strict): + name: str + children: list["Category"] = [] + parent: Optional["Category"] = None + + +class Open(BaseModel): + """Takes any extra key and returns it.""" + + model_config = ConfigDict(extra="allow") + kind: str + + +class Keywords(BaseModel): + """Property names that are keywords or predeclared in Go.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + type: str + func: int + range: list[int] + map: dict[str, int] + interface: Optional[str] = None + package: str = "main" + default: bool = False + select: Optional[str] = None + string: str = Field(alias="string") + error_: str = Field(alias="error") + dash_name: str = Field(alias="x-dash-name") + dot_name: str = Field(alias="dot.name") + upper: str = Field(alias="UPPER") + digit: str = Field(alias="1st") + + +class Secret(Strict): + username: str + password: SecretStr + + +# ---------------------------------------------------------------- errors + + +class Problem(Strict): + type: str + title: str + status: int + detail: Optional[str] = None + + +class Conflict(Strict): + message: str + current_version: int + + +# ---------------------------------------------------------------- state + +ITEMS: dict[uuid.UUID, Item] = {} +ORDERS: dict[int, Order] = {} +FILES: dict[str, bytes] = {} + +# ---------------------------------------------------------------- security + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={"items:read": "Read items", "items:write": "Write items", "orders": "Place orders"}, +) +bearer = HTTPBearer(auto_error=False) +basic = HTTPBasic() +key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +key_query = APIKeyQuery(name="api_key", auto_error=False) +key_cookie = APIKeyCookie(name="session", auto_error=False) + +TOKENS = {"tok-rw": ["items:read", "items:write", "orders"], "tok-r": ["items:read"]} + + +def user(scopes: SecurityScopes, token: str = Depends(oauth2)) -> str: + granted = TOKENS.get(token) + if granted is None: + raise HTTPException(401, "bad token", headers={"WWW-Authenticate": "Bearer"}) + for s in scopes.scopes: + if s not in granted: + raise HTTPException(403, f"missing scope {s}") + return token + + +def any_key( + h: Optional[str] = Depends(key_header), + q: Optional[str] = Depends(key_query), + c: Optional[str] = Depends(key_cookie), +) -> str: + for where, v in (("header", h), ("query", q), ("cookie", c)): + if v == "key-1": + return where + raise HTTPException(401, "no api key") + + +def admin(c: HTTPBasicCredentials = Depends(basic)) -> str: + if (c.username, c.password) != ("admin", "p@ss:w0rd"): + raise HTTPException(401, "bad credentials") + return c.username + + +def maybe_bearer(c: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[str]: + return c.credentials if c else None + + +def problem(status: int, title: str, detail: str = "") -> JSONResponse: + return JSONResponse({"type": "about:blank", "title": title, "status": status, "detail": detail or None}, status) + + +not_found = {404: {"model": Problem, "description": "No such resource"}} + +# ---------------------------------------------------------------- auth + + +@app.post("/token", tags=["auth"], operation_id="issueToken") +def issue_token(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]: + if (form.username, form.password) == ("alice", "secret"): + return {"access_token": "tok-rw", "token_type": "bearer", "scope": " ".join(form.scopes)} + if (form.username, form.password) == ("bob", "secret"): + return {"access_token": "tok-r", "token_type": "bearer", "scope": " ".join(form.scopes)} + raise HTTPException(400, "bad login") + + +@app.get("/whoami", tags=["auth"], operation_id="whoami") +def whoami(where: str = Depends(any_key), token: Optional[str] = Depends(maybe_bearer)) -> dict[str, Optional[str]]: + return {"key_in": where, "bearer": token} + + +@app.get("/admin/stats", tags=["auth"], operation_id="adminStats") +def admin_stats(_: str = Depends(admin)) -> dict[str, int]: + return {"items": len(ITEMS), "orders": len(ORDERS)} + + +# ---------------------------------------------------------------- echo: scalars and containers + + +@app.post("/echo/scalars", tags=["echo"], operation_id="echoScalars") +def echo_scalars(body: Scalars) -> Scalars: + return body + + +@app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") +def echo_containers(body: Containers) -> Containers: + return body + + +@app.post("/echo/keywords", tags=["echo"], operation_id="echoKeywords") +def echo_keywords(body: Keywords) -> Keywords: + return body + + +@app.post("/echo/open", tags=["echo"], operation_id="echoOpen") +def echo_open(body: Open) -> Open: + return body + + +@app.post("/echo/tree", tags=["echo"], operation_id="echoTree") +def echo_tree(body: Category) -> Category: + return body + + +@app.post("/echo/secret", tags=["echo"], operation_id="echoSecret") +def echo_secret(body: Secret) -> dict[str, Union[str, int]]: + return {"username": body.username, "password_length": len(body.password.get_secret_value())} + + +@app.post("/echo/list", tags=["echo"], operation_id="echoList") +def echo_list(body: list[Dimensions]) -> list[Dimensions]: + return body + + +@app.post("/echo/map", tags=["echo"], operation_id="echoMap") +def echo_map(body: dict[str, list[int]]) -> dict[str, int]: + return {k: sum(v) for k, v in body.items()} + + +@app.post("/echo/number", tags=["echo"], operation_id="echoNumber") +def echo_number(body: Annotated[float, Body()]) -> float: + return body * 2 + + +@app.post("/echo/text", tags=["echo"], operation_id="echoText", response_class=PlainTextResponse, + openapi_extra={"requestBody": {"required": True, "content": {"text/plain": {"schema": {"type": "string"}}}}}) +async def echo_text(request: Request) -> str: + return (await request.body()).decode()[::-1] + + +@app.post("/echo/raw", tags=["echo"], operation_id="echoRaw", + openapi_extra={"requestBody": {"required": True, "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + response_class=Response) +async def echo_raw(request: Request) -> Response: + data = await request.body() + return Response(bytes(reversed(data)), media_type="application/octet-stream") + + +@app.post("/echo/union", tags=["echo"], operation_id="echoUnion") +def echo_union(body: Union[int, str, list[int]]) -> dict[str, str]: + return {"type": type(body).__name__, "value": str(body)} + + +@app.post("/echo/optional", tags=["echo"], operation_id="echoOptional") +def echo_optional(body: Annotated[Optional[Dimensions], Body()] = None) -> dict[str, bool]: + return {"got": body is not None} + + +# ---------------------------------------------------------------- parameters + + +@app.get("/params/path/{id}/{day}/{color}/{rest:path}", tags=["params"], operation_id="pathParams") +def path_params(id: int, day: dt.date, color: Color, rest: str) -> dict[str, str]: + return {"id": str(id), "day": day.isoformat(), "color": color.value, "rest": rest} + + +@app.get("/params/query", tags=["params"], operation_id="queryParams") +def query_params( + q: str, + page_size: Annotated[int, Query(alias="page-size", ge=1, le=100)] = 20, + tags: Annotated[list[str], Query()] = [], + ids: Annotated[Optional[list[int]], Query()] = None, + colors: Annotated[list[Color], Query()] = [], + since: Optional[dt.datetime] = None, + ratio: Optional[float] = None, + active: Optional[bool] = None, + old: Annotated[Optional[str], Query(deprecated=True)] = None, +) -> dict[str, Any]: + return { + "q": q, + "pageSize": page_size, + "tags": tags, + "ids": ids, + "colors": [c.value for c in colors], + "since": since.isoformat() if since else None, + "ratio": ratio, + "active": active, + "old": old, + } + + +@app.get("/params/headers", tags=["params"], operation_id="headerParams") +def header_params( + x_request_id: Annotated[uuid.UUID, Header()], + x_retry: Annotated[Optional[int], Header()] = None, + x_flags: Annotated[list[str], Header()] = [], + accept_language: Annotated[Optional[str], Header()] = None, + tenant: Annotated[Optional[str], Cookie()] = None, + theme: Annotated[Optional[str], Cookie()] = None, +) -> dict[str, Any]: + return { + "requestId": str(x_request_id), + "retry": x_retry, + "flags": x_flags, + "lang": accept_language, + "tenant": tenant, + "theme": theme, + } + + +# ---------------------------------------------------------------- items CRUD + + +@app.post( + "/items", + status_code=201, + tags=["items"], + operation_id="createItem", + responses={ + 201: {"headers": {"Location": {"schema": {"type": "string"}, "description": "URL of the item"}}}, + 409: {"model": Problem, "description": "SKU taken"}, + }, +) +def create_item(body: ItemIn, response: Response, _: str = Security(user, scopes=["items:write"])) -> Item: + if any(i.sku == body.sku for i in ITEMS.values()): + return problem(409, "SKU taken", body.sku) + item = Item(id=uuid.uuid4(), created_at=dt.datetime.now(dt.timezone.utc), version=1, **body.model_dump()) + ITEMS[item.id] = item + response.headers["Location"] = f"/items/{item.id}" + return item + + +@app.get("/items", tags=["items"], operation_id="listItems") +def list_items( + color: Optional[Color] = None, + min_price: Optional[Decimal] = None, + cursor: Optional[str] = None, + limit: Annotated[int, Query(ge=1, le=50)] = 10, + _: str = Security(user, scopes=["items:read"]), +) -> Page[Item]: + items = sorted(ITEMS.values(), key=lambda i: i.sku) + items = [i for i in items if (color is None or i.color == color) and (min_price is None or i.price >= min_price)] + start = int(cursor or 0) + chunk = items[start : start + limit] + nxt = str(start + limit) if start + limit < len(items) else None + return Page[Item](items=chunk, total=len(items), next_cursor=nxt) + + +@app.get("/items/{item_id}", tags=["items"], operation_id="getItem", responses=not_found) +def get_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + return ITEMS[item_id] + + +@app.patch( + "/items/{item_id}", + tags=["items"], + operation_id="patchItem", + responses={**not_found, 409: {"model": Conflict, "description": "Stale version"}}, +) +def patch_item( + item_id: uuid.UUID, + body: ItemPatch, + if_match: Annotated[int, Header()], + _: str = Security(user, scopes=["items:write"]), +) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + cur = ITEMS[item_id] + if if_match != cur.version: + return JSONResponse({"message": "stale", "current_version": cur.version}, 409) + data = cur.model_dump() + sent = body.model_dump(exclude_unset=True) + for k in ("name", "price"): + if k in sent and sent[k] is None: + raise HTTPException(400, f"{k} may not be null") + data.update(sent) + data.update(version=cur.version + 1, updated_at=dt.datetime.now(dt.timezone.utc)) + data.pop("display_name", None) + ITEMS[item_id] = Item(**data) + return ITEMS[item_id] + + +@app.delete("/items/{item_id}", status_code=204, tags=["items"], operation_id="deleteItem", responses=not_found) +def delete_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:write"])): + if ITEMS.pop(item_id, None) is None: + return problem(404, "Not found", str(item_id)) + return Response(status_code=204) + + +@app.head("/items/{item_id}", tags=["items"], operation_id="itemExists", responses={404: {"description": "absent"}}) +def item_exists(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])): + return Response(status_code=200 if item_id in ITEMS else 404) + + +# ---------------------------------------------------------------- files + + +@app.post("/files", status_code=201, tags=["files"], operation_id="uploadFiles") +def upload_files( + files: Annotated[list[UploadFile], File(description="One or more files")], + folder: Annotated[str, Form()], + labels: Annotated[list[str], Form()] = [], + overwrite: Annotated[bool, Form()] = False, + meta: Annotated[Optional[str], Form(description="JSON text")] = None, + _: str = Depends(any_key), +) -> list[dict[str, Union[str, int]]]: + out = [] + for f in files: + data = f.file.read() + name = f"{folder}/{f.filename}" + if name in FILES and not overwrite: + raise HTTPException(409, f"{name} exists") + FILES[name] = data + out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": ",".join(labels), "meta": meta or ""}) + return out + + +@app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") +def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: + return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} + + +@app.get( + "/files/{name:path}", + tags=["files"], + operation_id="downloadFile", + response_class=Response, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, **not_found}, +) +def download_file(name: str): + if name not in FILES: + return problem(404, "Not found", name) + return Response(FILES[name], media_type="application/octet-stream") + + +@app.get("/export.csv", tags=["files"], operation_id="exportCsv", response_class=PlainTextResponse, + responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +def export_csv() -> PlainTextResponse: + rows = ["sku,name"] + [f"{i.sku},{i.name}" for i in sorted(ITEMS.values(), key=lambda i: i.sku)] + return PlainTextResponse("\n".join(rows) + "\n", media_type="text/csv") + + +# ---------------------------------------------------------------- orders + + +@app.post( + "/orders", + status_code=201, + tags=["orders"], + operation_id="placeOrder", + responses={202: {"model": Problem, "description": "Queued for review"}, 422: {"description": "Invalid"}}, +) +def place_order( + body: OrderIn, + dry_run: bool = False, + idempotency_key: Annotated[Optional[str], Header()] = None, + _: str = Security(user, scopes=["orders"]), +) -> Order: + total = sum(line.quantity for line in body.lines) + if total > 100: + return problem(202, "Queued for review", f"{total} units") + order = Order(id=len(ORDERS) + 1, status=OrderStatus.placed, placed_at=dt.datetime.now(dt.timezone.utc), **body.model_dump()) + if not dry_run: + ORDERS[order.id] = order + return order + + +@app.get("/orders/{order_id}", tags=["orders"], operation_id="getOrder", responses=not_found) +def get_order(order_id: Annotated[int, Path(ge=1)], _: str = Security(user, scopes=["orders"])) -> Order: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id] + + +@app.get("/orders/{order_id}/status", tags=["orders"], operation_id="orderStatus", responses=not_found) +def order_status(order_id: int, _: str = Security(user, scopes=["orders"])) -> OrderStatus: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id].status + + +@app.post("/orders/{order_id}/cancel", status_code=202, tags=["orders"], operation_id="cancelOrder", + responses={202: {"description": "Cancellation accepted; no body"}, **not_found}) +def cancel_order(order_id: int, reason: Annotated[Optional[str], Body(embed=True)] = None, _: str = Security(user, scopes=["orders"])): + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return Response(status_code=202) + + +# ---------------------------------------------------------------- misc + + +@app.get("/categories", tags=["misc"], operation_id="categoryTree") +def category_tree() -> list[Category]: + root = Category(name="root", children=[Category(name="a", children=[Category(name="a1")]), Category(name="b")]) + return [root] + + +@app.get("/count", tags=["misc"], operation_id="countItems") +def count_items() -> int: + return len(ITEMS) + + +@app.get("/maybe", tags=["misc"], operation_id="maybeItem") +def maybe_item(present: bool = False) -> Optional[Dimensions]: + return Dimensions(width_cm=1, height_cm=2) if present else None + + +@app.get("/legacy", tags=["misc"], deprecated=True) +def legacy_endpoint(x: int = 1) -> dict[str, int]: + """A deprecated operation with FastAPI's generated operationId.""" + return {"x": x} + + +@app.get("/redirect", tags=["misc"], operation_id="redirectToCount", response_class=RedirectResponse, status_code=307) +def redirect() -> RedirectResponse: + return RedirectResponse("/count", status_code=307) + + +@app.get("/stream", tags=["misc"], operation_id="streamLines", + responses={200: {"content": {"application/x-ndjson": {"schema": {"type": "string"}}}}}) +def stream_lines(n: int = 3) -> StreamingResponse: + return StreamingResponse((f'{{"i":{i}}}\n' for i in range(n)), media_type="application/x-ndjson") + + +@app.get("/numbers/{a}/{b}", tags=["misc"], operation_id="add") +def add(a: int, b: float, round_: Annotated[bool, Query(alias="round")] = False) -> float: + return round(a + b) if round_ else a + b + + +@app.get("/untagged", operation_id="untagged") +def untagged() -> dict[str, bool]: + return {"ok": True} diff --git a/testdata/e2e/backends/fastapi/pyproject.toml b/testdata/e2e/backends/fastapi/pyproject.toml new file mode 100644 index 0000000..7d4d0f2 --- /dev/null +++ b/testdata/e2e/backends/fastapi/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "e2e-fastapi" +version = "0" +description = "The FastAPI backend of the e2e tests (internal/e2e)." +requires-python = ">=3.12" +dependencies = [ + "fastapi==0.162.0", + "pydantic[email]==2.14.1", + "python-multipart", + "uvicorn", +] + +[tool.uv] +package = false diff --git a/testdata/e2e/backends/fastapi/uv.lock b/testdata/e2e/backends/fastapi/uv.lock new file mode 100644 index 0000000..c800670 --- /dev/null +++ b/testdata/e2e/backends/fastapi/uv.lock @@ -0,0 +1,313 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + +[[package]] +name = "e2e-fastapi" +version = "0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic", extra = ["email"] }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.162.0" }, + { name = "pydantic", extras = ["email"], specifier = "==2.14.1" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.162.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] diff --git a/testdata/e2e/runners/dart/runner.dart b/testdata/e2e/runners/dart/runner.dart new file mode 100644 index 0000000..c73e74d --- /dev/null +++ b/testdata/e2e/runners/dart/runner.dart @@ -0,0 +1,217 @@ +// Runner of the e2e scenarios for dart-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.dart, written for each run, hands it a +// factory of each client's Configuration and APIs; dart:mirrors finds the +// methods, their named parameters and the models' fromJson/toJson, so it +// runs on the VM (`dart run`), not compiled ahead of time. +import 'dart:convert'; +import 'dart:io'; +import 'dart:mirrors'; + +class Auth { + String? username, password, apiKey, bearer; + + Auth.fromJson(Map? j) + : username = j?['username'], + password = j?['password'], + apiKey = j?['apiKey'], + bearer = j?['bearer']; + + void merge(Auth o) { + username = o.username?.isNotEmpty == true ? o.username : username; + password = o.password?.isNotEmpty == true ? o.password : password; + apiKey = o.apiKey?.isNotEmpty == true ? o.apiKey : apiKey; + bearer = o.bearer?.isNotEmpty == true ? o.bearer : bearer; + } +} + +/// Client is one generated package: its Configuration for a base URL and +/// credentials, and its API objects for a Configuration. +class Client { + Client(this.configuration, this.apis, this.package); + + final Object Function(String base, Auth auth) configuration; + final List Function(Object cfg) apis; + final String package; +} + +String norm(String s) => s.replaceAll(RegExp(r'[^\p{L}\p{N}]', unicode: true), '').toLowerCase(); + +String expand(String s, Map vars, bool json) => + s.replaceAllMapped(RegExp(r'\$\{(\w+)\}'), (m) { + final v = vars[m[1]]; + if (v == null) throw StateError('${m[0]} was never saved'); + if (!json) return v; + final e = jsonEncode(v); + return e.substring(1, e.length - 1); + }); + +/// fromWire turns a wire value into what a parameter of type t takes. +Object? fromWire(TypeMirror t, Object? v) { + if (v == null) return null; + + if (v is Map && (v.containsKey(r'$file') || v.containsKey(r'$bytes'))) { + final c = (v[r'$file'] ?? v[r'$bytes']) as Map; + return c['base64'] != null ? base64Decode(c['base64'] as String) : utf8.encode((c['text'] ?? '') as String); + } + + if (t is ClassMirror && t.typeArguments.isNotEmpty) { + if (t.isSubtypeOf(reflectType(List)) && v is List) { + final item = t.typeArguments.first; + return _typedList(item, v.map((x) => fromWire(item, x)).toList()); + } + + if (t.isSubtypeOf(reflectType(Map)) && v is Map) { + final value = t.typeArguments.last; + return _typedMap(value, {for (final e in v.entries) e.key as String: fromWire(value, e.value)}); + } + } + + if (t is ClassMirror) { + if (t.reflectedType == DateTime && v is String) return DateTime.parse(v); + if (t.reflectedType == double && v is num) return v.toDouble(); + // A model reads JSON with its fromJson constructor, an enum with a + // static fromJson. + if (t.declarations.keys.any((k) => MirrorSystem.getName(k) == '${MirrorSystem.getName(t.simpleName)}.fromJson')) { + return t.newInstance(#fromJson, [v]).reflectee; + } + + if (t.staticMembers.containsKey(#fromJson)) return t.invoke(#fromJson, [v]).reflectee; + } + + return v; +} + +/// _typedList and _typedMap build a List or Map of the +/// parameter's own T, which a plain List would not satisfy. +Object _typedList(TypeMirror item, List values) { + final list = (reflectType(List, [item.reflectedType]) as ClassMirror).newInstance(#empty, [], {#growable: true}).reflectee as List; + for (final x in values) { + list.add(x); + } + return list; +} + +Object _typedMap(TypeMirror value, Map values) { + final map = (reflectType(Map, [String, value.reflectedType]) as ClassMirror).newInstance(Symbol(''), []).reflectee as Map; + values.forEach((k, x) => map[k] = x); + return map; +} + +/// toWire turns a result back into wire JSON. +Object? toWire(Object? v) { + if (v == null || v is String || v is num || v is bool) return v; + if (v is DateTime) return v.toUtc().toIso8601String(); + if (v is List && v is! List) return base64Encode(v); + if (v is List) return v.map(toWire).toList(); + if (v is Map) return {for (final e in v.entries) '${e.key}': toWire(e.value)}; + + final m = reflect(v); + if (m.type.instanceMembers.containsKey(#toJson)) return toWire(m.invoke(#toJson, []).reflectee); + + return v.toString(); +} + +void save(Object? result, Map? paths, Map vars) { + paths?.forEach((name, p) { + Object? v = result; + for (final key in (p as String).split('.').where((k) => k.isNotEmpty)) { + v = v is List ? v[int.parse(key)] : (v is Map ? v[key] : null); + } + vars[name] = v is String ? v : jsonEncode(v); + }); +} + +Future> callCase(Client client, Map suite, Map c, Auth auth, + Map vars) async { + final cfg = client.configuration(suite['base'] as String, auth); + final op = norm(c['op'] as String); + + for (final api in client.apis(cfg)) { + final m = reflect(api); + final method = m.type.instanceMembers.values.where((x) => x.isRegularMethod && norm(MirrorSystem.getName(x.simpleName)) == op); + if (method.isEmpty) continue; + + final mm = method.first; + final args = c['args'] == null ? {} : jsonDecode(expand(jsonEncode(c['args']), vars, true)) as Map; + final byNorm = {for (final p in (c['params'] as List? ?? [])) norm(p as String): p}; + final named = {}; + final used = {}; + + for (final p in mm.parameters) { + final field = MirrorSystem.getName(p.simpleName); + final name = byNorm[norm(field)] ?? 'body'; + if (!args.containsKey(name)) continue; + if (!used.add(name)) return {'fail': 'arg $name: fits fields $field and another'}; + named[p.simpleName] = fromWire(p.type, args[name]); + } + + for (final name in args.keys) { + if (!used.contains(name)) return {'fail': 'arg $name: no parameter of ${MirrorSystem.getName(mm.simpleName)} takes it'}; + } + + try { + final result = await (m.invoke(mm.simpleName, [], named).reflectee as Future); + final wire = toWire(result); + save(wire, c['save'] as Map?, vars); + return {'result': wire}; + } catch (e) { + final em = reflect(e); + if (em.type.instanceMembers.containsKey(#statusCode) && em.type.instanceMembers.containsKey(#body)) { + final body = (em.getField(#body).reflectee as String?) ?? ''; + Object? model; + try { + model = jsonDecode(body); + } catch (_) {} + return { + 'error': {'status': em.getField(#statusCode).reflectee, 'body': body, if (model != null) 'model': model}, + }; + } + return {'fail': '$e'}; + } + } + + return {'fail': 'no method for operation ${c['op']}'}; +} + +Future run(String inputPath, Map clients) async { + final input = jsonDecode(File(inputPath).readAsStringSync()) as Map; + + for (final suite in (input['suites'] as List).cast>()) { + final client = clients[suite['name']]!; + final vars = {}; + final auth = Auth.fromJson(suite['auth'] as Map?); + final cases = (suite['cases'] as List).cast>(); + + for (var i = 0; i < cases.length; i++) { + final c = cases[i]; + auth.merge(Auth.fromJson(c['auth'] as Map?)); + + String ex(String? s) { + try { + return expand(s ?? '', vars, false); + } catch (_) { + return s ?? ''; + } + } + + final current = Auth.fromJson({'username': ex(auth.username), 'password': ex(auth.password), 'apiKey': ex(auth.apiKey), 'bearer': ex(auth.bearer)}); + + if (suite['mock'] == true) { + final http = HttpClient(); + final req = await http.postUrl(Uri.parse('${suite['base']}/__e2e/case/$i')); + await (await req.close()).drain(); + http.close(); + } + + Map out; + try { + out = await callCase(client, suite, c, current, vars); + } catch (e, st) { + out = {'fail': 'panic: $e\n$st'}; + } + + stdout.writeln(jsonEncode({'suite': suite['name'], 'case': i, ...out})); + } + } +} diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go new file mode 100644 index 0000000..d6770d4 --- /dev/null +++ b/testdata/e2e/runners/go/runner.go @@ -0,0 +1,497 @@ +// Command runner calls the operations of generated go-fetch clients as the +// e2e scenarios say and prints one JSON line per case. It knows nothing of +// a particular client: main.go, written next to it for each run, hands it +// the API values and their Configuration, and it finds methods, parameters +// and results by reflection. Standard library only. +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "reflect" + "regexp" + "strconv" + "strings" + "time" + "unicode" +) + +// Auth is what a case authenticates with; the Configuration funcs read it +// at call time. +type Auth struct { + Username string `json:"username"` + Password string `json:"password"` + APIKey string `json:"apiKey"` + Bearer string `json:"bearer"` +} + +type input struct { + Suites []suite `json:"suites"` +} + +type suite struct { + Name string `json:"name"` + Base string `json:"base"` + Mock bool `json:"mock"` + Auth Auth `json:"auth"` + Cases []cas `json:"cases"` +} + +type cas struct { + Op string `json:"op"` + Params []string `json:"params"` + Args json.RawMessage `json:"args"` + Auth *Auth `json:"auth"` + Save map[string]string `json:"save"` +} + +type outcome struct { + Suite string `json:"suite"` + Case int `json:"case"` + Result json.RawMessage `json:"result,omitempty"` + Error *respErr `json:"error,omitempty"` + Fail string `json:"fail,omitempty"` +} + +type respErr struct { + Status int `json:"status"` + Model json.RawMessage `json:"model,omitempty"` + Body string `json:"body"` +} + +// setup builds the API values of one suite's client, sending to base, and +// returns them with the *Configuration they share. +type setup func(base string, auth *Auth) (apis []any, cfg any) + +func run(setups map[string]setup) { + var in input + + data, err := os.ReadFile(os.Args[1]) + if err == nil { + err = json.Unmarshal(data, &in) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + out := json.NewEncoder(os.Stdout) + + for _, s := range in.Suites { + auth := s.Auth + apis, cfg := setups[s.Name](s.Base, &auth) + vars := map[string]string{} + + for i, c := range s.Cases { + o := outcome{Suite: s.Name, Case: i} + + if c.Auth != nil { + auth = merge(auth, *c.Auth) + } + + auth = expandAuth(auth, vars) + setBasic(cfg, auth) + + if s.Mock { + mark(s.Base, i) + } + + callCase(&o, apis, c, vars) + _ = out.Encode(o) + } + } +} + +// mark tells the mock server which case the next requests belong to. +func mark(base string, i int) { + resp, err := http.Post(base+"/__e2e/case/"+strconv.Itoa(i), "text/plain", nil) + if err == nil { + _ = resp.Body.Close() + } +} + +func callCase(o *outcome, apis []any, c cas, vars map[string]string) { + // A panic in reflection or in the client fails this case, not the run. + defer func() { + if r := recover(); r != nil { + o.Result, o.Error, o.Fail = nil, nil, fmt.Sprint("panic: ", r) + } + }() + + method, ok := findMethod(apis, c.Op) + if !ok { + o.Fail = "no method for operation " + c.Op + + return + } + + args := map[string]json.RawMessage{} + if len(c.Args) > 0 { + expanded, err := expand(string(c.Args), vars, true) + if err == nil { + err = json.Unmarshal([]byte(expanded), &args) + } + + if err != nil { + o.Fail = "args: " + err.Error() + + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + in := []reflect.Value{reflect.ValueOf(ctx)} + + if t := method.Type(); t.NumIn() > 1 && t.In(1).Kind() == reflect.Struct { + p, err := params(t.In(1), c.Params, args) + if err != nil { + o.Fail = err.Error() + + return + } + + in = append(in, p) + } + + res := method.Call(in) + + if err, _ := res[len(res)-1].Interface().(error); err != nil { + o.Error, o.Fail = responseError(err) + + return + } + + if len(res) == 1 { + o.Result = json.RawMessage("null") + + return + } + + b, err := json.Marshal(res[0].Interface()) + if err != nil { + o.Fail = "result: " + err.Error() + + return + } + + o.Result = b + save(b, c.Save, vars) +} + +// findMethod finds the typed method of an operation: its operationId with +// the punctuation dropped, with "Method" after it first, the name a +// generator gives a FooRaw next to Foo (whose own FooRaw would match too). +func findMethod(apis []any, op string) (reflect.Value, bool) { + for _, want := range []string{norm(op) + "method", norm(op)} { + for _, api := range apis { + v := reflect.ValueOf(api) + for i := range v.NumMethod() { + if norm(v.Type().Method(i).Name) == want { + return v.Method(i), true + } + } + } + } + + return reflect.Value{}, false +} + +// params fills an operation's Params struct from the args, keyed by the +// spec's names. The field no spec name matches is the body, args["body"]. +func params(t reflect.Type, names []string, args map[string]json.RawMessage) (reflect.Value, error) { + p := reflect.New(t).Elem() + + byField := map[string]string{} + for _, name := range names { + byField[norm(name)] = name + } + + used := map[string]bool{} + + for i := range t.NumField() { + f := t.Field(i) + + name, isParam := byField[norm(f.Name)] + if !isParam { + name = "body" + } + + raw, ok := args[name] + if !ok { + continue + } + + if used[name] { + return p, fmt.Errorf("arg %s: fits fields %s and another", name, f.Name) + } + + used[name] = true + + if err := set(p.Field(i), raw); err != nil { + return p, fmt.Errorf("arg %s: %w", name, err) + } + } + + for name := range args { + if !used[name] { + return p, fmt.Errorf("arg %s: no field of %s takes it", name, t.Name()) + } + } + + return p, nil +} + +// fileArg is a file or raw body in the args: {"$file": {...}} or +// {"$bytes": {...}}, its content as text or base64. +type fileArg struct { + File *content `json:"$file"` + Bytes *content `json:"$bytes"` +} + +type content struct { + Name string `json:"name"` + Text string `json:"text"` + Base64 string `json:"base64"` +} + +func (c content) data() []byte { + if c.Base64 != "" { + b, _ := base64.StdEncoding.DecodeString(c.Base64) + + return b + } + + return []byte(c.Text) +} + +var readerType = reflect.TypeFor[io.Reader]() + +// set decodes raw into the field: JSON for every type, files and readers +// from their fileArg. +func set(f reflect.Value, raw json.RawMessage) error { + var fa fileArg + _ = json.Unmarshal(raw, &fa) + + switch { + case f.Type() == readerType && fa.Bytes != nil: + f.Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.Bytes.data())))) + + return nil + case isFile(f.Type()): + return setFile(f, raw) + } + + return json.Unmarshal(raw, f.Addr().Interface()) +} + +// isFile reports whether t is a client's File, *File or []File: a struct +// of Name and Body. +func isFile(t reflect.Type) bool { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { + t = t.Elem() + } + + if t.Kind() != reflect.Struct || t.NumField() != 2 { + return false + } + + n, nok := t.FieldByName("Name") + b, bok := t.FieldByName("Body") + + return nok && bok && n.Type.Kind() == reflect.String && b.Type == readerType +} + +func setFile(f reflect.Value, raw json.RawMessage) error { + switch f.Kind() { + case reflect.Slice: + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + + s := reflect.MakeSlice(f.Type(), len(items), len(items)) + for i, item := range items { + if err := setFile(s.Index(i), item); err != nil { + return err + } + } + + f.Set(s) + + return nil + case reflect.Pointer: + v := reflect.New(f.Type().Elem()) + if err := setFile(v.Elem(), raw); err != nil { + return err + } + + f.Set(v) + + return nil + } + + var fa fileArg + if err := json.Unmarshal(raw, &fa); err != nil || fa.File == nil { + return fmt.Errorf("want {\"$file\": ...}, got %s", raw) + } + + f.FieldByName("Name").SetString(fa.File.Name) + f.FieldByName("Body").Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.File.data())))) + + return nil +} + +// responseError reads a client's *ResponseError by its fields; any other +// error is a failure of the call itself. +func responseError(err error) (*respErr, string) { + for e := err; e != nil; e = errors.Unwrap(e) { + v := reflect.ValueOf(e) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + continue + } + + s := v.Elem() + status, body, model := s.FieldByName("StatusCode"), s.FieldByName("Body"), s.FieldByName("Model") + + if !status.IsValid() || !body.IsValid() || !model.IsValid() { + continue + } + + r := &respErr{Status: int(status.Int()), Body: string(body.Bytes())} + if !model.IsNil() { + r.Model, _ = json.Marshal(model.Interface()) + } + + return r, "" + } + + return nil, err.Error() +} + +// setBasic sets the basic credentials on the *Configuration, which holds +// them as values. +func setBasic(cfg any, a Auth) { + v := reflect.ValueOf(cfg).Elem() + if f := v.FieldByName("Username"); f.IsValid() { + f.SetString(a.Username) + } + + if f := v.FieldByName("Password"); f.IsValid() { + f.SetString(a.Password) + } +} + +var varRef = regexp.MustCompile(`\$\{(\w+)\}`) + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when inJSON; a name never saved is an error. +func expand(s string, vars map[string]string, inJSON bool) (string, error) { + var missing string + + out := varRef.ReplaceAllStringFunc(s, func(m string) string { + v, ok := vars[m[2:len(m)-1]] + if !ok { + missing = m + + return m + } + + if inJSON { + b, _ := json.Marshal(v) + + return string(b[1 : len(b)-1]) + } + + return v + }) + + if missing != "" { + return "", fmt.Errorf("%s was never saved", missing) + } + + return out, nil +} + +// merge sets the fields a case gives over the ones before; they stay for +// the cases after it. +func merge(a, over Auth) Auth { + for _, f := range []struct{ to, from *string }{ + {&a.Username, &over.Username}, {&a.Password, &over.Password}, {&a.APIKey, &over.APIKey}, {&a.Bearer, &over.Bearer}, + } { + if *f.from != "" { + *f.to = *f.from + } + } + + return a +} + +// expandAuth fills ${name} in the credentials; one not saved yet stays as +// it is, which the server then refuses. +func expandAuth(a Auth, vars map[string]string) Auth { + for _, f := range []*string{&a.Username, &a.Password, &a.APIKey, &a.Bearer} { + if v, err := expand(*f, vars, false); err == nil { + *f = v + } + } + + return a +} + +// save keeps values of the result for later cases: name -> a dotted path +// into it ("items.0.id"), the whole result for "". +func save(result []byte, paths map[string]string, vars map[string]string) { + for name, path := range paths { + var v any + if json.Unmarshal(result, &v) != nil { + continue + } + + for _, key := range strings.Split(path, ".") { + if key == "" { + continue + } + + switch x := v.(type) { + case map[string]any: + v = x[key] + case []any: + i, _ := strconv.Atoi(key) + if i < len(x) { + v = x[i] + } + } + } + + if s, ok := v.(string); ok { + vars[name] = s + } else { + b, _ := json.Marshal(v) + vars[name] = string(b) + } + } +} + +// norm drops all but letters and digits and lowers them, so a spec name +// and the identifier a generator makes of it compare equal. +func norm(s string) string { + var b strings.Builder + + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + } + } + + return b.String() +} diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts new file mode 100644 index 0000000..037ebed --- /dev/null +++ b/testdata/e2e/runners/ts/runner.ts @@ -0,0 +1,269 @@ +// Runner of the e2e scenarios for typescript-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.ts, written for each run, hands it the +// clients and the directories of their sources. Node's own fetch, FormData +// and File carry the requests. Types come from the clients' sources: the +// Request interfaces and the method signatures say which model's +// FromJSON/ToJSON turns wire JSON into the client's objects and back. + +declare const require: any; +declare const process: any; +declare const Buffer: any; + +const fs = require("fs"); +const path = require("path"); + +type Auth = { username?: string; password?: string; apiKey?: string; bearer?: string }; + +type Case = { op: string; params: string[]; args?: Record; auth?: Auth; save?: Record }; + +type Suite = { name: string; base: string; mock: boolean; auth?: Auth; cases: Case[]; schemes?: Record }; + +type Client = { module: any; dir: string }; + +type Method = { name: string; api: string; request?: string; result: string }; + +const norm = (s: string): string => s.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase(); + +// methods reads the typed method of each operation from the API sources: +// its name, the API class, the Request interface it takes and what it +// returns. +function methods(dir: string): { methods: Method[]; requests: Record> } { + const out: Method[] = []; + const requests: Record> = {}; + + for (const file of fs.readdirSync(path.join(dir, "apis"))) { + const src: string = fs.readFileSync(path.join(dir, "apis", file), "utf8"); + const api = /export class (\w+) extends runtime\.BaseAPI/.exec(src)?.[1]; + + for (const m of src.matchAll(/export interface (\w+) \{([\s\S]*?)\n\}/g)) { + const fields: Record = {}; + for (const f of m[2].matchAll(/^\s+(\w+)\??: (.+);$/gm)) { + fields[f[1]] = f[2]; + } + + requests[m[1]] = fields; + } + + if (!api) { + continue; + } + + for (const m of src.matchAll(/^ async (\w+)\((?:requestParameters: (\w+)(?: = \{\})?, )?initOverrides\?: [^)]*\): Promise<(.*)> \{$/gm)) { + if (!m[1].endsWith("Raw")) { + out.push({ name: m[1], api, request: m[2], result: m[3] }); + } + } + } + + return { methods: out, requests }; +} + +const nullable = (t: string): string => t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); + +// fromWire turns a wire value into what the client takes for type t. +function fromWire(client: any, t: string, v: any): any { + t = nullable(t); + if (v === null || v === undefined) { + return v; + } + + if (v && typeof v === "object" && (v.$file || v.$bytes)) { + const c = v.$file ?? v.$bytes; + const data = c.base64 !== undefined ? Buffer.from(c.base64, "base64") : Buffer.from(c.text ?? "", "utf8"); + + return v.$file ? new File([data], c.name) : new Blob([data]); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return v.map((x) => fromWire(client, m![1], x)); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, m![1], x)])); + } + + if (t === "Date") { + return new Date(v); + } + + const from = client[t + "FromJSON"]; + + return typeof from === "function" ? from(v) : v; +} + +// toWire turns a client's result of type t back into wire JSON. +async function toWire(client: any, t: string, v: any): Promise { + t = nullable(t); + if (v === null || v === undefined) { + return v ?? null; + } + + if (v instanceof Blob) { + return Buffer.from(await v.arrayBuffer()).toString("base64"); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return Promise.all(v.map((x) => toWire(client, m![1], x))); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + const out: Record = {}; + for (const [k, x] of Object.entries(v)) { + out[k] = await toWire(client, m[1], x); + } + + return out; + } + + if (v instanceof Date) { + return v.toISOString(); + } + + const to = client[t + "ToJSON"]; + + return typeof to === "function" ? to(v) : v; +} + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when json; a name never saved is an error. +function expand(s: string, vars: Record, json: boolean): string { + return s.replace(/\$\{(\w+)\}/g, (ref, name) => { + if (!(name in vars)) { + throw new Error(ref + " was never saved"); + } + + return json ? JSON.stringify(vars[name]).slice(1, -1) : vars[name]; + }); +} + +function save(result: any, paths: Record | undefined, vars: Record) { + for (const [name, p] of Object.entries(paths ?? {})) { + let v = result; + for (const key of p.split(".").filter(Boolean)) { + v = v?.[Array.isArray(v) ? Number(key) : key]; + } + + vars[name] = typeof v === "string" ? v : JSON.stringify(v); + } +} + +async function callCase(client: Client, info: ReturnType, suite: Suite, c: Case, auth: Auth, vars: Record) { + const cfg = new client.module.Configuration({ + basePath: suite.base, + username: auth.username, + password: auth.password, + apiKey: () => auth.apiKey ?? "", + // typescript-fetch sends an OAuth2 token as the whole Authorization + // header and prefixes only an HTTP bearer one: the function returns + // "Bearer ..." for OAuth2, as its users write it. + accessToken: (name?: string) => { + const oauth = ["oauth2", "openIdConnect"].includes(suite.schemes?.[name ?? ""] ?? ""); + + return auth.bearer ? (oauth ? "Bearer " : "") + auth.bearer : ""; + }, + }); + + const want = [norm(c.op) + "operation", norm(c.op) + "method", norm(c.op)]; + const m = want.map((w) => info.methods.find((x) => norm(x.name) === w)).find(Boolean); + if (!m) { + return { fail: "no method for operation " + c.op }; + } + + const api = new client.module[m.api](cfg); + const args = c.args ? JSON.parse(expand(JSON.stringify(c.args), vars, true)) : {}; + + const fields = m.request ? (info.requests[m.request] ?? {}) : {}; + const byNorm: Record = {}; + for (const p of c.params ?? []) { + byNorm[norm(p)] = p; + } + + const params: Record = {}; + const used = new Set(); + + for (const [field, type] of Object.entries(fields)) { + const name = byNorm[norm(field)] ?? "body"; + if (!(name in args)) { + continue; + } + + if (used.has(name)) { + return { fail: `arg ${name}: fits fields ${field} and another` }; + } + + used.add(name); + params[field] = fromWire(client.module, type, args[name]); + } + + for (const name of Object.keys(args)) { + if (!used.has(name)) { + return { fail: `arg ${name}: no field of ${m.request ?? m.name} takes it` }; + } + } + + try { + const result = m.request ? await api[m.name](params) : await api[m.name](); + const wire = m.result === "void" ? null : await toWire(client.module, m.result, result); + save(wire, c.save, vars); + + return { result: wire ?? null }; + } catch (e: any) { + if (e?.response && typeof e.response.status === "number") { + const body = await e.response.text(); + let model: any; + try { + model = JSON.parse(body); + } catch { + model = undefined; + } + + return { error: { status: e.response.status, model, body } }; + } + + return { fail: String(e?.cause ?? e) }; + } +} + +export async function run(clients: Record) { + const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + + for (const suite of input.suites as Suite[]) { + const client = clients[suite.name]; + const info = methods(client.dir); + const vars: Record = {}; + let auth: Auth = { ...(suite.auth ?? {}) }; + + for (const [i, c] of suite.cases.entries()) { + for (const [k, v] of Object.entries(c.auth ?? {})) { + if (v) { + (auth as any)[k] = v; + } + } + + const current: Auth = {}; + for (const [k, v] of Object.entries(auth)) { + try { + (current as any)[k] = expand(v ?? "", vars, false); + } catch { + (current as any)[k] = v; + } + } + + if (suite.mock) { + await fetch(`${suite.base}/__e2e/case/${i}`, { method: "POST" }); + } + + let out: any; + try { + out = await callCase(client, info, suite, c, current, vars); + } catch (e: any) { + out = { fail: "panic: " + String(e?.stack ?? e) }; + } + + process.stdout.write(JSON.stringify({ suite: suite.name, case: i, ...out }) + "\n"); + } + } +} diff --git a/testdata/e2e/scenarios/additional-properties.yaml b/testdata/e2e/scenarios/additional-properties.yaml new file mode 100644 index 0000000..b74ef23 --- /dev/null +++ b/testdata/e2e/scenarios/additional-properties.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/additional-properties-with-properties.yaml +cases: + - name: extra keys both ways + op: recordEvent + args: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + request: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + response: { json: { player: p, round1: 3, round2: 5 } } + result: { player: p, round1: 3, round2: 5 } + skip: { dart-fetch: "drops the keys next to the declared properties (additionalProperties); a DateTime goes out with .000Z", typescript-fetch: "drops the keys next to the declared properties (additionalProperties); a Date goes out with .000Z" } diff --git a/testdata/e2e/scenarios/discriminator.yaml b/testdata/e2e/scenarios/discriminator.yaml new file mode 100644 index 0000000..ff97fb8 --- /dev/null +++ b/testdata/e2e/scenarios/discriminator.yaml @@ -0,0 +1,4 @@ +spec: ../../specs/feature/discriminator-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected an identifier." } +cases: + - { name: mapped value, op: getPet, response: { json: { petType: canine, packSize: 4 } }, result: { petType: canine, packSize: 4 } } diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml new file mode 100644 index 0000000..54aa8db --- /dev/null +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -0,0 +1,395 @@ +# The FastAPI backend (testdata/e2e/backends/fastapi), called through a +# client generated from the spec it serves. Most endpoints echo what they +# parsed, so a result shows what the server received. Cases run in order and +# share the server's state: save keeps a value for ${name} in later cases. +backend: fastapi +skip: { dart-fetch: "does not compile: lib/src/apis/auth_api.dart:24: Expected an identifier." } +auth: { username: admin, password: "p@ss:w0rd", apiKey: key-1 } +cases: + # ------------------------------------------------------------ auth + - name: password form, bad login + op: issueToken + args: { username: alice, password: nope } + error: { status: 400, model: null, body: '{"detail":"bad login"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: password form with a scope + op: issueToken + args: { username: bob, password: secret, scope: "items:read" } + result: { access_token: tok-r, scope: "items:read" } + save: { token: access_token } + - name: api key and optional bearer + op: whoami + auth: { bearer: "${token}" } + result: { key_in: header, bearer: tok-r } + - name: basic auth with a colon in the password + op: adminStats + result: { items: 0, orders: 0 } + + # ------------------------------------------------------------ scalars and containers + - name: every scalar format + op: echoScalars + args: + body: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + b: true + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + result: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + - name: containers, empty and null + op: echoContainers + args: + body: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + result: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + skip: { typescript-fetch: "a tuple (prefixItems) renders an undefined FromJSON" } + - name: Go keywords and odd property names + op: echoKeywords + args: + body: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + result: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + - name: extra keys both ways + op: echoOpen + args: { body: { kind: k, extra: 1, nested: { a: [true] } } } + result: { kind: k, extra: 1, nested: { a: [true] } } + skip: { typescript-fetch: "drops the keys next to the declared properties (additionalProperties)" } + - name: recursive tree + op: echoTree + args: { body: { name: root, children: [{ name: a, children: [{ name: a1 }] }], parent: { name: up } } } + result: { name: root, children: [{ name: a, children: [{ name: a1, children: [] }] }], parent: { name: up } } + - name: writeOnly secret + op: echoSecret + args: { body: { username: u, password: "12345" } } + result: { username: u, password_length: 5 } + + # ------------------------------------------------------------ bodies + - name: array body + op: echoList + args: { body: [{ widthCm: 1, heightCm: 2 }] } + result: [{ widthCm: 1, heightCm: 2 }] + - name: map body + op: echoMap + args: { body: { a: [1, 2], b: [] } } + result: { a: 3, b: 0 } + - name: number body + op: echoNumber + args: { body: 2.25 } + result: 4.5 + - name: text/plain body + op: echoText + args: { body: héllo } + result: olléh + - name: octet-stream body + op: echoRaw + args: { body: { $bytes: { base64: AAH/ } } } + result: /wEA + - name: union body, int + op: echoUnion + args: { body: 5 } + result: { type: int, value: "5" } + - name: union body, string + op: echoUnion + args: { body: x } + result: { type: str, value: x } + - name: union body, list + op: echoUnion + args: { body: [1, 2] } + result: { type: list, value: "[1, 2]" } + - name: optional body left out + op: echoOptional + result: { got: false } + - name: optional body given + op: echoOptional + args: { body: { widthCm: 1, heightCm: 1 } } + result: { got: true } + + # ------------------------------------------------------------ parameters + - name: path int, date, enum with a space, path with slashes + op: pathParams + args: { id: -3, day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + result: { id: "-3", day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + - name: query values that need escaping, arrays, offset time + op: queryParams + args: + q: "a&b=c+d ž" + page-size: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + result: + q: "a&b=c+d ž" + pageSize: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + old: null + skip: { typescript-fetch: "a Date comes back in UTC" } + - name: query, optional ones left out + op: queryParams + args: { q: only } + result: { q: only, pageSize: 20, tags: [], active: null } + - name: headers and two cookies + op: headerParams + args: + x-request-id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + x-retry: 3 + x-flags: [a] + accept-language: "sk-SK,en;q=0.5" + tenant: t1 + theme: dark + result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } + skip: { typescript-fetch: "no cookie parameters" } + + # ------------------------------------------------------------ items + - name: read-only token cannot write + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "19.99" } } + error: { status: 403 } + - name: token with write scopes + op: issueToken + args: { username: alice, password: secret, scope: "items:read items:write orders" } + save: { token: access_token } + - name: create an item + op: createItem + auth: { bearer: "${token}" } + args: + body: + sku: ABC-0001 + name: Box + price: "19.99" + description: small + color: dark-blue + tags: [a] + dimensions: { widthCm: 10, heightCm: 5 } + supplier: { name: ACME, website: https://acme.example/ } + attributes: { material: wood } + result: + sku: ABC-0001 + price: "19.99" + version: 1 + displayName: Box (ABC-0001) + supplier: { name: ACME, website: https://acme.example/ } + dimensions: { widthCm: 10, heightCm: 5 } + id: $any + createdAt: $any + save: { item: id } + skip: { typescript-fetch: "runner: ToJSON leaves out a result's readOnly properties" } + - name: create a second item, price as a number + op: createItem + args: { body: { sku: ABC-0002, name: Bag, price: 5 } } + result: { sku: ABC-0002, price: "5" } + - name: duplicate SKU is a 409 Problem + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "1" } } + error: { status: 409, model: { status: 409, title: SKU taken, detail: ABC-0001 } } + - name: page and cursor + op: listItems + args: { limit: 1 } + result: { items: [{ sku: ABC-0001 }], total: 2, next_cursor: "1" } + - name: enum and decimal filters + op: listItems + args: { color: dark-blue, min_price: "10.5" } + result: { items: [{ sku: ABC-0001 }], total: 1 } + - name: get the item + op: getItem + args: { item_id: "${item}" } + result: { id: $any, sku: ABC-0001, description: small, color: dark-blue, attributes: { material: wood } } + - name: unknown item is a 404 Problem + op: getItem + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404, model: { status: 404, title: Not found } } + - name: patch clears with null, sends an empty list, keeps the rest + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { description: null, tags: [] } } + result: { description: null, tags: [], name: Box, version: 2, updatedAt: $any } + - name: stale version is a 409 Conflict + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { name: x } } + error: { status: 409, model: { message: stale, current_version: 2 } } + - name: undeclared 400 keeps the body + op: patchItem + args: { item_id: "${item}", if-match: 2, body: { name: null } } + error: { status: 400, model: null, body: '{"detail":"name may not be null"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: HEAD of an item that exists + op: itemExists + args: { item_id: "${item}" } + skip: { typescript-fetch: "fails on the empty body of a HEAD" } + - name: HEAD of one that does not + op: itemExists + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404 } + + # ------------------------------------------------------------ files + - name: two files, an array field and JSON text + op: uploadFiles + args: + files: [{ $file: { name: a b.txt, text: first } }, { $file: { name: ž.bin, base64: AP8= } }] + folder: docs + labels: [x, y] + meta: '{"k":1}' + result: [{ name: docs/a b.txt, size: 5, labels: "x,y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + - name: duplicate file is a 409 + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } + error: { status: 409 } + - name: bool form field + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs, overwrite: true } + result: [{ name: docs/a b.txt, size: 5 }] + - name: optional file left out + op: uploadAvatar + args: { image: { $file: { name: i.png, text: "12345" } } } + result: { image: 5, thumbnail: -1 } + - name: download by a path with a slash and unicode + op: downloadFile + args: { name: docs/ž.bin } + result: AP8= + - name: text/csv response + op: exportCsv + result: "sku,name\nABC-0001,Box\nABC-0002,Bag\n" + + # ------------------------------------------------------------ orders + - name: unions round trip + op: placeOrder + args: + idempotency-key: k1 + body: + lines: [{ sku: ABC-0001, quantity: 2, note: fragile }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + result: + id: 1 + status: placed + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + - name: a 202 with another body than the 201 + op: placeOrder + args: + body: + lines: [{ sku: ABC-0001, quantity: 500 }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + error: { status: 202, model: { title: Queued for review, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - name: get the order + op: getOrder + args: { order_id: 1 } + result: { id: 1, extraPayments: [{ method: transfer }, { method: voucher }] } + - name: enum response + op: orderStatus + args: { order_id: 1 } + result: placed + - name: 202 without a body, embedded body field + op: cancelOrder + args: { order_id: 1, body: { reason: changed my mind } } + skip: { typescript-fetch: "fails on a 2xx without a body" } + - name: a bound on a path parameter is a 422 + op: getOrder + args: { order_id: 0 } + error: { status: 422, model: { detail: [{ loc: [path, order_id] }] } } + + # ------------------------------------------------------------ misc + - name: recursive response + op: categoryTree + result: [{ name: root, children: [{ name: a, children: [{ name: a1 }] }, { name: b }] }] + - name: integer response + op: countItems + result: 2 + - name: nullable response present + op: maybeItem + args: { present: true } + result: { widthCm: 1, heightCm: 2 } + - name: nullable response null + op: maybeItem + result: null + - name: deprecated operation with FastAPI's operationId + op: legacy_endpoint_legacy_get + args: { x: 9 } + result: { x: 9 } + - name: 307 redirect followed + op: redirectToCount + - name: ndjson stream next to an empty JSON schema + op: streamLines + args: { n: 2 } + result: "{\"i\":0}\n{\"i\":1}\n" + skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } + - name: float path parameter and an aliased query + op: add + args: { a: 2, b: 0.75, round: true } + result: 3 + - name: untagged operation + op: untagged + result: { ok: true } + + # ------------------------------------------------------------ cleanup + - name: delete is a 204 + op: deleteItem + args: { item_id: "${item}" } + - name: delete again is a 404 + op: deleteItem + args: { item_id: "${item}" } + error: { status: 404 } diff --git a/testdata/e2e/scenarios/nullable-openapi31.yaml b/testdata/e2e/scenarios/nullable-openapi31.yaml new file mode 100644 index 0000000..0ad15dc --- /dev/null +++ b/testdata/e2e/scenarios/nullable-openapi31.yaml @@ -0,0 +1,16 @@ +spec: ../../specs/feature/nullable-openapi31.yaml +skip: { dart-fetch: "does not compile: lib/src/models/maybe_name.dart:13: Expected an identifier." } +cases: + - name: 3.1 null members, a nullable query and an enum array + op: listPets + args: { q: mi, size: [1, 3] } + request: { query: { q: mi, size: ["1", "3"] } } + response: { json: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] } + result: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array" } + - name: contentMediaType file and multipart arrays + op: uploadPhoto + args: { id: "1", file: { $file: { name: p.png, base64: iVBORw== } }, caption: hi, tags: [a, b], labels: [x, y] } + request: { body: { file: [{ filename: p.png, base64: iVBORw== }], caption: [hi], tags: [a, b], labels: ["x,y"] } } + response: { status: 204 } + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } diff --git a/testdata/e2e/scenarios/nullable.yaml b/testdata/e2e/scenarios/nullable.yaml new file mode 100644 index 0000000..afc8bc4 --- /dev/null +++ b/testdata/e2e/scenarios/nullable.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/nullable-required.yaml +skip: { dart-fetch: "does not compile: lib/src/models/record.dart:26: Expected an identifier." } +cases: + - name: required null, optional null and absent + op: createRecord + args: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5 } } + request: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5, optionalNotNullable: $absent } } + response: { json: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null } } + result: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null, optionalNullable: $absent } diff --git a/testdata/e2e/scenarios/parameters-deepobject.yaml b/testdata/e2e/scenarios/parameters-deepobject.yaml new file mode 100644 index 0000000..db17ffa --- /dev/null +++ b/testdata/e2e/scenarios/parameters-deepobject.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-deepobject.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - op: deepSearch + args: { filter: { name: "a b&c", minPrice: 9.5 } } + request: { query: { "filter[name]": "a b&c", "filter[minPrice]": "9.5" } } + response: { json: [found] } + result: [found] diff --git a/testdata/e2e/scenarios/parameters-locations.yaml b/testdata/e2e/scenarios/parameters-locations.yaml new file mode 100644 index 0000000..90c63b1 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -0,0 +1,20 @@ +spec: ../../specs/feature/parameters-locations.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:76: Expected to find '>'." } +cases: + - name: path, query, header and cookie + op: getItem + args: { orgId: "acme/eu", itemId: 42, verbose: false, X-Request-Id: r-1, sessionId: s-1 } + request: + path: /orgs/acme/eu/items/42 + query: { verbose: "false" } + header: { X-Request-Id: r-1 } + cookie: { sessionId: s-1 } + response: { json: { id: "42" } } + result: { id: "42" } + skip: { typescript-fetch: "no cookie parameters" } + - name: optional ones left out + op: getItem + args: { orgId: o, itemId: 1 } + request: { query: { verbose: $absent }, header: { X-Request-Id: $absent }, cookie: { sessionId: $absent } } + response: { json: {} } + result: {} diff --git a/testdata/e2e/scenarios/parameters-object-styles.yaml b/testdata/e2e/scenarios/parameters-object-styles.yaml new file mode 100644 index 0000000..2a693c4 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-object-styles.yaml @@ -0,0 +1,43 @@ +# Objects and arrays in every parameter style, against the OpenAPI +# serialization table (color = {R: 100, G: 200, B: 150}). +spec: ../../specs/feature/parameters-object-styles.yaml +cases: + - name: every style + op: paint + args: + a: { R: 100, G: 200, B: 150 } + b: { R: 100, G: 200, B: 150 } + c: { R: 100, G: 200, B: 150 } + d: { R: 100, G: 200, B: 150 } + e: { R: 100, G: 200, B: 150 } + f: { R: 100, G: 200, B: 150 } + g: [blue, black] + q: { R: 100, G: 200, B: 150 } + X-Color: { R: 100, G: 200, B: 150 } + X-Color-Exploded: { R: 100, G: 200, B: 150 } + X-Tags: [x, y] + prefs: [p, q] + request: + method: GET + path: "/simple/B,150,G,200,R,100/B=150,G=200,R=100/label/.B,150,G,200,R,100/.B=150.G=200.R=100/matrix/;e=B,150,G,200,R,100/;B=150;G=200;R=100/;g=blue;g=black" + query: { q: "B,150,G,200,R,100" } + header: { X-Color: "B,150,G,200,R,100", X-Color-Exploded: "B=150,G=200,R=100", X-Tags: "x,y" } + cookie: { prefs: "p,q" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } + - name: optional ones left out + op: paint + args: + a: { R: 1, G: 2, B: 3 } + b: { R: 1, G: 2, B: 3 } + c: { R: 1, G: 2, B: 3 } + d: { R: 1, G: 2, B: 3 } + e: { R: 1, G: 2, B: 3 } + f: { R: 1, G: 2, B: 3 } + g: [a] + request: + query: { q: $absent } + header: { X-Color: $absent, X-Tags: $absent } + cookie: { prefs: $absent } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } diff --git a/testdata/e2e/scenarios/parameters-objects-in-query.yaml b/testdata/e2e/scenarios/parameters-objects-in-query.yaml new file mode 100644 index 0000000..9e18a24 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-objects-in-query.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-explode-object.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected to find '}'." } +cases: + - name: exploded map, free-form object, deepObject + op: listItems + args: { labels: { color: red, size: L }, extra: { a: "1" }, range: { min: 1, max: 5 } } + request: { query: { color: red, size: L, a: "1", "range[min]": "1", "range[max]": "5" } } + response: { status: 204 } diff --git a/testdata/e2e/scenarios/parameters-path-style.yaml b/testdata/e2e/scenarios/parameters-path-style.yaml new file mode 100644 index 0000000..a1b3eca --- /dev/null +++ b/testdata/e2e/scenarios/parameters-path-style.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/parameters-path-style.yaml +cases: + - { op: getSimplePath, args: { id: [a, b] }, request: { path: "/simple/a,b" }, response: { status: 200 }, skip: { dart-fetch: "request.path: want \"/simple/a,b\", got \"/simple/[a, b]\"" } } + - { op: getMatrixPath, args: { id: [a, b] }, request: { path: "/matrix/;id=a,b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \";id=\"", typescript-fetch: "no matrix path style" } } + - { op: getLabelPath, args: { id: [a, b] }, request: { path: "/label/.a.b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \".\"", typescript-fetch: "no label path style" } } diff --git a/testdata/e2e/scenarios/parameters-scalar-formats.yaml b/testdata/e2e/scenarios/parameters-scalar-formats.yaml new file mode 100644 index 0000000..0d56d27 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-scalar-formats.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/parameters-scalar-formats.yaml +cases: + - name: shortest exact text of each scalar + op: search + args: { f32: 0.1, f64: 1e21, n: 0.30000000000000004, i64: 4611686018427387905, ok: false, day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + request: + query: { f32: "0.1", f64: "1000000000000000000000", n: "0.30000000000000004", i64: "4611686018427387905", ok: "false", day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\": string doesn't match pattern \"^[0-9]{4}-(0[1-9]|10", typescript-fetch: "JavaScript numbers and Date: no int64, 1e21 as 1e+21, times in UTC with milliseconds" } diff --git a/testdata/e2e/scenarios/parameters.yaml b/testdata/e2e/scenarios/parameters.yaml new file mode 100644 index 0000000..4984ecd --- /dev/null +++ b/testdata/e2e/scenarios/parameters.yaml @@ -0,0 +1,13 @@ +# Parameters in each location and style; the request is as the mock decoded +# it (path and query values unescaped). +spec: ../../specs/feature/parameters-array-style-explode.yaml +cases: + - name: array styles in query and path + op: filterItems + args: { pathIds: [a, b], formExplodeTrue: [x, y], formExplodeFalse: [x, y], spaceDelimited: [1, 2], pipeDelimited: [3, 4] } + request: + path: "/filter/a,b" + query: { formExplodeTrue: [x, y], formExplodeFalse: "x,y", spaceDelimited: "1 2", pipeDelimited: "3|4" } + response: { json: [ok] } + result: [ok] + skip: { dart-fetch: "request.path: want \"/filter/a,b\", got \"/filter/[a, b]\"", typescript-fetch: "explode false, spaceDelimited and pipeDelimited go out as an exploded list" } diff --git a/testdata/e2e/scenarios/request-bodies.yaml b/testdata/e2e/scenarios/request-bodies.yaml new file mode 100644 index 0000000..c1b7aa5 --- /dev/null +++ b/testdata/e2e/scenarios/request-bodies.yaml @@ -0,0 +1,18 @@ +spec: ../../specs/feature/request-body-json.yaml +cases: + - name: model body and result + op: createItem + args: { body: { name: box, quantity: 2 } } + request: { method: POST, header: { Content-Type: application/json }, body: { name: box, quantity: 2 } } + response: { json: { name: box, quantity: 2 } } + result: { name: box, quantity: 2 } + - name: array body + op: createItems + args: { body: [{ name: a }, { name: b, quantity: 0 }] } + request: { body: [{ name: a }, { name: b, quantity: 0 }] } + response: { status: 200 } + - name: integer body + op: setRawCount + args: { body: 7 } + request: { body: 7 } + response: { status: 200 } diff --git a/testdata/e2e/scenarios/request-body-forms.yaml b/testdata/e2e/scenarios/request-body-forms.yaml new file mode 100644 index 0000000..76b045b --- /dev/null +++ b/testdata/e2e/scenarios/request-body-forms.yaml @@ -0,0 +1,26 @@ +spec: ../../specs/feature/request-body-form-encoding.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - name: urlencoded fields by their encoding + op: createCustomer + args: + name: Ann + address: { city: Oslo, lines: [a, b] } + metadata: { plan: gold } + expand: [x, y] + tags: [t1, t2] + ids: [1, 2] + request: + header: { Content-Type: application/x-www-form-urlencoded } + body: + name: Ann + "address[city]": Oslo + "address[lines][0]": a + "address[lines][1]": b + "metadata[plan]": gold + "expand[0]": x + "expand[1]": y + tags: "t1,t2" + ids: ["1", "2"] + response: { status: 204 } + skip: { typescript-fetch: "the form encoding (deepObject, explode) is ignored" } diff --git a/testdata/e2e/scenarios/request-body-login.yaml b/testdata/e2e/scenarios/request-body-login.yaml new file mode 100644 index 0000000..18d32d1 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-login.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/request-body-form-urlencoded.yaml +cases: + - op: login + args: { username: "a@b.c", password: "p&s=s w", rememberMe: true, tags: [x, y] } + request: { body: { username: "a@b.c", password: "p&s=s w", rememberMe: "true", tags: [x, y] } } + response: { status: 200 } + skip: { dart-fetch: "request.body.tags: want [\"x\",\"y\"], got [\"[x, y]\"]", typescript-fetch: "a form array goes comma-joined, not exploded" } diff --git a/testdata/e2e/scenarios/request-body-multipart.yaml b/testdata/e2e/scenarios/request-body-multipart.yaml new file mode 100644 index 0000000..60e1cce --- /dev/null +++ b/testdata/e2e/scenarios/request-body-multipart.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/request-body-multipart.yaml +cases: + - name: a file, an optional text and a list of files + op: uploadFile + args: + file: { $file: { name: a.txt, text: hello } } + description: two files + attachments: [{ $file: { name: b.bin, base64: AP8= } }, { $file: { name: c.txt, text: c } }] + request: + body: + file: [{ filename: a.txt, text: hello }] + description: [two files] + attachments: [{ filename: b.bin, base64: AP8= }, { filename: c.txt, text: c }] + response: { status: 200 } + skip: { dart-fetch: "request: invalid for the spec: request body has an error: header Content-Type has unexpected value \"application/x-www-form-urlencoded\"" } diff --git a/testdata/e2e/scenarios/request-body-other-media.yaml b/testdata/e2e/scenarios/request-body-other-media.yaml new file mode 100644 index 0000000..368b720 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -0,0 +1,22 @@ +spec: ../../specs/feature/request-body-other-media.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:196: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +auth: { bearer: tok } +cases: + - name: text/plain body and response, bearer + op: postNote + args: { body: "line 1\nčo" } + request: { header: { Content-Type: text/plain, Authorization: Bearer tok }, body: "line 1\nčo" } + response: { text: "stored" } + result: stored + - name: octet-stream body and response + op: putBlob + args: { body: { $bytes: { base64: AAH/ } } } + request: { header: { Content-Type: application/octet-stream }, body: { $base64: AAH/ } } + response: { base64: /wEA } + result: /wEA + - name: multipart with an object part + op: postReport + args: { title: q3, meta: { author: ann, pages: 3 }, attachments: [{ $file: { name: a.txt, text: a } }] } + request: { body: { title: [q3], meta: [{ json: { author: ann, pages: 3 } }], attachments: [{ filename: a.txt, text: a }] } } + response: { status: 204 } + skip: { typescript-fetch: "an object multipart part goes as text, not application/json" } diff --git a/testdata/e2e/scenarios/responses-binary.yaml b/testdata/e2e/scenarios/responses-binary.yaml new file mode 100644 index 0000000..eb8c11c --- /dev/null +++ b/testdata/e2e/scenarios/responses-binary.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/responses-binary.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:65: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +cases: + - { op: downloadFile, args: { id: "1" }, response: { base64: AAH/ }, result: AAH/ } + - { op: getThumbnail, args: { id: "1" }, response: { header: { Content-Type: image/png }, base64: iVBORw== }, result: iVBORw== } diff --git a/testdata/e2e/scenarios/responses-mixed-2xx.yaml b/testdata/e2e/scenarios/responses-mixed-2xx.yaml new file mode 100644 index 0000000..8954a5f --- /dev/null +++ b/testdata/e2e/scenarios/responses-mixed-2xx.yaml @@ -0,0 +1,13 @@ +spec: ../../specs/feature/responses-mixed-2xx.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:23: Expected an identifier." } +cases: + - { name: 201 result, op: placeOrder, response: { status: 201, json: { id: 7 } }, result: { id: 7 } } + - name: 202 with another body is an error with its model + op: placeOrder + response: { status: 202, json: { title: queued, status: 202 } } + error: { status: 202, model: { title: queued, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - { name: ndjson as text, op: streamLines, response: { header: { Content-Type: application/x-ndjson }, text: "{\"i\":0}\n" }, result: "{\"i\":0}\n", skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } } + - { name: nullable result null, op: latestOrder, response: { json: null }, result: null } + - { name: nullable text, op: greeting, response: { text: hello }, result: hello } + - { name: any holds another 2xx, op: anything, response: { status: 201, json: { id: 1 } }, result: { id: 1 } } diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml new file mode 100644 index 0000000..32bb404 --- /dev/null +++ b/testdata/e2e/scenarios/responses.yaml @@ -0,0 +1,21 @@ +spec: ../../specs/feature/responses-status-codes.yaml +cases: + - { name: 200 model, op: getTask, args: { id: "1" }, response: { json: { id: "1", title: t } }, result: { id: "1", title: t } } + - name: 404 error model + op: getTask + args: { id: "2" } + response: { status: 404, json: { message: nope, code: 4 } } + error: { status: 404, model: { message: nope, code: 4 } } + - name: default error model for a 500 + op: getTask + args: { id: "3" } + response: { status: 500, json: { message: boom } } + error: { status: 500, model: { message: boom } } + - { name: 204 no body, op: deleteTask, args: { id: "1" }, response: { status: 204 } } + - { name: 201 with a body, op: createTask, args: { id: "1" }, response: { status: 201, json: { id: "1" } }, result: { id: "1" } } + - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null, skip: { typescript-fetch: "fails on a 2xx without a body next to one with a body" } } + - name: undeclared status keeps the text body + op: deleteTask + args: { id: "1" } + response: { status: 409, text: conflict, offSpec: true } + error: { status: 409, model: null, body: conflict } diff --git a/testdata/e2e/scenarios/security-http.yaml b/testdata/e2e/scenarios/security-http.yaml new file mode 100644 index 0000000..d095029 --- /dev/null +++ b/testdata/e2e/scenarios/security-http.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-http.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { username: u, password: "p:w", bearer: jwt } +cases: + - { op: getWithBasicAuth, request: { header: { Authorization: "Basic dTpwOnc=" } }, response: { status: 200 } } + - { op: getWithBearerAuth, request: { header: { Authorization: Bearer jwt } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security-oauth2.yaml b/testdata/e2e/scenarios/security-oauth2.yaml new file mode 100644 index 0000000..a4011f5 --- /dev/null +++ b/testdata/e2e/scenarios/security-oauth2.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-oauth2.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { bearer: tok } +cases: + - { op: getWithOAuthCode, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } + - { op: getWithOAuthClientCredentials, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security.yaml b/testdata/e2e/scenarios/security.yaml new file mode 100644 index 0000000..b4a176c --- /dev/null +++ b/testdata/e2e/scenarios/security.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/security-apikey.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { apiKey: k1 } +cases: + - { op: getWithHeaderKey, request: { header: { X-Api-Key: k1 } }, response: { status: 200 } } + - { op: getWithQueryKey, request: { query: { api_key: k1 } }, response: { status: 200 } } + - { op: getWithCookieKey, request: { cookie: { session: k1 } }, response: { status: 200 }, skip: { typescript-fetch: "no API key in a cookie" } } diff --git a/testdata/e2e/scenarios/unions-anyof.yaml b/testdata/e2e/scenarios/unions-anyof.yaml new file mode 100644 index 0000000..cae292c --- /dev/null +++ b/testdata/e2e/scenarios/unions-anyof.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/composition-anyof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/contact_method.dart:13: Expected an identifier." } +cases: + - op: createContact + args: { body: { email: a@b.c } } + request: { body: { email: a@b.c } } + response: { json: { phone: "+421" } } + result: { phone: "+421" } diff --git a/testdata/e2e/scenarios/unions-inline.yaml b/testdata/e2e/scenarios/unions-inline.yaml new file mode 100644 index 0000000..c6835a8 --- /dev/null +++ b/testdata/e2e/scenarios/unions-inline.yaml @@ -0,0 +1,10 @@ +spec: ../../specs/feature/composition-oneof-inline.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:82: Expected an identifier." } +cases: + - name: inline union body, property and items + op: admit + args: { id: s1, body: { kind: dog, barkVolume: 1.5 } } + request: { path: /shelters/s1/pets, body: { kind: dog, barkVolume: 1.5 } } + response: { json: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } } + result: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } + - { name: inline union response, op: randomPet, response: { json: { kind: cat, lives: 9 } }, result: { kind: cat, lives: 9 } } diff --git a/testdata/e2e/scenarios/unions.yaml b/testdata/e2e/scenarios/unions.yaml new file mode 100644 index 0000000..8f86715 --- /dev/null +++ b/testdata/e2e/scenarios/unions.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/composition-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/search_result.dart:8: Target of URI doesn't exist: 'arraynumber.dart'." } +cases: + - name: model variant round trip + op: search + args: { body: { petType: cat, huntingSkill: lazy } } + request: { body: { petType: cat, huntingSkill: lazy } } + response: { json: { petType: dog, packSize: 3 } } + result: { petType: dog, packSize: 3 } + - name: string and array variants + op: search + args: { body: "just text" } + request: { body: "just text" } + response: { json: [1, 2] } + result: [1, 2] diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json new file mode 100644 index 0000000..c2bbd30 --- /dev/null +++ b/testdata/e2e/specs/fastapi.json @@ -0,0 +1,3995 @@ +{ + "components": { + "schemas": { + "Body_cancelOrder": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "title": "Body_cancelOrder", + "type": "object" + }, + "Body_issueToken": { + "properties": { + "client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Id" + }, + "client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "password", + "title": "Client Secret" + }, + "grant_type": { + "anyOf": [ + { + "pattern": "^password$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Grant Type" + }, + "password": { + "format": "password", + "title": "Password", + "type": "string" + }, + "scope": { + "default": "", + "title": "Scope", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Body_issueToken", + "type": "object" + }, + "Body_uploadAvatar": { + "properties": { + "image": { + "contentMediaType": "application/octet-stream", + "title": "Image", + "type": "string" + }, + "thumbnail": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail" + } + }, + "required": [ + "image" + ], + "title": "Body_uploadAvatar", + "type": "object" + }, + "Body_uploadFiles": { + "properties": { + "files": { + "description": "One or more files", + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "meta": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON text", + "title": "Meta" + }, + "overwrite": { + "default": false, + "title": "Overwrite", + "type": "boolean" + } + }, + "required": [ + "files", + "folder" + ], + "title": "Body_uploadFiles", + "type": "object" + }, + "Card": { + "additionalProperties": false, + "properties": { + "expires": { + "format": "date", + "title": "Expires", + "type": "string" + }, + "last4": { + "pattern": "^\\d{4}$", + "title": "Last4", + "type": "string" + }, + "method": { + "const": "card", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "last4", + "expires" + ], + "title": "Card", + "type": "object" + }, + "Category-Input": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Input" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Input" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Category-Output": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Output" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Circle": { + "additionalProperties": false, + "properties": { + "radius": { + "title": "Radius", + "type": "number" + } + }, + "required": [ + "radius" + ], + "title": "Circle", + "type": "object" + }, + "Color": { + "enum": [ + "red", + "dark-blue", + "light green", + "2nd", + "type", + "" + ], + "title": "Color", + "type": "string" + }, + "Conflict": { + "additionalProperties": false, + "properties": { + "current_version": { + "title": "Current Version", + "type": "integer" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message", + "current_version" + ], + "title": "Conflict", + "type": "object" + }, + "Containers": { + "additionalProperties": false, + "properties": { + "by_name": { + "additionalProperties": { + "$ref": "#/components/schemas/Dimensions" + }, + "default": {}, + "title": "By Name", + "type": "object" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "default": {}, + "title": "Counts", + "type": "object" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "title": "Groups", + "type": "object" + }, + "matrix": { + "default": [], + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Matrix", + "type": "array" + }, + "maybe_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Maybe List" + }, + "maybe_map": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Maybe Map" + }, + "pair": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "title": "Pair", + "type": "array" + }, + "sparse": { + "default": [], + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": "Sparse", + "type": "array" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unique": { + "default": [], + "items": { + "type": "string" + }, + "title": "Unique", + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "tags", + "pair" + ], + "title": "Containers", + "type": "object" + }, + "Dimensions": { + "additionalProperties": false, + "properties": { + "depthCm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Depthcm" + }, + "heightCm": { + "title": "Heightcm", + "type": "number" + }, + "widthCm": { + "title": "Widthcm", + "type": "number" + } + }, + "required": [ + "widthCm", + "heightCm" + ], + "title": "Dimensions", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Item": { + "additionalProperties": false, + "description": "An item as the server returns it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "title": "Createdat", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "displayName": { + "readOnly": true, + "title": "Displayname", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "title": "Price", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "updatedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "version": { + "title": "Version", + "type": "integer" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price", + "id", + "createdAt", + "version", + "displayName" + ], + "title": "Item", + "type": "object" + }, + "ItemIn": { + "additionalProperties": false, + "description": "An item as a client sends it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price" + ], + "title": "ItemIn", + "type": "object" + }, + "ItemPatch": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "title": "ItemPatch", + "type": "object" + }, + "Keywords": { + "additionalProperties": false, + "description": "Property names that are keywords or predeclared in Go.", + "properties": { + "1st": { + "title": "1St", + "type": "string" + }, + "UPPER": { + "title": "Upper", + "type": "string" + }, + "default": { + "default": false, + "title": "Default", + "type": "boolean" + }, + "dot.name": { + "title": "Dot.Name", + "type": "string" + }, + "error": { + "title": "Error", + "type": "string" + }, + "func": { + "title": "Func", + "type": "integer" + }, + "interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Interface" + }, + "map": { + "additionalProperties": { + "type": "integer" + }, + "title": "Map", + "type": "object" + }, + "package": { + "default": "main", + "title": "Package", + "type": "string" + }, + "range": { + "items": { + "type": "integer" + }, + "title": "Range", + "type": "array" + }, + "select": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Select" + }, + "string": { + "title": "String", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "x-dash-name": { + "title": "X-Dash-Name", + "type": "string" + } + }, + "required": [ + "type", + "func", + "range", + "map", + "string", + "error", + "x-dash-name", + "dot.name", + "UPPER", + "1st" + ], + "title": "Keywords", + "type": "object" + }, + "Open": { + "additionalProperties": true, + "description": "Takes any extra key and returns it.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "Open", + "type": "object" + }, + "Order": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ], + "title": "Payment" + }, + "placedAt": { + "format": "date-time", + "title": "Placedat", + "type": "string" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + }, + "status": { + "$ref": "#/components/schemas/OrderStatus" + } + }, + "required": [ + "lines", + "payment", + "id", + "status", + "placedAt" + ], + "title": "Order", + "type": "object" + }, + "OrderIn": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ], + "title": "Payment" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + } + }, + "required": [ + "lines", + "payment" + ], + "title": "OrderIn", + "type": "object" + }, + "OrderLine": { + "additionalProperties": false, + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "quantity": { + "exclusiveMinimum": 0, + "title": "Quantity", + "type": "integer" + }, + "sku": { + "title": "Sku", + "type": "string" + } + }, + "required": [ + "sku", + "quantity" + ], + "title": "OrderLine", + "type": "object" + }, + "OrderStatus": { + "enum": [ + "placed", + "paid", + "shipped" + ], + "title": "OrderStatus", + "type": "string" + }, + "Page_Item_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Item" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "Page[Item]", + "type": "object" + }, + "Priority": { + "enum": [ + 1, + 5, + 10 + ], + "title": "Priority", + "type": "integer" + }, + "Problem": { + "additionalProperties": false, + "properties": { + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status" + ], + "title": "Problem", + "type": "object" + }, + "Rect": { + "additionalProperties": false, + "properties": { + "height": { + "title": "Height", + "type": "number" + }, + "width": { + "title": "Width", + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "title": "Rect", + "type": "object" + }, + "Scalars-Input": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "D" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Scalars-Output": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "title": "D", + "type": "string" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Secret": { + "additionalProperties": false, + "properties": { + "password": { + "format": "password", + "title": "Password", + "type": "string", + "writeOnly": true + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Secret", + "type": "object" + }, + "Supplier": { + "additionalProperties": false, + "properties": { + "contactEmail": { + "anyOf": [ + { + "format": "email", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Contactemail" + }, + "name": { + "title": "Name", + "type": "string" + }, + "website": { + "anyOf": [ + { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Website" + } + }, + "required": [ + "name" + ], + "title": "Supplier", + "type": "object" + }, + "Transfer": { + "additionalProperties": false, + "properties": { + "iban": { + "title": "Iban", + "type": "string" + }, + "method": { + "const": "transfer", + "title": "Method", + "type": "string" + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reference" + } + }, + "required": [ + "method", + "iban" + ], + "title": "Transfer", + "type": "object" + }, + "Unit": { + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "Voucher-Input": { + "additionalProperties": false, + "properties": { + "amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Amount" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + }, + "Voucher-Output": { + "additionalProperties": false, + "properties": { + "amount": { + "title": "Amount", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + } + }, + "securitySchemes": { + "APIKeyCookie": { + "in": "cookie", + "name": "session", + "type": "apiKey" + }, + "APIKeyHeader": { + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "APIKeyQuery": { + "in": "query", + "name": "api_key", + "type": "apiKey" + }, + "HTTPBasic": { + "scheme": "basic", + "type": "http" + }, + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + }, + "OAuth2PasswordBearer": { + "flows": { + "password": { + "scopes": { + "items:read": "Read items", + "items:write": "Write items", + "orders": "Place orders" + }, + "tokenUrl": "token" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "description": "Every common OpenAPI shape in one strict API.", + "title": "Warehouse", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/admin/stats": { + "get": { + "operationId": "adminStats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Adminstats", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBasic": [] + } + ], + "summary": "Admin Stats", + "tags": [ + "auth" + ] + } + }, + "/categories": { + "get": { + "operationId": "categoryTree", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Response Categorytree", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Category Tree", + "tags": [ + "misc" + ] + } + }, + "/count": { + "get": { + "operationId": "countItems", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Countitems", + "type": "integer" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Count Items", + "tags": [ + "misc" + ] + } + }, + "/echo/containers": { + "post": { + "operationId": "echoContainers", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Containers", + "tags": [ + "echo" + ] + } + }, + "/echo/keywords": { + "post": { + "operationId": "echoKeywords", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Keywords", + "tags": [ + "echo" + ] + } + }, + "/echo/list": { + "post": { + "operationId": "echoList", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Body", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Response Echolist", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo List", + "tags": [ + "echo" + ] + } + }, + "/echo/map": { + "post": { + "operationId": "echoMap", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Body", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Echomap", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Map", + "tags": [ + "echo" + ] + } + }, + "/echo/number": { + "post": { + "operationId": "echoNumber", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Body", + "type": "number" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Echonumber", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Number", + "tags": [ + "echo" + ] + } + }, + "/echo/open": { + "post": { + "operationId": "echoOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Open", + "tags": [ + "echo" + ] + } + }, + "/echo/optional": { + "post": { + "operationId": "echoOptional", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Echooptional", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Optional", + "tags": [ + "echo" + ] + } + }, + "/echo/raw": { + "post": { + "operationId": "echoRaw", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Raw", + "tags": [ + "echo" + ] + } + }, + "/echo/scalars": { + "post": { + "operationId": "echoScalars", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Scalars", + "tags": [ + "echo" + ] + } + }, + "/echo/secret": { + "post": { + "operationId": "echoSecret", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Response Echosecret", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Secret", + "tags": [ + "echo" + ] + } + }, + "/echo/text": { + "post": { + "operationId": "echoText", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Text", + "tags": [ + "echo" + ] + } + }, + "/echo/tree": { + "post": { + "operationId": "echoTree", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Tree", + "tags": [ + "echo" + ] + } + }, + "/echo/union": { + "post": { + "operationId": "echoUnion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Echounion", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Union", + "tags": [ + "echo" + ] + } + }, + "/export.csv": { + "get": { + "operationId": "exportCsv", + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Export Csv", + "tags": [ + "files" + ] + } + }, + "/files": { + "post": { + "operationId": "uploadFiles", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadFiles" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "object" + }, + "title": "Response Uploadfiles", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "APIKeyQuery": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Upload Files", + "tags": [ + "files" + ] + } + }, + "/files/avatar": { + "post": { + "operationId": "uploadAvatar", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadAvatar" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Uploadavatar", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Avatar", + "tags": [ + "files" + ] + } + }, + "/files/{name}": { + "get": { + "operationId": "downloadFile", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download File", + "tags": [ + "files" + ] + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ + { + "in": "query", + "name": "color", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ], + "title": "Color" + } + }, + { + "in": "query", + "name": "min_price", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Min Price" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 10, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Item_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "List Items", + "tags": [ + "items" + ] + }, + "post": { + "operationId": "createItem", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response", + "headers": { + "Location": { + "description": "URL of the item", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "SKU taken" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Create Item", + "tags": [ + "items" + ] + } + }, + "/items/{item_id}": { + "delete": { + "operationId": "deleteItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Delete Item", + "tags": [ + "items" + ] + }, + "get": { + "operationId": "getItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Get Item", + "tags": [ + "items" + ] + }, + "head": { + "operationId": "itemExists", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "404": { + "description": "absent" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Item Exists", + "tags": [ + "items" + ] + }, + "patch": { + "operationId": "patchItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "if-match", + "required": true, + "schema": { + "title": "If-Match", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Conflict" + } + } + }, + "description": "Stale version" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Patch Item", + "tags": [ + "items" + ] + } + }, + "/legacy": { + "get": { + "deprecated": true, + "description": "A deprecated operation with FastAPI's generated operationId.", + "operationId": "legacy_endpoint_legacy_get", + "parameters": [ + { + "in": "query", + "name": "x", + "required": false, + "schema": { + "default": 1, + "title": "X", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Legacy Endpoint Legacy Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Legacy Endpoint", + "tags": [ + "misc" + ] + } + }, + "/maybe": { + "get": { + "operationId": "maybeItem", + "parameters": [ + { + "in": "query", + "name": "present", + "required": false, + "schema": { + "default": false, + "title": "Present", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Response Maybeitem" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Maybe Item", + "tags": [ + "misc" + ] + } + }, + "/numbers/{a}/{b}": { + "get": { + "operationId": "add", + "parameters": [ + { + "in": "path", + "name": "a", + "required": true, + "schema": { + "title": "A", + "type": "integer" + } + }, + { + "in": "path", + "name": "b", + "required": true, + "schema": { + "title": "B", + "type": "number" + } + }, + { + "in": "query", + "name": "round", + "required": false, + "schema": { + "default": false, + "title": "Round", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Add", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Add", + "tags": [ + "misc" + ] + } + }, + "/orders": { + "post": { + "operationId": "placeOrder", + "parameters": [ + { + "in": "query", + "name": "dry_run", + "required": false, + "schema": { + "default": false, + "title": "Dry Run", + "type": "boolean" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Queued for review" + }, + "422": { + "description": "Invalid" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Place Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}": { + "get": { + "operationId": "getOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "minimum": 1, + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Get Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/cancel": { + "post": { + "operationId": "cancelOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_cancelOrder" + } + } + } + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Cancellation accepted; no body" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Cancel Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/status": { + "get": { + "operationId": "orderStatus", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderStatus" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Order Status", + "tags": [ + "orders" + ] + } + }, + "/params/headers": { + "get": { + "operationId": "headerParams", + "parameters": [ + { + "in": "header", + "name": "x-request-id", + "required": true, + "schema": { + "format": "uuid", + "title": "X-Request-Id", + "type": "string" + } + }, + { + "in": "header", + "name": "x-retry", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X-Retry" + } + }, + { + "in": "header", + "name": "x-flags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Flags", + "type": "array" + } + }, + { + "in": "header", + "name": "accept-language", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accept-Language" + } + }, + { + "in": "cookie", + "name": "tenant", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant" + } + }, + { + "in": "cookie", + "name": "theme", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Theme" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Params", + "tags": [ + "params" + ] + } + }, + "/params/path/{id}/{day}/{color}/{rest}": { + "get": { + "operationId": "pathParams", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "day", + "required": true, + "schema": { + "format": "date", + "title": "Day", + "type": "string" + } + }, + { + "in": "path", + "name": "color", + "required": true, + "schema": { + "$ref": "#/components/schemas/Color" + } + }, + { + "in": "path", + "name": "rest", + "required": true, + "schema": { + "title": "Rest", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Pathparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Path Params", + "tags": [ + "params" + ] + } + }, + "/params/query": { + "get": { + "operationId": "queryParams", + "parameters": [ + { + "in": "query", + "name": "q", + "required": true, + "schema": { + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "page-size", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Page-Size", + "type": "integer" + } + }, + { + "in": "query", + "name": "tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + } + }, + { + "in": "query", + "name": "ids", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ids" + } + }, + { + "in": "query", + "name": "colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "in": "query", + "name": "ratio", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ratio" + } + }, + { + "in": "query", + "name": "active", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + } + }, + { + "deprecated": true, + "in": "query", + "name": "old", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Old" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Queryparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Query Params", + "tags": [ + "params" + ] + } + }, + "/redirect": { + "get": { + "operationId": "redirectToCount", + "responses": { + "307": { + "description": "Successful Response" + } + }, + "summary": "Redirect", + "tags": [ + "misc" + ] + } + }, + "/stream": { + "get": { + "operationId": "streamLines", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 3, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "application/x-ndjson": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Lines", + "tags": [ + "misc" + ] + } + }, + "/token": { + "post": { + "operationId": "issueToken", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_issueToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Issuetoken", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Issue Token", + "tags": [ + "auth" + ] + } + }, + "/untagged": { + "get": { + "operationId": "untagged", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Untagged", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Untagged" + } + }, + "/whoami": { + "get": { + "operationId": "whoami", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Whoami", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "APIKeyQuery": [] + }, + { + "APIKeyCookie": [] + }, + { + "HTTPBearer": [] + } + ], + "summary": "Whoami", + "tags": [ + "auth" + ] + } + } + } +} diff --git a/testdata/specs/feature/README.md b/testdata/specs/feature/README.md index a3d58dc..6891f68 100644 --- a/testdata/specs/feature/README.md +++ b/testdata/specs/feature/README.md @@ -17,5 +17,9 @@ make regress REF=master # or any branch, tag or commit Run it before committing a template or parser change. No diff means the change reached nothing else; a diff shows exactly which generated files it changed. +They are also the input of the end-to-end tests: `testdata/e2e/scenarios` +calls their operations through each generated client against a validating +mock (`make e2e`). + A new spec covers one feature and passes `openapi-generator validate -i `. A new boolean generator option goes into `flipped` in `scripts/regress.sh`. diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml index 25480e4..9679d0a 100644 --- a/testdata/specs/feature/parameters-object-styles.yaml +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -19,7 +19,7 @@ paths: - { name: X-Color, in: header, schema: { $ref: "#/components/schemas/Color" } } - { name: X-Color-Exploded, in: header, explode: true, schema: { $ref: "#/components/schemas/Color" } } - { name: X-Tags, in: header, schema: { type: array, items: { type: string } } } - - { name: prefs, in: cookie, schema: { type: array, items: { type: string } } } + - { name: prefs, in: cookie, explode: false, schema: { type: array, items: { type: string } } } responses: "204": description: painted From 9d7f5677bfdaadacd760b46ea202c1a0f3659154 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 19:38:31 +0100 Subject: [PATCH 2/6] test(e2e): widen the FastAPI backend with the shapes that break clients Six rounds of looking for realistic FastAPI and pydantic shapes a generated client gets wrong on the wire, each added to the backend with calls that show it (61 -> 81 cases): - discriminated unions as the body, a nullable field, map values and list items, behind a camelCase key, and a callable discriminator; - naive and aware date-times, times with offsets, negative durations, dates in lists, maps and query arrays; runners now run in a non-UTC zone (TZ=Europe/Bratislava); - required nullables and null against a non-null default, with the keys the client sent echoed back; - a typed form model; multipart labels echo joined by "|" so one part "x,y" no longer passes for two; - parameters whose names collide once camelCased, under a router prefix and a shared dependency, and ones named like a client's locals; - enums and Literals of mixed JSON types and with null. The gaps they found are skips with their reasons: go-fetch cannot hold a naive date-time or an enum without a type; typescript-fetch sends a date query array as Date.toString(), a naive date-time in UTC, an explicit null of an optional nullable as absent, and does not compile a union form field; the parser merges parameters of one camelCase name. --- internal/e2e/gofetch_test.go | 2 + testdata/e2e/README.md | 9 +- testdata/e2e/backends/fastapi/app.py | 202 +++- testdata/e2e/scenarios/fastapi.yaml | 139 ++- testdata/e2e/specs/fastapi.json | 1653 ++++++++++++++++++++++++-- 5 files changed, 1918 insertions(+), 87 deletions(-) diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go index 0b25ac6..e647148 100644 --- a/internal/e2e/gofetch_test.go +++ b/internal/e2e/gofetch_test.go @@ -123,6 +123,8 @@ func execRunner(t *testing.T, cmd *exec.Cmd) map[string]map[int]outcome { var stderr bytes.Buffer + // Away from UTC, so a client that reads or writes local time shows it. + cmd.Env = append(os.Environ(), "TZ=Europe/Bratislava") cmd.Stderr = &stderr stdout, err := cmd.Output() diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 46fec84..6a7dc98 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -32,8 +32,10 @@ A run takes about 7 s with warm caches. The mock layer covers every parameter style and body encoding the feature specs declare. The backend shows the shapes a framework writes into its spec: pydantic's separate input and output schemas, OpenAPI 3.1 null unions, -`const`, Decimal as number or string, and FastAPI's empty JSON schema next -to another media type. +`const`, Decimal as number or string, FastAPI's empty JSON schema next to +another media type, discriminated unions in every position, naive and aware +date-times, required nullables, form models, enums of mixed JSON types and +parameters whose names collide once camelCased. ## Scenarios @@ -103,6 +105,9 @@ what the program prints with the scenarios. | `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | | `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | +Runners run with `TZ=Europe/Bratislava`, so a client that reads or writes +local time shows it. + typescript-fetch sends an OAuth2 token as the whole `Authorization` header, so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the input). Its error has no decoded model: the runner reports the error body diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 75ecc33..71b16ba 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -13,6 +13,7 @@ from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union from fastapi import ( + APIRouter, Body, Cookie, Depends, @@ -43,12 +44,16 @@ ) from pydantic import ( AliasGenerator, + AwareDatetime, BaseModel, ConfigDict, + Discriminator, EmailStr, Field, HttpUrl, + NaiveDatetime, SecretStr, + Tag, computed_field, ) from pydantic.alias_generators import to_camel @@ -123,6 +128,67 @@ class Scalars(Strict): unit: Literal["kg", "lb"] = "kg" +class Stamps(Strict): + """Times as ORM-backed services send them: naive, aware, in containers.""" + + naive: NaiveDatetime + aware: AwareDatetime + clock: dt.time + span: dt.timedelta + days: list[dt.date] = [] + seen: dict[str, dt.datetime] = {} + + +class Nulls(Strict): + """Null as a required value, and null that differs from a default.""" + + note: Optional[str] + due: Optional[dt.date] + color: Optional[Color] + dims: Optional["Dimensions"] + lines: Optional[list["Dimensions"]] + payment: Optional["Payment"] + retries: Optional[int] = 3 + extras: Optional[list["Dimensions"]] = None + + +class NullsEcho(Nulls): + sent: list[str] # the keys the client put on the wire + + +class FormIn(Strict): + """A form model: every value a client must turn into text itself.""" + + day: dt.date + when: AwareDatetime + price: Decimal + color: Color + tags: list[str] = [] + colors: list[Color] = [] + days: list[dt.date] = [] + qty: Optional[int] = None + agree: bool = False + + +class Retry(Enum): + """Members of two JSON types: the schema has an enum and no type.""" + + never = 0 + always = "always" + once = 1 + + +class Literals(Strict): + """Enums whose values are not one JSON type, null among them.""" + + retry: Retry + pin: Literal["x", None] + order: Optional[Literal["asc", "desc"]] + mode: Literal["auto", 0, 1] = "auto" + tag: Literal["a", None] = "a" + size: Literal[10, 25, 50] = 25 + + # ---------------------------------------------------------------- containers @@ -258,6 +324,42 @@ class OrderStatus(str, Enum): shipped = "shipped" +class Kind(str, Enum): + cat = "cat" + dog = "dog" + + +class Cat(Camel): + pet_kind: Literal[Kind.cat] + lives: int + + +class Dog(Camel): + pet_kind: Literal[Kind.dog] + good_boy: bool + + +# Tagged by a camelCase key whose values are enum members. +Pet = Annotated[Union[Cat, Dog], Field(discriminator="pet_kind")] + + +def _method(v: Any) -> Optional[str]: + return v.get("method") if isinstance(v, dict) else getattr(v, "method", None) + + +# A callable discriminator: the spec has a plain oneOf, no discriminator. +Tagged = Annotated[Union[Annotated[Card, Tag("card")], Annotated[Transfer, Tag("transfer")]], Discriminator(_method)] + + +class Wallet(Strict): + """The union in every place a model can hold it.""" + + primary: Optional[Payment] = None + by_label: dict[str, Payment] = {} + pets: list[Pet] = [] + tagged: Optional[Tagged] = None + + class Order(OrderIn): id: int status: OrderStatus @@ -322,6 +424,10 @@ class Conflict(Strict): # ---------------------------------------------------------------- state +# Nulls names Dimensions and Payment before they are defined. +Nulls.model_rebuild() +NullsEcho.model_rebuild() + ITEMS: dict[uuid.UUID, Item] = {} ORDERS: dict[int, Order] = {} FILES: dict[str, bytes] = {} @@ -408,6 +514,35 @@ def echo_scalars(body: Scalars) -> Scalars: return body +@app.post("/echo/literals", tags=["echo"], operation_id="echoLiterals") +def echo_literals(body: Literals) -> Literals: + return body + + +@app.post("/echo/nulls", tags=["echo"], operation_id="echoNulls") +def echo_nulls(body: Nulls) -> NullsEcho: + return NullsEcho(**body.model_dump(), sent=sorted(body.model_fields_set)) + + +@app.post("/echo/stamps", tags=["echo"], operation_id="echoStamps") +def echo_stamps(body: Stamps) -> Stamps: + return body + + +@app.get("/stamps", tags=["echo"], operation_id="stampsQuery") +def stamps_query( + days: Annotated[list[dt.date], Query()] = [], + since: Optional[NaiveDatetime] = None, +) -> Stamps: + return Stamps( + naive=since or dt.datetime(2024, 3, 1, 10, 0, 0, 500000), + aware=dt.datetime(2024, 3, 1, 10, tzinfo=dt.timezone.utc), + clock=dt.time(13, 45, 30, 250000), + span=dt.timedelta(seconds=-1.5), + days=days, + ) + + @app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") def echo_containers(body: Containers) -> Containers: return body @@ -468,6 +603,16 @@ def echo_union(body: Union[int, str, list[int]]) -> dict[str, str]: return {"type": type(body).__name__, "value": str(body)} +@app.post("/echo/pet", tags=["echo"], operation_id="echoPet") +def echo_pet(body: Annotated[Pet, Body()]) -> Pet: + return body + + +@app.post("/echo/wallet", tags=["echo"], operation_id="echoWallet") +def echo_wallet(body: Wallet) -> Wallet: + return body + + @app.post("/echo/optional", tags=["echo"], operation_id="echoOptional") def echo_optional(body: Annotated[Optional[Dimensions], Body()] = None) -> dict[str, bool]: return {"got": body is not None} @@ -630,10 +775,15 @@ def upload_files( if name in FILES and not overwrite: raise HTTPException(409, f"{name} exists") FILES[name] = data - out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": ",".join(labels), "meta": meta or ""}) + out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": "|".join(labels), "meta": meta or ""}) return out +@app.post("/forms/typed", tags=["files"], operation_id="submitForm") +def submit_form(body: Annotated[FormIn, Form()]) -> FormIn: + return body + + @app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} @@ -750,3 +900,53 @@ def add(a: int, b: float, round_: Annotated[bool, Query(alias="round")] = False) @app.get("/untagged", operation_id="untagged") def untagged() -> dict[str, bool]: return {"ok": True} + + +# ---------------------------------------------------------------- name clashes +# Parameters that differ on the wire but share a name once camelCased, and +# ones named like a client's own locals; a router prefix and a shared +# dependency bring them in as real services do. + + +def api_version(version: Annotated[str, Header(alias="Version")] = "1") -> str: + return version + + +clash = APIRouter(prefix="/tenants/{tenant}", tags=["clash"]) + + +@clash.get("/things/{item_id}", operation_id="clashLocations") +def clash_locations( + tenant: str, + item_id: int, + item_hdr: Annotated[Optional[str], Header(alias="Item-Id")] = None, + hv: str = Depends(api_version), + version: Optional[int] = None, +) -> dict[str, Any]: + return {"tenant": tenant, "path": item_id, "header": item_hdr, "hversion": hv, "qversion": version} + + +@clash.get("/sorted", operation_id="clashAliases") +def clash_aliases( + tenant: str, + old: Annotated[Optional[str], Query(alias="sort_by", deprecated=True)] = None, + new: Annotated[Optional[str], Query(alias="sortBy")] = None, +) -> dict[str, Optional[str]]: + return {"sort_by": old, "sortBy": new} + + +@clash.post("/notes", operation_id="clashForm") +def clash_form( + tenant: str, + q: Annotated[str, Query(alias="folder_name")], + f: Annotated[str, Form(alias="folderName")], +) -> dict[str, str]: + return {"query": q, "form": f} + + +@app.get("/fetch", tags=["clash"], operation_id="clashLocals") +def clash_locals(url: str, headers: Optional[str] = None, path: Optional[str] = None, query: Optional[str] = None) -> dict[str, Optional[str]]: + return {"url": url, "headers": headers, "path": path, "query": query} + + +app.include_router(clash) diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index 54aa8db..cab7b58 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -69,6 +69,76 @@ cases: priority: 10 unit: lb skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + - name: literals, string members and defaults + op: echoLiterals + args: { body: { retry: always, pin: x, order: asc } } + result: { retry: always, pin: x, order: asc, mode: auto, tag: a, size: 25 } + - name: literals, int members of a mixed enum + op: echoLiterals + args: { body: { retry: 0, pin: x, order: null, mode: 1, size: 50 } } + result: { retry: 0, pin: x, order: null, mode: 1, tag: a, size: 50 } + skip: { go-fetch: "an enum without a type (members of two JSON types) is a Go string: a number can be neither sent nor decoded" } + - name: literals, null as an enum member + op: echoLiterals + args: { body: { retry: 1, pin: null, order: desc, tag: null } } + result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } + skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default", go-fetch: "an enum without a type is a Go string (no number); enum [x, null] has no null: required sends \"\", optional drops it" } + - name: required nullables all null, optional nullables left out + op: echoNulls + args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } + result: + note: null + due: null + color: null + dims: null + lines: null + payment: null + retries: 3 + extras: null + sent: [color, dims, due, lines, note, payment] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: required nullables set, falsy values, explicit null over a default + op: echoNulls + args: + body: { note: "", due: "2024-02-29", color: "", dims: { widthCm: 1, heightCm: 2 }, lines: [], payment: { method: transfer, iban: SK31 }, retries: null, extras: null } + result: + note: "" + due: "2024-02-29" + color: "" + dims: { widthCm: 1, heightCm: 2 } + lines: [] + payment: { method: transfer, iban: SK31 } + retries: null + extras: null + sent: [color, dims, due, extras, lines, note, payment, retries] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: naive, aware and contained times round trip + op: echoStamps + args: + body: + naive: "2024-03-01T10:00:00.5" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.25+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + result: + naive: "2024-03-01T10:00:00.500000" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.250000+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + - name: an offset on a naive field is refused by the server + op: echoStamps + args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } + error: { status: 422, model: { detail: [{ type: timezone_naive }] } } + - name: a naive time in the response and the query, a date list in the query + op: stampsQuery + args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } + result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } + skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } - name: containers, empty and null op: echoContainers args: @@ -147,6 +217,41 @@ cases: op: echoUnion args: { body: [1, 2] } result: { type: list, value: "[1, 2]" } + # A discriminated union as the whole body, a nullable field, map values + # and list items, behind a camelCase alias, and a callable discriminator. + - name: union body, the second member + op: echoPet + args: { body: { petKind: dog, goodBoy: true } } + result: { petKind: dog, goodBoy: true } + - name: union body, the first member + op: echoPet + args: { body: { petKind: cat, lives: 9 } } + result: { petKind: cat, lives: 9 } + - name: union body of the wrong shape is refused + op: echoPet + args: { body: { petKind: cat, goodBoy: true } } + error: { status: 422 } + - name: unions as a nullable field, map values, list items and by a callable + op: echoWallet + args: + body: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + result: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + - name: an explicit null union + op: echoWallet + args: { body: { primary: null } } + result: { primary: null, by_label: {}, pets: [] } + - name: unset unions are left out + op: echoWallet + args: { body: {} } + result: { primary: null, tagged: null } - name: optional body left out op: echoOptional result: { got: false } @@ -286,7 +391,8 @@ cases: folder: docs labels: [x, y] meta: '{"k":1}' - result: [{ name: docs/a b.txt, size: 5, labels: "x,y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + result: [{ name: docs/a b.txt, size: 5, labels: "x|y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } - name: duplicate file is a 409 op: uploadFiles args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } @@ -295,6 +401,16 @@ cases: op: uploadFiles args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs, overwrite: true } result: [{ name: docs/a b.txt, size: 5 }] + - name: typed form, lists as repeated keys + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + result: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: typed form, defaults left out, a number for a Decimal + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } + result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } - name: optional file left out op: uploadAvatar args: { image: { $file: { name: i.png, text: "12345" } } } @@ -385,6 +501,27 @@ cases: op: untagged result: { ok: true } + # ------------------------------------------------------------ name clashes + - name: one camel name for a path parameter and a header, a query and a header + op: clashLocations + args: { tenant: acme, item_id: 7, Item-Id: h-7, Version: "2", version: 3 } + result: { tenant: acme, path: 7, header: h-7, hversion: "2", qversion: 3 } + skip: { typescript-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent", go-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent" } + - name: a snake and a camel alias of one query parameter + op: clashAliases + args: { tenant: acme, sort_by: a, sortBy: b } + result: { sort_by: a, sortBy: b } + skip: { typescript-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent", go-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent" } + - name: a query parameter and a form field of one camel name + op: clashForm + args: { tenant: acme, folder_name: q, folderName: f } + result: { query: q, form: f } + skip: { typescript-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)", go-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)" } + - name: query parameters named like a client's locals + op: clashLocals + args: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + result: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + # ------------------------------------------------------------ cleanup - name: delete is a 204 op: deleteItem diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index c2bbd30..56cb652 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -18,6 +18,19 @@ "title": "Body_cancelOrder", "type": "object" }, + "Body_clashForm": { + "properties": { + "folderName": { + "title": "Foldername", + "type": "string" + } + }, + "required": [ + "folderName" + ], + "title": "Body_clashForm", + "type": "object" + }, "Body_issueToken": { "properties": { "client_id": { @@ -178,6 +191,26 @@ "title": "Card", "type": "object" }, + "Cat": { + "additionalProperties": false, + "properties": { + "lives": { + "title": "Lives", + "type": "integer" + }, + "petKind": { + "const": "cat", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "lives" + ], + "title": "Cat", + "type": "object" + }, "Category-Input": { "additionalProperties": false, "properties": { @@ -446,6 +479,177 @@ "title": "Dimensions", "type": "object" }, + "Dog": { + "additionalProperties": false, + "properties": { + "goodBoy": { + "title": "Goodboy", + "type": "boolean" + }, + "petKind": { + "const": "dog", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "goodBoy" + ], + "title": "Dog", + "type": "object" + }, + "FormIn-Input": { + "additionalProperties": false, + "description": "A form model: every value a client must turn into text itself.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, + "FormIn-Output": { + "additionalProperties": false, + "description": "A form model: every value a client must turn into text itself.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "title": "Price", + "type": "string" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -900,79 +1104,407 @@ "title": "Keywords", "type": "object" }, - "Open": { - "additionalProperties": true, - "description": "Takes any extra key and returns it.", + "Literals": { + "additionalProperties": false, + "description": "Enums whose values are not one JSON type, null among them.", "properties": { - "kind": { - "title": "Kind", - "type": "string" + "mode": { + "default": "auto", + "enum": [ + "auto", + 0, + 1 + ], + "title": "Mode" + }, + "order": { + "anyOf": [ + { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order" + }, + "pin": { + "enum": [ + "x", + null + ], + "title": "Pin" + }, + "retry": { + "$ref": "#/components/schemas/Retry" + }, + "size": { + "default": 25, + "enum": [ + 10, + 25, + 50 + ], + "title": "Size", + "type": "integer" + }, + "tag": { + "default": "a", + "enum": [ + "a", + null + ], + "title": "Tag" } }, "required": [ - "kind" + "retry", + "pin", + "order" ], - "title": "Open", + "title": "Literals", "type": "object" }, - "Order": { + "Nulls": { "additionalProperties": false, + "description": "Null as a required value, and null that differs from a default.", "properties": { - "code": { + "color": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" }, { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", "type": "string" }, { "type": "null" } ], - "title": "Code" + "title": "Due" }, - "extraPayments": { - "default": [], - "items": { - "discriminator": { - "mapping": { - "card": "#/components/schemas/Card", - "transfer": "#/components/schemas/Transfer", - "voucher": "#/components/schemas/Voucher-Output" + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" }, - "propertyName": "method" + "type": "array" }, - "oneOf": [ - { - "$ref": "#/components/schemas/Card" - }, - { - "$ref": "#/components/schemas/Transfer" - }, - { - "$ref": "#/components/schemas/Voucher-Output" - } - ] - }, - "title": "Extrapayments", - "type": "array" + { + "type": "null" + } + ], + "title": "Extras" }, - "gift": { + "lines": { "anyOf": [ { - "type": "boolean" + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" }, { - "type": "string" + "type": "null" } ], - "default": false, - "title": "Gift" + "title": "Lines" }, - "id": { - "title": "Id", - "type": "integer" + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment" + ], + "title": "Nulls", + "type": "object" + }, + "NullsEcho": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Due" + }, + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Extras" + }, + "lines": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Lines" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + }, + "sent": { + "items": { + "type": "string" + }, + "title": "Sent", + "type": "array" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment", + "sent" + ], + "title": "NullsEcho", + "type": "object" + }, + "Open": { + "additionalProperties": true, + "description": "Takes any extra key and returns it.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "Open", + "type": "object" + }, + "Order": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "id": { + "title": "Id", + "type": "integer" }, "lines": { "items": { @@ -1279,6 +1811,15 @@ "title": "Rect", "type": "object" }, + "Retry": { + "description": "Members of two JSON types: the schema has an enum and no type.", + "enum": [ + 0, + "always", + 1 + ], + "title": "Retry" + }, "Scalars-Input": { "additionalProperties": false, "description": "Every scalar format pydantic writes into a schema.", @@ -1549,34 +2090,86 @@ "title": "Secret", "type": "object" }, - "Supplier": { + "Stamps": { "additionalProperties": false, + "description": "Times as ORM-backed services send them: naive, aware, in containers.", "properties": { - "contactEmail": { - "anyOf": [ - { - "format": "email", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Contactemail" + "aware": { + "format": "date-time", + "title": "Aware", + "type": "string" }, - "name": { - "title": "Name", + "clock": { + "format": "time", + "title": "Clock", "type": "string" }, - "website": { - "anyOf": [ - { - "format": "uri", - "maxLength": 2083, - "minLength": 1, - "type": "string" - }, - { + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "naive": { + "format": "date-time", + "title": "Naive", + "type": "string" + }, + "seen": { + "additionalProperties": { + "format": "date-time", + "type": "string" + }, + "default": {}, + "title": "Seen", + "type": "object" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + } + }, + "required": [ + "naive", + "aware", + "clock", + "span" + ], + "title": "Stamps", + "type": "object" + }, + "Supplier": { + "additionalProperties": false, + "properties": { + "contactEmail": { + "anyOf": [ + { + "format": "email", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Contactemail" + }, + "name": { + "title": "Name", + "type": "string" + }, + "website": { + "anyOf": [ + { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + { "type": "null" } ], @@ -1724,6 +2317,212 @@ ], "title": "Voucher", "type": "object" + }, + "Wallet-Input": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" + }, + "Wallet-Output": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" } }, "securitySchemes": { @@ -1980,6 +2779,47 @@ ] } }, + "/echo/literals": { + "post": { + "operationId": "echoLiterals", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Literals", + "tags": [ + "echo" + ] + } + }, "/echo/map": { "post": { "operationId": "echoMap", @@ -2032,15 +2872,14 @@ ] } }, - "/echo/number": { + "/echo/nulls": { "post": { - "operationId": "echoNumber", + "operationId": "echoNulls", "requestBody": { "content": { "application/json": { "schema": { - "title": "Body", - "type": "number" + "$ref": "#/components/schemas/Nulls" } } }, @@ -2051,8 +2890,7 @@ "content": { "application/json": { "schema": { - "title": "Response Echonumber", - "type": "number" + "$ref": "#/components/schemas/NullsEcho" } } }, @@ -2069,20 +2907,21 @@ "description": "Validation Error" } }, - "summary": "Echo Number", + "summary": "Echo Nulls", "tags": [ "echo" ] } }, - "/echo/open": { + "/echo/number": { "post": { - "operationId": "echoOpen", + "operationId": "echoNumber", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Open" + "title": "Body", + "type": "number" } } }, @@ -2093,7 +2932,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Open" + "title": "Response Echonumber", + "type": "number" } } }, @@ -2110,13 +2950,54 @@ "description": "Validation Error" } }, - "summary": "Echo Open", + "summary": "Echo Number", "tags": [ "echo" ] } }, - "/echo/optional": { + "/echo/open": { + "post": { + "operationId": "echoOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Open", + "tags": [ + "echo" + ] + } + }, + "/echo/optional": { "post": { "operationId": "echoOptional", "requestBody": { @@ -2168,6 +3049,77 @@ ] } }, + "/echo/pet": { + "post": { + "operationId": "echoPet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Response Echopet" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Pet", + "tags": [ + "echo" + ] + } + }, "/echo/raw": { "post": { "operationId": "echoRaw", @@ -2294,6 +3246,47 @@ ] } }, + "/echo/stamps": { + "post": { + "operationId": "echoStamps", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Stamps", + "tags": [ + "echo" + ] + } + }, "/echo/text": { "post": { "operationId": "echoText", @@ -2425,6 +3418,47 @@ ] } }, + "/echo/wallet": { + "post": { + "operationId": "echoWallet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wallet", + "tags": [ + "echo" + ] + } + }, "/export.csv": { "get": { "operationId": "exportCsv", @@ -2451,6 +3485,107 @@ ] } }, + "/fetch": { + "get": { + "operationId": "clashLocals", + "parameters": [ + { + "in": "query", + "name": "url", + "required": true, + "schema": { + "title": "Url", + "type": "string" + } + }, + { + "in": "query", + "name": "headers", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Headers" + } + }, + { + "in": "query", + "name": "path", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + { + "in": "query", + "name": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashlocals", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locals", + "tags": [ + "clash" + ] + } + }, "/files": { "post": { "operationId": "uploadFiles", @@ -2615,10 +3750,51 @@ ] } }, - "/items": { - "get": { - "operationId": "listItems", - "parameters": [ + "/forms/typed": { + "post": { + "operationId": "submitForm", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FormIn-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormIn-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Submit Form", + "tags": [ + "files" + ] + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ { "in": "query", "name": "color", @@ -3832,6 +5008,70 @@ ] } }, + "/stamps": { + "get": { + "operationId": "stampsQuery", + "parameters": [ + { + "in": "query", + "name": "days", + "required": false, + "schema": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stamps Query", + "tags": [ + "echo" + ] + } + }, "/stream": { "get": { "operationId": "streamLines", @@ -3878,6 +5118,253 @@ ] } }, + "/tenants/{tenant}/notes": { + "post": { + "operationId": "clashForm", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "query", + "name": "folder_name", + "required": true, + "schema": { + "title": "Folder Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_clashForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Clashform", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Form", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/sorted": { + "get": { + "operationId": "clashAliases", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "deprecated": true, + "in": "query", + "name": "sort_by", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Sort By" + } + }, + { + "in": "query", + "name": "sortBy", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sortby" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashaliases", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Aliases", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/things/{item_id}": { + "get": { + "operationId": "clashLocations", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "in": "header", + "name": "Item-Id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Item-Id" + } + }, + { + "in": "header", + "name": "Version", + "required": false, + "schema": { + "default": "1", + "title": "Version", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Clashlocations", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locations", + "tags": [ + "clash" + ] + } + }, "/token": { "post": { "operationId": "issueToken", From 389e90d9fb4ea72e371e844e4db3c559155ac336 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:41:13 +0100 Subject: [PATCH 3/6] test(e2e): number clashing names and drop the skips go-fetch now passes The runners number a repeated identifier as the generators now do (itemId, itemId2), so the FastAPI name-clash cases run. The naive date-time, mixed enum and null enum cases pass for go-fetch, and the explicit null of an optional nullable enum for typescript-fetch. --- internal/e2e/scenario_test.go | 14 +++++++------- testdata/e2e/runners/dart/runner.dart | 10 +++++++++- testdata/e2e/runners/go/runner.go | 16 +++++++++++++++- testdata/e2e/runners/ts/runner.ts | 8 +++++++- testdata/e2e/scenarios/fastapi.yaml | 10 +++------- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index 0ab7853..c78437f 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -8,6 +8,7 @@ package e2e import ( "bytes" "encoding/json" + "maps" "os" "path/filepath" "slices" @@ -116,9 +117,11 @@ func loadScenarios(t *testing.T) []*scenario { return out } -// opParams lists, per operationId, the names a case's args may use: the -// parameters and, for a form body, its properties. A runner uses them to -// tell the body's field from the parameters'. +// opParams lists, per operationId, the names a case's args may use, in the +// order of the spec: the parameters and, for a form body, its properties +// (sorted). A runner uses them to tell the body's field from the +// parameters', and their order to tell apart two names that a generator +// gives one identifier, the second one numbered (itemId, itemId2). func opParams(doc *openapi3.T) map[string][]string { out := map[string][]string{} @@ -136,14 +139,11 @@ func opParams(doc *openapi3.T) map[string][]string { for ct, mt := range op.RequestBody.Value.Content { form := strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" if form && mt.Schema != nil && mt.Schema.Value != nil { - for name := range mt.Schema.Value.Properties { - names = append(names, name) - } + names = append(names, slices.Sorted(maps.Keys(mt.Schema.Value.Properties))...) } } } - slices.Sort(names) out[op.OperationID] = names } } diff --git a/testdata/e2e/runners/dart/runner.dart b/testdata/e2e/runners/dart/runner.dart index c73e74d..999d3ad 100644 --- a/testdata/e2e/runners/dart/runner.dart +++ b/testdata/e2e/runners/dart/runner.dart @@ -134,7 +134,15 @@ Future> callCase(Client client, Map suite, final mm = method.first; final args = c['args'] == null ? {} : jsonDecode(expand(jsonEncode(c['args']), vars, true)) as Map; - final byNorm = {for (final p in (c['params'] as List? ?? [])) norm(p as String): p}; + // The second parameter of one identifier is numbered, as generators do. + final byNorm = {}; + for (final p in (c['params'] as List? ?? []).cast()) { + var key = norm(p); + for (var n = 2; byNorm.containsKey(key); n++) { + key = '${norm(p)}$n'; + } + byNorm[key] = p; + } final named = {}; final used = {}; diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go index d6770d4..edea0ec 100644 --- a/testdata/e2e/runners/go/runner.go +++ b/testdata/e2e/runners/go/runner.go @@ -213,7 +213,7 @@ func params(t reflect.Type, names []string, args map[string]json.RawMessage) (re byField := map[string]string{} for _, name := range names { - byField[norm(name)] = name + byField[numbered(byField, norm(name))] = name } used := map[string]bool{} @@ -482,6 +482,20 @@ func save(result []byte, paths map[string]string, vars map[string]string) { } } +// numbered is key, or key2, key3 when taken: a generator numbers the second +// parameter of one identifier the same way. +func numbered(taken map[string]string, key string) string { + if _, ok := taken[key]; !ok { + return key + } + + for n := 2; ; n++ { + if k := key + strconv.Itoa(n); taken[k] == "" { + return k + } + } +} + // norm drops all but letters and digits and lowers them, so a spec name // and the identifier a generator makes of it compare equal. func norm(s string) string { diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index 037ebed..41d0b96 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -177,8 +177,14 @@ async function callCase(client: Client, info: ReturnType, suite: const fields = m.request ? (info.requests[m.request] ?? {}) : {}; const byNorm: Record = {}; + // The second parameter of one identifier is numbered, as generators do. for (const p of c.params ?? []) { - byNorm[norm(p)] = p; + let key = norm(p); + for (let n = 2; key in byNorm; n++) { + key = norm(p) + n; + } + + byNorm[key] = p; } const params: Record = {}; diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index cab7b58..d8b47ed 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -77,12 +77,11 @@ cases: op: echoLiterals args: { body: { retry: 0, pin: x, order: null, mode: 1, size: 50 } } result: { retry: 0, pin: x, order: null, mode: 1, tag: a, size: 50 } - skip: { go-fetch: "an enum without a type (members of two JSON types) is a Go string: a number can be neither sent nor decoded" } - name: literals, null as an enum member op: echoLiterals args: { body: { retry: 1, pin: null, order: desc, tag: null } } result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } - skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default", go-fetch: "an enum without a type is a Go string (no number); enum [x, null] has no null: required sends \"\", optional drops it" } + skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default" } - name: required nullables all null, optional nullables left out op: echoNulls args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } @@ -129,7 +128,7 @@ cases: span: "-PT1.5S" days: ["2024-01-31", "2024-12-31"] seen: { a: "2024-03-01T10:00:00Z" } - skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)" } - name: an offset on a naive field is refused by the server op: echoStamps args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } @@ -138,7 +137,7 @@ cases: op: stampsQuery args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } - skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z", go-fetch: "a time.Time holds no naive date-time: it can neither send nor decode one" } + skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z" } - name: containers, empty and null op: echoContainers args: @@ -506,17 +505,14 @@ cases: op: clashLocations args: { tenant: acme, item_id: 7, Item-Id: h-7, Version: "2", version: 3 } result: { tenant: acme, path: 7, header: h-7, hversion: "2", qversion: 3 } - skip: { typescript-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent", go-fetch: "parameters of one camelCase name (item_id and Item-Id, Version and version) collapse into one field: the parser's deduplicateParams keys on the name, so the other is never sent" } - name: a snake and a camel alias of one query parameter op: clashAliases args: { tenant: acme, sort_by: a, sortBy: b } result: { sort_by: a, sortBy: b } - skip: { typescript-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent", go-fetch: "sort_by and sortBy collapse into one field (parser deduplicateParams): one is never sent" } - name: a query parameter and a form field of one camel name op: clashForm args: { tenant: acme, folder_name: q, folderName: f } result: { query: q, form: f } - skip: { typescript-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)", go-fetch: "a query parameter and a form field of one camel name collapse into one (parser deduplicateParams)" } - name: query parameters named like a client's locals op: clashLocals args: { url: "https://x.example/?a=1", headers: h, path: p, query: q } From 107e2cd0e8fadf8b32d804d20b0f19879feb490c Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 20:34:19 +0100 Subject: [PATCH 4/6] test(e2e): cover numbers past int64, list headers and a form model with files --- internal/e2e/scenario_test.go | 4 +- testdata/e2e/backends/fastapi/app.py | 46 ++++++ testdata/e2e/scenarios/fastapi.yaml | 33 +++- testdata/e2e/specs/fastapi.json | 237 +++++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index c78437f..c0401cd 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -121,7 +121,9 @@ func loadScenarios(t *testing.T) []*scenario { // order of the spec: the parameters and, for a form body, its properties // (sorted). A runner uses them to tell the body's field from the // parameters', and their order to tell apart two names that a generator -// gives one identifier, the second one numbered (itemId, itemId2). +// gives one identifier, the second one numbered (itemId, itemId2). kin +// does not keep a schema's property order, so two form fields of one +// identifier must stand in the spec in sorted order too. func opParams(doc *openapi3.T) map[string][]string { out := map[string][]string{} diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 71b16ba..bd8bbdd 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -128,6 +128,16 @@ class Scalars(Strict): unit: Literal["kg", "lb"] = "kg" +class Wide(Strict): + """Numbers past int64 and float64's usual range: Python ints have no bound + and pydantic writes plain integer for them, a uint64 bound as a float.""" + + big: int + u64: Annotated[int, Field(ge=0, le=2**64 - 1)] + f: float + tiny: Annotated[float, Field(gt=0, multiple_of=1e-7)] + + class Stamps(Strict): """Times as ORM-backed services send them: naive, aware, in containers.""" @@ -156,6 +166,16 @@ class NullsEcho(Nulls): sent: list[str] # the keys the client put on the wire +class UploadIn(Strict): + """A form model holding files: FastAPI declares its body + application/x-www-form-urlencoded, yet only multipart carries a file.""" + + folder: str + files: list[UploadFile] + labels: list[str] = [] + cover: Optional[UploadFile] = None + + class FormIn(Strict): """A form model: every value a client must turn into text itself.""" @@ -543,6 +563,11 @@ def stamps_query( ) +@app.post("/echo/wide", tags=["echo"], operation_id="echoWide") +def echo_wide(body: Wide) -> Wide: + return body + + @app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") def echo_containers(body: Containers) -> Containers: return body @@ -670,6 +695,17 @@ def header_params( } +@app.get("/params/header-lists", tags=["params"], operation_id="headerLists") +def header_lists( + x_tags: Annotated[list[str], Header()] = [], + x_ids: Annotated[list[int], Header()] = [], + x_colors: Annotated[list[Color], Header()] = [], +) -> dict[str, Any]: + """FastAPI reads a list header from repeated lines; the spec's simple + style is one comma-joined line, which it takes as one item.""" + return {"tags": x_tags, "ids": x_ids, "colors": [c.value for c in x_colors]} + + # ---------------------------------------------------------------- items CRUD @@ -784,6 +820,16 @@ def submit_form(body: Annotated[FormIn, Form()]) -> FormIn: return body +@app.post("/files/model", tags=["files"], operation_id="uploadModel") +def upload_model(body: Annotated[UploadIn, Form()]) -> dict[str, Any]: + return { + "folder": body.folder, + "files": {f.filename: len(f.file.read()) for f in body.files}, + "labels": body.labels, + "cover": body.cover.filename if body.cover else None, + } + + @app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index d8b47ed..a9a8a1b 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -81,7 +81,6 @@ cases: op: echoLiterals args: { body: { retry: 1, pin: null, order: desc, tag: null } } result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } - skip: { typescript-fetch: "an explicit null of an optional nullable enum goes out as absent, so the server applies its default" } - name: required nullables all null, optional nullables left out op: echoNulls args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } @@ -196,6 +195,17 @@ cases: op: echoNumber args: { body: 2.25 } result: 4.5 + - name: integers past int64 + op: echoWide + args: { body: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } } + result: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } + skip: + go-fetch: a plain integer is an int, which holds no value past int64 + typescript-fetch: a JavaScript number loses integers past 2^53 + - name: exponent floats + op: echoWide + args: { body: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } } + result: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } - name: text/plain body op: echoText args: { body: héllo } @@ -301,6 +311,17 @@ cases: theme: dark result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } skip: { typescript-fetch: "no cookie parameters" } + - name: list headers, one item each + op: headerLists + args: { x-tags: [a], x-ids: [7], x-colors: [dark-blue] } + result: { tags: [a], ids: [7], colors: [dark-blue] } + - name: list headers, two items each + op: headerLists + args: { x-tags: [a, b], x-ids: [1, 2], x-colors: [red, light green] } + result: { tags: [a, b], ids: [1, 2], colors: [red, light green] } + skip: + go-fetch: &listHeader "a list header goes as one comma-joined line, as the spec's simple style says; FastAPI reads repeated lines only (422 on \"1,2\")" + typescript-fetch: *listHeader # ------------------------------------------------------------ items - name: read-only token cannot write @@ -410,6 +431,16 @@ cases: args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: a form model with files, declared url-encoded + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }, { $file: { name: b.txt, text: de } }], labels: [x, y] } + result: { folder: docs, files: { a.txt: 3, b.txt: 2 }, labels: [x, y], cover: null } + skip: { typescript-fetch: &urlFile "a form declared url-encoded goes as URLSearchParams even with a file, a Blob as text" } + - name: a form model's optional file given + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }], cover: { $file: { name: c.png, text: C } } } + result: { files: { a.txt: 3 }, cover: c.png } + skip: { typescript-fetch: *urlFile } - name: optional file left out op: uploadAvatar args: { image: { $file: { name: i.png, text: "12345" } } } diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index 56cb652..3260735 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -2221,6 +2221,50 @@ "title": "Unit", "type": "string" }, + "UploadIn": { + "additionalProperties": false, + "description": "A form model holding files: FastAPI declares its body\napplication/x-www-form-urlencoded, yet only multipart carries a file.", + "properties": { + "cover": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover" + }, + "files": { + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + } + }, + "required": [ + "folder", + "files" + ], + "title": "UploadIn", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -2523,6 +2567,40 @@ }, "title": "Wallet", "type": "object" + }, + "Wide": { + "additionalProperties": false, + "description": "Numbers past int64 and float64's usual range: Python ints have no bound\nand pydantic writes plain integer for them, a uint64 bound as a float.", + "properties": { + "big": { + "title": "Big", + "type": "integer" + }, + "f": { + "title": "F", + "type": "number" + }, + "tiny": { + "exclusiveMinimum": 0, + "multipleOf": 1e-7, + "title": "Tiny", + "type": "number" + }, + "u64": { + "maximum": 18446744073709552000, + "minimum": 0, + "title": "U64", + "type": "integer" + } + }, + "required": [ + "big", + "u64", + "f", + "tiny" + ], + "title": "Wide", + "type": "object" } }, "securitySchemes": { @@ -3459,6 +3537,47 @@ ] } }, + "/echo/wide": { + "post": { + "operationId": "echoWide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wide", + "tags": [ + "echo" + ] + } + }, "/export.csv": { "get": { "operationId": "exportCsv", @@ -3697,6 +3816,49 @@ ] } }, + "/files/model": { + "post": { + "operationId": "uploadModel", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UploadIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Uploadmodel", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Model", + "tags": [ + "files" + ] + } + }, "/files/{name}": { "get": { "operationId": "downloadFile", @@ -4631,6 +4793,81 @@ ] } }, + "/params/header-lists": { + "get": { + "description": "FastAPI reads a list header from repeated lines; the spec's simple\nstyle is one comma-joined line, which it takes as one item.", + "operationId": "headerLists", + "parameters": [ + { + "in": "header", + "name": "x-tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Tags", + "type": "array" + } + }, + { + "in": "header", + "name": "x-ids", + "required": false, + "schema": { + "default": [], + "items": { + "type": "integer" + }, + "title": "X-Ids", + "type": "array" + } + }, + { + "in": "header", + "name": "x-colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "X-Colors", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerlists", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Lists", + "tags": [ + "params" + ] + } + }, "/params/headers": { "get": { "operationId": "headerParams", From b1bbfbffc91606b33a3878b9e329962613013e56 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:36:46 +0100 Subject: [PATCH 5/6] test(e2e): stream JSON Lines and server-sent events from FastAPI The backend streams from yield endpoints, which FastAPI describes with 3.2's itemSchema: JSON Lines of a model and of integers, events of a model, of a discriminated union over POST and of raw data with names and ids. The Go runner drains a streamed result into the list of its items. --- testdata/e2e/README.md | 8 +- testdata/e2e/backends/fastapi/app.py | 58 +++++ testdata/e2e/runners/go/runner.go | 38 ++- testdata/e2e/scenarios/fastapi.yaml | 32 +++ testdata/e2e/specs/fastapi.json | 343 +++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 4 deletions(-) diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 6a7dc98..26baee0 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -34,8 +34,9 @@ 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. +date-times, required nullables, form models, enums of mixed JSON types, +parameters whose names collide once camelCased, and streamed responses +(JSON Lines and server-sent events from `yield`, 3.2's `itemSchema`). ## Scenarios @@ -134,7 +135,8 @@ A runner for another generator follows the same protocol: - 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`. + base64 string, a streamed one the list of its items (an event as + `{ data, event, id, retry }`), 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 diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index bd8bbdd..8e165f3 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -7,6 +7,7 @@ import datetime as dt import uuid +from collections.abc import AsyncIterable, Iterable from decimal import Decimal from enum import Enum, IntEnum from ipaddress import IPv4Address @@ -30,6 +31,7 @@ UploadFile, ) from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse +from fastapi.sse import EventSourceResponse, ServerSentEvent from fastapi.security import ( APIKeyCookie, APIKeyHeader, @@ -938,6 +940,62 @@ def stream_lines(n: int = 3) -> StreamingResponse: return StreamingResponse((f'{{"i":{i}}}\n' for i in range(n)), media_type="application/x-ndjson") +# ---------------------------------------------------------------- streams: FastAPI's yield, JSON Lines and SSE + + +class Tick(Strict): + i: int + label: str + + +class Delta(Strict): + kind: Literal["delta"] = "delta" + text: str + + +class Done(Strict): + kind: Literal["done"] = "done" + words: int + + +class Prompt(Strict): + prompt: str + + +@app.get("/ticks", tags=["streams"], operation_id="streamTicks") +def stream_ticks(n: Annotated[int, Query(ge=0, le=10)] = 2) -> Iterable[Tick]: + """FastAPI describes a yield endpoint with 3.2's itemSchema.""" + for i in range(n): + yield Tick(i=i, label=f"t{i}") + + +@app.get("/ticks/ints", tags=["streams"], operation_id="streamInts") +async def stream_ints(n: int = 3) -> AsyncIterable[int]: + for i in range(n): + yield i + + +@app.get("/events", tags=["streams"], operation_id="streamEvents", response_class=EventSourceResponse) +def stream_events(n: int = 2) -> Iterable[Tick]: + for i in range(n): + yield Tick(i=i, label=f"e{i}") + + +@app.post("/chat", tags=["streams"], operation_id="chat", response_class=EventSourceResponse) +async def chat(body: Prompt) -> AsyncIterable[Annotated[Union[Delta, Done], Field(discriminator="kind")]]: + """Server-sent events over POST, a discriminated union per event.""" + words = body.prompt.split() + for w in words: + yield Delta(text=w) + yield Done(words=len(words)) + + +@app.get("/events/raw", tags=["streams"], operation_id="streamRawEvents", response_class=EventSourceResponse) +def stream_raw_events(): + yield ServerSentEvent(data={"i": 1}, event="tick", id="1") + yield ServerSentEvent(data="bye", event="end", id="2") + + @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 diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go index edea0ec..a0c1fa4 100644 --- a/testdata/e2e/runners/go/runner.go +++ b/testdata/e2e/runners/go/runner.go @@ -177,7 +177,19 @@ func callCase(o *outcome, apis []any, c cas, vars map[string]string) { return } - b, err := json.Marshal(res[0].Interface()) + result := res[0].Interface() + if res[0].Kind() == reflect.Func { + items, err := drain(res[0]) + if err != nil { + o.Fail = "stream: " + err.Error() + + return + } + + result = items + } + + b, err := json.Marshal(result) if err != nil { o.Fail = "result: " + err.Error() @@ -188,6 +200,30 @@ func callCase(o *outcome, apis []any, c cas, vars map[string]string) { save(b, c.Save, vars) } +// drain ranges over a streamed result, an iter.Seq2[T, error], and returns +// its items; the first error ends it. +func drain(seq reflect.Value) ([]any, error) { + items := []any{} + + var failed error + + yield := reflect.MakeFunc(seq.Type().In(0), func(in []reflect.Value) []reflect.Value { + if err, _ := in[1].Interface().(error); err != nil { + failed = err + + return []reflect.Value{reflect.ValueOf(false)} + } + + items = append(items, in[0].Interface()) + + return []reflect.Value{reflect.ValueOf(true)} + }) + + seq.Call([]reflect.Value{yield}) + + return items, failed +} + // 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). diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index a9a8a1b..7227035 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -523,6 +523,38 @@ cases: 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: JSON Lines of a model + op: streamTicks + args: { n: 2 } + result: [{ i: 0, label: t0 }, { i: 1, label: t1 }] + skip: &noItems { typescript-fetch: "a response with only an itemSchema (3.2 streaming) is void: the items are dropped" } + - name: JSON Lines, none + op: streamTicks + args: { n: 0 } + result: [] + skip: *noItems + - name: JSON Lines of integers + op: streamInts + result: [0, 1, 2] + skip: *noItems + - name: a bound on a stream's query is a 422 + op: streamTicks + args: { n: 11 } + error: { status: 422, model: { detail: [{ loc: [query, n] }] } } + - name: server-sent events of a model + op: streamEvents + args: { n: 2 } + result: [{ data: { i: 0, label: e0 } }, { data: { i: 1, label: e1 } }] + skip: *noItems + - name: server-sent events over POST, a discriminated union per event + op: chat + args: { body: { prompt: hello world } } + result: [{ data: { kind: delta, text: hello } }, { data: { kind: delta, text: world } }, { data: { kind: done, words: 2 } }] + skip: *noItems + - name: server-sent events with names and ids, data as text + op: streamRawEvents + result: [{ event: tick, id: "1", data: '{"i": 1}' }, { event: end, id: "2", data: '"bye"' }] + skip: *noItems - name: float path parameter and an aliased query op: add args: { a: 2, b: 0.75, round: true } diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index 3260735..07e9735 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -449,6 +449,26 @@ "title": "Containers", "type": "object" }, + "Delta": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "delta", + "default": "delta", + "title": "Kind", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "Delta", + "type": "object" + }, "Dimensions": { "additionalProperties": false, "properties": { @@ -499,6 +519,26 @@ "title": "Dog", "type": "object" }, + "Done": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "done", + "default": "done", + "title": "Kind", + "type": "string" + }, + "words": { + "title": "Words", + "type": "integer" + } + }, + "required": [ + "words" + ], + "title": "Done", + "type": "object" + }, "FormIn-Input": { "additionalProperties": false, "description": "A form model: every value a client must turn into text itself.", @@ -1792,6 +1832,20 @@ "title": "Problem", "type": "object" }, + "Prompt": { + "additionalProperties": false, + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "title": "Prompt", + "type": "object" + }, "Rect": { "additionalProperties": false, "properties": { @@ -2182,6 +2236,25 @@ "title": "Supplier", "type": "object" }, + "Tick": { + "additionalProperties": false, + "properties": { + "i": { + "title": "I", + "type": "integer" + }, + "label": { + "title": "Label", + "type": "string" + } + }, + "required": [ + "i", + "label" + ], + "title": "Tick", + "type": "object" + }, "Transfer": { "additionalProperties": false, "properties": { @@ -2704,6 +2777,85 @@ ] } }, + "/chat": { + "post": { + "description": "Server-sent events over POST, a discriminated union per event.", + "operationId": "chat", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Prompt" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "text/event-stream": { + "itemSchema": { + "properties": { + "data": { + "contentMediaType": "application/json", + "contentSchema": { + "discriminator": { + "mapping": { + "delta": "#/components/schemas/Delta", + "done": "#/components/schemas/Done" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Delta" + }, + { + "$ref": "#/components/schemas/Done" + } + ], + "title": "Streamitem Chat" + }, + "type": "string" + }, + "event": { + "type": "string" + }, + "id": { + "type": "string" + }, + "retry": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Chat", + "tags": [ + "streams" + ] + } + }, "/count": { "get": { "operationId": "countItems", @@ -3578,6 +3730,107 @@ ] } }, + "/events": { + "get": { + "operationId": "streamEvents", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 2, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/event-stream": { + "itemSchema": { + "properties": { + "data": { + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/Tick" + }, + "type": "string" + }, + "event": { + "type": "string" + }, + "id": { + "type": "string" + }, + "retry": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Events", + "tags": [ + "streams" + ] + } + }, + "/events/raw": { + "get": { + "operationId": "streamRawEvents", + "responses": { + "200": { + "content": { + "text/event-stream": { + "itemSchema": { + "properties": { + "data": { + "type": "string" + }, + "event": { + "type": "string" + }, + "id": { + "type": "string" + }, + "retry": { + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Stream Raw Events", + "tags": [ + "streams" + ] + } + }, "/export.csv": { "get": { "operationId": "exportCsv", @@ -5602,6 +5855,96 @@ ] } }, + "/ticks": { + "get": { + "description": "FastAPI describes a yield endpoint with 3.2's itemSchema.", + "operationId": "streamTicks", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 2, + "maximum": 10, + "minimum": 0, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/Tick" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Ticks", + "tags": [ + "streams" + ] + } + }, + "/ticks/ints": { + "get": { + "operationId": "streamInts", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 3, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/jsonl": { + "itemSchema": { + "title": "Streamitem Streamints", + "type": "integer" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Ints", + "tags": [ + "streams" + ] + } + }, "/token": { "post": { "operationId": "issueToken", From 8f85f940af5f3fae7bb00a28d552cfff42fad0d6 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Sun, 25 Oct 2026 21:59:42 +0100 Subject: [PATCH 6/6] test(e2e): generate cases from the specs' schemas and compare them exactly Besides the written scenarios, TestMock now makes cases from every JSON response and request body schema of the feature specs, the FastAPI spec (on the mock) and any spec in E2E_SPECS: all properties, only the required ones, nullables null, each union branch, edge scalars and extra keys, each value checked against its schema with kin-openapi. They go through the same mock and runners, and match gains an exact mode for them: no other keys, null is not absent. go-fetch passes all of them. A failing case of typescript-fetch or dart-fetch counts as failed but skips (valueLenient) until their gaps are fixed. --- internal/e2e/e2e_test.go | 28 +- internal/e2e/match_test.go | 64 ++- internal/e2e/mock_test.go | 10 +- internal/e2e/scenario_test.go | 6 +- internal/e2e/values_test.go | 769 +++++++++++++++++++++++++++ testdata/e2e/README.md | 31 +- testdata/e2e/backends/fastapi/app.py | 2 +- testdata/e2e/specs/fastapi.json | 3 + 8 files changed, 879 insertions(+), 34 deletions(-) create mode 100644 internal/e2e/values_test.go diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 73bf716..c257871 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -57,11 +57,13 @@ func requireE2E(t *testing.T) { // 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. +// the client must return what the case says from the mock's response. The +// cases generated from the specs' schemas (values_test.go) run the same way. func TestMock(t *testing.T) { requireE2E(t) scenarios := loadScenarios(t) + generated := valueScenarios(t) forEachRunner(t, func(t *testing.T, r runner) { t.Helper() @@ -72,7 +74,9 @@ func TestMock(t *testing.T) { byName = map[string]*scenario{} ) - for _, s := range scenarios { + // One run takes the generated scenarios after the written ones: a + // client build per generator, not two. + for _, s := range slices.Concat(scenarios, generated) { if s.Spec == "" { continue } @@ -236,6 +240,11 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou t.Skipf("%s (%s)", reason, strings.Join(diffs, "; ")) + case len(diffs) > 0 && k.Exact && valueLenient[generator]: + c.fail++ + + t.Skip("lenient until its gaps are fixed:\n" + strings.Join(diffs, "\n")) + case len(diffs) > 0: c.fail++ @@ -267,8 +276,17 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string { 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 && k.Exact: + // The body alone, absent when none was sent: null is not absent. + sent := map[string]any{} + if b, ok := got["body"]; ok { + sent["body"] = b + } + + diffs = append(diffs, match("request", decodeJSON(k.Request), sent, true)...) + case k.Request != nil: - diffs = append(diffs, match("request", decodeJSON(k.Request), got)...) + diffs = append(diffs, match("request", decodeJSON(k.Request), got, false)...) } } @@ -287,12 +305,12 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string { got["model"] = decodeJSON(o.Error.Model) } - diffs = append(diffs, match("error", decodeJSON(k.Error), got)...) + diffs = append(diffs, match("error", decodeJSON(k.Error), got, k.Exact)...) 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))...) + diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result), k.Exact)...) } return diffs diff --git a/internal/e2e/match_test.go b/internal/e2e/match_test.go index 6d662df..6f09e11 100644 --- a/internal/e2e/match_test.go +++ b/internal/e2e/match_test.go @@ -15,8 +15,9 @@ import ( // 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 { +// match in any case. Exact, for the generated cases, allows no other keys +// and no string for a list. It returns one line per difference. +func match(path string, want, got any, exact bool) []string { switch w := want.(type) { case string: switch w { @@ -26,7 +27,7 @@ func match(path string, want, got any) []string { return []string{path + ": want absent, got " + show(got)} } - if g, ok := got.([]any); ok && len(g) == 1 && strings.HasPrefix(path, "request.") { + if g, ok := got.([]any); ok && len(g) == 1 && !exact && strings.HasPrefix(path, "request.") { got = g[0] } @@ -59,7 +60,13 @@ func match(path string, want, got any) []string { case !present: diffs = append(diffs, path+"."+k+": missing, want "+show(w[k])) default: - diffs = append(diffs, match(path+"."+k, w[k], gv)...) + diffs = append(diffs, match(path+"."+k, w[k], gv, exact)...) + } + } + + for _, k := range slices.Sorted(maps.Keys(g)) { + if _, wanted := w[k]; exact && !wanted { + diffs = append(diffs, path+"."+k+": want absent, got "+show(g[k])) } } @@ -73,7 +80,7 @@ func match(path string, want, got any) []string { var diffs []string for i := range w { - diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i])...) + diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], exact)...) } return diffs @@ -127,31 +134,38 @@ func show(v any) string { func TestMatch(t *testing.T) { tests := []struct { name, path, want, got string - ok bool + exact, 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}, + {"extra keys allowed", "result", `{"a":1}`, `{"a":1,"b":2}`, false, true}, + {"missing key", "result", `{"a":1}`, `{"b":1}`, false, false}, + {"key case matters in a result", "result", `{"id":"1"}`, `{"ID":"1"}`, false, false}, + {"header name in any case", "request.header", `{"X-Request-ID":"r"}`, `{"X-Request-Id":["r"]}`, false, true}, + {"string for a one-value list in a request", "request.query", `{"q":"a"}`, `{"q":["a"]}`, false, true}, + {"but not in a result", "result", `"stored"`, `["stored"]`, false, false}, + {"array exact length", "result", `[1]`, `[1,2]`, false, false}, + {"$any present", "result", `{"a":"$any"}`, `{"a":null}`, false, true}, + {"$any missing", "result", `{"a":"$any"}`, `{}`, false, false}, + {"$absent missing", "result", `{"a":"$absent"}`, `{}`, false, true}, + {"$absent null is present", "result", `{"a":"$absent"}`, `{"a":null}`, false, false}, + {"null is null", "result", `null`, `null`, false, true}, + {"null is not an object", "result", `null`, `{}`, false, false}, + {"1 and 1.0", "result", `1`, `1.0`, false, true}, + {"int64 exact", "result", `4611686018427387905`, `4611686018427387904`, false, false}, + {"past int64 exact", "result", `12345678901234567890`, `12345678901234567891`, false, false}, + {"number is not a string", "result", `1`, `"1"`, false, false}, + {"exact: same keys", "result", `{"a":{"b":null}}`, `{"a":{"b":null}}`, true, true}, + {"exact: no extra key", "result", `{"a":1}`, `{"a":1,"b":2}`, true, false}, + {"exact: no extra key nested", "result", `[{"a":1}]`, `[{"a":1,"b":null}]`, true, false}, + {"exact: null is not absent", "result", `{"a":null}`, `{}`, true, false}, + {"exact: absent is not null", "result", `{}`, `{"a":null}`, true, false}, + {"exact: no string for a list", "request.body", `{"q":"a"}`, `{"q":["a"]}`, true, false}, + {"exact: $any still any", "error", `{"status":500,"body":"$any"}`, `{"status":500,"body":"x"}`, true, true}, } for _, tt := range tests { - diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got))) + diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), tt.exact) 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) + t.Errorf("%s: match(%s, %s, exact %t) = %v, want ok %t", tt.name, tt.want, tt.got, tt.exact, diffs, tt.ok) } } } diff --git a/internal/e2e/mock_test.go b/internal/e2e/mock_test.go index 25033e9..304f1ad 100644 --- a/internal/e2e/mock_test.go +++ b/internal/e2e/mock_test.go @@ -58,7 +58,15 @@ func newMock(t *testing.T, specPath string, cases []kase) *mock { // 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 { + // An example is documentation: a wrong one (real specs have them) does + // not stop the mock. A 3.1 spec may hold 3.2's itemSchema, as the + // generator's parser allows. + opts := []openapi3.ValidationOption{openapi3.DisableExamplesValidation()} + if strings.HasPrefix(doc.OpenAPI, "3.1") { + opts = append(opts, openapi3.IsOpenAPI32OrLater()) + } + + if m.router, err = legacy.NewRouter(doc, opts...); err != nil { t.Fatal(err) } diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index c0401cd..0657098 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -52,7 +52,11 @@ type kase struct { 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"` + + // Exact compares the result, the error and the request's body with no + // other keys allowed: a generated case (values_test.go) sets it. + Exact bool `json:"-"` + Skip map[string]string `json:"skip"` } // mockResponse is what the mock answers a case with: a status, headers and diff --git a/internal/e2e/values_test.go b/internal/e2e/values_test.go new file mode 100644 index 0000000..e2dbc49 --- /dev/null +++ b/internal/e2e/values_test.go @@ -0,0 +1,769 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "maps" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" + "go.yaml.in/yaml/v3" +) + +// valueLenient are the generators whose failing generated cases are +// counted and skipped, not failed: they have known gaps a later change +// fixes. Without an entry a generator is strict, as go-fetch is. +var valueLenient = map[string]bool{"typescript-fetch": true, "dart-fetch": true} + +// valueScenarios generates a scenario for each feature spec, the FastAPI +// spec (served by the mock) and each spec in E2E_SPECS (comma-separated +// paths), and logs the operations it leaves out. +func valueScenarios(t *testing.T) []*scenario { + t.Helper() + + specs, err := filepath.Glob("../../testdata/specs/feature/*.yaml") + if err != nil { + t.Fatal(err) + } + + specs = append(specs, "../../testdata/e2e/specs/fastapi.json") + if extra := os.Getenv("E2E_SPECS"); extra != "" { + specs = append(specs, strings.Split(extra, ",")...) + } + + var ( + out []*scenario + skipped []string + n int + ) + + for _, spec := range specs { + s, why := valueScenario(t, spec) + if skipped, n = append(skipped, why...), n+len(s.Cases); len(s.Cases) > 0 { + out = append(out, s) + } + } + + t.Logf("values: %d cases from %d of %d specs; %d operations left out:\n%s", + n, len(out), len(specs), len(skipped), strings.Join(skipped, "\n")) + + return out +} + +// valueScenario generates the cases of one spec, its operations in sorted +// order, and lists the operations it leaves out and why. +func valueScenario(t *testing.T, spec string) (*scenario, []string) { + t.Helper() + + name := "values/" + strings.TrimSuffix(filepath.Base(spec), filepath.Ext(spec)) + spec, doc := servable(t, spec) + s := &scenario{Name: name, Spec: spec, Auth: &auth{Username: "u", Password: "p", APIKey: "k", Bearer: "t"}} + + var skipped []string + + for _, path := range slices.Sorted(maps.Keys(doc.Paths.Map())) { + item := doc.Paths.Value(path) + ops := item.Operations() + + for _, method := range slices.Sorted(maps.Keys(ops)) { + if method == http.MethodHead { + skipped = append(skipped, fmt.Sprintf("%s %s %s: a HEAD answer has no body", name, method, path)) + + continue + } + + cases, why := valueCases(item, ops[method], doc.IsOpenAPI31OrLater()) + if why != "" { + skipped = append(skipped, fmt.Sprintf("%s %s %s: %s", name, method, path, why)) + } + + s.Cases = append(s.Cases, cases...) + } + } + + return s, skipped +} + +var pathParam = regexp.MustCompile(`\{[^}]*\}`) + +// servable loads spec as the mock can serve it: when it has to change it, +// it writes a copy and returns that path. It converts a Swagger 2.0 spec to +// OpenAPI 3, and of the paths that differ only in their parameters' names, +// which no router tells apart, it keeps the first. Real specs have both. +func servable(t *testing.T, spec string) (string, *openapi3.T) { + t.Helper() + + data, err := os.ReadFile(spec) + if err != nil { + t.Fatal(err) + } + + var y map[string]any + if err := yaml.Unmarshal(data, &y); err != nil { + t.Fatalf("%s: %v", spec, err) + } + + var ( + doc *openapi3.T + changed = y["swagger"] != nil + ) + + if changed { + var v2 openapi2.T + if err = json.Unmarshal(mustJSON(y), &v2); err == nil { + doc, err = openapi2conv.ToV3(&v2) + } + } else { + doc, err = openapi3.NewLoader().LoadFromFile(spec) + } + + if err != nil { + t.Fatalf("%s: %v", spec, err) + } + + shapes := map[string]bool{} + + for _, path := range slices.Sorted(maps.Keys(doc.Paths.Map())) { + shape := pathParam.ReplaceAllString(path, "{}") + if shapes[shape] { + doc.Paths.Delete(path) + + changed = true + } + + shapes[shape] = true + } + + if !changed { + return spec, doc + } + + out := filepath.Join(t.TempDir(), filepath.Base(spec)+".json") + write(t, out, string(mustJSON(doc))) + + if doc, err = openapi3.NewLoader().LoadFromFile(out); err != nil { + t.Fatalf("%s: %v", spec, err) + } + + return out, doc +} + +// valueCases makes an operation's generated cases: each value of a JSON +// response is the mock's answer and the result, or the error's model; each +// value of a JSON request body is the body to send, answered by the first +// 2xx. Required parameters get a value of their own and a required body +// its first value; an operation that cannot have them is left out, why +// says so. +func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]kase, string) { + if op.OperationID == "" { + return nil, "no operationId" + } + + var ( + args = map[string]any{} + query bool // a query is sent + deepArray string // a deepObject array, which kin refuses in any query (Stripe's expand) + dropped int // values their schema rejects: a generator gap to log + ) + + for _, p := range slices.Concat(item.Parameters, op.Parameters) { + if p.Value == nil { + continue + } + + if p.Value.Style == openapi3.SerializationDeepObject && p.Value.Schema != nil && p.Value.Schema.Value.Type.Is(openapi3.TypeArray) { + deepArray = p.Value.Name + } + + if !p.Value.Required { + continue + } + + v, ok := paramValue(p.Value.Schema, is31) + if !ok { + return nil, "no scalar value for parameter " + p.Value.Name + } + + args[p.Value.Name] = v + query = query || p.Value.In == openapi3.ParameterInQuery + } + + if query && deepArray != "" { + return nil, "the mock cannot validate a query next to the deepObject array " + deepArray + } + + var bodies []namedValue + + if rb := op.RequestBody; rb != nil && rb.Value != nil { + // A form's fields are arguments of their own. + form := slices.ContainsFunc(slices.Collect(maps.Keys(rb.Value.Content)), func(ct string) bool { + return strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" + }) + + if _, mt := jsonMedia(rb.Value.Content); mt != nil && !form { + bodies = valuesOf(mt.Schema, true, is31, &dropped) + } + + if rb.Value.Required && len(bodies) == 0 { + return nil, "the required body is not JSON, or a form too, or has no valid value" + } + + if rb.Value.Required { + args["body"] = bodies[0].raw + } + } + + var ( + cases []kase + answer *mockResponse // the first 2xx's, for the request cases + result *openapi3.SchemaRef // the first 2xx's JSON; another 2xx's body is an error + ) + + for _, code := range slices.Sorted(maps.Keys(op.Responses.Map())) { + status, err := strconv.Atoi(code) + + resp := op.Responses.Value(code) + if err != nil || status < 200 || status >= 300 && status < 400 || resp.Value == nil { + continue + } + + ct, mt := jsonMedia(resp.Value.Content) + if mt == nil { + if answer == nil && status < 300 && len(resp.Value.Content) == 0 { + answer = &mockResponse{Status: status} + } + + continue + } + + if result == nil && status < 300 { + result = mt.Schema + } + + // An any result ({}) holds every 2xx body. + isResult := status < 300 && (mt.Schema == result || mt.Schema.Ref != "" && mt.Schema.Ref == result.Ref || result.Value.IsEmpty()) + + for _, v := range valuesOf(mt.Schema, false, is31, &dropped) { + r := &mockResponse{Status: status, Header: map[string]string{"Content-Type": ct}, JSON: v.raw} + if answer == nil && status < 300 { + answer = r + } + + if status == http.StatusNoContent { + break + } + + k := kase{Name: fmt.Sprintf("response %d %s", status, v.name), Op: op.OperationID, Args: mustJSON(args), Response: r, Exact: true} + if isResult { + k.Result = v.raw + } else { + k.Error = mustJSON(map[string]any{"status": status, "model": v.raw, "body": "$any"}) + } + + cases = append(cases, k) + } + } + + if len(bodies) > 0 && answer == nil { + return cases, "no 2xx answer without a body or with JSON, for the request cases" + } + + for _, b := range bodies { + a := maps.Clone(args) + a["body"] = b.raw + + // An optional body of null may go out as null or as none: the mock + // checks only that the request is valid. + want := mustJSON(map[string]any{"body": b.raw}) + if string(b.raw) == "null" && !op.RequestBody.Value.Required { + want = nil + } + + cases = append(cases, kase{ + Name: "request " + b.name, Op: op.OperationID, Args: mustJSON(a), Exact: true, + Request: want, Response: answer, + }) + } + + if dropped > 0 { + return cases, fmt.Sprintf("%d generated values their schema rejects, left out", dropped) + } + + return cases, "" +} + +// jsonMedia finds the first JSON media type of a content, in sorted order. +func jsonMedia(content openapi3.Content) (string, *openapi3.MediaType) { + for _, ct := range slices.Sorted(maps.Keys(content)) { + mt, _, _ := mime.ParseMediaType(ct) + if mt == "application/json" || strings.HasSuffix(mt, "+json") { + return ct, content[ct] + } + } + + return "", nil +} + +// paramValue is a valid scalar of a parameter's schema. +func paramValue(ref *openapi3.SchemaRef, is31 bool) (any, bool) { + if ref == nil || ref.Value == nil { + return nil, false + } + + v := (&valueGen{mode: "all", is31: is31, open: map[*openapi3.Schema]int{}}).value(ref, 0) + + switch v.(type) { + case string, float64, bool: + return v, valid(ref.Value, mustJSON(v), true, is31) + } + + return nil, false +} + +type namedValue struct { + name string + raw json.RawMessage +} + +// valuesOf generates the values of a schema, each valid for it and each +// once: all properties, only the required ones, every nullable null, each +// union branch in turn (for all and nulls), and edge scalars with extra keys. +func valuesOf(ref *openapi3.SchemaRef, request, is31 bool, dropped *int) []namedValue { + if ref == nil || ref.Value == nil { + return nil + } + + var ( + out []namedValue + seen = map[string]bool{} + ) + + for _, mode := range []string{"all", "required", "nulls", "edge"} { + for branch := 0; ; branch++ { + g := &valueGen{mode: mode, branch: branch, request: request, is31: is31, open: map[*openapi3.Schema]int{}} + raw := mustJSON(g.value(ref, 0)) + + ok := valid(ref.Value, raw, request, is31) + if !ok { + *dropped++ + } + + if !seen[string(raw)] && ok { + seen[string(raw)] = true + + name := mode + if branch > 0 { + name += " " + strconv.Itoa(branch) + } + + out = append(out, namedValue{name, raw}) + } + + if mode == "required" || mode == "edge" || branch+1 >= g.widest { + break + } + } + } + + return out +} + +// valid reports whether the schema allows the JSON value, as the mock's +// validation will check it. +func valid(s *openapi3.Schema, raw json.RawMessage, request, is31 bool) bool { + opts := []openapi3.SchemaValidationOption{openapi3.VisitAsResponse()} + if request { + opts[0] = openapi3.VisitAsRequest() + } + + if is31 { + opts = append(opts, openapi3.EnableJSONSchema2020()) + } + + var v any + + return json.Unmarshal(raw, &v) == nil && s.VisitJSON(v, opts...) == nil +} + +// valueGen makes one value of a schema. Its mode picks which: all +// properties, only the required ones, every nullable null, or edge +// scalars; branch picks a union's branch and varies scalars and items. +type valueGen struct { + mode string + branch int + request, is31 bool + + open map[*openapi3.Schema]int // schemas being made, to end a recursion + widest int // the most branches a union had +} + +func (g *valueGen) value(ref *openapi3.SchemaRef, depth int) any { + // A schema that requires itself has no value; null ends it, and the + // validation drops what holds it. + s := ref.Value + if g.mode == "nulls" && g.nullable(s) || depth > 32 { + return nil + } + + g.open[s]++ + defer func() { g.open[s]-- }() + + // Past a depth, or inside itself, a schema takes only what it requires. + deep := depth > 6 || g.open[s] > 1 + + switch { + case s.Const != nil: + return s.Const + case len(s.Enum) > 0: + return g.enum(s) + case len(s.AllOf) > 0: + return g.allOf(ref, depth, deep) + case len(branches(s)) > 0: + return g.union(ref, depth, deep) + } + + switch g.typeOf(s) { + case openapi3.TypeString: + return g.str(s) + case openapi3.TypeInteger: + return clamp(s, g.integer(s)) + case openapi3.TypeNumber: + return clamp(s, g.number(s)) + case openapi3.TypeBoolean: + return g.mode != "edge" + case openapi3.TypeNull: + return nil + case openapi3.TypeArray: + return g.array(s, depth, deep) + case openapi3.TypeObject: + return g.object(ref, depth, deep) + } + + // Not an object: a union branch of constraints alone yields to the + // union's own. + if g.mode == "edge" { + return []any{1, "two", nil, map[string]any{"k": true}} + } + + return "anything" +} + +// nullable reports whether null is a value of s: nullable (before 3.1), a +// null type or union branch. +func (g *valueGen) nullable(s *openapi3.Schema) bool { + if s.Nullable && !g.is31 || s.Type.IncludesNull() { + return true + } + + return slices.ContainsFunc(slices.Concat(s.OneOf, s.AnyOf), func(b *openapi3.SchemaRef) bool { + return b.Value != nil && b.Value.Type.Is(openapi3.TypeNull) + }) +} + +// typeOf is the schema's first type other than null, or one its keywords +// imply. +func (g *valueGen) typeOf(s *openapi3.Schema) string { + for _, t := range s.Type.Slice() { + if t != openapi3.TypeNull { + return t + } + } + + switch { + case s.Type.Is(openapi3.TypeNull): + return openapi3.TypeNull + case len(s.Properties) > 0 || s.AdditionalProperties.Schema != nil: + return openapi3.TypeObject + case s.Items != nil: + return openapi3.TypeArray + } + + return "" +} + +func (g *valueGen) enum(s *openapi3.Schema) any { + vals := slices.DeleteFunc(slices.Clone(s.Enum), func(v any) bool { return v == nil }) + + switch { + case len(vals) == 0: + return nil + case g.mode == "edge": + return vals[len(vals)-1] + } + + return vals[g.branch%len(vals)] +} + +// allOf merges the objects of its parts and its own properties. +func (g *valueGen) allOf(ref *openapi3.SchemaRef, depth int, deep bool) any { + merged := map[string]any{} + + var other any + + for _, part := range ref.Value.AllOf { + v := g.value(part, depth+1) + if m, ok := v.(map[string]any); ok { + maps.Copy(merged, m) + } else if v != nil { + other = v + } + + if d := part.Value.Discriminator; d != nil { + g.discriminate(merged, d, ref.Ref) + } + } + + if len(merged) == 0 && other != nil { + return other + } + + if len(ref.Value.Properties) > 0 { + maps.Copy(merged, g.object(ref, depth, deep)) + } + + return merged +} + +// branches are the oneOf and anyOf branches but a null one. +func branches(s *openapi3.Schema) openapi3.SchemaRefs { + return slices.DeleteFunc(slices.Concat(s.OneOf, s.AnyOf), func(b *openapi3.SchemaRef) bool { + return b.Value == nil || b.Value.Type.Is(openapi3.TypeNull) + }) +} + +// union makes the branch g.branch picks, or deep the first one that does +// not recurse; an object gets the union's own properties and its +// discriminator. +func (g *valueGen) union(ref *openapi3.SchemaRef, depth int, deep bool) any { + alts := branches(ref.Value) + g.widest = max(g.widest, len(alts)) + + pick := alts[g.branch%len(alts)] + if deep { + if i := slices.IndexFunc(alts, func(b *openapi3.SchemaRef) bool { return g.open[b.Value] == 0 }); i >= 0 { + pick = alts[i] + } + } + + v := g.value(pick, depth+1) + + // A branch of constraints alone (required: [a]) is the union's object. + m, ok := v.(map[string]any) + if !ok && len(ref.Value.Properties) > 0 { + m, ok = map[string]any{}, true + } + + if !ok { + return v + } + + if len(ref.Value.Properties) > 0 { + for k, x := range g.object(ref, depth, deep) { + if _, has := m[k]; !has { + m[k] = x + } + } + } + + if d := ref.Value.Discriminator; d != nil { + g.discriminate(m, d, pick.Ref) + } + + return m +} + +// discriminate sets the discriminator property of m, when m has it, to the +// value that names the schema at ref: its mapping key, or its name. +func (g *valueGen) discriminate(m map[string]any, d *openapi3.Discriminator, ref string) { + if _, has := m[d.PropertyName]; !has || ref == "" { + return + } + + name := ref[strings.LastIndex(ref, "/")+1:] + m[d.PropertyName] = name + + for _, key := range slices.Sorted(maps.Keys(d.Mapping)) { + if to := d.Mapping[key].Ref; to == ref || to == name || strings.HasSuffix(to, "/"+name) { + m[d.PropertyName] = key + + return + } + } +} + +func (g *valueGen) object(ref *openapi3.SchemaRef, depth int, deep bool) map[string]any { + s, m := ref.Value, map[string]any{} + + for _, k := range slices.Sorted(maps.Keys(s.Properties)) { + p := s.Properties[k] + + switch { + case p.Value == nil, g.request && p.Value.ReadOnly, !g.request && p.Value.WriteOnly: + continue + case (g.mode == "required" || deep) && !slices.Contains(s.Required, k): + continue + } + + m[k] = g.value(p, depth+1) + } + + if g.mode != "required" && !deep { + if ap := s.AdditionalProperties.Schema; ap != nil && ap.Value != nil { + m["extra_1"] = g.value(ap, depth+1) + } else if has := s.AdditionalProperties.Has; has != nil && *has { + m["extra_1"] = map[string]any{"nested": []any{1, "a", nil, true}} + } + } + + if d := s.Discriminator; d != nil { + g.discriminate(m, d, ref.Ref) + } + + return m +} + +func (g *valueGen) array(s *openapi3.Schema, depth int, deep bool) []any { + n := 2 + if g.mode == "required" || deep { + n = 0 + } + + n = max(n, int(s.MinItems)) //nolint:gosec // a schema's minItems is small + if s.MaxItems != nil { + n = min(n, int(*s.MaxItems)) //nolint:gosec // and so is its maxItems + } + + out := make([]any, 0, n) + + for i := range n { + switch { + case i < len(s.PrefixItems): + out = append(out, g.value(s.PrefixItems[i], depth+1)) + case s.Items != nil && s.Items.Value != nil: + // Each item its own branch: an array of a union holds two. + g.branch += i + out = append(out, g.value(s.Items, depth+1)) + g.branch -= i + + default: + out = append(out, "item") + } + } + + if s.UniqueItems { + // Edge items are all alike. + out = slices.CompactFunc(out, func(a, b any) bool { return string(mustJSON(a)) == string(mustJSON(b)) }) + } + + return out +} + +func (g *valueGen) str(s *openapi3.Schema) string { + v := "s" + strconv.Itoa(g.branch) + + switch s.Format { + case "date-time": + v = "2024-01-02T03:04:05Z" + if g.mode == "edge" { + v = "2024-02-29T23:59:59.123456789+05:30" + } + + case "date": + v = "2024-02-29" + case "time": + v = "13:14:15" + case "uuid": + v = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + case "email": + v = "a@example.com" + case "uri", "url": + v = "https://example.com/a?b=c&d=%20" + case "byte": + v = "aGVsbG8=" + default: + if g.mode == "edge" { + v = "ž \"q\" &b \u2028 😀 \\ /" + } + } + + // A pattern takes the schema's example: the backend gives one. + if s.Pattern != "" { + ex, _ := s.Example.(string) + if len(s.Examples) > 0 { + ex, _ = s.Examples[0].(string) + } + + v = ex + } + + r := []rune(v) + if s.MaxLength != nil && uint64(len(r)) > *s.MaxLength { + r = r[:*s.MaxLength] + } + + for uint64(len(r)) < s.MinLength { + r = append(r, 'x') + } + + return string(r) +} + +func (g *valueGen) integer(s *openapi3.Schema) float64 { + switch { + case g.mode != "edge": + return float64(7 + g.branch) + case s.Format == "int32": + return -2147483648 + } + + return 9007199254740991 // 2^53-1, the largest a JavaScript number holds exactly +} + +func (g *valueGen) number(s *openapi3.Schema) float64 { + switch { + case s.MultipleOf != nil: + return *s.MultipleOf * 3 + case g.mode == "edge": + return 0.1 + } + + return 1.5 +} + +// clamp moves v into the schema's minimum and maximum. +func clamp(s *openapi3.Schema, v float64) float64 { + if s.Min != nil && v < *s.Min { + v = *s.Min + if s.ExclusiveMin.IsTrue() { + v++ + } + } + + if s.Max != nil && v > *s.Max { + v = *s.Max + if s.ExclusiveMax.IsTrue() { + v-- + } + } + + return v +} + +func mustJSON(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + + return b +} diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 26baee0..80a38e3 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -8,6 +8,7 @@ checks what arrived, not only the status code. 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 +E2E_SPECS=a.yaml,b.json make e2e # generated cases for more specs too make e2e-update # rewrites specs/fastapi.json from the backend ``` @@ -20,7 +21,7 @@ make e2e-update # rewrites specs/fastapi.json from the bac | `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. +A run takes about 12 s with warm caches. ## Layers @@ -93,6 +94,34 @@ 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. +## Generated values + +Next to the scenarios, `internal/e2e/values_test.go` makes cases from the +schemas of every feature spec, of `specs/fastapi.json` (served by the mock, +not the backend) and of each spec in `E2E_SPECS`. For each JSON response +and JSON request body it writes a few values: all properties, only the +required ones, every nullable null, each `oneOf`/`anyOf` branch in turn, and +edge scalars (unicode with U+2028, a date-time with nanoseconds at +05:30, +2^53-1, 0.1, the last enum value) with extra keys where +`additionalProperties` allows them; a string with a `pattern` takes the +schema's example. A value kin-openapi finds invalid for its schema is +dropped, and the run logs how many. Nothing is random: every run makes the +same cases. + +- A **response** value is the mock's answer and the `result`, or the + `error`'s model for a 4xx/5xx, or a 2xx whose body is not the result's. +- A **request** value is `args.body` and must arrive as `request.body`; an + optional body of null may also arrive as none. +- Required parameters get a simple value. An operation without one (an + array in the path, a form body, a HEAD) is left out; the run logs which. +- They compare **exactly**: no other keys, and null is not absent. + +go-fetch must pass every case. A failing case of typescript-fetch or +dart-fetch counts as failed in the table but skips (`valueLenient`) until +their gaps are fixed. A Swagger +2.0 spec in `E2E_SPECS` is converted to OpenAPI 3 first, and of paths that +differ only in their parameter names the first is kept. + ## Runners A runner turns the scenarios into calls of one generator's client and uses diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 8e165f3..0c95e9a 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -297,7 +297,7 @@ class Page(BaseModel, Generic[T]): class Card(Strict): method: Literal["card"] - last4: Annotated[str, Field(pattern=r"^\d{4}$")] + last4: Annotated[str, Field(pattern=r"^\d{4}$", examples=["4242"])] expires: dt.date diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index 07e9735..7460375 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -173,6 +173,9 @@ "type": "string" }, "last4": { + "examples": [ + "4242" + ], "pattern": "^\\d{4}$", "title": "Last4", "type": "string"