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..c257871 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,400 @@ +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. 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() + + var ( + suites []suite + mocks = map[string]*mock{} + byName = map[string]*scenario{} + ) + + // 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 + } + + 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 && 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++ + + 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 && 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, false)...) + } + } + + 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, 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), k.Exact)...) + } + + return diffs +} + +// startBackend runs `uv run uvicorn app:app` in dir on a free port and +// returns its URL once it answers; the test stops it. +func startBackend(t *testing.T, uv, dir string) string { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + addr, _ := l.Addr().(*net.TCPAddr) + port := strconv.Itoa(addr.Port) + _ = l.Close() + + cmd := exec.Command(uv, "run", "--quiet", "--locked", "uvicorn", "app:app", "--port", port, "--log-level", "warning") + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + exited := make(chan error, 1) + + go func() { exited <- cmd.Wait() }() + + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + + <-exited + }) + + base := "http://127.0.0.1:" + port + + for deadline := time.Now().Add(2 * time.Minute); time.Now().Before(deadline); { + select { + case err := <-exited: + exited <- err + + t.Fatalf("the backend exited: %v", err) + + case <-time.After(200 * time.Millisecond): + } + + if resp, err := http.Get(base + "/openapi.json"); err == nil { + _ = resp.Body.Close() + + return base + } + } + + t.Fatal("the backend did not start in 2 minutes") + + return "" +} + +// checkSnapshot compares the served spec with the committed one and returns +// the path of the served spec, written next to the test's temp files. +func checkSnapshot(t *testing.T, served any, snapshot string) string { + t.Helper() + + data, err := json.MarshalIndent(served, "", " ") + if err != nil { + t.Fatal(err) + } + + data = append(data, '\n') + + if os.Getenv("E2E_UPDATE") != "" { + if err := os.WriteFile(snapshot, data, 0o644); err != nil { //nolint:gosec // a file in the repository + t.Fatal(err) + } + } else if old, err := os.ReadFile(snapshot); err != nil || !bytes.Equal(old, data) { + t.Errorf("the backend's spec differs from %s; rerun with `make e2e-update` and review the diff", snapshot) + } + + path := t.TempDir() + "/openapi.json" + write(t, path, string(data)) + + return path +} diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go new file mode 100644 index 0000000..e647148 --- /dev/null +++ b/internal/e2e/gofetch_test.go @@ -0,0 +1,198 @@ +package e2e + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// suite is one scenario as a runner runs it: where to send, the cases, and +// for each the names its args use. +type suite struct { + Name string `json:"name"` + Base string `json:"base"` + Mock bool `json:"mock"` + Auth *auth `json:"auth,omitempty"` + Cases []suiteCase `json:"cases"` + Spec string `json:"-"` + + // Schemes are the spec's security schemes by name and type (apiKey, + // http, oauth2, openIdConnect), for a client whose token function + // depends on it. + Schemes map[string]string `json:"schemes"` +} + +type suiteCase struct { + Op string `json:"op"` + Params []string `json:"params"` + Args json.RawMessage `json:"args,omitempty"` + Auth *auth `json:"auth,omitempty"` + Save map[string]string `json:"save,omitempty"` +} + +// outcome is what a runner reports for a case: the result as wire JSON, +// the *ResponseError, or why the call itself failed. +type outcome struct { + Suite string `json:"suite"` + Case int `json:"case"` + Result json.RawMessage `json:"result"` + Error *struct { + Status int `json:"status"` + Model json.RawMessage `json:"model"` + Body string `json:"body"` + } `json:"error"` + Fail string `json:"fail"` +} + +// runGoFetch generates a go-fetch client per suite into one module, builds +// testdata/e2e/runners/go with a main that hands it those clients, runs the +// suites and returns the outcomes by suite name and case. +func runGoFetch(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + goBin, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + write(t, filepath.Join(dir, "go.mod"), "module e2e\n\ngo 1.24\n") + + runner, err := os.ReadFile("../../testdata/e2e/runners/go/runner.go") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.go"), string(runner)) + + var imports, setups strings.Builder + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "go-fetch", + TemplateDir: "../../templates/go-fetch", + AdditionalProperties: []string{"packageName=" + pkg}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&imports, "\t%s \"e2e/%s\"\n", pkg, pkg) + fmt.Fprintf(&setups, "\t\t%q: func(base string, a *Auth) ([]any, any) {\n", s.Name) + fmt.Fprintf(&setups, "\t\t\tcfg := &%s.Configuration{BasePath: base, APIKey: func(context.Context, string, string) (string, error) { return a.APIKey, nil }, "+ + "AccessToken: func(context.Context, string, string, []string) (string, error) { return a.Bearer, nil }}\n\n", pkg) + fmt.Fprintf(&setups, "\t\t\treturn []any{%s}, cfg\n\t\t},\n", constructors(t, filepath.Join(dir, pkg), pkg)) + } + + write(t, filepath.Join(dir, "main.go"), "package main\n\nimport (\n\t\"context\"\n\n"+imports.String()+ + ")\n\nfunc main() {\n\trun(map[string]setup{\n"+setups.String()+"\t})\n}\n") + + build := exec.Command(goBin, "build", "-o", "runner", ".") + build.Dir = dir + + build.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build runner: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(filepath.Join(dir, "runner"), filepath.Join(dir, "input.json"))) +} + +// execRunner runs a built runner and reads its outcome lines. A runner that +// dies leaves the cases after it without an outcome; the ones before still +// count. +func execRunner(t *testing.T, cmd *exec.Cmd) map[string]map[int]outcome { + t.Helper() + + var stderr bytes.Buffer + + // Away from UTC, so a client that reads or writes local time shows it. + cmd.Env = append(os.Environ(), "TZ=Europe/Bratislava") + cmd.Stderr = &stderr + + stdout, err := cmd.Output() + if err != nil { + t.Errorf("runner: %v\n%s", err, stderr.String()) + } + + out := map[string]map[int]outcome{} + + sc := bufio.NewScanner(bytes.NewReader(stdout)) + sc.Buffer(nil, 1<<26) + + for sc.Scan() { + var o outcome + if err := json.Unmarshal(sc.Bytes(), &o); err != nil { + t.Fatalf("runner output %q: %v", sc.Text(), err) + } + + if out[o.Suite] == nil { + out[o.Suite] = map[int]outcome{} + } + + out[o.Suite][o.Case] = o + } + + return out +} + +// constructors lists the NewAPI(cfg) calls of a generated package. +func constructors(t *testing.T, dir, pkg string) string { + t.Helper() + + files, _ := filepath.Glob(filepath.Join(dir, "api_*.go")) + + var calls []string + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + + for _, line := range strings.Split(string(data), "\n") { + if name, ok := strings.CutPrefix(line, "func New"); ok { + calls = append(calls, pkg+".New"+name[:strings.IndexByte(name, '(')]+"(cfg)") + } + } + } + + return strings.Join(calls, ", ") +} + +// writeInput writes the suites as the runner's input.json in dir. +func writeInput(t *testing.T, dir string, suites []suite) { + t.Helper() + + in, err := json.Marshal(map[string]any{"suites": suites}) + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "input.json"), string(in)) +} + +func write(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/e2e/match_test.go b/internal/e2e/match_test.go new file mode 100644 index 0000000..6f09e11 --- /dev/null +++ b/internal/e2e/match_test.go @@ -0,0 +1,171 @@ +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. 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 { + case "$any": + return nil + case "$absent": + return []string{path + ": want absent, got " + show(got)} + } + + if g, ok := got.([]any); ok && len(g) == 1 && !exact && 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, 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])) + } + } + + 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], exact)...) + } + + 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 + exact, ok bool + }{ + {"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)), tt.exact) + if (len(diffs) == 0) != 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 new file mode 100644 index 0000000..304f1ad --- /dev/null +++ b/internal/e2e/mock_test.go @@ -0,0 +1,294 @@ +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: "/"}} + + // 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) + } + + 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..0657098 --- /dev/null +++ b/internal/e2e/scenario_test.go @@ -0,0 +1,172 @@ +// Package e2e runs generated clients against servers: a mock that validates +// every request against the spec and records it, and real backends. The +// cases are the language-neutral scenarios in testdata/e2e/scenarios; each +// generator has a runner that turns them into calls of its client. Run with +// `make e2e`. +package e2e + +import ( + "bytes" + "encoding/json" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "go.yaml.in/yaml/v3" +) + +// scenario is one testdata/e2e/scenarios file: the cases for one spec, sent +// to the mock (spec) or to a running backend (backend). +type scenario struct { + Name string `json:"-"` + Spec string `json:"spec"` + Backend string `json:"backend"` + Auth *auth `json:"auth"` + Cases []kase `json:"cases"` + + // Skip skips every case for a generator, one whose client for the spec + // does not even compile; a case's own skip says more. + Skip map[string]string `json:"skip"` +} + +type auth struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Bearer string `json:"bearer,omitempty"` +} + +// kase is one call: the operation and its arguments by the spec's names, +// what the mock must receive and answer, and what the client must return. +type kase struct { + Name string `json:"name"` + Op string `json:"op"` + Args json.RawMessage `json:"args"` + Auth *auth `json:"auth"` + Save map[string]string `json:"save"` + Request json.RawMessage `json:"request"` + Response *mockResponse `json:"response"` + Result json.RawMessage `json:"result"` // null is a null result, absent is unchecked + Error json.RawMessage `json:"error"` + + // 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 +// one of a JSON, text or base64 body. OffSpec marks one the spec does not +// allow on purpose (an undeclared status), which is otherwise an error. +type mockResponse struct { + OffSpec bool `json:"offSpec"` + Status int `json:"status"` + Header map[string]string `json:"header"` + JSON json.RawMessage `json:"json"` + Text *string `json:"text"` + Base64 string `json:"base64"` +} + +const scenarioDir = "../../testdata/e2e/scenarios" + +// loadScenarios reads every scenario file, YAML turned into JSON so the +// values compare as the wire's (numbers as json.Number). +func loadScenarios(t *testing.T) []*scenario { + t.Helper() + + files, err := filepath.Glob(filepath.Join(scenarioDir, "*.yaml")) + if err != nil || len(files) == 0 { + t.Fatalf("no scenarios in %s: %v", scenarioDir, err) + } + + var out []*scenario + + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + var y any + if err := yaml.Unmarshal(data, &y); err != nil { + t.Fatalf("%s: %v", file, err) + } + + j, err := json.Marshal(y) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + + s := &scenario{Name: strings.TrimSuffix(filepath.Base(file), ".yaml")} + + dec := json.NewDecoder(bytes.NewReader(j)) + dec.UseNumber() + dec.DisallowUnknownFields() + + if err := dec.Decode(s); err != nil { + t.Fatalf("%s: %v", file, err) + } + + if s.Spec != "" { + s.Spec = filepath.Join(scenarioDir, s.Spec) + } + + out = append(out, s) + } + + return out +} + +// opParams lists, per operationId, the names a case's args may use, in the +// order of the spec: the parameters and, for a form body, its properties +// (sorted). A runner uses them to tell the body's field from the +// parameters', and their order to tell apart two names that a generator +// gives one identifier, the second one numbered (itemId, itemId2). kin +// does not keep a schema's property order, so two form fields of one +// identifier must stand in the spec in sorted order too. +func opParams(doc *openapi3.T) map[string][]string { + out := map[string][]string{} + + for _, item := range doc.Paths.Map() { + for _, op := range item.Operations() { + var names []string + + for _, p := range slices.Concat(item.Parameters, op.Parameters) { + if p.Value != nil { + names = append(names, p.Value.Name) + } + } + + if op.RequestBody != nil && op.RequestBody.Value != nil { + for ct, mt := range op.RequestBody.Value.Content { + form := strings.HasPrefix(ct, "multipart/") || ct == "application/x-www-form-urlencoded" + if form && mt.Schema != nil && mt.Schema.Value != nil { + names = append(names, slices.Sorted(maps.Keys(mt.Schema.Value.Properties))...) + } + } + } + + out[op.OperationID] = names + } + } + + return out +} + +// decodeJSON decodes JSON keeping numbers exact. +func decodeJSON(b []byte) any { + var v any + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + if err := dec.Decode(&v); err != nil { + return string(b) + } + + return v +} diff --git a/internal/e2e/typescript_test.go b/internal/e2e/typescript_test.go new file mode 100644 index 0000000..6b0bdd5 --- /dev/null +++ b/internal/e2e/typescript_test.go @@ -0,0 +1,84 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/xseman/openapi-generator/internal/gen" +) + +// runTypeScript generates a typescript-fetch client per suite, compiles +// them with testdata/e2e/runners/ts to CommonJS and runs the suites with +// node. +func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { + t.Helper() + + tsc, err := exec.LookPath("tsc") + if err != nil { + t.Fatal("the typescript-fetch runner compiles with tsc (npm i -g typescript)") + } + + node, err := exec.LookPath("node") + if err != nil { + t.Fatal("the typescript-fetch runner runs with node 22 or later") + } + + dir := t.TempDir() + + runner, err := os.ReadFile("../../testdata/e2e/runners/ts/runner.ts") + if err != nil { + t.Fatal(err) + } + + write(t, filepath.Join(dir, "runner.ts"), string(runner)) + + var main strings.Builder + + main.WriteString("import { run } from \"./runner\";\n") + + for i, s := range suites { + pkg := "c" + strconv.Itoa(i) + + err := gen.Generate(gen.Options{ + InputSpec: s.Spec, + OutputDir: filepath.Join(dir, pkg), + GeneratorName: "typescript-fetch", + TemplateDir: "../../templates/typescript-fetch", + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s: %v", s.Name, err) + } + + fmt.Fprintf(&main, "import * as %s from \"./%s/index\";\n", pkg, pkg) + } + + main.WriteString("\nrun({\n") + + for i, s := range suites { + fmt.Fprintf(&main, " %q: { module: c%d, dir: %q },\n", s.Name, i, filepath.Join(dir, "c"+strconv.Itoa(i))) + } + + main.WriteString("});\n") + write(t, filepath.Join(dir, "main.ts"), main.String()) + write(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions": {"target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], + "outDir": "dist", "strict": false, "skipLibCheck": true, "noEmitOnError": false}, "files": ["main.ts"]}`) + + // Type errors in a client are its generator's; the JavaScript is + // emitted anyway and the cases show what they break. + build := exec.Command(tsc, "-p", ".") + build.Dir = dir + + if out, err := build.CombinedOutput(); err != nil { + t.Logf("tsc: %v\n%s", err, out) + } + + writeInput(t, dir, suites) + + return execRunner(t, exec.Command(node, filepath.Join(dir, "dist", "main.js"), filepath.Join(dir, "input.json"))) +} diff --git a/internal/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 new file mode 100644 index 0000000..80a38e3 --- /dev/null +++ b/testdata/e2e/README.md @@ -0,0 +1,174 @@ +# 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 +E2E_SPECS=a.yaml,b.json make e2e # generated cases for more specs too +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 12 s with warm caches. + +## Layers + +| Layer | Server | Spec | Checks | +| ------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Mock | `internal/e2e`: validates each request against the spec (kin-openapi), records it, answers with the case's `response` | a feature spec, `testdata/specs/feature` | the request is valid and arrives as `request` says; the client returns `result` or `error` from the response | +| Backend | `backends/fastapi`: a strict FastAPI app (pydantic rejects extra keys) that echoes what it parsed | the one the server serves, compared with `specs/fastapi.json` | the client returns `result` or `error`; the server's validation and echo show what it received | + +The mock layer covers every parameter style and body encoding the feature +specs declare. The backend shows the shapes a framework writes into its +spec: pydantic's separate input and output schemas, OpenAPI 3.1 null unions, +`const`, Decimal as number or string, FastAPI's empty JSON schema next to +another media type, discriminated unions in every position, naive and aware +date-times, required nullables, form models, enums of mixed JSON types, +parameters whose names collide once camelCased, and streamed responses +(JSON Lines and server-sent events from `yield`, 3.2's `itemSchema`). + +## 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. + +## 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 +the client the way its users do. `internal/e2e` generates every suite's +client, builds one program that holds them all and runs it, then compares +what the program prints with the scenarios. + +| Runner | Client | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runners/go` | go-fetch: finds methods, `Params` fields and results by reflection; standard library only | +| `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | +| `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | + +Runners run with `TZ=Europe/Bratislava`, so a client that reads or writes +local time shows it. + +typescript-fetch sends an OAuth2 token as the whole `Authorization` header, +so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the +input). Its error has no decoded model: the runner reports the error body +parsed as JSON. A case one generator cannot pass has +`skip: { : why }`. A spec whose client does not compile at all +has the same `skip` at the top of its file. + +A runner for another generator follows the same protocol: + +1. **Input** is a JSON file given as the first argument: + `{ suites: [{ name, base, mock, auth, schemes, cases: [{ op, params, args, auth, save }] }] }`. + - `params` lists the names the operation's args may use (parameters and + form fields); any other arg is `body`. + - A case's `auth` fields replace those before it for the rest of the + suite. +2. **Before each case** of a `mock` suite, the runner sends + `POST {base}/__e2e/case/{index}` so that the mock knows which case the + next request belongs to. +3. **Arguments.** `${name}` in `args` and `auth` is a value an earlier + case saved: + - `save: { name: path }` takes a dotted path into the result + (`items.0.id`). + - A string is saved as it is, anything else as its JSON. + - A name never saved fails the case. +4. **Output** is one JSON line per case, `{ suite, case, result | error | fail }`: + - `result` is the client's result as wire JSON; a binary result is a + base64 string, 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 + 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..0c95e9a --- /dev/null +++ b/testdata/e2e/backends/fastapi/app.py @@ -0,0 +1,1056 @@ +"""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 collections.abc import AsyncIterable, Iterable +from decimal import Decimal +from enum import Enum, IntEnum +from ipaddress import IPv4Address +from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union + +from fastapi import ( + APIRouter, + Body, + Cookie, + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Path, + Query, + Request, + Response, + Security, + UploadFile, +) +from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse +from fastapi.sse import EventSourceResponse, ServerSentEvent +from fastapi.security import ( + APIKeyCookie, + APIKeyHeader, + APIKeyQuery, + HTTPAuthorizationCredentials, + HTTPBasic, + HTTPBasicCredentials, + HTTPBearer, + OAuth2PasswordBearer, + OAuth2PasswordRequestForm, + SecurityScopes, +) +from pydantic import ( + AliasGenerator, + AwareDatetime, + BaseModel, + ConfigDict, + Discriminator, + EmailStr, + Field, + HttpUrl, + NaiveDatetime, + SecretStr, + Tag, + computed_field, +) +from pydantic.alias_generators import to_camel + +app = FastAPI( + title="Warehouse", + version="2.0.0", + description="Every common OpenAPI shape in one strict API.", +) + + +# ---------------------------------------------------------------- base models + + +class Strict(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class Camel(BaseModel): + """camelCase on the wire, snake_case in Python, as most JSON APIs.""" + + model_config = ConfigDict(extra="forbid", alias_generator=AliasGenerator(to_camel), populate_by_name=True) + + +# ---------------------------------------------------------------- enums + + +class Color(str, Enum): + red = "red" + dark_blue = "dark-blue" + light_green = "light green" + n2 = "2nd" + type_ = "type" + empty = "" + + +class Priority(IntEnum): + low = 1 + mid = 5 + high = 10 + + +class Unit(str, Enum): + kg = "kg" + lb = "lb" + + +# ---------------------------------------------------------------- scalars + + +class Scalars(Strict): + """Every scalar format pydantic writes into a schema.""" + + i: int + i64: Annotated[int, Field(ge=-(2**63), le=2**63 - 1)] + f: float + d: Decimal + b: bool + s: Annotated[str, Field(min_length=1, max_length=40, pattern=r"^[^\x00]+$")] + when: dt.datetime + day: dt.date + clock: dt.time + span: dt.timedelta + id: uuid.UUID + email: EmailStr + url: HttpUrl + ip: IPv4Address + blob: bytes + anything: Any = None + color: Color + priority: Priority + unit: Literal["kg", "lb"] = "kg" + + +class Wide(Strict): + """Numbers past int64 and float64's usual range: Python ints have no bound + and pydantic writes plain integer for them, a uint64 bound as a float.""" + + big: int + u64: Annotated[int, Field(ge=0, le=2**64 - 1)] + f: float + tiny: Annotated[float, Field(gt=0, multiple_of=1e-7)] + + +class Stamps(Strict): + """Times as ORM-backed services send them: naive, aware, in containers.""" + + naive: NaiveDatetime + aware: AwareDatetime + clock: dt.time + span: dt.timedelta + days: list[dt.date] = [] + seen: dict[str, dt.datetime] = {} + + +class Nulls(Strict): + """Null as a required value, and null that differs from a default.""" + + note: Optional[str] + due: Optional[dt.date] + color: Optional[Color] + dims: Optional["Dimensions"] + lines: Optional[list["Dimensions"]] + payment: Optional["Payment"] + retries: Optional[int] = 3 + extras: Optional[list["Dimensions"]] = None + + +class NullsEcho(Nulls): + sent: list[str] # the keys the client put on the wire + + +class UploadIn(Strict): + """A form model holding files: FastAPI declares its body + application/x-www-form-urlencoded, yet only multipart carries a file.""" + + folder: str + files: list[UploadFile] + labels: list[str] = [] + cover: Optional[UploadFile] = None + + +class FormIn(Strict): + """A form model: every value a client must turn into text itself.""" + + day: dt.date + when: AwareDatetime + price: Decimal + color: Color + tags: list[str] = [] + colors: list[Color] = [] + days: list[dt.date] = [] + qty: Optional[int] = None + agree: bool = False + + +class Retry(Enum): + """Members of two JSON types: the schema has an enum and no type.""" + + never = 0 + always = "always" + once = 1 + + +class Literals(Strict): + """Enums whose values are not one JSON type, null among them.""" + + retry: Retry + pin: Literal["x", None] + order: Optional[Literal["asc", "desc"]] + mode: Literal["auto", 0, 1] = "auto" + tag: Literal["a", None] = "a" + size: Literal[10, 25, 50] = 25 + + +# ---------------------------------------------------------------- containers + + +class Dimensions(Camel): + width_cm: float + height_cm: float + depth_cm: Optional[float] = None + + +class Containers(Strict): + tags: list[str] + unique: set[str] = set() + pair: tuple[int, str] + matrix: list[list[int]] = [] + sparse: list[Optional[int]] = [] + counts: dict[str, int] = {} + groups: dict[str, list[str]] = {} + by_name: dict[str, Dimensions] = {} + maybe_list: Optional[list[str]] = None + maybe_map: Optional[dict[str, Optional[str]]] = None + + +# ---------------------------------------------------------------- items + + +class Supplier(Camel): + name: str + contact_email: Optional[EmailStr] = None + website: Optional[HttpUrl] = None + + +class ItemIn(Camel): + """An item as a client sends it.""" + + sku: Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$", examples=["ABC-1234"])] + name: str + description: Optional[str] = Field(default=None, description="Free text; null clears it.") + price: Decimal + weight: Optional[float] = None + unit: Unit = Unit.kg + color: Optional[Color] = None + priority: Priority = Priority.mid + tags: list[str] = [] + dimensions: Optional[Dimensions] = None + supplier: Optional[Supplier] = None + attributes: dict[str, str] = {} + discontinued: bool = Field(default=False, deprecated=True) + + +class Item(ItemIn): + """An item as the server returns it.""" + + id: uuid.UUID + created_at: dt.datetime + updated_at: Optional[dt.datetime] = None + version: int + + @computed_field + @property + def display_name(self) -> str: + return f"{self.name} ({self.sku})" + + +class ItemPatch(Camel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[Decimal] = None + color: Optional[Color] = None + tags: Optional[list[str]] = None + dimensions: Optional[Dimensions] = None + + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + items: list[T] + total: int + next_cursor: Optional[str] = None + + +# ---------------------------------------------------------------- unions + + +class Card(Strict): + method: Literal["card"] + last4: Annotated[str, Field(pattern=r"^\d{4}$", examples=["4242"])] + expires: dt.date + + +class Transfer(Strict): + method: Literal["transfer"] + iban: str + reference: Optional[str] = None + + +class Voucher(Strict): + method: Literal["voucher"] + code: str + amount: Decimal + + +Payment = Annotated[Union[Card, Transfer, Voucher], Field(discriminator="method")] + + +class Circle(Strict): + radius: float + + +class Rect(Strict): + width: float + height: float + + +class OrderLine(Camel): + sku: str + quantity: Annotated[int, Field(gt=0)] + note: Optional[str] = None + + +class OrderIn(Camel): + lines: Annotated[list[OrderLine], Field(min_length=1)] + payment: Payment + shape: Optional[Union[Circle, Rect]] = None + gift: Union[bool, str] = False + code: Optional[Union[int, str]] = None + extra_payments: list[Payment] = [] + + +class OrderStatus(str, Enum): + placed = "placed" + paid = "paid" + shipped = "shipped" + + +class Kind(str, Enum): + cat = "cat" + dog = "dog" + + +class Cat(Camel): + pet_kind: Literal[Kind.cat] + lives: int + + +class Dog(Camel): + pet_kind: Literal[Kind.dog] + good_boy: bool + + +# Tagged by a camelCase key whose values are enum members. +Pet = Annotated[Union[Cat, Dog], Field(discriminator="pet_kind")] + + +def _method(v: Any) -> Optional[str]: + return v.get("method") if isinstance(v, dict) else getattr(v, "method", None) + + +# A callable discriminator: the spec has a plain oneOf, no discriminator. +Tagged = Annotated[Union[Annotated[Card, Tag("card")], Annotated[Transfer, Tag("transfer")]], Discriminator(_method)] + + +class Wallet(Strict): + """The union in every place a model can hold it.""" + + primary: Optional[Payment] = None + by_label: dict[str, Payment] = {} + pets: list[Pet] = [] + tagged: Optional[Tagged] = None + + +class Order(OrderIn): + id: int + status: OrderStatus + placed_at: dt.datetime + + +# ---------------------------------------------------------------- recursion, open objects, keywords + + +class Category(Strict): + name: str + children: list["Category"] = [] + parent: Optional["Category"] = None + + +class Open(BaseModel): + """Takes any extra key and returns it.""" + + model_config = ConfigDict(extra="allow") + kind: str + + +class Keywords(BaseModel): + """Property names that are keywords or predeclared in Go.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + type: str + func: int + range: list[int] + map: dict[str, int] + interface: Optional[str] = None + package: str = "main" + default: bool = False + select: Optional[str] = None + string: str = Field(alias="string") + error_: str = Field(alias="error") + dash_name: str = Field(alias="x-dash-name") + dot_name: str = Field(alias="dot.name") + upper: str = Field(alias="UPPER") + digit: str = Field(alias="1st") + + +class Secret(Strict): + username: str + password: SecretStr + + +# ---------------------------------------------------------------- errors + + +class Problem(Strict): + type: str + title: str + status: int + detail: Optional[str] = None + + +class Conflict(Strict): + message: str + current_version: int + + +# ---------------------------------------------------------------- state + +# Nulls names Dimensions and Payment before they are defined. +Nulls.model_rebuild() +NullsEcho.model_rebuild() + +ITEMS: dict[uuid.UUID, Item] = {} +ORDERS: dict[int, Order] = {} +FILES: dict[str, bytes] = {} + +# ---------------------------------------------------------------- security + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={"items:read": "Read items", "items:write": "Write items", "orders": "Place orders"}, +) +bearer = HTTPBearer(auto_error=False) +basic = HTTPBasic() +key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +key_query = APIKeyQuery(name="api_key", auto_error=False) +key_cookie = APIKeyCookie(name="session", auto_error=False) + +TOKENS = {"tok-rw": ["items:read", "items:write", "orders"], "tok-r": ["items:read"]} + + +def user(scopes: SecurityScopes, token: str = Depends(oauth2)) -> str: + granted = TOKENS.get(token) + if granted is None: + raise HTTPException(401, "bad token", headers={"WWW-Authenticate": "Bearer"}) + for s in scopes.scopes: + if s not in granted: + raise HTTPException(403, f"missing scope {s}") + return token + + +def any_key( + h: Optional[str] = Depends(key_header), + q: Optional[str] = Depends(key_query), + c: Optional[str] = Depends(key_cookie), +) -> str: + for where, v in (("header", h), ("query", q), ("cookie", c)): + if v == "key-1": + return where + raise HTTPException(401, "no api key") + + +def admin(c: HTTPBasicCredentials = Depends(basic)) -> str: + if (c.username, c.password) != ("admin", "p@ss:w0rd"): + raise HTTPException(401, "bad credentials") + return c.username + + +def maybe_bearer(c: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> Optional[str]: + return c.credentials if c else None + + +def problem(status: int, title: str, detail: str = "") -> JSONResponse: + return JSONResponse({"type": "about:blank", "title": title, "status": status, "detail": detail or None}, status) + + +not_found = {404: {"model": Problem, "description": "No such resource"}} + +# ---------------------------------------------------------------- auth + + +@app.post("/token", tags=["auth"], operation_id="issueToken") +def issue_token(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]: + if (form.username, form.password) == ("alice", "secret"): + return {"access_token": "tok-rw", "token_type": "bearer", "scope": " ".join(form.scopes)} + if (form.username, form.password) == ("bob", "secret"): + return {"access_token": "tok-r", "token_type": "bearer", "scope": " ".join(form.scopes)} + raise HTTPException(400, "bad login") + + +@app.get("/whoami", tags=["auth"], operation_id="whoami") +def whoami(where: str = Depends(any_key), token: Optional[str] = Depends(maybe_bearer)) -> dict[str, Optional[str]]: + return {"key_in": where, "bearer": token} + + +@app.get("/admin/stats", tags=["auth"], operation_id="adminStats") +def admin_stats(_: str = Depends(admin)) -> dict[str, int]: + return {"items": len(ITEMS), "orders": len(ORDERS)} + + +# ---------------------------------------------------------------- echo: scalars and containers + + +@app.post("/echo/scalars", tags=["echo"], operation_id="echoScalars") +def echo_scalars(body: Scalars) -> Scalars: + return body + + +@app.post("/echo/literals", tags=["echo"], operation_id="echoLiterals") +def echo_literals(body: Literals) -> Literals: + return body + + +@app.post("/echo/nulls", tags=["echo"], operation_id="echoNulls") +def echo_nulls(body: Nulls) -> NullsEcho: + return NullsEcho(**body.model_dump(), sent=sorted(body.model_fields_set)) + + +@app.post("/echo/stamps", tags=["echo"], operation_id="echoStamps") +def echo_stamps(body: Stamps) -> Stamps: + return body + + +@app.get("/stamps", tags=["echo"], operation_id="stampsQuery") +def stamps_query( + days: Annotated[list[dt.date], Query()] = [], + since: Optional[NaiveDatetime] = None, +) -> Stamps: + return Stamps( + naive=since or dt.datetime(2024, 3, 1, 10, 0, 0, 500000), + aware=dt.datetime(2024, 3, 1, 10, tzinfo=dt.timezone.utc), + clock=dt.time(13, 45, 30, 250000), + span=dt.timedelta(seconds=-1.5), + days=days, + ) + + +@app.post("/echo/wide", tags=["echo"], operation_id="echoWide") +def echo_wide(body: Wide) -> Wide: + return body + + +@app.post("/echo/containers", tags=["echo"], operation_id="echoContainers") +def echo_containers(body: Containers) -> Containers: + return body + + +@app.post("/echo/keywords", tags=["echo"], operation_id="echoKeywords") +def echo_keywords(body: Keywords) -> Keywords: + return body + + +@app.post("/echo/open", tags=["echo"], operation_id="echoOpen") +def echo_open(body: Open) -> Open: + return body + + +@app.post("/echo/tree", tags=["echo"], operation_id="echoTree") +def echo_tree(body: Category) -> Category: + return body + + +@app.post("/echo/secret", tags=["echo"], operation_id="echoSecret") +def echo_secret(body: Secret) -> dict[str, Union[str, int]]: + return {"username": body.username, "password_length": len(body.password.get_secret_value())} + + +@app.post("/echo/list", tags=["echo"], operation_id="echoList") +def echo_list(body: list[Dimensions]) -> list[Dimensions]: + return body + + +@app.post("/echo/map", tags=["echo"], operation_id="echoMap") +def echo_map(body: dict[str, list[int]]) -> dict[str, int]: + return {k: sum(v) for k, v in body.items()} + + +@app.post("/echo/number", tags=["echo"], operation_id="echoNumber") +def echo_number(body: Annotated[float, Body()]) -> float: + return body * 2 + + +@app.post("/echo/text", tags=["echo"], operation_id="echoText", response_class=PlainTextResponse, + openapi_extra={"requestBody": {"required": True, "content": {"text/plain": {"schema": {"type": "string"}}}}}) +async def echo_text(request: Request) -> str: + return (await request.body()).decode()[::-1] + + +@app.post("/echo/raw", tags=["echo"], operation_id="echoRaw", + openapi_extra={"requestBody": {"required": True, "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}, + response_class=Response) +async def echo_raw(request: Request) -> Response: + data = await request.body() + return Response(bytes(reversed(data)), media_type="application/octet-stream") + + +@app.post("/echo/union", tags=["echo"], operation_id="echoUnion") +def echo_union(body: Union[int, str, list[int]]) -> dict[str, str]: + return {"type": type(body).__name__, "value": str(body)} + + +@app.post("/echo/pet", tags=["echo"], operation_id="echoPet") +def echo_pet(body: Annotated[Pet, Body()]) -> Pet: + return body + + +@app.post("/echo/wallet", tags=["echo"], operation_id="echoWallet") +def echo_wallet(body: Wallet) -> Wallet: + return body + + +@app.post("/echo/optional", tags=["echo"], operation_id="echoOptional") +def echo_optional(body: Annotated[Optional[Dimensions], Body()] = None) -> dict[str, bool]: + return {"got": body is not None} + + +# ---------------------------------------------------------------- parameters + + +@app.get("/params/path/{id}/{day}/{color}/{rest:path}", tags=["params"], operation_id="pathParams") +def path_params(id: int, day: dt.date, color: Color, rest: str) -> dict[str, str]: + return {"id": str(id), "day": day.isoformat(), "color": color.value, "rest": rest} + + +@app.get("/params/query", tags=["params"], operation_id="queryParams") +def query_params( + q: str, + page_size: Annotated[int, Query(alias="page-size", ge=1, le=100)] = 20, + tags: Annotated[list[str], Query()] = [], + ids: Annotated[Optional[list[int]], Query()] = None, + colors: Annotated[list[Color], Query()] = [], + since: Optional[dt.datetime] = None, + ratio: Optional[float] = None, + active: Optional[bool] = None, + old: Annotated[Optional[str], Query(deprecated=True)] = None, +) -> dict[str, Any]: + return { + "q": q, + "pageSize": page_size, + "tags": tags, + "ids": ids, + "colors": [c.value for c in colors], + "since": since.isoformat() if since else None, + "ratio": ratio, + "active": active, + "old": old, + } + + +@app.get("/params/headers", tags=["params"], operation_id="headerParams") +def header_params( + x_request_id: Annotated[uuid.UUID, Header()], + x_retry: Annotated[Optional[int], Header()] = None, + x_flags: Annotated[list[str], Header()] = [], + accept_language: Annotated[Optional[str], Header()] = None, + tenant: Annotated[Optional[str], Cookie()] = None, + theme: Annotated[Optional[str], Cookie()] = None, +) -> dict[str, Any]: + return { + "requestId": str(x_request_id), + "retry": x_retry, + "flags": x_flags, + "lang": accept_language, + "tenant": tenant, + "theme": theme, + } + + +@app.get("/params/header-lists", tags=["params"], operation_id="headerLists") +def header_lists( + x_tags: Annotated[list[str], Header()] = [], + x_ids: Annotated[list[int], Header()] = [], + x_colors: Annotated[list[Color], Header()] = [], +) -> dict[str, Any]: + """FastAPI reads a list header from repeated lines; the spec's simple + style is one comma-joined line, which it takes as one item.""" + return {"tags": x_tags, "ids": x_ids, "colors": [c.value for c in x_colors]} + + +# ---------------------------------------------------------------- items CRUD + + +@app.post( + "/items", + status_code=201, + tags=["items"], + operation_id="createItem", + responses={ + 201: {"headers": {"Location": {"schema": {"type": "string"}, "description": "URL of the item"}}}, + 409: {"model": Problem, "description": "SKU taken"}, + }, +) +def create_item(body: ItemIn, response: Response, _: str = Security(user, scopes=["items:write"])) -> Item: + if any(i.sku == body.sku for i in ITEMS.values()): + return problem(409, "SKU taken", body.sku) + item = Item(id=uuid.uuid4(), created_at=dt.datetime.now(dt.timezone.utc), version=1, **body.model_dump()) + ITEMS[item.id] = item + response.headers["Location"] = f"/items/{item.id}" + return item + + +@app.get("/items", tags=["items"], operation_id="listItems") +def list_items( + color: Optional[Color] = None, + min_price: Optional[Decimal] = None, + cursor: Optional[str] = None, + limit: Annotated[int, Query(ge=1, le=50)] = 10, + _: str = Security(user, scopes=["items:read"]), +) -> Page[Item]: + items = sorted(ITEMS.values(), key=lambda i: i.sku) + items = [i for i in items if (color is None or i.color == color) and (min_price is None or i.price >= min_price)] + start = int(cursor or 0) + chunk = items[start : start + limit] + nxt = str(start + limit) if start + limit < len(items) else None + return Page[Item](items=chunk, total=len(items), next_cursor=nxt) + + +@app.get("/items/{item_id}", tags=["items"], operation_id="getItem", responses=not_found) +def get_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + return ITEMS[item_id] + + +@app.patch( + "/items/{item_id}", + tags=["items"], + operation_id="patchItem", + responses={**not_found, 409: {"model": Conflict, "description": "Stale version"}}, +) +def patch_item( + item_id: uuid.UUID, + body: ItemPatch, + if_match: Annotated[int, Header()], + _: str = Security(user, scopes=["items:write"]), +) -> Item: + if item_id not in ITEMS: + return problem(404, "Not found", str(item_id)) + cur = ITEMS[item_id] + if if_match != cur.version: + return JSONResponse({"message": "stale", "current_version": cur.version}, 409) + data = cur.model_dump() + sent = body.model_dump(exclude_unset=True) + for k in ("name", "price"): + if k in sent and sent[k] is None: + raise HTTPException(400, f"{k} may not be null") + data.update(sent) + data.update(version=cur.version + 1, updated_at=dt.datetime.now(dt.timezone.utc)) + data.pop("display_name", None) + ITEMS[item_id] = Item(**data) + return ITEMS[item_id] + + +@app.delete("/items/{item_id}", status_code=204, tags=["items"], operation_id="deleteItem", responses=not_found) +def delete_item(item_id: uuid.UUID, _: str = Security(user, scopes=["items:write"])): + if ITEMS.pop(item_id, None) is None: + return problem(404, "Not found", str(item_id)) + return Response(status_code=204) + + +@app.head("/items/{item_id}", tags=["items"], operation_id="itemExists", responses={404: {"description": "absent"}}) +def item_exists(item_id: uuid.UUID, _: str = Security(user, scopes=["items:read"])): + return Response(status_code=200 if item_id in ITEMS else 404) + + +# ---------------------------------------------------------------- files + + +@app.post("/files", status_code=201, tags=["files"], operation_id="uploadFiles") +def upload_files( + files: Annotated[list[UploadFile], File(description="One or more files")], + folder: Annotated[str, Form()], + labels: Annotated[list[str], Form()] = [], + overwrite: Annotated[bool, Form()] = False, + meta: Annotated[Optional[str], Form(description="JSON text")] = None, + _: str = Depends(any_key), +) -> list[dict[str, Union[str, int]]]: + out = [] + for f in files: + data = f.file.read() + name = f"{folder}/{f.filename}" + if name in FILES and not overwrite: + raise HTTPException(409, f"{name} exists") + FILES[name] = data + out.append({"name": name, "size": len(data), "type": f.content_type or "", "labels": "|".join(labels), "meta": meta or ""}) + return out + + +@app.post("/forms/typed", tags=["files"], operation_id="submitForm") +def submit_form(body: Annotated[FormIn, Form()]) -> FormIn: + return body + + +@app.post("/files/model", tags=["files"], operation_id="uploadModel") +def upload_model(body: Annotated[UploadIn, Form()]) -> dict[str, Any]: + return { + "folder": body.folder, + "files": {f.filename: len(f.file.read()) for f in body.files}, + "labels": body.labels, + "cover": body.cover.filename if body.cover else None, + } + + +@app.post("/files/avatar", tags=["files"], operation_id="uploadAvatar") +def upload_avatar(image: Annotated[UploadFile, File()], thumbnail: Annotated[Optional[UploadFile], File()] = None) -> dict[str, int]: + return {"image": len(image.file.read()), "thumbnail": len(thumbnail.file.read()) if thumbnail else -1} + + +@app.get( + "/files/{name:path}", + tags=["files"], + operation_id="downloadFile", + response_class=Response, + responses={200: {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, **not_found}, +) +def download_file(name: str): + if name not in FILES: + return problem(404, "Not found", name) + return Response(FILES[name], media_type="application/octet-stream") + + +@app.get("/export.csv", tags=["files"], operation_id="exportCsv", response_class=PlainTextResponse, + responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}}) +def export_csv() -> PlainTextResponse: + rows = ["sku,name"] + [f"{i.sku},{i.name}" for i in sorted(ITEMS.values(), key=lambda i: i.sku)] + return PlainTextResponse("\n".join(rows) + "\n", media_type="text/csv") + + +# ---------------------------------------------------------------- orders + + +@app.post( + "/orders", + status_code=201, + tags=["orders"], + operation_id="placeOrder", + responses={202: {"model": Problem, "description": "Queued for review"}, 422: {"description": "Invalid"}}, +) +def place_order( + body: OrderIn, + dry_run: bool = False, + idempotency_key: Annotated[Optional[str], Header()] = None, + _: str = Security(user, scopes=["orders"]), +) -> Order: + total = sum(line.quantity for line in body.lines) + if total > 100: + return problem(202, "Queued for review", f"{total} units") + order = Order(id=len(ORDERS) + 1, status=OrderStatus.placed, placed_at=dt.datetime.now(dt.timezone.utc), **body.model_dump()) + if not dry_run: + ORDERS[order.id] = order + return order + + +@app.get("/orders/{order_id}", tags=["orders"], operation_id="getOrder", responses=not_found) +def get_order(order_id: Annotated[int, Path(ge=1)], _: str = Security(user, scopes=["orders"])) -> Order: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id] + + +@app.get("/orders/{order_id}/status", tags=["orders"], operation_id="orderStatus", responses=not_found) +def order_status(order_id: int, _: str = Security(user, scopes=["orders"])) -> OrderStatus: + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return ORDERS[order_id].status + + +@app.post("/orders/{order_id}/cancel", status_code=202, tags=["orders"], operation_id="cancelOrder", + responses={202: {"description": "Cancellation accepted; no body"}, **not_found}) +def cancel_order(order_id: int, reason: Annotated[Optional[str], Body(embed=True)] = None, _: str = Security(user, scopes=["orders"])): + if order_id not in ORDERS: + return problem(404, "Not found", str(order_id)) + return Response(status_code=202) + + +# ---------------------------------------------------------------- misc + + +@app.get("/categories", tags=["misc"], operation_id="categoryTree") +def category_tree() -> list[Category]: + root = Category(name="root", children=[Category(name="a", children=[Category(name="a1")]), Category(name="b")]) + return [root] + + +@app.get("/count", tags=["misc"], operation_id="countItems") +def count_items() -> int: + return len(ITEMS) + + +@app.get("/maybe", tags=["misc"], operation_id="maybeItem") +def maybe_item(present: bool = False) -> Optional[Dimensions]: + return Dimensions(width_cm=1, height_cm=2) if present else None + + +@app.get("/legacy", tags=["misc"], deprecated=True) +def legacy_endpoint(x: int = 1) -> dict[str, int]: + """A deprecated operation with FastAPI's generated operationId.""" + return {"x": x} + + +@app.get("/redirect", tags=["misc"], operation_id="redirectToCount", response_class=RedirectResponse, status_code=307) +def redirect() -> RedirectResponse: + return RedirectResponse("/count", status_code=307) + + +@app.get("/stream", tags=["misc"], operation_id="streamLines", + responses={200: {"content": {"application/x-ndjson": {"schema": {"type": "string"}}}}}) +def stream_lines(n: int = 3) -> StreamingResponse: + return StreamingResponse((f'{{"i":{i}}}\n' for i in range(n)), media_type="application/x-ndjson") + + +# ---------------------------------------------------------------- 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 + + +@app.get("/untagged", operation_id="untagged") +def untagged() -> dict[str, bool]: + return {"ok": True} + + +# ---------------------------------------------------------------- name clashes +# Parameters that differ on the wire but share a name once camelCased, and +# ones named like a client's own locals; a router prefix and a shared +# dependency bring them in as real services do. + + +def api_version(version: Annotated[str, Header(alias="Version")] = "1") -> str: + return version + + +clash = APIRouter(prefix="/tenants/{tenant}", tags=["clash"]) + + +@clash.get("/things/{item_id}", operation_id="clashLocations") +def clash_locations( + tenant: str, + item_id: int, + item_hdr: Annotated[Optional[str], Header(alias="Item-Id")] = None, + hv: str = Depends(api_version), + version: Optional[int] = None, +) -> dict[str, Any]: + return {"tenant": tenant, "path": item_id, "header": item_hdr, "hversion": hv, "qversion": version} + + +@clash.get("/sorted", operation_id="clashAliases") +def clash_aliases( + tenant: str, + old: Annotated[Optional[str], Query(alias="sort_by", deprecated=True)] = None, + new: Annotated[Optional[str], Query(alias="sortBy")] = None, +) -> dict[str, Optional[str]]: + return {"sort_by": old, "sortBy": new} + + +@clash.post("/notes", operation_id="clashForm") +def clash_form( + tenant: str, + q: Annotated[str, Query(alias="folder_name")], + f: Annotated[str, Form(alias="folderName")], +) -> dict[str, str]: + return {"query": q, "form": f} + + +@app.get("/fetch", tags=["clash"], operation_id="clashLocals") +def clash_locals(url: str, headers: Optional[str] = None, path: Optional[str] = None, query: Optional[str] = None) -> dict[str, Optional[str]]: + return {"url": url, "headers": headers, "path": path, "query": query} + + +app.include_router(clash) diff --git a/testdata/e2e/backends/fastapi/pyproject.toml b/testdata/e2e/backends/fastapi/pyproject.toml new file mode 100644 index 0000000..7d4d0f2 --- /dev/null +++ b/testdata/e2e/backends/fastapi/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "e2e-fastapi" +version = "0" +description = "The FastAPI backend of the e2e tests (internal/e2e)." +requires-python = ">=3.12" +dependencies = [ + "fastapi==0.162.0", + "pydantic[email]==2.14.1", + "python-multipart", + "uvicorn", +] + +[tool.uv] +package = false diff --git a/testdata/e2e/backends/fastapi/uv.lock b/testdata/e2e/backends/fastapi/uv.lock new file mode 100644 index 0000000..c800670 --- /dev/null +++ b/testdata/e2e/backends/fastapi/uv.lock @@ -0,0 +1,313 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, +] + +[[package]] +name = "e2e-fastapi" +version = "0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic", extra = ["email"] }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.162.0" }, + { name = "pydantic", extras = ["email"], specifier = "==2.14.1" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.162.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "starlette" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] diff --git a/testdata/e2e/runners/dart/runner.dart b/testdata/e2e/runners/dart/runner.dart new file mode 100644 index 0000000..999d3ad --- /dev/null +++ b/testdata/e2e/runners/dart/runner.dart @@ -0,0 +1,225 @@ +// Runner of the e2e scenarios for dart-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.dart, written for each run, hands it a +// factory of each client's Configuration and APIs; dart:mirrors finds the +// methods, their named parameters and the models' fromJson/toJson, so it +// runs on the VM (`dart run`), not compiled ahead of time. +import 'dart:convert'; +import 'dart:io'; +import 'dart:mirrors'; + +class Auth { + String? username, password, apiKey, bearer; + + Auth.fromJson(Map? j) + : username = j?['username'], + password = j?['password'], + apiKey = j?['apiKey'], + bearer = j?['bearer']; + + void merge(Auth o) { + username = o.username?.isNotEmpty == true ? o.username : username; + password = o.password?.isNotEmpty == true ? o.password : password; + apiKey = o.apiKey?.isNotEmpty == true ? o.apiKey : apiKey; + bearer = o.bearer?.isNotEmpty == true ? o.bearer : bearer; + } +} + +/// Client is one generated package: its Configuration for a base URL and +/// credentials, and its API objects for a Configuration. +class Client { + Client(this.configuration, this.apis, this.package); + + final Object Function(String base, Auth auth) configuration; + final List Function(Object cfg) apis; + final String package; +} + +String norm(String s) => s.replaceAll(RegExp(r'[^\p{L}\p{N}]', unicode: true), '').toLowerCase(); + +String expand(String s, Map vars, bool json) => + s.replaceAllMapped(RegExp(r'\$\{(\w+)\}'), (m) { + final v = vars[m[1]]; + if (v == null) throw StateError('${m[0]} was never saved'); + if (!json) return v; + final e = jsonEncode(v); + return e.substring(1, e.length - 1); + }); + +/// fromWire turns a wire value into what a parameter of type t takes. +Object? fromWire(TypeMirror t, Object? v) { + if (v == null) return null; + + if (v is Map && (v.containsKey(r'$file') || v.containsKey(r'$bytes'))) { + final c = (v[r'$file'] ?? v[r'$bytes']) as Map; + return c['base64'] != null ? base64Decode(c['base64'] as String) : utf8.encode((c['text'] ?? '') as String); + } + + if (t is ClassMirror && t.typeArguments.isNotEmpty) { + if (t.isSubtypeOf(reflectType(List)) && v is List) { + final item = t.typeArguments.first; + return _typedList(item, v.map((x) => fromWire(item, x)).toList()); + } + + if (t.isSubtypeOf(reflectType(Map)) && v is Map) { + final value = t.typeArguments.last; + return _typedMap(value, {for (final e in v.entries) e.key as String: fromWire(value, e.value)}); + } + } + + if (t is ClassMirror) { + if (t.reflectedType == DateTime && v is String) return DateTime.parse(v); + if (t.reflectedType == double && v is num) return v.toDouble(); + // A model reads JSON with its fromJson constructor, an enum with a + // static fromJson. + if (t.declarations.keys.any((k) => MirrorSystem.getName(k) == '${MirrorSystem.getName(t.simpleName)}.fromJson')) { + return t.newInstance(#fromJson, [v]).reflectee; + } + + if (t.staticMembers.containsKey(#fromJson)) return t.invoke(#fromJson, [v]).reflectee; + } + + return v; +} + +/// _typedList and _typedMap build a List or Map of the +/// parameter's own T, which a plain List would not satisfy. +Object _typedList(TypeMirror item, List values) { + final list = (reflectType(List, [item.reflectedType]) as ClassMirror).newInstance(#empty, [], {#growable: true}).reflectee as List; + for (final x in values) { + list.add(x); + } + return list; +} + +Object _typedMap(TypeMirror value, Map values) { + final map = (reflectType(Map, [String, value.reflectedType]) as ClassMirror).newInstance(Symbol(''), []).reflectee as Map; + values.forEach((k, x) => map[k] = x); + return map; +} + +/// toWire turns a result back into wire JSON. +Object? toWire(Object? v) { + if (v == null || v is String || v is num || v is bool) return v; + if (v is DateTime) return v.toUtc().toIso8601String(); + if (v is List && v is! List) return base64Encode(v); + if (v is List) return v.map(toWire).toList(); + if (v is Map) return {for (final e in v.entries) '${e.key}': toWire(e.value)}; + + final m = reflect(v); + if (m.type.instanceMembers.containsKey(#toJson)) return toWire(m.invoke(#toJson, []).reflectee); + + return v.toString(); +} + +void save(Object? result, Map? paths, Map vars) { + paths?.forEach((name, p) { + Object? v = result; + for (final key in (p as String).split('.').where((k) => k.isNotEmpty)) { + v = v is List ? v[int.parse(key)] : (v is Map ? v[key] : null); + } + vars[name] = v is String ? v : jsonEncode(v); + }); +} + +Future> callCase(Client client, Map suite, Map c, Auth auth, + Map vars) async { + final cfg = client.configuration(suite['base'] as String, auth); + final op = norm(c['op'] as String); + + for (final api in client.apis(cfg)) { + final m = reflect(api); + final method = m.type.instanceMembers.values.where((x) => x.isRegularMethod && norm(MirrorSystem.getName(x.simpleName)) == op); + if (method.isEmpty) continue; + + final mm = method.first; + final args = c['args'] == null ? {} : jsonDecode(expand(jsonEncode(c['args']), vars, true)) as Map; + // The second parameter of one identifier is numbered, as generators do. + final byNorm = {}; + for (final p in (c['params'] as List? ?? []).cast()) { + var key = norm(p); + for (var n = 2; byNorm.containsKey(key); n++) { + key = '${norm(p)}$n'; + } + byNorm[key] = p; + } + final named = {}; + final used = {}; + + for (final p in mm.parameters) { + final field = MirrorSystem.getName(p.simpleName); + final name = byNorm[norm(field)] ?? 'body'; + if (!args.containsKey(name)) continue; + if (!used.add(name)) return {'fail': 'arg $name: fits fields $field and another'}; + named[p.simpleName] = fromWire(p.type, args[name]); + } + + for (final name in args.keys) { + if (!used.contains(name)) return {'fail': 'arg $name: no parameter of ${MirrorSystem.getName(mm.simpleName)} takes it'}; + } + + try { + final result = await (m.invoke(mm.simpleName, [], named).reflectee as Future); + final wire = toWire(result); + save(wire, c['save'] as Map?, vars); + return {'result': wire}; + } catch (e) { + final em = reflect(e); + if (em.type.instanceMembers.containsKey(#statusCode) && em.type.instanceMembers.containsKey(#body)) { + final body = (em.getField(#body).reflectee as String?) ?? ''; + Object? model; + try { + model = jsonDecode(body); + } catch (_) {} + return { + 'error': {'status': em.getField(#statusCode).reflectee, 'body': body, if (model != null) 'model': model}, + }; + } + return {'fail': '$e'}; + } + } + + return {'fail': 'no method for operation ${c['op']}'}; +} + +Future run(String inputPath, Map clients) async { + final input = jsonDecode(File(inputPath).readAsStringSync()) as Map; + + for (final suite in (input['suites'] as List).cast>()) { + final client = clients[suite['name']]!; + final vars = {}; + final auth = Auth.fromJson(suite['auth'] as Map?); + final cases = (suite['cases'] as List).cast>(); + + for (var i = 0; i < cases.length; i++) { + final c = cases[i]; + auth.merge(Auth.fromJson(c['auth'] as Map?)); + + String ex(String? s) { + try { + return expand(s ?? '', vars, false); + } catch (_) { + return s ?? ''; + } + } + + final current = Auth.fromJson({'username': ex(auth.username), 'password': ex(auth.password), 'apiKey': ex(auth.apiKey), 'bearer': ex(auth.bearer)}); + + if (suite['mock'] == true) { + final http = HttpClient(); + final req = await http.postUrl(Uri.parse('${suite['base']}/__e2e/case/$i')); + await (await req.close()).drain(); + http.close(); + } + + Map out; + try { + out = await callCase(client, suite, c, current, vars); + } catch (e, st) { + out = {'fail': 'panic: $e\n$st'}; + } + + stdout.writeln(jsonEncode({'suite': suite['name'], 'case': i, ...out})); + } + } +} diff --git a/testdata/e2e/runners/go/runner.go b/testdata/e2e/runners/go/runner.go new file mode 100644 index 0000000..a0c1fa4 --- /dev/null +++ b/testdata/e2e/runners/go/runner.go @@ -0,0 +1,547 @@ +// 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 + } + + 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() + + return + } + + o.Result = b + 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). +func findMethod(apis []any, op string) (reflect.Value, bool) { + for _, want := range []string{norm(op) + "method", norm(op)} { + for _, api := range apis { + v := reflect.ValueOf(api) + for i := range v.NumMethod() { + if norm(v.Type().Method(i).Name) == want { + return v.Method(i), true + } + } + } + } + + return reflect.Value{}, false +} + +// params fills an operation's Params struct from the args, keyed by the +// spec's names. The field no spec name matches is the body, args["body"]. +func params(t reflect.Type, names []string, args map[string]json.RawMessage) (reflect.Value, error) { + p := reflect.New(t).Elem() + + byField := map[string]string{} + for _, name := range names { + byField[numbered(byField, norm(name))] = name + } + + used := map[string]bool{} + + for i := range t.NumField() { + f := t.Field(i) + + name, isParam := byField[norm(f.Name)] + if !isParam { + name = "body" + } + + raw, ok := args[name] + if !ok { + continue + } + + if used[name] { + return p, fmt.Errorf("arg %s: fits fields %s and another", name, f.Name) + } + + used[name] = true + + if err := set(p.Field(i), raw); err != nil { + return p, fmt.Errorf("arg %s: %w", name, err) + } + } + + for name := range args { + if !used[name] { + return p, fmt.Errorf("arg %s: no field of %s takes it", name, t.Name()) + } + } + + return p, nil +} + +// fileArg is a file or raw body in the args: {"$file": {...}} or +// {"$bytes": {...}}, its content as text or base64. +type fileArg struct { + File *content `json:"$file"` + Bytes *content `json:"$bytes"` +} + +type content struct { + Name string `json:"name"` + Text string `json:"text"` + Base64 string `json:"base64"` +} + +func (c content) data() []byte { + if c.Base64 != "" { + b, _ := base64.StdEncoding.DecodeString(c.Base64) + + return b + } + + return []byte(c.Text) +} + +var readerType = reflect.TypeFor[io.Reader]() + +// set decodes raw into the field: JSON for every type, files and readers +// from their fileArg. +func set(f reflect.Value, raw json.RawMessage) error { + var fa fileArg + _ = json.Unmarshal(raw, &fa) + + switch { + case f.Type() == readerType && fa.Bytes != nil: + f.Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.Bytes.data())))) + + return nil + case isFile(f.Type()): + return setFile(f, raw) + } + + return json.Unmarshal(raw, f.Addr().Interface()) +} + +// isFile reports whether t is a client's File, *File or []File: a struct +// of Name and Body. +func isFile(t reflect.Type) bool { + for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { + t = t.Elem() + } + + if t.Kind() != reflect.Struct || t.NumField() != 2 { + return false + } + + n, nok := t.FieldByName("Name") + b, bok := t.FieldByName("Body") + + return nok && bok && n.Type.Kind() == reflect.String && b.Type == readerType +} + +func setFile(f reflect.Value, raw json.RawMessage) error { + switch f.Kind() { + case reflect.Slice: + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return err + } + + s := reflect.MakeSlice(f.Type(), len(items), len(items)) + for i, item := range items { + if err := setFile(s.Index(i), item); err != nil { + return err + } + } + + f.Set(s) + + return nil + case reflect.Pointer: + v := reflect.New(f.Type().Elem()) + if err := setFile(v.Elem(), raw); err != nil { + return err + } + + f.Set(v) + + return nil + } + + var fa fileArg + if err := json.Unmarshal(raw, &fa); err != nil || fa.File == nil { + return fmt.Errorf("want {\"$file\": ...}, got %s", raw) + } + + f.FieldByName("Name").SetString(fa.File.Name) + f.FieldByName("Body").Set(reflect.ValueOf(io.Reader(bytes.NewReader(fa.File.data())))) + + return nil +} + +// responseError reads a client's *ResponseError by its fields; any other +// error is a failure of the call itself. +func responseError(err error) (*respErr, string) { + for e := err; e != nil; e = errors.Unwrap(e) { + v := reflect.ValueOf(e) + if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct { + continue + } + + s := v.Elem() + status, body, model := s.FieldByName("StatusCode"), s.FieldByName("Body"), s.FieldByName("Model") + + if !status.IsValid() || !body.IsValid() || !model.IsValid() { + continue + } + + r := &respErr{Status: int(status.Int()), Body: string(body.Bytes())} + if !model.IsNil() { + r.Model, _ = json.Marshal(model.Interface()) + } + + return r, "" + } + + return nil, err.Error() +} + +// setBasic sets the basic credentials on the *Configuration, which holds +// them as values. +func setBasic(cfg any, a Auth) { + v := reflect.ValueOf(cfg).Elem() + if f := v.FieldByName("Username"); f.IsValid() { + f.SetString(a.Username) + } + + if f := v.FieldByName("Password"); f.IsValid() { + f.SetString(a.Password) + } +} + +var varRef = regexp.MustCompile(`\$\{(\w+)\}`) + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when inJSON; a name never saved is an error. +func expand(s string, vars map[string]string, inJSON bool) (string, error) { + var missing string + + out := varRef.ReplaceAllStringFunc(s, func(m string) string { + v, ok := vars[m[2:len(m)-1]] + if !ok { + missing = m + + return m + } + + if inJSON { + b, _ := json.Marshal(v) + + return string(b[1 : len(b)-1]) + } + + return v + }) + + if missing != "" { + return "", fmt.Errorf("%s was never saved", missing) + } + + return out, nil +} + +// merge sets the fields a case gives over the ones before; they stay for +// the cases after it. +func merge(a, over Auth) Auth { + for _, f := range []struct{ to, from *string }{ + {&a.Username, &over.Username}, {&a.Password, &over.Password}, {&a.APIKey, &over.APIKey}, {&a.Bearer, &over.Bearer}, + } { + if *f.from != "" { + *f.to = *f.from + } + } + + return a +} + +// expandAuth fills ${name} in the credentials; one not saved yet stays as +// it is, which the server then refuses. +func expandAuth(a Auth, vars map[string]string) Auth { + for _, f := range []*string{&a.Username, &a.Password, &a.APIKey, &a.Bearer} { + if v, err := expand(*f, vars, false); err == nil { + *f = v + } + } + + return a +} + +// save keeps values of the result for later cases: name -> a dotted path +// into it ("items.0.id"), the whole result for "". +func save(result []byte, paths map[string]string, vars map[string]string) { + for name, path := range paths { + var v any + if json.Unmarshal(result, &v) != nil { + continue + } + + for _, key := range strings.Split(path, ".") { + if key == "" { + continue + } + + switch x := v.(type) { + case map[string]any: + v = x[key] + case []any: + i, _ := strconv.Atoi(key) + if i < len(x) { + v = x[i] + } + } + } + + if s, ok := v.(string); ok { + vars[name] = s + } else { + b, _ := json.Marshal(v) + vars[name] = string(b) + } + } +} + +// numbered is key, or key2, key3 when taken: a generator numbers the second +// parameter of one identifier the same way. +func numbered(taken map[string]string, key string) string { + if _, ok := taken[key]; !ok { + return key + } + + for n := 2; ; n++ { + if k := key + strconv.Itoa(n); taken[k] == "" { + return k + } + } +} + +// norm drops all but letters and digits and lowers them, so a spec name +// and the identifier a generator makes of it compare equal. +func norm(s string) string { + var b strings.Builder + + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + } + } + + return b.String() +} diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts new file mode 100644 index 0000000..41d0b96 --- /dev/null +++ b/testdata/e2e/runners/ts/runner.ts @@ -0,0 +1,275 @@ +// Runner of the e2e scenarios for typescript-fetch clients: calls their +// operations as the scenarios say and prints one JSON line per case (see +// testdata/e2e/README.md). main.ts, written for each run, hands it the +// clients and the directories of their sources. Node's own fetch, FormData +// and File carry the requests. Types come from the clients' sources: the +// Request interfaces and the method signatures say which model's +// FromJSON/ToJSON turns wire JSON into the client's objects and back. + +declare const require: any; +declare const process: any; +declare const Buffer: any; + +const fs = require("fs"); +const path = require("path"); + +type Auth = { username?: string; password?: string; apiKey?: string; bearer?: string }; + +type Case = { op: string; params: string[]; args?: Record; auth?: Auth; save?: Record }; + +type Suite = { name: string; base: string; mock: boolean; auth?: Auth; cases: Case[]; schemes?: Record }; + +type Client = { module: any; dir: string }; + +type Method = { name: string; api: string; request?: string; result: string }; + +const norm = (s: string): string => s.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase(); + +// methods reads the typed method of each operation from the API sources: +// its name, the API class, the Request interface it takes and what it +// returns. +function methods(dir: string): { methods: Method[]; requests: Record> } { + const out: Method[] = []; + const requests: Record> = {}; + + for (const file of fs.readdirSync(path.join(dir, "apis"))) { + const src: string = fs.readFileSync(path.join(dir, "apis", file), "utf8"); + const api = /export class (\w+) extends runtime\.BaseAPI/.exec(src)?.[1]; + + for (const m of src.matchAll(/export interface (\w+) \{([\s\S]*?)\n\}/g)) { + const fields: Record = {}; + for (const f of m[2].matchAll(/^\s+(\w+)\??: (.+);$/gm)) { + fields[f[1]] = f[2]; + } + + requests[m[1]] = fields; + } + + if (!api) { + continue; + } + + for (const m of src.matchAll(/^ async (\w+)\((?:requestParameters: (\w+)(?: = \{\})?, )?initOverrides\?: [^)]*\): Promise<(.*)> \{$/gm)) { + if (!m[1].endsWith("Raw")) { + out.push({ name: m[1], api, request: m[2], result: m[3] }); + } + } + } + + return { methods: out, requests }; +} + +const nullable = (t: string): string => t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); + +// fromWire turns a wire value into what the client takes for type t. +function fromWire(client: any, t: string, v: any): any { + t = nullable(t); + if (v === null || v === undefined) { + return v; + } + + if (v && typeof v === "object" && (v.$file || v.$bytes)) { + const c = v.$file ?? v.$bytes; + const data = c.base64 !== undefined ? Buffer.from(c.base64, "base64") : Buffer.from(c.text ?? "", "utf8"); + + return v.$file ? new File([data], c.name) : new Blob([data]); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return v.map((x) => fromWire(client, m![1], x)); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, m![1], x)])); + } + + if (t === "Date") { + return new Date(v); + } + + const from = client[t + "FromJSON"]; + + return typeof from === "function" ? from(v) : v; +} + +// toWire turns a client's result of type t back into wire JSON. +async function toWire(client: any, t: string, v: any): Promise { + t = nullable(t); + if (v === null || v === undefined) { + return v ?? null; + } + + if (v instanceof Blob) { + return Buffer.from(await v.arrayBuffer()).toString("base64"); + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { + return Promise.all(v.map((x) => toWire(client, m![1], x))); + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { + const out: Record = {}; + for (const [k, x] of Object.entries(v)) { + out[k] = await toWire(client, m[1], x); + } + + return out; + } + + if (v instanceof Date) { + return v.toISOString(); + } + + const to = client[t + "ToJSON"]; + + return typeof to === "function" ? to(v) : v; +} + +// expand replaces ${name} with a value a case before saved, escaped for a +// JSON string when json; a name never saved is an error. +function expand(s: string, vars: Record, json: boolean): string { + return s.replace(/\$\{(\w+)\}/g, (ref, name) => { + if (!(name in vars)) { + throw new Error(ref + " was never saved"); + } + + return json ? JSON.stringify(vars[name]).slice(1, -1) : vars[name]; + }); +} + +function save(result: any, paths: Record | undefined, vars: Record) { + for (const [name, p] of Object.entries(paths ?? {})) { + let v = result; + for (const key of p.split(".").filter(Boolean)) { + v = v?.[Array.isArray(v) ? Number(key) : key]; + } + + vars[name] = typeof v === "string" ? v : JSON.stringify(v); + } +} + +async function callCase(client: Client, info: ReturnType, suite: Suite, c: Case, auth: Auth, vars: Record) { + const cfg = new client.module.Configuration({ + basePath: suite.base, + username: auth.username, + password: auth.password, + apiKey: () => auth.apiKey ?? "", + // typescript-fetch sends an OAuth2 token as the whole Authorization + // header and prefixes only an HTTP bearer one: the function returns + // "Bearer ..." for OAuth2, as its users write it. + accessToken: (name?: string) => { + const oauth = ["oauth2", "openIdConnect"].includes(suite.schemes?.[name ?? ""] ?? ""); + + return auth.bearer ? (oauth ? "Bearer " : "") + auth.bearer : ""; + }, + }); + + const want = [norm(c.op) + "operation", norm(c.op) + "method", norm(c.op)]; + const m = want.map((w) => info.methods.find((x) => norm(x.name) === w)).find(Boolean); + if (!m) { + return { fail: "no method for operation " + c.op }; + } + + const api = new client.module[m.api](cfg); + const args = c.args ? JSON.parse(expand(JSON.stringify(c.args), vars, true)) : {}; + + const fields = m.request ? (info.requests[m.request] ?? {}) : {}; + const byNorm: Record = {}; + // The second parameter of one identifier is numbered, as generators do. + for (const p of c.params ?? []) { + let key = norm(p); + for (let n = 2; key in byNorm; n++) { + key = norm(p) + n; + } + + byNorm[key] = p; + } + + const params: Record = {}; + const used = new Set(); + + for (const [field, type] of Object.entries(fields)) { + const name = byNorm[norm(field)] ?? "body"; + if (!(name in args)) { + continue; + } + + if (used.has(name)) { + return { fail: `arg ${name}: fits fields ${field} and another` }; + } + + used.add(name); + params[field] = fromWire(client.module, type, args[name]); + } + + for (const name of Object.keys(args)) { + if (!used.has(name)) { + return { fail: `arg ${name}: no field of ${m.request ?? m.name} takes it` }; + } + } + + try { + const result = m.request ? await api[m.name](params) : await api[m.name](); + const wire = m.result === "void" ? null : await toWire(client.module, m.result, result); + save(wire, c.save, vars); + + return { result: wire ?? null }; + } catch (e: any) { + if (e?.response && typeof e.response.status === "number") { + const body = await e.response.text(); + let model: any; + try { + model = JSON.parse(body); + } catch { + model = undefined; + } + + return { error: { status: e.response.status, model, body } }; + } + + return { fail: String(e?.cause ?? e) }; + } +} + +export async function run(clients: Record) { + const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + + for (const suite of input.suites as Suite[]) { + const client = clients[suite.name]; + const info = methods(client.dir); + const vars: Record = {}; + let auth: Auth = { ...(suite.auth ?? {}) }; + + for (const [i, c] of suite.cases.entries()) { + for (const [k, v] of Object.entries(c.auth ?? {})) { + if (v) { + (auth as any)[k] = v; + } + } + + const current: Auth = {}; + for (const [k, v] of Object.entries(auth)) { + try { + (current as any)[k] = expand(v ?? "", vars, false); + } catch { + (current as any)[k] = v; + } + } + + if (suite.mock) { + await fetch(`${suite.base}/__e2e/case/${i}`, { method: "POST" }); + } + + let out: any; + try { + out = await callCase(client, info, suite, c, current, vars); + } catch (e: any) { + out = { fail: "panic: " + String(e?.stack ?? e) }; + } + + process.stdout.write(JSON.stringify({ suite: suite.name, case: i, ...out }) + "\n"); + } + } +} diff --git a/testdata/e2e/scenarios/additional-properties.yaml b/testdata/e2e/scenarios/additional-properties.yaml new file mode 100644 index 0000000..b74ef23 --- /dev/null +++ b/testdata/e2e/scenarios/additional-properties.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/additional-properties-with-properties.yaml +cases: + - name: extra keys both ways + op: recordEvent + args: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + request: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } + response: { json: { player: p, round1: 3, round2: 5 } } + result: { player: p, round1: 3, round2: 5 } + skip: { dart-fetch: "drops the keys next to the declared properties (additionalProperties); a DateTime goes out with .000Z", typescript-fetch: "drops the keys next to the declared properties (additionalProperties); a Date goes out with .000Z" } diff --git a/testdata/e2e/scenarios/discriminator.yaml b/testdata/e2e/scenarios/discriminator.yaml new file mode 100644 index 0000000..ff97fb8 --- /dev/null +++ b/testdata/e2e/scenarios/discriminator.yaml @@ -0,0 +1,4 @@ +spec: ../../specs/feature/discriminator-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected an identifier." } +cases: + - { name: mapped value, op: getPet, response: { json: { petType: canine, packSize: 4 } }, result: { petType: canine, packSize: 4 } } diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml new file mode 100644 index 0000000..7227035 --- /dev/null +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -0,0 +1,591 @@ +# The FastAPI backend (testdata/e2e/backends/fastapi), called through a +# client generated from the spec it serves. Most endpoints echo what they +# parsed, so a result shows what the server received. Cases run in order and +# share the server's state: save keeps a value for ${name} in later cases. +backend: fastapi +skip: { dart-fetch: "does not compile: lib/src/apis/auth_api.dart:24: Expected an identifier." } +auth: { username: admin, password: "p@ss:w0rd", apiKey: key-1 } +cases: + # ------------------------------------------------------------ auth + - name: password form, bad login + op: issueToken + args: { username: alice, password: nope } + error: { status: 400, model: null, body: '{"detail":"bad login"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: password form with a scope + op: issueToken + args: { username: bob, password: secret, scope: "items:read" } + result: { access_token: tok-r, scope: "items:read" } + save: { token: access_token } + - name: api key and optional bearer + op: whoami + auth: { bearer: "${token}" } + result: { key_in: header, bearer: tok-r } + - name: basic auth with a colon in the password + op: adminStats + result: { items: 0, orders: 0 } + + # ------------------------------------------------------------ scalars and containers + - name: every scalar format + op: echoScalars + args: + body: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + b: true + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + result: + i: -7 + i64: 4611686018427387905 + f: 0.1 + d: "12345678901234567890.123456789" + s: 'žluťoučký kůň "quoted" \ /' + when: "2024-02-29T23:59:58.123456+05:30" + day: "2024-01-31" + clock: "13:45:30" + span: PT1H30M + id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + email: a.b+c@example.com + url: https://example.com/a?b=1 + ip: 10.0.0.1 + blob: aGVsbG8Ad29ybGQ= + anything: { x: [1, y, null] } + color: light green + priority: 10 + unit: lb + skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + - name: literals, string members and defaults + op: echoLiterals + args: { body: { retry: always, pin: x, order: asc } } + result: { retry: always, pin: x, order: asc, mode: auto, tag: a, size: 25 } + - name: literals, int members of a mixed enum + op: echoLiterals + args: { body: { retry: 0, pin: x, order: null, mode: 1, size: 50 } } + result: { retry: 0, pin: x, order: null, mode: 1, tag: a, size: 50 } + - name: literals, null as an enum member + op: echoLiterals + args: { body: { retry: 1, pin: null, order: desc, tag: null } } + result: { retry: 1, pin: null, order: desc, tag: null, mode: auto, size: 25 } + - name: required nullables all null, optional nullables left out + op: echoNulls + args: { body: { note: null, due: null, color: null, dims: null, lines: null, payment: null } } + result: + note: null + due: null + color: null + dims: null + lines: null + payment: null + retries: 3 + extras: null + sent: [color, dims, due, lines, note, payment] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: required nullables set, falsy values, explicit null over a default + op: echoNulls + args: + body: { note: "", due: "2024-02-29", color: "", dims: { widthCm: 1, heightCm: 2 }, lines: [], payment: { method: transfer, iban: SK31 }, retries: null, extras: null } + result: + note: "" + due: "2024-02-29" + color: "" + dims: { widthCm: 1, heightCm: 2 } + lines: [] + payment: { method: transfer, iban: SK31 } + retries: null + extras: null + sent: [color, dims, due, extras, lines, note, payment, retries] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } + - name: naive, aware and contained times round trip + op: echoStamps + args: + body: + naive: "2024-03-01T10:00:00.5" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.25+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + result: + naive: "2024-03-01T10:00:00.500000" + aware: "2024-03-01T10:00:00+05:30" + clock: "13:45:30.250000+02:00" + span: "-PT1.5S" + days: ["2024-01-31", "2024-12-31"] + seen: { a: "2024-03-01T10:00:00Z" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)" } + - name: an offset on a naive field is refused by the server + op: echoStamps + args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } + error: { status: 422, model: { detail: [{ type: timezone_naive }] } } + - name: a naive time in the response and the query, a date list in the query + op: stampsQuery + args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } + result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } + skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z" } + - name: containers, empty and null + op: echoContainers + args: + body: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + result: + tags: [] + unique: [a] + pair: [1, one] + matrix: [[1, 2], [], [3]] + sparse: [1, null, 3] + counts: { "a b": 1, "": 0 } + groups: { g: [x, y] } + by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } + maybe_list: null + maybe_map: { k: null, v: "1" } + skip: { typescript-fetch: "a tuple (prefixItems) renders an undefined FromJSON" } + - name: Go keywords and odd property names + op: echoKeywords + args: + body: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + result: { type: t, func: 1, range: [1, 2], map: { m: 1 }, interface: null, package: p, default: true, select: s, string: str, error: e, x-dash-name: d, dot.name: ".", UPPER: U, 1st: "1" } + - name: extra keys both ways + op: echoOpen + args: { body: { kind: k, extra: 1, nested: { a: [true] } } } + result: { kind: k, extra: 1, nested: { a: [true] } } + skip: { typescript-fetch: "drops the keys next to the declared properties (additionalProperties)" } + - name: recursive tree + op: echoTree + args: { body: { name: root, children: [{ name: a, children: [{ name: a1 }] }], parent: { name: up } } } + result: { name: root, children: [{ name: a, children: [{ name: a1, children: [] }] }], parent: { name: up } } + - name: writeOnly secret + op: echoSecret + args: { body: { username: u, password: "12345" } } + result: { username: u, password_length: 5 } + + # ------------------------------------------------------------ bodies + - name: array body + op: echoList + args: { body: [{ widthCm: 1, heightCm: 2 }] } + result: [{ widthCm: 1, heightCm: 2 }] + - name: map body + op: echoMap + args: { body: { a: [1, 2], b: [] } } + result: { a: 3, b: 0 } + - name: number body + op: echoNumber + args: { body: 2.25 } + result: 4.5 + - name: integers past int64 + op: echoWide + args: { body: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } } + result: { big: 100000000000000000000, u64: 18446744073709551615, f: 1e+308, tiny: 1e-7 } + skip: + go-fetch: a plain integer is an int, which holds no value past int64 + typescript-fetch: a JavaScript number loses integers past 2^53 + - name: exponent floats + op: echoWide + args: { body: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } } + result: { big: -1, u64: 0, f: 5e-324, tiny: 3e-7 } + - name: text/plain body + op: echoText + args: { body: héllo } + result: olléh + - name: octet-stream body + op: echoRaw + args: { body: { $bytes: { base64: AAH/ } } } + result: /wEA + - name: union body, int + op: echoUnion + args: { body: 5 } + result: { type: int, value: "5" } + - name: union body, string + op: echoUnion + args: { body: x } + result: { type: str, value: x } + - name: union body, list + op: echoUnion + args: { body: [1, 2] } + result: { type: list, value: "[1, 2]" } + # A discriminated union as the whole body, a nullable field, map values + # and list items, behind a camelCase alias, and a callable discriminator. + - name: union body, the second member + op: echoPet + args: { body: { petKind: dog, goodBoy: true } } + result: { petKind: dog, goodBoy: true } + - name: union body, the first member + op: echoPet + args: { body: { petKind: cat, lives: 9 } } + result: { petKind: cat, lives: 9 } + - name: union body of the wrong shape is refused + op: echoPet + args: { body: { petKind: cat, goodBoy: true } } + error: { status: 422 } + - name: unions as a nullable field, map values, list items and by a callable + op: echoWallet + args: + body: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + result: + primary: { method: voucher, code: X, amount: "1.50" } + by_label: { a: { method: card, last4: "1234", expires: "2030-01-31" }, b: { method: transfer, iban: DE00 } } + pets: [{ petKind: cat, lives: 1 }] + tagged: { method: transfer, iban: DE11 } + - name: an explicit null union + op: echoWallet + args: { body: { primary: null } } + result: { primary: null, by_label: {}, pets: [] } + - name: unset unions are left out + op: echoWallet + args: { body: {} } + result: { primary: null, tagged: null } + - name: optional body left out + op: echoOptional + result: { got: false } + - name: optional body given + op: echoOptional + args: { body: { widthCm: 1, heightCm: 1 } } + result: { got: true } + + # ------------------------------------------------------------ parameters + - name: path int, date, enum with a space, path with slashes + op: pathParams + args: { id: -3, day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + result: { id: "-3", day: "2024-12-24", color: light green, rest: "dir/a b/c%d" } + - name: query values that need escaping, arrays, offset time + op: queryParams + args: + q: "a&b=c+d ž" + page-size: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + result: + q: "a&b=c+d ž" + pageSize: 5 + tags: [x, "y,z"] + ids: [1, 2] + colors: [red, light green] + since: "2024-05-01T10:00:00+02:00" + ratio: 0.1 + active: false + old: null + skip: { typescript-fetch: "a Date comes back in UTC" } + - name: query, optional ones left out + op: queryParams + args: { q: only } + result: { q: only, pageSize: 20, tags: [], active: null } + - name: headers and two cookies + op: headerParams + args: + x-request-id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 + x-retry: 3 + x-flags: [a] + accept-language: "sk-SK,en;q=0.5" + tenant: t1 + theme: dark + result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } + skip: { typescript-fetch: "no cookie parameters" } + - name: list headers, one item each + op: headerLists + args: { x-tags: [a], x-ids: [7], x-colors: [dark-blue] } + result: { tags: [a], ids: [7], colors: [dark-blue] } + - name: list headers, two items each + op: headerLists + args: { x-tags: [a, b], x-ids: [1, 2], x-colors: [red, light green] } + result: { tags: [a, b], ids: [1, 2], colors: [red, light green] } + skip: + go-fetch: &listHeader "a list header goes as one comma-joined line, as the spec's simple style says; FastAPI reads repeated lines only (422 on \"1,2\")" + typescript-fetch: *listHeader + + # ------------------------------------------------------------ items + - name: read-only token cannot write + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "19.99" } } + error: { status: 403 } + - name: token with write scopes + op: issueToken + args: { username: alice, password: secret, scope: "items:read items:write orders" } + save: { token: access_token } + - name: create an item + op: createItem + auth: { bearer: "${token}" } + args: + body: + sku: ABC-0001 + name: Box + price: "19.99" + description: small + color: dark-blue + tags: [a] + dimensions: { widthCm: 10, heightCm: 5 } + supplier: { name: ACME, website: https://acme.example/ } + attributes: { material: wood } + result: + sku: ABC-0001 + price: "19.99" + version: 1 + displayName: Box (ABC-0001) + supplier: { name: ACME, website: https://acme.example/ } + dimensions: { widthCm: 10, heightCm: 5 } + id: $any + createdAt: $any + save: { item: id } + skip: { typescript-fetch: "runner: ToJSON leaves out a result's readOnly properties" } + - name: create a second item, price as a number + op: createItem + args: { body: { sku: ABC-0002, name: Bag, price: 5 } } + result: { sku: ABC-0002, price: "5" } + - name: duplicate SKU is a 409 Problem + op: createItem + args: { body: { sku: ABC-0001, name: Box, price: "1" } } + error: { status: 409, model: { status: 409, title: SKU taken, detail: ABC-0001 } } + - name: page and cursor + op: listItems + args: { limit: 1 } + result: { items: [{ sku: ABC-0001 }], total: 2, next_cursor: "1" } + - name: enum and decimal filters + op: listItems + args: { color: dark-blue, min_price: "10.5" } + result: { items: [{ sku: ABC-0001 }], total: 1 } + - name: get the item + op: getItem + args: { item_id: "${item}" } + result: { id: $any, sku: ABC-0001, description: small, color: dark-blue, attributes: { material: wood } } + - name: unknown item is a 404 Problem + op: getItem + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404, model: { status: 404, title: Not found } } + - name: patch clears with null, sends an empty list, keeps the rest + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { description: null, tags: [] } } + result: { description: null, tags: [], name: Box, version: 2, updatedAt: $any } + - name: stale version is a 409 Conflict + op: patchItem + args: { item_id: "${item}", if-match: 1, body: { name: x } } + error: { status: 409, model: { message: stale, current_version: 2 } } + - name: undeclared 400 keeps the body + op: patchItem + args: { item_id: "${item}", if-match: 2, body: { name: null } } + error: { status: 400, model: null, body: '{"detail":"name may not be null"}' } + skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } + - name: HEAD of an item that exists + op: itemExists + args: { item_id: "${item}" } + skip: { typescript-fetch: "fails on the empty body of a HEAD" } + - name: HEAD of one that does not + op: itemExists + args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } + error: { status: 404 } + + # ------------------------------------------------------------ files + - name: two files, an array field and JSON text + op: uploadFiles + args: + files: [{ $file: { name: a b.txt, text: first } }, { $file: { name: ž.bin, base64: AP8= } }] + folder: docs + labels: [x, y] + meta: '{"k":1}' + result: [{ name: docs/a b.txt, size: 5, labels: "x|y", meta: '{"k":1}' }, { name: docs/ž.bin, size: 2 }] + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } + - name: duplicate file is a 409 + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } + error: { status: 409 } + - name: bool form field + op: uploadFiles + args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs, overwrite: true } + result: [{ name: docs/a b.txt, size: 5 }] + - name: typed form, lists as repeated keys + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + result: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: typed form, defaults left out, a number for a Decimal + op: submitForm + args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } + result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } + skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } + - name: a form model with files, declared url-encoded + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }, { $file: { name: b.txt, text: de } }], labels: [x, y] } + result: { folder: docs, files: { a.txt: 3, b.txt: 2 }, labels: [x, y], cover: null } + skip: { typescript-fetch: &urlFile "a form declared url-encoded goes as URLSearchParams even with a file, a Blob as text" } + - name: a form model's optional file given + op: uploadModel + args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }], cover: { $file: { name: c.png, text: C } } } + result: { files: { a.txt: 3 }, cover: c.png } + skip: { typescript-fetch: *urlFile } + - name: optional file left out + op: uploadAvatar + args: { image: { $file: { name: i.png, text: "12345" } } } + result: { image: 5, thumbnail: -1 } + - name: download by a path with a slash and unicode + op: downloadFile + args: { name: docs/ž.bin } + result: AP8= + - name: text/csv response + op: exportCsv + result: "sku,name\nABC-0001,Box\nABC-0002,Bag\n" + + # ------------------------------------------------------------ orders + - name: unions round trip + op: placeOrder + args: + idempotency-key: k1 + body: + lines: [{ sku: ABC-0001, quantity: 2, note: fragile }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + result: + id: 1 + status: placed + payment: { method: card, last4: "4242", expires: "2030-01-01" } + shape: { width: 1, height: 2 } + gift: wrap it + code: 7 + extraPayments: [{ method: transfer, iban: SK31 }, { method: voucher, code: V, amount: "2.50" }] + - name: a 202 with another body than the 201 + op: placeOrder + args: + body: + lines: [{ sku: ABC-0001, quantity: 500 }] + payment: { method: card, last4: "4242", expires: "2030-01-01" } + error: { status: 202, model: { title: Queued for review, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - name: get the order + op: getOrder + args: { order_id: 1 } + result: { id: 1, extraPayments: [{ method: transfer }, { method: voucher }] } + - name: enum response + op: orderStatus + args: { order_id: 1 } + result: placed + - name: 202 without a body, embedded body field + op: cancelOrder + args: { order_id: 1, body: { reason: changed my mind } } + skip: { typescript-fetch: "fails on a 2xx without a body" } + - name: a bound on a path parameter is a 422 + op: getOrder + args: { order_id: 0 } + error: { status: 422, model: { detail: [{ loc: [path, order_id] }] } } + + # ------------------------------------------------------------ misc + - name: recursive response + op: categoryTree + result: [{ name: root, children: [{ name: a, children: [{ name: a1 }] }, { name: b }] }] + - name: integer response + op: countItems + result: 2 + - name: nullable response present + op: maybeItem + args: { present: true } + result: { widthCm: 1, heightCm: 2 } + - name: nullable response null + op: maybeItem + result: null + - name: deprecated operation with FastAPI's operationId + op: legacy_endpoint_legacy_get + args: { x: 9 } + result: { x: 9 } + - name: 307 redirect followed + op: redirectToCount + - name: ndjson stream next to an empty JSON schema + op: streamLines + args: { n: 2 } + result: "{\"i\":0}\n{\"i\":1}\n" + skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } + - name: 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 } + result: 3 + - name: untagged operation + op: untagged + result: { ok: true } + + # ------------------------------------------------------------ name clashes + - name: one camel name for a path parameter and a header, a query and a header + op: clashLocations + args: { tenant: acme, item_id: 7, Item-Id: h-7, Version: "2", version: 3 } + result: { tenant: acme, path: 7, header: h-7, hversion: "2", qversion: 3 } + - name: a snake and a camel alias of one query parameter + op: clashAliases + args: { tenant: acme, sort_by: a, sortBy: b } + result: { sort_by: a, sortBy: b } + - name: a query parameter and a form field of one camel name + op: clashForm + args: { tenant: acme, folder_name: q, folderName: f } + result: { query: q, form: f } + - name: query parameters named like a client's locals + op: clashLocals + args: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + result: { url: "https://x.example/?a=1", headers: h, path: p, query: q } + + # ------------------------------------------------------------ cleanup + - name: delete is a 204 + op: deleteItem + args: { item_id: "${item}" } + - name: delete again is a 404 + op: deleteItem + args: { item_id: "${item}" } + error: { status: 404 } diff --git a/testdata/e2e/scenarios/nullable-openapi31.yaml b/testdata/e2e/scenarios/nullable-openapi31.yaml new file mode 100644 index 0000000..0ad15dc --- /dev/null +++ b/testdata/e2e/scenarios/nullable-openapi31.yaml @@ -0,0 +1,16 @@ +spec: ../../specs/feature/nullable-openapi31.yaml +skip: { dart-fetch: "does not compile: lib/src/models/maybe_name.dart:13: Expected an identifier." } +cases: + - name: 3.1 null members, a nullable query and an enum array + op: listPets + args: { q: mi, size: [1, 3] } + request: { query: { q: mi, size: ["1", "3"] } } + response: { json: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] } + result: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] + skip: { typescript-fetch: "null and absent are one to it for an optional nullable array" } + - name: contentMediaType file and multipart arrays + op: uploadPhoto + args: { id: "1", file: { $file: { name: p.png, base64: iVBORw== } }, caption: hi, tags: [a, b], labels: [x, y] } + request: { body: { file: [{ filename: p.png, base64: iVBORw== }], caption: [hi], tags: [a, b], labels: ["x,y"] } } + response: { status: 204 } + skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } diff --git a/testdata/e2e/scenarios/nullable.yaml b/testdata/e2e/scenarios/nullable.yaml new file mode 100644 index 0000000..afc8bc4 --- /dev/null +++ b/testdata/e2e/scenarios/nullable.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/nullable-required.yaml +skip: { dart-fetch: "does not compile: lib/src/models/record.dart:26: Expected an identifier." } +cases: + - name: required null, optional null and absent + op: createRecord + args: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5 } } + request: { body: { requiredNotNullable: a, requiredNullable: null, optionalNullable: null, requiredNullableInt: 5, optionalNotNullable: $absent } } + response: { json: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null } } + result: { requiredNotNullable: a, requiredNullable: null, requiredNullableInt: null, optionalNullable: $absent } diff --git a/testdata/e2e/scenarios/parameters-deepobject.yaml b/testdata/e2e/scenarios/parameters-deepobject.yaml new file mode 100644 index 0000000..db17ffa --- /dev/null +++ b/testdata/e2e/scenarios/parameters-deepobject.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-deepobject.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - op: deepSearch + args: { filter: { name: "a b&c", minPrice: 9.5 } } + request: { query: { "filter[name]": "a b&c", "filter[minPrice]": "9.5" } } + response: { json: [found] } + result: [found] diff --git a/testdata/e2e/scenarios/parameters-locations.yaml b/testdata/e2e/scenarios/parameters-locations.yaml new file mode 100644 index 0000000..90c63b1 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -0,0 +1,20 @@ +spec: ../../specs/feature/parameters-locations.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:76: Expected to find '>'." } +cases: + - name: path, query, header and cookie + op: getItem + args: { orgId: "acme/eu", itemId: 42, verbose: false, X-Request-Id: r-1, sessionId: s-1 } + request: + path: /orgs/acme/eu/items/42 + query: { verbose: "false" } + header: { X-Request-Id: r-1 } + cookie: { sessionId: s-1 } + response: { json: { id: "42" } } + result: { id: "42" } + skip: { typescript-fetch: "no cookie parameters" } + - name: optional ones left out + op: getItem + args: { orgId: o, itemId: 1 } + request: { query: { verbose: $absent }, header: { X-Request-Id: $absent }, cookie: { sessionId: $absent } } + response: { json: {} } + result: {} diff --git a/testdata/e2e/scenarios/parameters-object-styles.yaml b/testdata/e2e/scenarios/parameters-object-styles.yaml new file mode 100644 index 0000000..2a693c4 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-object-styles.yaml @@ -0,0 +1,43 @@ +# Objects and arrays in every parameter style, against the OpenAPI +# serialization table (color = {R: 100, G: 200, B: 150}). +spec: ../../specs/feature/parameters-object-styles.yaml +cases: + - name: every style + op: paint + args: + a: { R: 100, G: 200, B: 150 } + b: { R: 100, G: 200, B: 150 } + c: { R: 100, G: 200, B: 150 } + d: { R: 100, G: 200, B: 150 } + e: { R: 100, G: 200, B: 150 } + f: { R: 100, G: 200, B: 150 } + g: [blue, black] + q: { R: 100, G: 200, B: 150 } + X-Color: { R: 100, G: 200, B: 150 } + X-Color-Exploded: { R: 100, G: 200, B: 150 } + X-Tags: [x, y] + prefs: [p, q] + request: + method: GET + path: "/simple/B,150,G,200,R,100/B=150,G=200,R=100/label/.B,150,G,200,R,100/.B=150.G=200.R=100/matrix/;e=B,150,G,200,R,100/;B=150;G=200;R=100/;g=blue;g=black" + query: { q: "B,150,G,200,R,100" } + header: { X-Color: "B,150,G,200,R,100", X-Color-Exploded: "B=150,G=200,R=100", X-Tags: "x,y" } + cookie: { prefs: "p,q" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } + - name: optional ones left out + op: paint + args: + a: { R: 1, G: 2, B: 3 } + b: { R: 1, G: 2, B: 3 } + c: { R: 1, G: 2, B: 3 } + d: { R: 1, G: 2, B: 3 } + e: { R: 1, G: 2, B: 3 } + f: { R: 1, G: 2, B: 3 } + g: [a] + request: + query: { q: $absent } + header: { X-Color: $absent, X-Tags: $absent } + cookie: { prefs: $absent } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } diff --git a/testdata/e2e/scenarios/parameters-objects-in-query.yaml b/testdata/e2e/scenarios/parameters-objects-in-query.yaml new file mode 100644 index 0000000..9e18a24 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-objects-in-query.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/parameters-explode-object.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:21: Expected to find '}'." } +cases: + - name: exploded map, free-form object, deepObject + op: listItems + args: { labels: { color: red, size: L }, extra: { a: "1" }, range: { min: 1, max: 5 } } + request: { query: { color: red, size: L, a: "1", "range[min]": "1", "range[max]": "5" } } + response: { status: 204 } diff --git a/testdata/e2e/scenarios/parameters-path-style.yaml b/testdata/e2e/scenarios/parameters-path-style.yaml new file mode 100644 index 0000000..a1b3eca --- /dev/null +++ b/testdata/e2e/scenarios/parameters-path-style.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/parameters-path-style.yaml +cases: + - { op: getSimplePath, args: { id: [a, b] }, request: { path: "/simple/a,b" }, response: { status: 200 }, skip: { dart-fetch: "request.path: want \"/simple/a,b\", got \"/simple/[a, b]\"" } } + - { op: getMatrixPath, args: { id: [a, b] }, request: { path: "/matrix/;id=a,b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \";id=\"", typescript-fetch: "no matrix path style" } } + - { op: getLabelPath, args: { id: [a, b] }, request: { path: "/label/.a.b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \".\"", typescript-fetch: "no label path style" } } diff --git a/testdata/e2e/scenarios/parameters-scalar-formats.yaml b/testdata/e2e/scenarios/parameters-scalar-formats.yaml new file mode 100644 index 0000000..0d56d27 --- /dev/null +++ b/testdata/e2e/scenarios/parameters-scalar-formats.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/parameters-scalar-formats.yaml +cases: + - name: shortest exact text of each scalar + op: search + args: { f32: 0.1, f64: 1e21, n: 0.30000000000000004, i64: 4611686018427387905, ok: false, day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + request: + query: { f32: "0.1", f64: "1000000000000000000000", n: "0.30000000000000004", i64: "4611686018427387905", ok: "false", day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\": string doesn't match pattern \"^[0-9]{4}-(0[1-9]|10", typescript-fetch: "JavaScript numbers and Date: no int64, 1e21 as 1e+21, times in UTC with milliseconds" } diff --git a/testdata/e2e/scenarios/parameters.yaml b/testdata/e2e/scenarios/parameters.yaml new file mode 100644 index 0000000..4984ecd --- /dev/null +++ b/testdata/e2e/scenarios/parameters.yaml @@ -0,0 +1,13 @@ +# Parameters in each location and style; the request is as the mock decoded +# it (path and query values unescaped). +spec: ../../specs/feature/parameters-array-style-explode.yaml +cases: + - name: array styles in query and path + op: filterItems + args: { pathIds: [a, b], formExplodeTrue: [x, y], formExplodeFalse: [x, y], spaceDelimited: [1, 2], pipeDelimited: [3, 4] } + request: + path: "/filter/a,b" + query: { formExplodeTrue: [x, y], formExplodeFalse: "x,y", spaceDelimited: "1 2", pipeDelimited: "3|4" } + response: { json: [ok] } + result: [ok] + skip: { dart-fetch: "request.path: want \"/filter/a,b\", got \"/filter/[a, b]\"", typescript-fetch: "explode false, spaceDelimited and pipeDelimited go out as an exploded list" } diff --git a/testdata/e2e/scenarios/request-bodies.yaml b/testdata/e2e/scenarios/request-bodies.yaml new file mode 100644 index 0000000..c1b7aa5 --- /dev/null +++ b/testdata/e2e/scenarios/request-bodies.yaml @@ -0,0 +1,18 @@ +spec: ../../specs/feature/request-body-json.yaml +cases: + - name: model body and result + op: createItem + args: { body: { name: box, quantity: 2 } } + request: { method: POST, header: { Content-Type: application/json }, body: { name: box, quantity: 2 } } + response: { json: { name: box, quantity: 2 } } + result: { name: box, quantity: 2 } + - name: array body + op: createItems + args: { body: [{ name: a }, { name: b, quantity: 0 }] } + request: { body: [{ name: a }, { name: b, quantity: 0 }] } + response: { status: 200 } + - name: integer body + op: setRawCount + args: { body: 7 } + request: { body: 7 } + response: { status: 200 } diff --git a/testdata/e2e/scenarios/request-body-forms.yaml b/testdata/e2e/scenarios/request-body-forms.yaml new file mode 100644 index 0000000..76b045b --- /dev/null +++ b/testdata/e2e/scenarios/request-body-forms.yaml @@ -0,0 +1,26 @@ +spec: ../../specs/feature/request-body-form-encoding.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected to find '}'." } +cases: + - name: urlencoded fields by their encoding + op: createCustomer + args: + name: Ann + address: { city: Oslo, lines: [a, b] } + metadata: { plan: gold } + expand: [x, y] + tags: [t1, t2] + ids: [1, 2] + request: + header: { Content-Type: application/x-www-form-urlencoded } + body: + name: Ann + "address[city]": Oslo + "address[lines][0]": a + "address[lines][1]": b + "metadata[plan]": gold + "expand[0]": x + "expand[1]": y + tags: "t1,t2" + ids: ["1", "2"] + response: { status: 204 } + skip: { typescript-fetch: "the form encoding (deepObject, explode) is ignored" } diff --git a/testdata/e2e/scenarios/request-body-login.yaml b/testdata/e2e/scenarios/request-body-login.yaml new file mode 100644 index 0000000..18d32d1 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-login.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/request-body-form-urlencoded.yaml +cases: + - op: login + args: { username: "a@b.c", password: "p&s=s w", rememberMe: true, tags: [x, y] } + request: { body: { username: "a@b.c", password: "p&s=s w", rememberMe: "true", tags: [x, y] } } + response: { status: 200 } + skip: { dart-fetch: "request.body.tags: want [\"x\",\"y\"], got [\"[x, y]\"]", typescript-fetch: "a form array goes comma-joined, not exploded" } diff --git a/testdata/e2e/scenarios/request-body-multipart.yaml b/testdata/e2e/scenarios/request-body-multipart.yaml new file mode 100644 index 0000000..60e1cce --- /dev/null +++ b/testdata/e2e/scenarios/request-body-multipart.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/request-body-multipart.yaml +cases: + - name: a file, an optional text and a list of files + op: uploadFile + args: + file: { $file: { name: a.txt, text: hello } } + description: two files + attachments: [{ $file: { name: b.bin, base64: AP8= } }, { $file: { name: c.txt, text: c } }] + request: + body: + file: [{ filename: a.txt, text: hello }] + description: [two files] + attachments: [{ filename: b.bin, base64: AP8= }, { filename: c.txt, text: c }] + response: { status: 200 } + skip: { dart-fetch: "request: invalid for the spec: request body has an error: header Content-Type has unexpected value \"application/x-www-form-urlencoded\"" } diff --git a/testdata/e2e/scenarios/request-body-other-media.yaml b/testdata/e2e/scenarios/request-body-other-media.yaml new file mode 100644 index 0000000..368b720 --- /dev/null +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -0,0 +1,22 @@ +spec: ../../specs/feature/request-body-other-media.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:196: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +auth: { bearer: tok } +cases: + - name: text/plain body and response, bearer + op: postNote + args: { body: "line 1\nčo" } + request: { header: { Content-Type: text/plain, Authorization: Bearer tok }, body: "line 1\nčo" } + response: { text: "stored" } + result: stored + - name: octet-stream body and response + op: putBlob + args: { body: { $bytes: { base64: AAH/ } } } + request: { header: { Content-Type: application/octet-stream }, body: { $base64: AAH/ } } + response: { base64: /wEA } + result: /wEA + - name: multipart with an object part + op: postReport + args: { title: q3, meta: { author: ann, pages: 3 }, attachments: [{ $file: { name: a.txt, text: a } }] } + request: { body: { title: [q3], meta: [{ json: { author: ann, pages: 3 } }], attachments: [{ filename: a.txt, text: a }] } } + response: { status: 204 } + skip: { typescript-fetch: "an object multipart part goes as text, not application/json" } diff --git a/testdata/e2e/scenarios/responses-binary.yaml b/testdata/e2e/scenarios/responses-binary.yaml new file mode 100644 index 0000000..eb8c11c --- /dev/null +++ b/testdata/e2e/scenarios/responses-binary.yaml @@ -0,0 +1,5 @@ +spec: ../../specs/feature/responses-binary.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:65: The static method or constructor 'fromJson' isn't defined for the context type 'List'." } +cases: + - { op: downloadFile, args: { id: "1" }, response: { base64: AAH/ }, result: AAH/ } + - { op: getThumbnail, args: { id: "1" }, response: { header: { Content-Type: image/png }, base64: iVBORw== }, result: iVBORw== } diff --git a/testdata/e2e/scenarios/responses-mixed-2xx.yaml b/testdata/e2e/scenarios/responses-mixed-2xx.yaml new file mode 100644 index 0000000..8954a5f --- /dev/null +++ b/testdata/e2e/scenarios/responses-mixed-2xx.yaml @@ -0,0 +1,13 @@ +spec: ../../specs/feature/responses-mixed-2xx.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:23: Expected an identifier." } +cases: + - { name: 201 result, op: placeOrder, response: { status: 201, json: { id: 7 } }, result: { id: 7 } } + - name: 202 with another body is an error with its model + op: placeOrder + response: { status: 202, json: { title: queued, status: 202 } } + error: { status: 202, model: { title: queued, status: 202 } } + skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } + - { name: ndjson as text, op: streamLines, response: { header: { Content-Type: application/x-ndjson }, text: "{\"i\":0}\n" }, result: "{\"i\":0}\n", skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } } + - { name: nullable result null, op: latestOrder, response: { json: null }, result: null } + - { name: nullable text, op: greeting, response: { text: hello }, result: hello } + - { name: any holds another 2xx, op: anything, response: { status: 201, json: { id: 1 } }, result: { id: 1 } } diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml new file mode 100644 index 0000000..32bb404 --- /dev/null +++ b/testdata/e2e/scenarios/responses.yaml @@ -0,0 +1,21 @@ +spec: ../../specs/feature/responses-status-codes.yaml +cases: + - { name: 200 model, op: getTask, args: { id: "1" }, response: { json: { id: "1", title: t } }, result: { id: "1", title: t } } + - name: 404 error model + op: getTask + args: { id: "2" } + response: { status: 404, json: { message: nope, code: 4 } } + error: { status: 404, model: { message: nope, code: 4 } } + - name: default error model for a 500 + op: getTask + args: { id: "3" } + response: { status: 500, json: { message: boom } } + error: { status: 500, model: { message: boom } } + - { name: 204 no body, op: deleteTask, args: { id: "1" }, response: { status: 204 } } + - { name: 201 with a body, op: createTask, args: { id: "1" }, response: { status: 201, json: { id: "1" } }, result: { id: "1" } } + - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null, skip: { typescript-fetch: "fails on a 2xx without a body next to one with a body" } } + - name: undeclared status keeps the text body + op: deleteTask + args: { id: "1" } + response: { status: 409, text: conflict, offSpec: true } + error: { status: 409, model: null, body: conflict } diff --git a/testdata/e2e/scenarios/security-http.yaml b/testdata/e2e/scenarios/security-http.yaml new file mode 100644 index 0000000..d095029 --- /dev/null +++ b/testdata/e2e/scenarios/security-http.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-http.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { username: u, password: "p:w", bearer: jwt } +cases: + - { op: getWithBasicAuth, request: { header: { Authorization: "Basic dTpwOnc=" } }, response: { status: 200 } } + - { op: getWithBearerAuth, request: { header: { Authorization: Bearer jwt } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security-oauth2.yaml b/testdata/e2e/scenarios/security-oauth2.yaml new file mode 100644 index 0000000..a4011f5 --- /dev/null +++ b/testdata/e2e/scenarios/security-oauth2.yaml @@ -0,0 +1,6 @@ +spec: ../../specs/feature/security-oauth2.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { bearer: tok } +cases: + - { op: getWithOAuthCode, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } + - { op: getWithOAuthClientCredentials, request: { header: { Authorization: Bearer tok } }, response: { status: 200 } } diff --git a/testdata/e2e/scenarios/security.yaml b/testdata/e2e/scenarios/security.yaml new file mode 100644 index 0000000..b4a176c --- /dev/null +++ b/testdata/e2e/scenarios/security.yaml @@ -0,0 +1,7 @@ +spec: ../../specs/feature/security-apikey.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:20: Expected an identifier." } +auth: { apiKey: k1 } +cases: + - { op: getWithHeaderKey, request: { header: { X-Api-Key: k1 } }, response: { status: 200 } } + - { op: getWithQueryKey, request: { query: { api_key: k1 } }, response: { status: 200 } } + - { op: getWithCookieKey, request: { cookie: { session: k1 } }, response: { status: 200 }, skip: { typescript-fetch: "no API key in a cookie" } } diff --git a/testdata/e2e/scenarios/unions-anyof.yaml b/testdata/e2e/scenarios/unions-anyof.yaml new file mode 100644 index 0000000..cae292c --- /dev/null +++ b/testdata/e2e/scenarios/unions-anyof.yaml @@ -0,0 +1,8 @@ +spec: ../../specs/feature/composition-anyof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/contact_method.dart:13: Expected an identifier." } +cases: + - op: createContact + args: { body: { email: a@b.c } } + request: { body: { email: a@b.c } } + response: { json: { phone: "+421" } } + result: { phone: "+421" } diff --git a/testdata/e2e/scenarios/unions-inline.yaml b/testdata/e2e/scenarios/unions-inline.yaml new file mode 100644 index 0000000..c6835a8 --- /dev/null +++ b/testdata/e2e/scenarios/unions-inline.yaml @@ -0,0 +1,10 @@ +spec: ../../specs/feature/composition-oneof-inline.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:82: Expected an identifier." } +cases: + - name: inline union body, property and items + op: admit + args: { id: s1, body: { kind: dog, barkVolume: 1.5 } } + request: { path: /shelters/s1/pets, body: { kind: dog, barkVolume: 1.5 } } + response: { json: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } } + result: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } + - { name: inline union response, op: randomPet, response: { json: { kind: cat, lives: 9 } }, result: { kind: cat, lives: 9 } } diff --git a/testdata/e2e/scenarios/unions.yaml b/testdata/e2e/scenarios/unions.yaml new file mode 100644 index 0000000..8f86715 --- /dev/null +++ b/testdata/e2e/scenarios/unions.yaml @@ -0,0 +1,15 @@ +spec: ../../specs/feature/composition-oneof.yaml +skip: { dart-fetch: "does not compile: lib/src/models/search_result.dart:8: Target of URI doesn't exist: 'arraynumber.dart'." } +cases: + - name: model variant round trip + op: search + args: { body: { petType: cat, huntingSkill: lazy } } + request: { body: { petType: cat, huntingSkill: lazy } } + response: { json: { petType: dog, packSize: 3 } } + result: { petType: dog, packSize: 3 } + - name: string and array variants + op: search + args: { body: "just text" } + request: { body: "just text" } + response: { json: [1, 2] } + result: [1, 2] diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json new file mode 100644 index 0000000..7460375 --- /dev/null +++ b/testdata/e2e/specs/fastapi.json @@ -0,0 +1,6065 @@ +{ + "components": { + "schemas": { + "Body_cancelOrder": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "title": "Body_cancelOrder", + "type": "object" + }, + "Body_clashForm": { + "properties": { + "folderName": { + "title": "Foldername", + "type": "string" + } + }, + "required": [ + "folderName" + ], + "title": "Body_clashForm", + "type": "object" + }, + "Body_issueToken": { + "properties": { + "client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Id" + }, + "client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "format": "password", + "title": "Client Secret" + }, + "grant_type": { + "anyOf": [ + { + "pattern": "^password$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Grant Type" + }, + "password": { + "format": "password", + "title": "Password", + "type": "string" + }, + "scope": { + "default": "", + "title": "Scope", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Body_issueToken", + "type": "object" + }, + "Body_uploadAvatar": { + "properties": { + "image": { + "contentMediaType": "application/octet-stream", + "title": "Image", + "type": "string" + }, + "thumbnail": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail" + } + }, + "required": [ + "image" + ], + "title": "Body_uploadAvatar", + "type": "object" + }, + "Body_uploadFiles": { + "properties": { + "files": { + "description": "One or more files", + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "meta": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "JSON text", + "title": "Meta" + }, + "overwrite": { + "default": false, + "title": "Overwrite", + "type": "boolean" + } + }, + "required": [ + "files", + "folder" + ], + "title": "Body_uploadFiles", + "type": "object" + }, + "Card": { + "additionalProperties": false, + "properties": { + "expires": { + "format": "date", + "title": "Expires", + "type": "string" + }, + "last4": { + "examples": [ + "4242" + ], + "pattern": "^\\d{4}$", + "title": "Last4", + "type": "string" + }, + "method": { + "const": "card", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "last4", + "expires" + ], + "title": "Card", + "type": "object" + }, + "Cat": { + "additionalProperties": false, + "properties": { + "lives": { + "title": "Lives", + "type": "integer" + }, + "petKind": { + "const": "cat", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "lives" + ], + "title": "Cat", + "type": "object" + }, + "Category-Input": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Input" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Input" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Category-Output": { + "additionalProperties": false, + "properties": { + "children": { + "default": [], + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Children", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/Category-Output" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "title": "Category", + "type": "object" + }, + "Circle": { + "additionalProperties": false, + "properties": { + "radius": { + "title": "Radius", + "type": "number" + } + }, + "required": [ + "radius" + ], + "title": "Circle", + "type": "object" + }, + "Color": { + "enum": [ + "red", + "dark-blue", + "light green", + "2nd", + "type", + "" + ], + "title": "Color", + "type": "string" + }, + "Conflict": { + "additionalProperties": false, + "properties": { + "current_version": { + "title": "Current Version", + "type": "integer" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "message", + "current_version" + ], + "title": "Conflict", + "type": "object" + }, + "Containers": { + "additionalProperties": false, + "properties": { + "by_name": { + "additionalProperties": { + "$ref": "#/components/schemas/Dimensions" + }, + "default": {}, + "title": "By Name", + "type": "object" + }, + "counts": { + "additionalProperties": { + "type": "integer" + }, + "default": {}, + "title": "Counts", + "type": "object" + }, + "groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "title": "Groups", + "type": "object" + }, + "matrix": { + "default": [], + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Matrix", + "type": "array" + }, + "maybe_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Maybe List" + }, + "maybe_map": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Maybe Map" + }, + "pair": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "title": "Pair", + "type": "array" + }, + "sparse": { + "default": [], + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": "Sparse", + "type": "array" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unique": { + "default": [], + "items": { + "type": "string" + }, + "title": "Unique", + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "tags", + "pair" + ], + "title": "Containers", + "type": "object" + }, + "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": { + "depthCm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Depthcm" + }, + "heightCm": { + "title": "Heightcm", + "type": "number" + }, + "widthCm": { + "title": "Widthcm", + "type": "number" + } + }, + "required": [ + "widthCm", + "heightCm" + ], + "title": "Dimensions", + "type": "object" + }, + "Dog": { + "additionalProperties": false, + "properties": { + "goodBoy": { + "title": "Goodboy", + "type": "boolean" + }, + "petKind": { + "const": "dog", + "title": "Petkind", + "type": "string" + } + }, + "required": [ + "petKind", + "goodBoy" + ], + "title": "Dog", + "type": "object" + }, + "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.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, + "FormIn-Output": { + "additionalProperties": false, + "description": "A form model: every value a client must turn into text itself.", + "properties": { + "agree": { + "default": false, + "title": "Agree", + "type": "boolean" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "colors": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "price": { + "title": "Price", + "type": "string" + }, + "qty": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "day", + "when", + "price", + "color" + ], + "title": "FormIn", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Item": { + "additionalProperties": false, + "description": "An item as the server returns it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "title": "Createdat", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "displayName": { + "readOnly": true, + "title": "Displayname", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "title": "Price", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "updatedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "version": { + "title": "Version", + "type": "integer" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price", + "id", + "createdAt", + "version", + "displayName" + ], + "title": "Item", + "type": "object" + }, + "ItemIn": { + "additionalProperties": false, + "description": "An item as a client sends it.", + "properties": { + "attributes": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Attributes", + "type": "object" + }, + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Free text; null clears it.", + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "discontinued": { + "default": false, + "deprecated": true, + "title": "Discontinued", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Price" + }, + "priority": { + "$ref": "#/components/schemas/Priority", + "default": 5 + }, + "sku": { + "examples": [ + "ABC-1234" + ], + "pattern": "^[A-Z]{3}-\\d{4}$", + "title": "Sku", + "type": "string" + }, + "supplier": { + "anyOf": [ + { + "$ref": "#/components/schemas/Supplier" + }, + { + "type": "null" + } + ] + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "unit": { + "$ref": "#/components/schemas/Unit", + "default": "kg" + }, + "weight": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight" + } + }, + "required": [ + "sku", + "name", + "price" + ], + "title": "ItemIn", + "type": "object" + }, + "ItemPatch": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "dimensions": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "price": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "title": "ItemPatch", + "type": "object" + }, + "Keywords": { + "additionalProperties": false, + "description": "Property names that are keywords or predeclared in Go.", + "properties": { + "1st": { + "title": "1St", + "type": "string" + }, + "UPPER": { + "title": "Upper", + "type": "string" + }, + "default": { + "default": false, + "title": "Default", + "type": "boolean" + }, + "dot.name": { + "title": "Dot.Name", + "type": "string" + }, + "error": { + "title": "Error", + "type": "string" + }, + "func": { + "title": "Func", + "type": "integer" + }, + "interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Interface" + }, + "map": { + "additionalProperties": { + "type": "integer" + }, + "title": "Map", + "type": "object" + }, + "package": { + "default": "main", + "title": "Package", + "type": "string" + }, + "range": { + "items": { + "type": "integer" + }, + "title": "Range", + "type": "array" + }, + "select": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Select" + }, + "string": { + "title": "String", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "x-dash-name": { + "title": "X-Dash-Name", + "type": "string" + } + }, + "required": [ + "type", + "func", + "range", + "map", + "string", + "error", + "x-dash-name", + "dot.name", + "UPPER", + "1st" + ], + "title": "Keywords", + "type": "object" + }, + "Literals": { + "additionalProperties": false, + "description": "Enums whose values are not one JSON type, null among them.", + "properties": { + "mode": { + "default": "auto", + "enum": [ + "auto", + 0, + 1 + ], + "title": "Mode" + }, + "order": { + "anyOf": [ + { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Order" + }, + "pin": { + "enum": [ + "x", + null + ], + "title": "Pin" + }, + "retry": { + "$ref": "#/components/schemas/Retry" + }, + "size": { + "default": 25, + "enum": [ + 10, + 25, + 50 + ], + "title": "Size", + "type": "integer" + }, + "tag": { + "default": "a", + "enum": [ + "a", + null + ], + "title": "Tag" + } + }, + "required": [ + "retry", + "pin", + "order" + ], + "title": "Literals", + "type": "object" + }, + "Nulls": { + "additionalProperties": false, + "description": "Null as a required value, and null that differs from a default.", + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Due" + }, + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Extras" + }, + "lines": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Lines" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment" + ], + "title": "Nulls", + "type": "object" + }, + "NullsEcho": { + "additionalProperties": false, + "properties": { + "color": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ] + }, + "dims": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ] + }, + "due": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Due" + }, + "extras": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Extras" + }, + "lines": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Lines" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "payment": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Payment" + }, + "retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 3, + "title": "Retries" + }, + "sent": { + "items": { + "type": "string" + }, + "title": "Sent", + "type": "array" + } + }, + "required": [ + "note", + "due", + "color", + "dims", + "lines", + "payment", + "sent" + ], + "title": "NullsEcho", + "type": "object" + }, + "Open": { + "additionalProperties": true, + "description": "Takes any extra key and returns it.", + "properties": { + "kind": { + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "Open", + "type": "object" + }, + "Order": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ], + "title": "Payment" + }, + "placedAt": { + "format": "date-time", + "title": "Placedat", + "type": "string" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + }, + "status": { + "$ref": "#/components/schemas/OrderStatus" + } + }, + "required": [ + "lines", + "payment", + "id", + "status", + "placedAt" + ], + "title": "Order", + "type": "object" + }, + "OrderIn": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "extraPayments": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "title": "Extrapayments", + "type": "array" + }, + "gift": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": false, + "title": "Gift" + }, + "lines": { + "items": { + "$ref": "#/components/schemas/OrderLine" + }, + "minItems": 1, + "title": "Lines", + "type": "array" + }, + "payment": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ], + "title": "Payment" + }, + "shape": { + "anyOf": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Rect" + }, + { + "type": "null" + } + ], + "title": "Shape" + } + }, + "required": [ + "lines", + "payment" + ], + "title": "OrderIn", + "type": "object" + }, + "OrderLine": { + "additionalProperties": false, + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "quantity": { + "exclusiveMinimum": 0, + "title": "Quantity", + "type": "integer" + }, + "sku": { + "title": "Sku", + "type": "string" + } + }, + "required": [ + "sku", + "quantity" + ], + "title": "OrderLine", + "type": "object" + }, + "OrderStatus": { + "enum": [ + "placed", + "paid", + "shipped" + ], + "title": "OrderStatus", + "type": "string" + }, + "Page_Item_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/Item" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "Page[Item]", + "type": "object" + }, + "Priority": { + "enum": [ + 1, + 5, + 10 + ], + "title": "Priority", + "type": "integer" + }, + "Problem": { + "additionalProperties": false, + "properties": { + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status" + ], + "title": "Problem", + "type": "object" + }, + "Prompt": { + "additionalProperties": false, + "properties": { + "prompt": { + "title": "Prompt", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "title": "Prompt", + "type": "object" + }, + "Rect": { + "additionalProperties": false, + "properties": { + "height": { + "title": "Height", + "type": "number" + }, + "width": { + "title": "Width", + "type": "number" + } + }, + "required": [ + "width", + "height" + ], + "title": "Rect", + "type": "object" + }, + "Retry": { + "description": "Members of two JSON types: the schema has an enum and no type.", + "enum": [ + 0, + "always", + 1 + ], + "title": "Retry" + }, + "Scalars-Input": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "D" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Scalars-Output": { + "additionalProperties": false, + "description": "Every scalar format pydantic writes into a schema.", + "properties": { + "anything": { + "title": "Anything" + }, + "b": { + "title": "B", + "type": "boolean" + }, + "blob": { + "contentMediaType": "application/octet-stream", + "title": "Blob", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "color": { + "$ref": "#/components/schemas/Color" + }, + "d": { + "title": "D", + "type": "string" + }, + "day": { + "format": "date", + "title": "Day", + "type": "string" + }, + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "f": { + "title": "F", + "type": "number" + }, + "i": { + "title": "I", + "type": "integer" + }, + "i64": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "title": "I64", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "ip": { + "format": "ipv4", + "title": "Ip", + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "s": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[^\\x00]+$", + "title": "S", + "type": "string" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + }, + "unit": { + "default": "kg", + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "url": { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "title": "Url", + "type": "string" + }, + "when": { + "format": "date-time", + "title": "When", + "type": "string" + } + }, + "required": [ + "i", + "i64", + "f", + "d", + "b", + "s", + "when", + "day", + "clock", + "span", + "id", + "email", + "url", + "ip", + "blob", + "color", + "priority" + ], + "title": "Scalars", + "type": "object" + }, + "Secret": { + "additionalProperties": false, + "properties": { + "password": { + "format": "password", + "title": "Password", + "type": "string", + "writeOnly": true + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Secret", + "type": "object" + }, + "Stamps": { + "additionalProperties": false, + "description": "Times as ORM-backed services send them: naive, aware, in containers.", + "properties": { + "aware": { + "format": "date-time", + "title": "Aware", + "type": "string" + }, + "clock": { + "format": "time", + "title": "Clock", + "type": "string" + }, + "days": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + }, + "naive": { + "format": "date-time", + "title": "Naive", + "type": "string" + }, + "seen": { + "additionalProperties": { + "format": "date-time", + "type": "string" + }, + "default": {}, + "title": "Seen", + "type": "object" + }, + "span": { + "format": "duration", + "title": "Span", + "type": "string" + } + }, + "required": [ + "naive", + "aware", + "clock", + "span" + ], + "title": "Stamps", + "type": "object" + }, + "Supplier": { + "additionalProperties": false, + "properties": { + "contactEmail": { + "anyOf": [ + { + "format": "email", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Contactemail" + }, + "name": { + "title": "Name", + "type": "string" + }, + "website": { + "anyOf": [ + { + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Website" + } + }, + "required": [ + "name" + ], + "title": "Supplier", + "type": "object" + }, + "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": { + "iban": { + "title": "Iban", + "type": "string" + }, + "method": { + "const": "transfer", + "title": "Method", + "type": "string" + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reference" + } + }, + "required": [ + "method", + "iban" + ], + "title": "Transfer", + "type": "object" + }, + "Unit": { + "enum": [ + "kg", + "lb" + ], + "title": "Unit", + "type": "string" + }, + "UploadIn": { + "additionalProperties": false, + "description": "A form model holding files: FastAPI declares its body\napplication/x-www-form-urlencoded, yet only multipart carries a file.", + "properties": { + "cover": { + "anyOf": [ + { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover" + }, + "files": { + "items": { + "contentMediaType": "application/octet-stream", + "type": "string" + }, + "title": "Files", + "type": "array" + }, + "folder": { + "title": "Folder", + "type": "string" + }, + "labels": { + "default": [], + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + } + }, + "required": [ + "folder", + "files" + ], + "title": "UploadIn", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "Voucher-Input": { + "additionalProperties": false, + "properties": { + "amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Amount" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + }, + "Voucher-Output": { + "additionalProperties": false, + "properties": { + "amount": { + "title": "Amount", + "type": "string" + }, + "code": { + "title": "Code", + "type": "string" + }, + "method": { + "const": "voucher", + "title": "Method", + "type": "string" + } + }, + "required": [ + "method", + "code", + "amount" + ], + "title": "Voucher", + "type": "object" + }, + "Wallet-Input": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Input" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Input" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" + }, + "Wallet-Output": { + "additionalProperties": false, + "description": "The union in every place a model can hold it.", + "properties": { + "by_label": { + "additionalProperties": { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + "default": {}, + "title": "By Label", + "type": "object" + }, + "pets": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ] + }, + "title": "Pets", + "type": "array" + }, + "primary": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "card": "#/components/schemas/Card", + "transfer": "#/components/schemas/Transfer", + "voucher": "#/components/schemas/Voucher-Output" + }, + "propertyName": "method" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + }, + { + "$ref": "#/components/schemas/Voucher-Output" + } + ] + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "tagged": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/Card" + }, + { + "$ref": "#/components/schemas/Transfer" + } + ] + }, + { + "type": "null" + } + ], + "title": "Tagged" + } + }, + "title": "Wallet", + "type": "object" + }, + "Wide": { + "additionalProperties": false, + "description": "Numbers past int64 and float64's usual range: Python ints have no bound\nand pydantic writes plain integer for them, a uint64 bound as a float.", + "properties": { + "big": { + "title": "Big", + "type": "integer" + }, + "f": { + "title": "F", + "type": "number" + }, + "tiny": { + "exclusiveMinimum": 0, + "multipleOf": 1e-7, + "title": "Tiny", + "type": "number" + }, + "u64": { + "maximum": 18446744073709552000, + "minimum": 0, + "title": "U64", + "type": "integer" + } + }, + "required": [ + "big", + "u64", + "f", + "tiny" + ], + "title": "Wide", + "type": "object" + } + }, + "securitySchemes": { + "APIKeyCookie": { + "in": "cookie", + "name": "session", + "type": "apiKey" + }, + "APIKeyHeader": { + "in": "header", + "name": "X-API-Key", + "type": "apiKey" + }, + "APIKeyQuery": { + "in": "query", + "name": "api_key", + "type": "apiKey" + }, + "HTTPBasic": { + "scheme": "basic", + "type": "http" + }, + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + }, + "OAuth2PasswordBearer": { + "flows": { + "password": { + "scopes": { + "items:read": "Read items", + "items:write": "Write items", + "orders": "Place orders" + }, + "tokenUrl": "token" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "description": "Every common OpenAPI shape in one strict API.", + "title": "Warehouse", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/admin/stats": { + "get": { + "operationId": "adminStats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Adminstats", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBasic": [] + } + ], + "summary": "Admin Stats", + "tags": [ + "auth" + ] + } + }, + "/categories": { + "get": { + "operationId": "categoryTree", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Category-Output" + }, + "title": "Response Categorytree", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Category Tree", + "tags": [ + "misc" + ] + } + }, + "/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", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Countitems", + "type": "integer" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Count Items", + "tags": [ + "misc" + ] + } + }, + "/echo/containers": { + "post": { + "operationId": "echoContainers", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Containers" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Containers", + "tags": [ + "echo" + ] + } + }, + "/echo/keywords": { + "post": { + "operationId": "echoKeywords", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Keywords" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Keywords", + "tags": [ + "echo" + ] + } + }, + "/echo/list": { + "post": { + "operationId": "echoList", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Body", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Dimensions" + }, + "title": "Response Echolist", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo List", + "tags": [ + "echo" + ] + } + }, + "/echo/literals": { + "post": { + "operationId": "echoLiterals", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Literals" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Literals", + "tags": [ + "echo" + ] + } + }, + "/echo/map": { + "post": { + "operationId": "echoMap", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Body", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Echomap", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Map", + "tags": [ + "echo" + ] + } + }, + "/echo/nulls": { + "post": { + "operationId": "echoNulls", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nulls" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NullsEcho" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Nulls", + "tags": [ + "echo" + ] + } + }, + "/echo/number": { + "post": { + "operationId": "echoNumber", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Body", + "type": "number" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Echonumber", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Number", + "tags": [ + "echo" + ] + } + }, + "/echo/open": { + "post": { + "operationId": "echoOpen", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Open" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Open", + "tags": [ + "echo" + ] + } + }, + "/echo/optional": { + "post": { + "operationId": "echoOptional", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Echooptional", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Optional", + "tags": [ + "echo" + ] + } + }, + "/echo/pet": { + "post": { + "operationId": "echoPet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + }, + "propertyName": "petKind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "title": "Response Echopet" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Pet", + "tags": [ + "echo" + ] + } + }, + "/echo/raw": { + "post": { + "operationId": "echoRaw", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Raw", + "tags": [ + "echo" + ] + } + }, + "/echo/scalars": { + "post": { + "operationId": "echoScalars", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Scalars-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Scalars", + "tags": [ + "echo" + ] + } + }, + "/echo/secret": { + "post": { + "operationId": "echoSecret", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Response Echosecret", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Secret", + "tags": [ + "echo" + ] + } + }, + "/echo/stamps": { + "post": { + "operationId": "echoStamps", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Stamps", + "tags": [ + "echo" + ] + } + }, + "/echo/text": { + "post": { + "operationId": "echoText", + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Echo Text", + "tags": [ + "echo" + ] + } + }, + "/echo/tree": { + "post": { + "operationId": "echoTree", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Tree", + "tags": [ + "echo" + ] + } + }, + "/echo/union": { + "post": { + "operationId": "echoUnion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Echounion", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Union", + "tags": [ + "echo" + ] + } + }, + "/echo/wallet": { + "post": { + "operationId": "echoWallet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wallet-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wallet", + "tags": [ + "echo" + ] + } + }, + "/echo/wide": { + "post": { + "operationId": "echoWide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Wide" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Wide", + "tags": [ + "echo" + ] + } + }, + "/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", + "responses": { + "200": { + "content": { + "text/csv": { + "schema": { + "type": "string" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Export Csv", + "tags": [ + "files" + ] + } + }, + "/fetch": { + "get": { + "operationId": "clashLocals", + "parameters": [ + { + "in": "query", + "name": "url", + "required": true, + "schema": { + "title": "Url", + "type": "string" + } + }, + { + "in": "query", + "name": "headers", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Headers" + } + }, + { + "in": "query", + "name": "path", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + } + }, + { + "in": "query", + "name": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashlocals", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locals", + "tags": [ + "clash" + ] + } + }, + "/files": { + "post": { + "operationId": "uploadFiles", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadFiles" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "object" + }, + "title": "Response Uploadfiles", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + }, + { + "APIKeyQuery": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Upload Files", + "tags": [ + "files" + ] + } + }, + "/files/avatar": { + "post": { + "operationId": "uploadAvatar", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_uploadAvatar" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Uploadavatar", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Avatar", + "tags": [ + "files" + ] + } + }, + "/files/model": { + "post": { + "operationId": "uploadModel", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UploadIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Uploadmodel", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Model", + "tags": [ + "files" + ] + } + }, + "/files/{name}": { + "get": { + "operationId": "downloadFile", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download File", + "tags": [ + "files" + ] + } + }, + "/forms/typed": { + "post": { + "operationId": "submitForm", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FormIn-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormIn-Output" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Submit Form", + "tags": [ + "files" + ] + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [ + { + "in": "query", + "name": "color", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Color" + }, + { + "type": "null" + } + ], + "title": "Color" + } + }, + { + "in": "query", + "name": "min_price", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Min Price" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 10, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_Item_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "List Items", + "tags": [ + "items" + ] + }, + "post": { + "operationId": "createItem", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response", + "headers": { + "Location": { + "description": "URL of the item", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "SKU taken" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Create Item", + "tags": [ + "items" + ] + } + }, + "/items/{item_id}": { + "delete": { + "operationId": "deleteItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Delete Item", + "tags": [ + "items" + ] + }, + "get": { + "operationId": "getItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Get Item", + "tags": [ + "items" + ] + }, + "head": { + "operationId": "itemExists", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "404": { + "description": "absent" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:read" + ] + } + ], + "summary": "Item Exists", + "tags": [ + "items" + ] + }, + "patch": { + "operationId": "patchItem", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "if-match", + "required": true, + "schema": { + "title": "If-Match", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Conflict" + } + } + }, + "description": "Stale version" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "items:write" + ] + } + ], + "summary": "Patch Item", + "tags": [ + "items" + ] + } + }, + "/legacy": { + "get": { + "deprecated": true, + "description": "A deprecated operation with FastAPI's generated operationId.", + "operationId": "legacy_endpoint_legacy_get", + "parameters": [ + { + "in": "query", + "name": "x", + "required": false, + "schema": { + "default": 1, + "title": "X", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "integer" + }, + "title": "Response Legacy Endpoint Legacy Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Legacy Endpoint", + "tags": [ + "misc" + ] + } + }, + "/maybe": { + "get": { + "operationId": "maybeItem", + "parameters": [ + { + "in": "query", + "name": "present", + "required": false, + "schema": { + "default": false, + "title": "Present", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Dimensions" + }, + { + "type": "null" + } + ], + "title": "Response Maybeitem" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Maybe Item", + "tags": [ + "misc" + ] + } + }, + "/numbers/{a}/{b}": { + "get": { + "operationId": "add", + "parameters": [ + { + "in": "path", + "name": "a", + "required": true, + "schema": { + "title": "A", + "type": "integer" + } + }, + { + "in": "path", + "name": "b", + "required": true, + "schema": { + "title": "B", + "type": "number" + } + }, + { + "in": "query", + "name": "round", + "required": false, + "schema": { + "default": false, + "title": "Round", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "title": "Response Add", + "type": "number" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Add", + "tags": [ + "misc" + ] + } + }, + "/orders": { + "post": { + "operationId": "placeOrder", + "parameters": [ + { + "in": "query", + "name": "dry_run", + "required": false, + "schema": { + "default": false, + "title": "Dry Run", + "type": "boolean" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Queued for review" + }, + "422": { + "description": "Invalid" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Place Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}": { + "get": { + "operationId": "getOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "minimum": 1, + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Get Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/cancel": { + "post": { + "operationId": "cancelOrder", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_cancelOrder" + } + } + } + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Cancellation accepted; no body" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Cancel Order", + "tags": [ + "orders" + ] + } + }, + "/orders/{order_id}/status": { + "get": { + "operationId": "orderStatus", + "parameters": [ + { + "in": "path", + "name": "order_id", + "required": true, + "schema": { + "title": "Order Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderStatus" + } + } + }, + "description": "Successful Response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "orders" + ] + } + ], + "summary": "Order Status", + "tags": [ + "orders" + ] + } + }, + "/params/header-lists": { + "get": { + "description": "FastAPI reads a list header from repeated lines; the spec's simple\nstyle is one comma-joined line, which it takes as one item.", + "operationId": "headerLists", + "parameters": [ + { + "in": "header", + "name": "x-tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Tags", + "type": "array" + } + }, + { + "in": "header", + "name": "x-ids", + "required": false, + "schema": { + "default": [], + "items": { + "type": "integer" + }, + "title": "X-Ids", + "type": "array" + } + }, + { + "in": "header", + "name": "x-colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "X-Colors", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerlists", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Lists", + "tags": [ + "params" + ] + } + }, + "/params/headers": { + "get": { + "operationId": "headerParams", + "parameters": [ + { + "in": "header", + "name": "x-request-id", + "required": true, + "schema": { + "format": "uuid", + "title": "X-Request-Id", + "type": "string" + } + }, + { + "in": "header", + "name": "x-retry", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X-Retry" + } + }, + { + "in": "header", + "name": "x-flags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "X-Flags", + "type": "array" + } + }, + { + "in": "header", + "name": "accept-language", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Accept-Language" + } + }, + { + "in": "cookie", + "name": "tenant", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant" + } + }, + { + "in": "cookie", + "name": "theme", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Theme" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Headerparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Header Params", + "tags": [ + "params" + ] + } + }, + "/params/path/{id}/{day}/{color}/{rest}": { + "get": { + "operationId": "pathParams", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "day", + "required": true, + "schema": { + "format": "date", + "title": "Day", + "type": "string" + } + }, + { + "in": "path", + "name": "color", + "required": true, + "schema": { + "$ref": "#/components/schemas/Color" + } + }, + { + "in": "path", + "name": "rest", + "required": true, + "schema": { + "title": "Rest", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Pathparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Path Params", + "tags": [ + "params" + ] + } + }, + "/params/query": { + "get": { + "operationId": "queryParams", + "parameters": [ + { + "in": "query", + "name": "q", + "required": true, + "schema": { + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "page-size", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Page-Size", + "type": "integer" + } + }, + { + "in": "query", + "name": "tags", + "required": false, + "schema": { + "default": [], + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + } + }, + { + "in": "query", + "name": "ids", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ids" + } + }, + { + "in": "query", + "name": "colors", + "required": false, + "schema": { + "default": [], + "items": { + "$ref": "#/components/schemas/Color" + }, + "title": "Colors", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "in": "query", + "name": "ratio", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ratio" + } + }, + { + "in": "query", + "name": "active", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + } + }, + { + "deprecated": true, + "in": "query", + "name": "old", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Old" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Queryparams", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Query Params", + "tags": [ + "params" + ] + } + }, + "/redirect": { + "get": { + "operationId": "redirectToCount", + "responses": { + "307": { + "description": "Successful Response" + } + }, + "summary": "Redirect", + "tags": [ + "misc" + ] + } + }, + "/stamps": { + "get": { + "operationId": "stampsQuery", + "parameters": [ + { + "in": "query", + "name": "days", + "required": false, + "schema": { + "default": [], + "items": { + "format": "date", + "type": "string" + }, + "title": "Days", + "type": "array" + } + }, + { + "in": "query", + "name": "since", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Since" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stamps" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stamps Query", + "tags": [ + "echo" + ] + } + }, + "/stream": { + "get": { + "operationId": "streamLines", + "parameters": [ + { + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 3, + "title": "N", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "application/x-ndjson": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Stream Lines", + "tags": [ + "misc" + ] + } + }, + "/tenants/{tenant}/notes": { + "post": { + "operationId": "clashForm", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "query", + "name": "folder_name", + "required": true, + "schema": { + "title": "Folder Name", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_clashForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Clashform", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Form", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/sorted": { + "get": { + "operationId": "clashAliases", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "deprecated": true, + "in": "query", + "name": "sort_by", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Sort By" + } + }, + { + "in": "query", + "name": "sortBy", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sortby" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": "Response Clashaliases", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Aliases", + "tags": [ + "clash" + ] + } + }, + "/tenants/{tenant}/things/{item_id}": { + "get": { + "operationId": "clashLocations", + "parameters": [ + { + "in": "path", + "name": "tenant", + "required": true, + "schema": { + "title": "Tenant", + "type": "string" + } + }, + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "version", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "in": "header", + "name": "Item-Id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Item-Id" + } + }, + { + "in": "header", + "name": "Version", + "required": false, + "schema": { + "default": "1", + "title": "Version", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Clashlocations", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Clash Locations", + "tags": [ + "clash" + ] + } + }, + "/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", + "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