From 2a77b58b979bd76cfb08bcf866f1b98cb68641a0 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Fri, 18 Sep 2026 13:58:28 -0700 Subject: [PATCH 1/2] Build complete Dex basic process template --- .agents/skills/dex-developer/SKILL.md | 10 + .agents/skills/dex-developer/upstream | 1 + .github/workflows/ci.yml | 40 + .gitignore | 8 + .gitmodules | 3 + .superverse/template.json | 20 + AGENTS.md | 29 + Makefile | 32 + README.md | 77 +- cmd/server/main.go | 85 + go.mod | 43 + go.sum | 96 + internal/api/generated/oas_cfg_gen.go | 323 ++ internal/api/generated/oas_client_gen.go | 441 +++ internal/api/generated/oas_handlers_gen.go | 594 ++++ internal/api/generated/oas_interfaces_gen.go | 14 + internal/api/generated/oas_json_gen.go | 1068 ++++++ internal/api/generated/oas_labeler_gen.go | 42 + internal/api/generated/oas_middleware_gen.go | 10 + internal/api/generated/oas_operations_gen.go | 13 + internal/api/generated/oas_parameters_gen.go | 185 ++ .../api/generated/oas_request_decoders_gen.go | 173 + .../api/generated/oas_request_encoders_gen.go | 39 + .../generated/oas_response_decoders_gen.go | 610 ++++ .../generated/oas_response_encoders_gen.go | 193 ++ internal/api/generated/oas_router_gen.go | 404 +++ internal/api/generated/oas_schemas_gen.go | 347 ++ internal/api/generated/oas_server_gen.go | 46 + .../api/generated/oas_unimplemented_gen.go | 42 + internal/api/generated/oas_validators_gen.go | 302 ++ internal/api/handler.go | 94 + internal/process/flow.go | 246 ++ internal/process/flow_test.go | 26 + internal/process/integration_test.go | 156 + internal/process/service.go | 105 + internal/runtime/runtime.go | 65 + internal/templatecontract/contract_test.go | 75 + openapi/openapi.yaml | 170 + scripts/check-generated.sh | 10 + scripts/run-e2e.sh | 15 + scripts/with-dex.sh | 39 + superverse.yaml | 10 + tools/openapi/go.mod | 31 + tools/openapi/go.sum | 67 + web/e2e/basic-process.spec.ts | 21 + web/index.html | 12 + web/openapi-ts.config.ts | 9 + web/package-lock.json | 2869 +++++++++++++++++ web/package.json | 32 + web/playwright.config.ts | 17 + web/src/App.test.tsx | 11 + web/src/App.tsx | 102 + web/src/api/generated/client.gen.ts | 16 + web/src/api/generated/client/client.gen.ts | 277 ++ web/src/api/generated/client/index.ts | 27 + web/src/api/generated/client/types.gen.ts | 218 ++ web/src/api/generated/client/utils.gen.ts | 316 ++ web/src/api/generated/core/auth.gen.ts | 48 + .../api/generated/core/bodySerializer.gen.ts | 82 + web/src/api/generated/core/params.gen.ts | 178 + .../api/generated/core/pathSerializer.gen.ts | 171 + .../generated/core/queryKeySerializer.gen.ts | 117 + .../generated/core/serverSentEvents.gen.ts | 242 ++ web/src/api/generated/core/types.gen.ts | 110 + web/src/api/generated/core/utils.gen.ts | 140 + web/src/api/generated/index.ts | 4 + web/src/api/generated/sdk.gen.ts | 41 + web/src/api/generated/types.gen.ts | 153 + web/src/main.tsx | 10 + web/src/styles.css | 24 + web/src/test/setup.ts | 1 + web/tsconfig.json | 21 + web/vite.config.ts | 11 + 73 files changed, 11677 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/dex-developer/SKILL.md create mode 160000 .agents/skills/dex-developer/upstream create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .superverse/template.json create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/generated/oas_cfg_gen.go create mode 100644 internal/api/generated/oas_client_gen.go create mode 100644 internal/api/generated/oas_handlers_gen.go create mode 100644 internal/api/generated/oas_interfaces_gen.go create mode 100644 internal/api/generated/oas_json_gen.go create mode 100644 internal/api/generated/oas_labeler_gen.go create mode 100644 internal/api/generated/oas_middleware_gen.go create mode 100644 internal/api/generated/oas_operations_gen.go create mode 100644 internal/api/generated/oas_parameters_gen.go create mode 100644 internal/api/generated/oas_request_decoders_gen.go create mode 100644 internal/api/generated/oas_request_encoders_gen.go create mode 100644 internal/api/generated/oas_response_decoders_gen.go create mode 100644 internal/api/generated/oas_response_encoders_gen.go create mode 100644 internal/api/generated/oas_router_gen.go create mode 100644 internal/api/generated/oas_schemas_gen.go create mode 100644 internal/api/generated/oas_server_gen.go create mode 100644 internal/api/generated/oas_unimplemented_gen.go create mode 100644 internal/api/generated/oas_validators_gen.go create mode 100644 internal/api/handler.go create mode 100644 internal/process/flow.go create mode 100644 internal/process/flow_test.go create mode 100644 internal/process/integration_test.go create mode 100644 internal/process/service.go create mode 100644 internal/runtime/runtime.go create mode 100644 internal/templatecontract/contract_test.go create mode 100644 openapi/openapi.yaml create mode 100755 scripts/check-generated.sh create mode 100755 scripts/run-e2e.sh create mode 100755 scripts/with-dex.sh create mode 100644 superverse.yaml create mode 100644 tools/openapi/go.mod create mode 100644 tools/openapi/go.sum create mode 100644 web/e2e/basic-process.spec.ts create mode 100644 web/index.html create mode 100644 web/openapi-ts.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/playwright.config.ts create mode 100644 web/src/App.test.tsx create mode 100644 web/src/App.tsx create mode 100644 web/src/api/generated/client.gen.ts create mode 100644 web/src/api/generated/client/client.gen.ts create mode 100644 web/src/api/generated/client/index.ts create mode 100644 web/src/api/generated/client/types.gen.ts create mode 100644 web/src/api/generated/client/utils.gen.ts create mode 100644 web/src/api/generated/core/auth.gen.ts create mode 100644 web/src/api/generated/core/bodySerializer.gen.ts create mode 100644 web/src/api/generated/core/params.gen.ts create mode 100644 web/src/api/generated/core/pathSerializer.gen.ts create mode 100644 web/src/api/generated/core/queryKeySerializer.gen.ts create mode 100644 web/src/api/generated/core/serverSentEvents.gen.ts create mode 100644 web/src/api/generated/core/types.gen.ts create mode 100644 web/src/api/generated/core/utils.gen.ts create mode 100644 web/src/api/generated/index.ts create mode 100644 web/src/api/generated/sdk.gen.ts create mode 100644 web/src/api/generated/types.gen.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/test/setup.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.agents/skills/dex-developer/SKILL.md b/.agents/skills/dex-developer/SKILL.md new file mode 100644 index 0000000..69988b5 --- /dev/null +++ b/.agents/skills/dex-developer/SKILL.md @@ -0,0 +1,10 @@ +--- +name: dex-developer +description: Develop, debug, test, and operate this Superdurable Dex application. +--- + +# Dex Developer + +Read `upstream/plugins/dex/skills/dex-developer/SKILL.md` completely and follow +it as the authoritative Dex application-development skill. Resolve every +relative reference from that upstream skill's directory. diff --git a/.agents/skills/dex-developer/upstream b/.agents/skills/dex-developer/upstream new file mode 160000 index 0000000..3ccc473 --- /dev/null +++ b/.agents/skills/dex-developer/upstream @@ -0,0 +1 @@ +Subproject commit 3ccc473171f6fa104af62b0add538f335d6f63fe diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67045da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: Template CI + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: | + go.sum + tools/openapi/go.sum + - uses: actions/setup-node@v4 + with: + node-version: 22.22.0 + cache: npm + cache-dependency-path: web/package-lock.json + - name: Install dexcli + run: | + curl --fail --silent --show-error --location \ + https://github.com/superdurable/dex/releases/download/cli-v0.10.0/dexcli_v0.10.0_linux_amd64.tar.gz \ + --output /tmp/dexcli.tar.gz + echo "0cee3b0795147b581c45d2258b0c2d581cd35ce75c515027e2d9b294ce364e2d /tmp/dexcli.tar.gz" | sha256sum --check + mkdir -p "$HOME/.local/bin" + tar -xzf /tmp/dexcli.tar.gz -C "$HOME/.local/bin" dexcli + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Install Playwright Chromium + working-directory: web + run: npx playwright install --with-deps chromium + - name: Check template + run: make check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..561b557 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/bin/ +/tmp/ +/.local/ +/web/dist/ +/web/node_modules/ +/web/playwright-report/ +/web/test-results/ +*.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..cda8b49 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".agents/skills/dex-developer/upstream"] + path = .agents/skills/dex-developer/upstream + url = https://github.com/superdurable/skill-dex-developer.git diff --git a/.superverse/template.json b/.superverse/template.json new file mode 100644 index 0000000..a2a0658 --- /dev/null +++ b/.superverse/template.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "buildProfile": "go-react-v1", + "templateVersion": "1.0.0", + "minimumSandboxRuntimeContractRevision": 2, + "openapiSpec": "openapi/openapi.yaml", + "agentInstructions": "AGENTS.md", + "dexSkill": ".agents/skills/dex-developer/SKILL.md", + "commands": { + "bootstrap": "make bootstrap", + "generate": "make generate", + "checkGenerated": "make check-generated", + "testUnit": "make test-unit", + "testIntegration": "make test-integration", + "testE2E": "make test-e2e", + "build": "make build", + "dev": "make dev", + "check": "make check" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1d3b144 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Basic Process Template Instructions + +This is a complete Superverse `go-react-v1` application. Read +`.superverse/template.json`, `openapi/openapi.yaml`, and the local +`dex-developer` skill before changing Dex behavior. + +`openapi/openapi.yaml` is the only HTTP contract source. Never edit files below +`internal/api/generated` or `web/src/api/generated` by hand. Change the spec, +run `make generate`, and update server, UI, integration, and E2E coverage in the +same change. + +The Dex Flow has stable Step, Attribute, Channel, Timer, and RPC identities. +Keep external effects in `Execute`; `WaitForApproval.WaitFor` only declares the +approval Channel and reminder Timer. Register every durable primitive in the +Flow persistence schema. Preserve open-Flow compatibility unless the user +explicitly requests a migration. + +After each edit batch, run the narrowest relevant Make target. Before calling +`commit_and_push`, run `make check` successfully and include it in verification. +If `make check` fails or cannot run, report `blocked=true`. Do not weaken, skip, +or delete a failing check. + +Stable commands are `make bootstrap`, `make generate`, `make check-generated`, +`make test-unit`, `make test-integration`, `make test-e2e`, `make build`, +`make dev`, and `make check`. + +When structure, commands, or required tooling changes, update this file, +`.superverse/template.json`, `README.md`, and contract tests together. Do not +maintain a separate static repository map. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e2ef2a --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +SHELL := /usr/bin/env bash + +.PHONY: bootstrap generate check-generated test-unit test-integration test-e2e build dev check +bootstrap: + go mod download + go -C tools/openapi mod download + npm --prefix web ci +generate: + go -C tools/openapi tool ogen --target ../../internal/api/generated --package generated ../../openapi/openapi.yaml + npm --prefix web run generate +check-generated: + ./scripts/check-generated.sh +test-unit: + go test ./... + npm --prefix web test +test-integration: + ./scripts/with-dex.sh go test -tags=integration ./... +test-e2e: + ./scripts/with-dex.sh ./scripts/run-e2e.sh +build: + npm --prefix web run build + go build -o bin/basic-process ./cmd/server +dev: + ./scripts/with-dex.sh bash -c 'npm --prefix web run build && go run ./cmd/server' +check: bootstrap check-generated + @test -z "$$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*'))" || { gofmt -d $$(gofmt -l $$(find . -name '*.go' -not -path './.agents/*')); exit 1; } + go mod tidy -diff + go vet ./... + $(MAKE) test-unit + $(MAKE) test-integration + $(MAKE) test-e2e + $(MAKE) build diff --git a/README.md b/README.md index a57775b..8750d8c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# dex-template-basic-process -The most basic dex application as a basic automation process with human approval and reminder +# Dex Basic Process Template + +A complete Superverse `go-react-v1` template for a durable approval automation. +The Go backend and React TypeScript UI share one OpenAPI contract. The Dex Flow +validates a request, waits durably for approval, emits recurring reminders, runs +the approved automation, and completes with a typed result. + +The durable lifecycle is: + +1. `StartProcess` +2. `ValidateRequest` +3. `WaitForApproval` +4. `EmitReminder` +5. `ExecuteApprovedAutomation` +6. `CompleteProcess` + +`WaitForApproval` races an Approval Channel against a durable 15-minute Timer. +When the Timer fires, `EmitReminder` increments the durable reminder count and +returns to the waiting step. Approval advances to the execution and completion +steps. + +## Start locally + +```bash +make bootstrap +make dev +``` + +Open . `make dev` owns a local `dexcli dev` process and +cleans it up on exit. + +## Contract and generated code + +`openapi/openapi.yaml` is authoritative. Ogen creates the Go server contract in +`internal/api/generated`; Hey API creates the TypeScript client in +`web/src/api/generated`. + +```bash +make generate +make check-generated +``` + +Generated files are committed so a checkout is immediately understandable. +Never edit them manually. + +## Verification + +```bash +make test-unit +make test-integration +make test-e2e +make check +``` + +Integration tests start a real Dex Server with `dexcli dev`. Playwright drives +the production UI and uses `dexcli flow skip-timer` to exercise the reminder +branch without waiting fifteen minutes. Every poll has a deadline. + +`make check` is the required completion gate for coding agents and CI. + +The supported sandbox runtime is contract revision 2. It provides Go, Node.js, +npm, Python 3, `dexcli`, Ogen's cached module dependencies, and Chromium +Headless Shell. JavaScript packages remain pinned by `web/package-lock.json`. + +## Dex skill + +The local skill entry delegates to the pinned public +`skill-dex-developer` submodule. Initialize it with: + +```bash +git submodule update --init --recursive +``` + +Template maintainers update the pin explicitly; generated applications never +follow the skill repository's `main` branch implicitly. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d314ad1 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + api "github.com/superdurable/dex-template-basic-process/internal/api" + appRuntime "github.com/superdurable/dex-template-basic-process/internal/runtime" +) + +func main() { + if err := run(); err != nil { + slog.Error("application stopped", "error", err) + os.Exit(1) + } +} + +func run() error { + logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + runtime, err := appRuntime.New(logger) + if err != nil { + return err + } + defer runtime.Close() + apiHandler, err := api.NewHandler(runtime.Processes) + if err != nil { + return fmt.Errorf("create OpenAPI handler: %w", err) + } + mux := http.NewServeMux() + mux.Handle("/api/", apiHandler) + mux.Handle("/", staticHandler("web/dist")) + server := &http.Server{Addr: ":" + environment("PORT", "8080"), Handler: mux, ReadHeaderTimeout: 5 * time.Second} + workerResult := runtime.StartWorker() + serverResult := make(chan error, 1) + go func() { serverResult <- server.ListenAndServe() }() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + select { + case <-ctx.Done(): + case err := <-workerResult: + if err != nil { + return fmt.Errorf("run Dex Worker: %w", err) + } + case err := <-serverResult: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("run HTTP server: %w", err) + } + } + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdown) +} + +func staticHandler(root string) http.Handler { + files := http.FileServer(http.Dir(root)) + return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet && request.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + asset := filepath.Join(root, filepath.Clean(request.URL.Path)) + if request.URL.Path != "/" { + if info, err := os.Stat(asset); err == nil && !info.IsDir() { + files.ServeHTTP(w, request) + return + } + } + http.ServeFile(w, request, filepath.Join(root, "index.html")) + }) +} + +func environment(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..508d236 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/superdurable/dex-template-basic-process + +go 1.25.0 + +require ( + github.com/go-faster/errors v0.8.0 + github.com/go-faster/jx v1.2.0 + github.com/google/uuid v1.6.0 + github.com/ogen-go/ogen v1.24.0 + github.com/superdurable/dex/blob-cache-go v0.1.0 + github.com/superdurable/dex/sdk-go v0.10.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.4.2 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8a033e1 --- /dev/null +++ b/go.sum @@ -0,0 +1,96 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= +github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ogen-go/ogen v1.24.0 h1:NehBG/8s0JeM6jYHPJeFJntBG3cE/xQgZd1q8BchPNM= +github.com/ogen-go/ogen v1.24.0/go.mod h1:hcg4aTzLcu/MS1SGGahUriPBEOZB3c82RGIC6dYcdFU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/superdurable/dex/blob-cache-go v0.1.0 h1:+c3H5YBWG3DlICOHbgT9IUM5vTlfLYGP5Vd5sWr7WTY= +github.com/superdurable/dex/blob-cache-go v0.1.0/go.mod h1:Atepb7+sztvDCztVKmlvEKCSKFCkHKtDhoFYjaFmtEw= +github.com/superdurable/dex/sdk-go v0.10.0 h1:TtXm17mxRE3ZdXI9hltIWDb3v6UsboHj/K5gEncO4Xg= +github.com/superdurable/dex/sdk-go v0.10.0/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/api/generated/oas_cfg_gen.go b/internal/api/generated/oas_cfg_gen.go new file mode 100644 index 0000000..60c78e0 --- /dev/null +++ b/internal/api/generated/oas_cfg_gen.go @@ -0,0 +1,323 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "net/http" + "strings" + + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/ogenregex" + "github.com/ogen-go/ogen/otelogen" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +var regexMap = map[string]ogenregex.Regexp{ + "^[a-z][a-z0-9_]*$": ogenregex.MustCompile("^[a-z][a-z0-9_]*$"), + "^process-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$": ogenregex.MustCompile("^process-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"), +} +var ( + // Allocate option closure once. + clientSpanKind = trace.WithSpanKind(trace.SpanKindClient) + // Allocate option closure once. + serverSpanKind = trace.WithSpanKind(trace.SpanKindServer) +) + +type ( + optionFunc[C any] func(*C) + otelOptionFunc func(*otelConfig) +) + +type otelConfig struct { + TracerProvider trace.TracerProvider + Tracer trace.Tracer + MeterProvider metric.MeterProvider + Meter metric.Meter + Attributes []attribute.KeyValue +} + +func (cfg *otelConfig) initOTEL() { + if cfg.TracerProvider == nil { + cfg.TracerProvider = otel.GetTracerProvider() + } + if cfg.MeterProvider == nil { + cfg.MeterProvider = otel.GetMeterProvider() + } + cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name, + trace.WithInstrumentationVersion(otelogen.SemVersion()), + ) + cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name, + metric.WithInstrumentationVersion(otelogen.SemVersion()), + ) +} + +// ErrorHandler is error handler. +type ErrorHandler = ogenerrors.ErrorHandler + +type serverConfig struct { + otelConfig + NotFound http.HandlerFunc + MethodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string) + ErrorHandler ErrorHandler + Prefix string + Middleware Middleware + MaxMultipartMemory int64 +} + +// ServerOption is server config option. +type ServerOption interface { + applyServer(*serverConfig) +} + +var _ ServerOption = (optionFunc[serverConfig])(nil) + +func (o optionFunc[C]) applyServer(c *C) { + o(c) +} + +var _ ServerOption = (otelOptionFunc)(nil) + +func (o otelOptionFunc) applyServer(c *serverConfig) { + o(&c.otelConfig) +} + +func newServerConfig(opts ...ServerOption) serverConfig { + cfg := serverConfig{ + NotFound: http.NotFound, + MethodNotAllowed: nil, + ErrorHandler: ogenerrors.DefaultErrorHandler, + Middleware: nil, + MaxMultipartMemory: 32 << 20, // 32 MB + } + for _, opt := range opts { + opt.applyServer(&cfg) + } + cfg.initOTEL() + return cfg +} + +type baseServer struct { + cfg serverConfig + requests metric.Int64Counter + errors metric.Int64Counter + duration metric.Float64Histogram +} + +func (s baseServer) notFound(w http.ResponseWriter, r *http.Request) { + s.cfg.NotFound(w, r) +} + +type notAllowedParams struct { + allowedMethods string + allowedHeaders map[string]string + acceptPost string + acceptPatch string +} + +func (s baseServer) notAllowed(w http.ResponseWriter, r *http.Request, params notAllowedParams) { + h := w.Header() + isOptions := r.Method == "OPTIONS" + if isOptions { + h.Set("Access-Control-Allow-Methods", params.allowedMethods) + if params.allowedHeaders != nil { + m := r.Header.Get("Access-Control-Request-Method") + if m != "" { + allowedHeaders, ok := params.allowedHeaders[strings.ToUpper(m)] + if ok { + h.Set("Access-Control-Allow-Headers", allowedHeaders) + } + } + } + if params.acceptPost != "" { + h.Set("Accept-Post", params.acceptPost) + } + if params.acceptPatch != "" { + h.Set("Accept-Patch", params.acceptPatch) + } + } + if s.cfg.MethodNotAllowed != nil { + s.cfg.MethodNotAllowed(w, r, params.allowedMethods) + return + } + status := http.StatusNoContent + if !isOptions { + h.Set("Allow", params.allowedMethods) + status = http.StatusMethodNotAllowed + } + w.WriteHeader(status) +} + +func (cfg serverConfig) baseServer() (s baseServer, err error) { + s = baseServer{cfg: cfg} + if s.requests, err = otelogen.ServerRequestCountCounter(s.cfg.Meter); err != nil { + return s, err + } + if s.errors, err = otelogen.ServerErrorsCountCounter(s.cfg.Meter); err != nil { + return s, err + } + if s.duration, err = otelogen.ServerDurationHistogram(s.cfg.Meter); err != nil { + return s, err + } + return s, nil +} + +type clientConfig struct { + otelConfig + Client ht.Client +} + +// ClientOption is client config option. +type ClientOption interface { + applyClient(*clientConfig) +} + +var _ ClientOption = (optionFunc[clientConfig])(nil) + +func (o optionFunc[C]) applyClient(c *C) { + o(c) +} + +var _ ClientOption = (otelOptionFunc)(nil) + +func (o otelOptionFunc) applyClient(c *clientConfig) { + o(&c.otelConfig) +} + +func newClientConfig(opts ...ClientOption) clientConfig { + cfg := clientConfig{ + Client: http.DefaultClient, + } + for _, opt := range opts { + opt.applyClient(&cfg) + } + cfg.initOTEL() + return cfg +} + +type baseClient struct { + cfg clientConfig + requests metric.Int64Counter + errors metric.Int64Counter + duration metric.Float64Histogram +} + +func (cfg clientConfig) baseClient() (c baseClient, err error) { + c = baseClient{cfg: cfg} + if c.requests, err = otelogen.ClientRequestCountCounter(c.cfg.Meter); err != nil { + return c, err + } + if c.errors, err = otelogen.ClientErrorsCountCounter(c.cfg.Meter); err != nil { + return c, err + } + if c.duration, err = otelogen.ClientDurationHistogram(c.cfg.Meter); err != nil { + return c, err + } + return c, nil +} + +// Option is config option. +type Option interface { + ServerOption + ClientOption +} + +// WithTracerProvider specifies a tracer provider to use for creating a tracer. +// +// If none is specified, the global provider is used. +func WithTracerProvider(provider trace.TracerProvider) Option { + return otelOptionFunc(func(cfg *otelConfig) { + if provider != nil { + cfg.TracerProvider = provider + } + }) +} + +// WithMeterProvider specifies a meter provider to use for creating a meter. +// +// If none is specified, the otel.GetMeterProvider() is used. +func WithMeterProvider(provider metric.MeterProvider) Option { + return otelOptionFunc(func(cfg *otelConfig) { + if provider != nil { + cfg.MeterProvider = provider + } + }) +} + +// WithAttributes specifies default otel attributes. +func WithAttributes(attributes ...attribute.KeyValue) Option { + return otelOptionFunc(func(cfg *otelConfig) { + cfg.Attributes = attributes + }) +} + +// WithClient specifies http client to use. +func WithClient(client ht.Client) ClientOption { + return optionFunc[clientConfig](func(cfg *clientConfig) { + if client != nil { + cfg.Client = client + } + }) +} + +// WithNotFound specifies Not Found handler to use. +func WithNotFound(notFound http.HandlerFunc) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if notFound != nil { + cfg.NotFound = notFound + } + }) +} + +// WithMethodNotAllowed specifies Method Not Allowed handler to use. +func WithMethodNotAllowed(methodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if methodNotAllowed != nil { + cfg.MethodNotAllowed = methodNotAllowed + } + }) +} + +// WithErrorHandler specifies error handler to use. +func WithErrorHandler(h ErrorHandler) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if h != nil { + cfg.ErrorHandler = h + } + }) +} + +// WithPathPrefix specifies server path prefix. +func WithPathPrefix(prefix string) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + cfg.Prefix = prefix + }) +} + +// WithMiddleware specifies middlewares to use. +func WithMiddleware(m ...Middleware) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + switch len(m) { + case 0: + cfg.Middleware = nil + case 1: + cfg.Middleware = m[0] + default: + cfg.Middleware = middleware.ChainMiddlewares(m...) + } + }) +} + +// WithMaxMultipartMemory specifies limit of memory for storing file parts. +// File parts which can't be stored in memory will be stored on disk in temporary files. +func WithMaxMultipartMemory(max int64) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if max > 0 { + cfg.MaxMultipartMemory = max + } + }) +} diff --git a/internal/api/generated/oas_client_gen.go b/internal/api/generated/oas_client_gen.go new file mode 100644 index 0000000..5ffb2e1 --- /dev/null +++ b/internal/api/generated/oas_client_gen.go @@ -0,0 +1,441 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "context" + "io" + "net/url" + "strings" + "time" + + "github.com/go-faster/errors" + "github.com/ogen-go/ogen/conv" + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/otelogen" + "github.com/ogen-go/ogen/uri" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" +) + +func trimTrailingSlashes(u *url.URL) { + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = strings.TrimRight(u.RawPath, "/") +} + +// Invoker invokes operations described by OpenAPI v3 specification. +type Invoker interface { + // ApproveFlow invokes approveFlow operation. + // + // POST /api/flows/{flowId}/approvals + ApproveFlow(ctx context.Context, request *ApprovalRequest, params ApproveFlowParams) (ApproveFlowRes, error) + // CreateFlow invokes createFlow operation. + // + // POST /api/flows + CreateFlow(ctx context.Context, request *CreateFlowRequest) (CreateFlowRes, error) + // GetFlow invokes getFlow operation. + // + // GET /api/flows/{flowId} + GetFlow(ctx context.Context, params GetFlowParams) (GetFlowRes, error) + // GetHealth invokes getHealth operation. + // + // GET /api/health + GetHealth(ctx context.Context) (*HealthResponse, error) +} + +// Client implements OAS client. +type Client struct { + serverURL *url.URL + baseClient +} + +// NewClient initializes new Client defined by OAS. +func NewClient(serverURL string, opts ...ClientOption) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + trimTrailingSlashes(u) + + c, err := newClientConfig(opts...).baseClient() + if err != nil { + return nil, err + } + return &Client{ + serverURL: u, + baseClient: c, + }, nil +} + +type serverURLKey struct{} + +// WithServerURL sets context key to override server URL. +func WithServerURL(ctx context.Context, u *url.URL) context.Context { + return context.WithValue(ctx, serverURLKey{}, u) +} + +func (c *Client) requestURL(ctx context.Context) *url.URL { + u, ok := ctx.Value(serverURLKey{}).(*url.URL) + if !ok { + return c.serverURL + } + return u +} + +// ApproveFlow invokes approveFlow operation. +// +// POST /api/flows/{flowId}/approvals +func (c *Client) ApproveFlow(ctx context.Context, request *ApprovalRequest, params ApproveFlowParams) (ApproveFlowRes, error) { + res, err := c.sendApproveFlow(ctx, request, params) + return res, err +} + +func (c *Client) sendApproveFlow(ctx context.Context, request *ApprovalRequest, params ApproveFlowParams) (res ApproveFlowRes, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("approveFlow"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/flows/{flowId}/approvals"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, ApproveFlowOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [3]string + pathParts[0] = "/api/flows/" + { + // Encode "flowId" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "flowId", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.FlowId)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + pathParts[2] = "/approvals" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeApproveFlowRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer func() { + // Drain the body to EOF before closing, so the underlying + // connection can be reused by the Transport regardless of the + // response status code. See https://github.com/ogen-go/ogen/issues/1670. + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() + + stage = "DecodeResponse" + result, err := decodeApproveFlowResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// CreateFlow invokes createFlow operation. +// +// POST /api/flows +func (c *Client) CreateFlow(ctx context.Context, request *CreateFlowRequest) (CreateFlowRes, error) { + res, err := c.sendCreateFlow(ctx, request) + return res, err +} + +func (c *Client) sendCreateFlow(ctx context.Context, request *CreateFlowRequest) (res CreateFlowRes, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createFlow"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/flows"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, CreateFlowOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/flows" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeCreateFlowRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer func() { + // Drain the body to EOF before closing, so the underlying + // connection can be reused by the Transport regardless of the + // response status code. See https://github.com/ogen-go/ogen/issues/1670. + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() + + stage = "DecodeResponse" + result, err := decodeCreateFlowResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetFlow invokes getFlow operation. +// +// GET /api/flows/{flowId} +func (c *Client) GetFlow(ctx context.Context, params GetFlowParams) (GetFlowRes, error) { + res, err := c.sendGetFlow(ctx, params) + return res, err +} + +func (c *Client) sendGetFlow(ctx context.Context, params GetFlowParams) (res GetFlowRes, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getFlow"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/flows/{flowId}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetFlowOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/flows/" + { + // Encode "flowId" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "flowId", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.FlowId)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer func() { + // Drain the body to EOF before closing, so the underlying + // connection can be reused by the Transport regardless of the + // response status code. See https://github.com/ogen-go/ogen/issues/1670. + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() + + stage = "DecodeResponse" + result, err := decodeGetFlowResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetHealth invokes getHealth operation. +// +// GET /api/health +func (c *Client) GetHealth(ctx context.Context) (*HealthResponse, error) { + res, err := c.sendGetHealth(ctx) + return res, err +} + +func (c *Client) sendGetHealth(ctx context.Context) (res *HealthResponse, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getHealth"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/health"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetHealthOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/health" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer func() { + // Drain the body to EOF before closing, so the underlying + // connection can be reused by the Transport regardless of the + // response status code. See https://github.com/ogen-go/ogen/issues/1670. + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() + + stage = "DecodeResponse" + result, err := decodeGetHealthResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} diff --git a/internal/api/generated/oas_handlers_gen.go b/internal/api/generated/oas_handlers_gen.go new file mode 100644 index 0000000..3e9cf24 --- /dev/null +++ b/internal/api/generated/oas_handlers_gen.go @@ -0,0 +1,594 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "context" + "net/http" + "time" + + "github.com/go-faster/errors" + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/otelogen" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" +) + +type codeRecorder struct { + http.ResponseWriter + status int +} + +func (c *codeRecorder) WriteHeader(status int) { + c.status = status + c.ResponseWriter.WriteHeader(status) +} + +func (c *codeRecorder) Unwrap() http.ResponseWriter { + return c.ResponseWriter +} + +// handleApproveFlowRequest handles approveFlow operation. +// +// POST /api/flows/{flowId}/approvals +func (s *Server) handleApproveFlowRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("approveFlow"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/flows/{flowId}/approvals"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), ApproveFlowOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(attrs...) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: ApproveFlowOperation, + ID: "approveFlow", + } + ) + params, err := decodeApproveFlowParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + request, rawBody, close, err := s.decodeApproveFlowRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response ApproveFlowRes + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: ApproveFlowOperation, + OperationSummary: "", + OperationID: "approveFlow", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "flowId", + In: "path", + }: params.FlowId, + }, + Raw: r, + } + + type ( + Request = *ApprovalRequest + Params = ApproveFlowParams + Response = ApproveFlowRes + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackApproveFlowParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.ApproveFlow(ctx, request, params) + return response, err + }, + ) + } else { + response, err = s.h.ApproveFlow(ctx, request, params) + } + if err != nil { + defer recordError("Internal", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodeApproveFlowResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleCreateFlowRequest handles createFlow operation. +// +// POST /api/flows +func (s *Server) handleCreateFlowRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createFlow"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/flows"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CreateFlowOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(attrs...) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: CreateFlowOperation, + ID: "createFlow", + } + ) + + var rawBody []byte + request, rawBody, close, err := s.decodeCreateFlowRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response CreateFlowRes + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CreateFlowOperation, + OperationSummary: "", + OperationID: "createFlow", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = *CreateFlowRequest + Params = struct{} + Response = CreateFlowRes + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.CreateFlow(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.CreateFlow(ctx, request) + } + if err != nil { + defer recordError("Internal", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodeCreateFlowResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetFlowRequest handles getFlow operation. +// +// GET /api/flows/{flowId} +func (s *Server) handleGetFlowRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getFlow"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/flows/{flowId}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetFlowOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(attrs...) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: GetFlowOperation, + ID: "getFlow", + } + ) + params, err := decodeGetFlowParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response GetFlowRes + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetFlowOperation, + OperationSummary: "", + OperationID: "getFlow", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "flowId", + In: "path", + }: params.FlowId, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetFlowParams + Response = GetFlowRes + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetFlowParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetFlow(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetFlow(ctx, params) + } + if err != nil { + defer recordError("Internal", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodeGetFlowResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetHealthRequest handles getHealth operation. +// +// GET /api/health +func (s *Server) handleGetHealthRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getHealth"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/health"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetHealthOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(attrs...) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + ) + + var rawBody []byte + + var response *HealthResponse + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetHealthOperation, + OperationSummary: "", + OperationID: "getHealth", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = *HealthResponse + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetHealth(ctx) + return response, err + }, + ) + } else { + response, err = s.h.GetHealth(ctx) + } + if err != nil { + defer recordError("Internal", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodeGetHealthResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} diff --git a/internal/api/generated/oas_interfaces_gen.go b/internal/api/generated/oas_interfaces_gen.go new file mode 100644 index 0000000..df635c8 --- /dev/null +++ b/internal/api/generated/oas_interfaces_gen.go @@ -0,0 +1,14 @@ +// Code generated by ogen, DO NOT EDIT. +package generated + +type ApproveFlowRes interface { + approveFlowRes() +} + +type CreateFlowRes interface { + createFlowRes() +} + +type GetFlowRes interface { + getFlowRes() +} diff --git a/internal/api/generated/oas_json_gen.go b/internal/api/generated/oas_json_gen.go new file mode 100644 index 0000000..736139b --- /dev/null +++ b/internal/api/generated/oas_json_gen.go @@ -0,0 +1,1068 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "math/bits" + "strconv" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "github.com/ogen-go/ogen/validate" +) + +// Encode implements json.Marshaler. +func (s *ApprovalRequest) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *ApprovalRequest) encodeFields(e *jx.Encoder) { + { + e.FieldStart("approved") + s.Approved.Encode(e) + } +} + +var jsonFieldsNameOfApprovalRequest = [1]string{ + 0: "approved", +} + +// Decode decodes ApprovalRequest from json. +func (s *ApprovalRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApprovalRequest to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "approved": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Approved.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"approved\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode ApprovalRequest") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfApprovalRequest) { + name = jsonFieldsNameOfApprovalRequest[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ApprovalRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApprovalRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ApprovalRequestApproved as json. +func (s ApprovalRequestApproved) Encode(e *jx.Encoder) { + e.Bool(bool(s)) +} + +// Decode decodes ApprovalRequestApproved from json. +func (s *ApprovalRequestApproved) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApprovalRequestApproved to nil") + } + v, err := d.Bool() + if err != nil { + return err + } + *s = ApprovalRequestApproved(v) + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ApprovalRequestApproved) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApprovalRequestApproved) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ApproveFlowBadRequest as json. +func (s *ApproveFlowBadRequest) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes ApproveFlowBadRequest from json. +func (s *ApproveFlowBadRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApproveFlowBadRequest to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = ApproveFlowBadRequest(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ApproveFlowBadRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApproveFlowBadRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ApproveFlowConflict as json. +func (s *ApproveFlowConflict) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes ApproveFlowConflict from json. +func (s *ApproveFlowConflict) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApproveFlowConflict to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = ApproveFlowConflict(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ApproveFlowConflict) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApproveFlowConflict) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ApproveFlowNotFound as json. +func (s *ApproveFlowNotFound) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes ApproveFlowNotFound from json. +func (s *ApproveFlowNotFound) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApproveFlowNotFound to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = ApproveFlowNotFound(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ApproveFlowNotFound) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApproveFlowNotFound) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ApproveFlowServiceUnavailable as json. +func (s *ApproveFlowServiceUnavailable) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes ApproveFlowServiceUnavailable from json. +func (s *ApproveFlowServiceUnavailable) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ApproveFlowServiceUnavailable to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = ApproveFlowServiceUnavailable(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ApproveFlowServiceUnavailable) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ApproveFlowServiceUnavailable) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes CreateFlowBadRequest as json. +func (s *CreateFlowBadRequest) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes CreateFlowBadRequest from json. +func (s *CreateFlowBadRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode CreateFlowBadRequest to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = CreateFlowBadRequest(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *CreateFlowBadRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *CreateFlowBadRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *CreateFlowRequest) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *CreateFlowRequest) encodeFields(e *jx.Encoder) { + { + e.FieldStart("title") + e.Str(s.Title) + } +} + +var jsonFieldsNameOfCreateFlowRequest = [1]string{ + 0: "title", +} + +// Decode decodes CreateFlowRequest from json. +func (s *CreateFlowRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode CreateFlowRequest to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "title": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Title = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"title\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode CreateFlowRequest") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfCreateFlowRequest) { + name = jsonFieldsNameOfCreateFlowRequest[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *CreateFlowRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *CreateFlowRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes CreateFlowServiceUnavailable as json. +func (s *CreateFlowServiceUnavailable) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes CreateFlowServiceUnavailable from json. +func (s *CreateFlowServiceUnavailable) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode CreateFlowServiceUnavailable to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = CreateFlowServiceUnavailable(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *CreateFlowServiceUnavailable) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *CreateFlowServiceUnavailable) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *ErrorResponse) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *ErrorResponse) encodeFields(e *jx.Encoder) { + { + e.FieldStart("error") + e.Str(s.Error) + } + { + e.FieldStart("message") + e.Str(s.Message) + } +} + +var jsonFieldsNameOfErrorResponse = [2]string{ + 0: "error", + 1: "message", +} + +// Decode decodes ErrorResponse from json. +func (s *ErrorResponse) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ErrorResponse to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "error": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Error = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + case "message": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Message = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"message\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode ErrorResponse") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfErrorResponse) { + name = jsonFieldsNameOfErrorResponse[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ErrorResponse) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ErrorResponse) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *FlowView) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *FlowView) encodeFields(e *jx.Encoder) { + { + e.FieldStart("flowId") + e.Str(s.FlowId) + } + { + e.FieldStart("title") + e.Str(s.Title) + } + { + e.FieldStart("state") + s.State.Encode(e) + } + { + e.FieldStart("reminderCount") + e.Int64(s.ReminderCount) + } + { + if s.Result.Set { + e.FieldStart("result") + s.Result.Encode(e) + } + } +} + +var jsonFieldsNameOfFlowView = [5]string{ + 0: "flowId", + 1: "title", + 2: "state", + 3: "reminderCount", + 4: "result", +} + +// Decode decodes FlowView from json. +func (s *FlowView) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode FlowView to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "flowId": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.FlowId = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"flowId\"") + } + case "title": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Title = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"title\"") + } + case "state": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + if err := s.State.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"state\"") + } + case "reminderCount": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int64() + s.ReminderCount = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"reminderCount\"") + } + case "result": + if err := func() error { + s.Result.Reset() + if err := s.Result.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"result\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode FlowView") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfFlowView) { + name = jsonFieldsNameOfFlowView[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *FlowView) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *FlowView) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes GetFlowBadRequest as json. +func (s *GetFlowBadRequest) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes GetFlowBadRequest from json. +func (s *GetFlowBadRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GetFlowBadRequest to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = GetFlowBadRequest(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GetFlowBadRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GetFlowBadRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes GetFlowNotFound as json. +func (s *GetFlowNotFound) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes GetFlowNotFound from json. +func (s *GetFlowNotFound) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GetFlowNotFound to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = GetFlowNotFound(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GetFlowNotFound) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GetFlowNotFound) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes GetFlowServiceUnavailable as json. +func (s *GetFlowServiceUnavailable) Encode(e *jx.Encoder) { + unwrapped := (*ErrorResponse)(s) + + unwrapped.Encode(e) +} + +// Decode decodes GetFlowServiceUnavailable from json. +func (s *GetFlowServiceUnavailable) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GetFlowServiceUnavailable to nil") + } + var unwrapped ErrorResponse + if err := func() error { + if err := unwrapped.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = GetFlowServiceUnavailable(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GetFlowServiceUnavailable) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GetFlowServiceUnavailable) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *HealthResponse) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *HealthResponse) encodeFields(e *jx.Encoder) { + { + e.FieldStart("status") + s.Status.Encode(e) + } +} + +var jsonFieldsNameOfHealthResponse = [1]string{ + 0: "status", +} + +// Decode decodes HealthResponse from json. +func (s *HealthResponse) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode HealthResponse to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "status": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + default: + return errors.Errorf("unexpected field %q", k) + } + return nil + }); err != nil { + return errors.Wrap(err, "decode HealthResponse") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfHealthResponse) { + name = jsonFieldsNameOfHealthResponse[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *HealthResponse) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *HealthResponse) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes HealthResponseStatus as json. +func (s HealthResponseStatus) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes HealthResponseStatus from json. +func (s *HealthResponseStatus) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode HealthResponseStatus to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch HealthResponseStatus(v) { + case HealthResponseStatusOk: + *s = HealthResponseStatusOk + default: + *s = HealthResponseStatus(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s HealthResponseStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *HealthResponseStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes string as json. +func (o OptString) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes string from json. +func (o *OptString) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptString to nil") + } + o.Set = true + v, err := d.Str() + if err != nil { + return err + } + o.Value = string(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptString) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptString) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ProcessState as json. +func (s ProcessState) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ProcessState from json. +func (s *ProcessState) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ProcessState to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ProcessState(v) { + case ProcessStateStarted: + *s = ProcessStateStarted + case ProcessStateValidated: + *s = ProcessStateValidated + case ProcessStateWaitingForApproval: + *s = ProcessStateWaitingForApproval + case ProcessStateReminderEmitted: + *s = ProcessStateReminderEmitted + case ProcessStateApproved: + *s = ProcessStateApproved + case ProcessStateExecuting: + *s = ProcessStateExecuting + case ProcessStateCompleted: + *s = ProcessStateCompleted + default: + *s = ProcessState(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ProcessState) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ProcessState) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} diff --git a/internal/api/generated/oas_labeler_gen.go b/internal/api/generated/oas_labeler_gen.go new file mode 100644 index 0000000..a5b4671 --- /dev/null +++ b/internal/api/generated/oas_labeler_gen.go @@ -0,0 +1,42 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" +) + +// Labeler is used to allow adding custom attributes to the server request metrics. +type Labeler struct { + attrs []attribute.KeyValue +} + +// Add attributes to the Labeler. +func (l *Labeler) Add(attrs ...attribute.KeyValue) { + l.attrs = append(l.attrs, attrs...) +} + +// AttributeSet returns the attributes added to the Labeler as an attribute.Set. +func (l *Labeler) AttributeSet() attribute.Set { + return attribute.NewSet(l.attrs...) +} + +type labelerContextKey struct{} + +// LabelerFromContext retrieves the Labeler from the provided context, if present. +// +// If no Labeler was found in the provided context a new, empty Labeler is returned and the second +// return value is false. In this case it is safe to use the Labeler but any attributes added to +// it will not be used. +func LabelerFromContext(ctx context.Context) (*Labeler, bool) { + if l, ok := ctx.Value(labelerContextKey{}).(*Labeler); ok { + return l, true + } + return &Labeler{}, false +} + +func contextWithLabeler(ctx context.Context, l *Labeler) context.Context { + return context.WithValue(ctx, labelerContextKey{}, l) +} diff --git a/internal/api/generated/oas_middleware_gen.go b/internal/api/generated/oas_middleware_gen.go new file mode 100644 index 0000000..a48058a --- /dev/null +++ b/internal/api/generated/oas_middleware_gen.go @@ -0,0 +1,10 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "github.com/ogen-go/ogen/middleware" +) + +// Middleware is middleware type. +type Middleware = middleware.Middleware diff --git a/internal/api/generated/oas_operations_gen.go b/internal/api/generated/oas_operations_gen.go new file mode 100644 index 0000000..66a2abd --- /dev/null +++ b/internal/api/generated/oas_operations_gen.go @@ -0,0 +1,13 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +// OperationName is the ogen operation name +type OperationName = string + +const ( + ApproveFlowOperation OperationName = "ApproveFlow" + CreateFlowOperation OperationName = "CreateFlow" + GetFlowOperation OperationName = "GetFlow" + GetHealthOperation OperationName = "GetHealth" +) diff --git a/internal/api/generated/oas_parameters_gen.go b/internal/api/generated/oas_parameters_gen.go new file mode 100644 index 0000000..eeea622 --- /dev/null +++ b/internal/api/generated/oas_parameters_gen.go @@ -0,0 +1,185 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "net/http" + "net/url" + + "github.com/go-faster/errors" + "github.com/ogen-go/ogen/conv" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/uri" + "github.com/ogen-go/ogen/validate" +) + +// ApproveFlowParams is parameters of approveFlow operation. +type ApproveFlowParams struct { + FlowId string +} + +func unpackApproveFlowParams(packed middleware.Parameters) (params ApproveFlowParams) { + { + key := middleware.ParameterKey{ + Name: "flowId", + In: "path", + } + params.FlowId = packed[key].(string) + } + return params +} + +func decodeApproveFlowParams(args [1]string, argsEscaped bool, r *http.Request) (params ApproveFlowParams, _ error) { + // Decode path: flowId. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "flowId", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.FlowId = c + return nil + }(); err != nil { + return err + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: regexMap["^process-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"], + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(params.FlowId)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "flowId", + In: "path", + Err: err, + } + } + return params, nil +} + +// GetFlowParams is parameters of getFlow operation. +type GetFlowParams struct { + FlowId string +} + +func unpackGetFlowParams(packed middleware.Parameters) (params GetFlowParams) { + { + key := middleware.ParameterKey{ + Name: "flowId", + In: "path", + } + params.FlowId = packed[key].(string) + } + return params +} + +func decodeGetFlowParams(args [1]string, argsEscaped bool, r *http.Request) (params GetFlowParams, _ error) { + // Decode path: flowId. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "flowId", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.FlowId = c + return nil + }(); err != nil { + return err + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: regexMap["^process-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"], + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(params.FlowId)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "flowId", + In: "path", + Err: err, + } + } + return params, nil +} diff --git a/internal/api/generated/oas_request_decoders_gen.go b/internal/api/generated/oas_request_decoders_gen.go new file mode 100644 index 0000000..686acc5 --- /dev/null +++ b/internal/api/generated/oas_request_decoders_gen.go @@ -0,0 +1,173 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "bytes" + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/validate" +) + +func (s *Server) decodeApproveFlowRequest(r *http.Request) ( + req *ApprovalRequest, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request ApprovalRequest + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, rawBody, close, errors.Wrap(err, "validate") + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} + +func (s *Server) decodeCreateFlowRequest(r *http.Request) ( + req *CreateFlowRequest, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request CreateFlowRequest + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, rawBody, close, errors.Wrap(err, "validate") + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} diff --git a/internal/api/generated/oas_request_encoders_gen.go b/internal/api/generated/oas_request_encoders_gen.go new file mode 100644 index 0000000..825f700 --- /dev/null +++ b/internal/api/generated/oas_request_encoders_gen.go @@ -0,0 +1,39 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "bytes" + "net/http" + + "github.com/go-faster/jx" + ht "github.com/ogen-go/ogen/http" +) + +func encodeApproveFlowRequest( + req *ApprovalRequest, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + +func encodeCreateFlowRequest( + req *CreateFlowRequest, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} diff --git a/internal/api/generated/oas_response_decoders_gen.go b/internal/api/generated/oas_response_decoders_gen.go new file mode 100644 index 0000000..c1ee407 --- /dev/null +++ b/internal/api/generated/oas_response_decoders_gen.go @@ -0,0 +1,610 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/validate" +) + +func decodeApproveFlowResponse(resp *http.Response) (res ApproveFlowRes, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response FlowView + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 400: + // Code 400. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response ApproveFlowBadRequest + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 404: + // Code 404. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response ApproveFlowNotFound + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 409: + // Code 409. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response ApproveFlowConflict + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 503: + // Code 503. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response ApproveFlowServiceUnavailable + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCodeWithResponse(resp) +} + +func decodeCreateFlowResponse(resp *http.Response) (res CreateFlowRes, _ error) { + switch resp.StatusCode { + case 201: + // Code 201. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response FlowView + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 400: + // Code 400. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response CreateFlowBadRequest + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 503: + // Code 503. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response CreateFlowServiceUnavailable + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCodeWithResponse(resp) +} + +func decodeGetFlowResponse(resp *http.Response) (res GetFlowRes, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response FlowView + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 400: + // Code 400. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response GetFlowBadRequest + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 404: + // Code 404. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response GetFlowNotFound + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 503: + // Code 503. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response GetFlowServiceUnavailable + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCodeWithResponse(resp) +} + +func decodeGetHealthResponse(resp *http.Response) (res *HealthResponse, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response HealthResponse + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCodeWithResponse(resp) +} diff --git a/internal/api/generated/oas_response_encoders_gen.go b/internal/api/generated/oas_response_encoders_gen.go new file mode 100644 index 0000000..be9d024 --- /dev/null +++ b/internal/api/generated/oas_response_encoders_gen.go @@ -0,0 +1,193 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func encodeApproveFlowResponse(response ApproveFlowRes, w http.ResponseWriter, span trace.Span) error { + switch response := response.(type) { + case *FlowView: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *ApproveFlowBadRequest: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(400) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *ApproveFlowNotFound: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(404) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *ApproveFlowConflict: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(409) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *ApproveFlowServiceUnavailable: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(503) + span.SetStatus(codes.Error, http.StatusText(503)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + default: + return errors.Errorf("unexpected response type: %T", response) + } +} + +func encodeCreateFlowResponse(response CreateFlowRes, w http.ResponseWriter, span trace.Span) error { + switch response := response.(type) { + case *FlowView: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(201) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *CreateFlowBadRequest: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(400) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *CreateFlowServiceUnavailable: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(503) + span.SetStatus(codes.Error, http.StatusText(503)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + default: + return errors.Errorf("unexpected response type: %T", response) + } +} + +func encodeGetFlowResponse(response GetFlowRes, w http.ResponseWriter, span trace.Span) error { + switch response := response.(type) { + case *FlowView: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *GetFlowBadRequest: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(400) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *GetFlowNotFound: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(404) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + case *GetFlowServiceUnavailable: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(503) + span.SetStatus(codes.Error, http.StatusText(503)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil + + default: + return errors.Errorf("unexpected response type: %T", response) + } +} + +func encodeGetHealthResponse(response *HealthResponse, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} diff --git a/internal/api/generated/oas_router_gen.go b/internal/api/generated/oas_router_gen.go new file mode 100644 index 0000000..4f361ce --- /dev/null +++ b/internal/api/generated/oas_router_gen.go @@ -0,0 +1,404 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "net/http" + "net/url" + "strings" + + "github.com/ogen-go/ogen/uri" +) + +var ( + rn4AllowedHeaders = map[string]string{ + "POST": "Content-Type", + } + rn3AllowedHeaders = map[string]string{ + "POST": "Content-Type", + } +) + +func (s *Server) cutPrefix(path string) (string, bool) { + prefix := s.cfg.Prefix + if prefix == "" { + return path, true + } + if !strings.HasPrefix(path, prefix) { + // Prefix doesn't match. + return "", false + } + // Cut prefix from the path. + return strings.TrimPrefix(path, prefix), true +} + +// ServeHTTP serves http request as defined by OpenAPI v3 specification, +// calling handler that matches the path or returning not found error. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + elem := r.URL.Path + elemIsEscaped := false + if rawPath := r.URL.RawPath; rawPath != "" { + if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok { + elem = normalized + elemIsEscaped = strings.ContainsRune(elem, '%') + } + } + + elem, ok := s.cutPrefix(elem) + if !ok || len(elem) == 0 { + s.notFound(w, r) + return + } + args := [1]string{} + + // Static code generated router with unwrapped path search. + switch { + default: + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/api/" + + if l := len("/api/"); len(elem) >= l && elem[0:l] == "/api/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'f': // Prefix: "flows" + + if l := len("flows"); len(elem) >= l && elem[0:l] == "flows" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "POST": + s.handleCreateFlowRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "POST", + allowedHeaders: rn4AllowedHeaders, + acceptPost: "application/json", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "flowId" + // Match until "/" + idx := strings.IndexByte(elem, '/') + if idx < 0 { + idx = len(elem) + } + args[0] = elem[:idx] + elem = elem[idx:] + + if len(elem) == 0 { + switch r.Method { + case "GET": + s.handleGetFlowRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case '/': // Prefix: "/approvals" + + if l := len("/approvals"); len(elem) >= l && elem[0:l] == "/approvals" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handleApproveFlowRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "POST", + allowedHeaders: rn3AllowedHeaders, + acceptPost: "application/json", + acceptPatch: "", + }) + } + + return + } + + } + + } + + case 'h': // Prefix: "health" + + if l := len("health"); len(elem) >= l && elem[0:l] == "health" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetHealthRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + } + } + s.notFound(w, r) +} + +// Route is route object. +type Route struct { + name string + summary string + operationID string + operationGroup string + pathPattern string + count int + args [1]string +} + +// Name returns ogen operation name. +// +// It is guaranteed to be unique and not empty. +func (r Route) Name() string { + return r.name +} + +// Summary returns OpenAPI summary. +func (r Route) Summary() string { + return r.summary +} + +// OperationID returns OpenAPI operationId. +func (r Route) OperationID() string { + return r.operationID +} + +// OperationGroup returns the x-ogen-operation-group value. +func (r Route) OperationGroup() string { + return r.operationGroup +} + +// PathPattern returns OpenAPI path. +func (r Route) PathPattern() string { + return r.pathPattern +} + +// Args returns parsed arguments. +func (r Route) Args() []string { + return r.args[:r.count] +} + +// FindRoute finds Route for given method and path. +// +// Note: this method does not unescape path or handle reserved characters in path properly. Use FindPath instead. +func (s *Server) FindRoute(method, path string) (Route, bool) { + return s.FindPath(method, &url.URL{Path: path}) +} + +// FindPath finds Route for given method and URL. +func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { + var ( + elem = u.Path + args = r.args + ) + if rawPath := u.RawPath; rawPath != "" { + if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok { + elem = normalized + } + defer func() { + for i, arg := range r.args[:r.count] { + if unescaped, err := url.PathUnescape(arg); err == nil { + r.args[i] = unescaped + } + } + }() + } + + elem, ok := s.cutPrefix(elem) + if !ok { + return r, false + } + + // Static code generated router with unwrapped path search. + switch { + default: + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/api/" + + if l := len("/api/"); len(elem) >= l && elem[0:l] == "/api/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'f': // Prefix: "flows" + + if l := len("flows"); len(elem) >= l && elem[0:l] == "flows" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "POST": + r.name = CreateFlowOperation + r.summary = "" + r.operationID = "createFlow" + r.operationGroup = "" + r.pathPattern = "/api/flows" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "flowId" + // Match until "/" + idx := strings.IndexByte(elem, '/') + if idx < 0 { + idx = len(elem) + } + args[0] = elem[:idx] + elem = elem[idx:] + + if len(elem) == 0 { + switch method { + case "GET": + r.name = GetFlowOperation + r.summary = "" + r.operationID = "getFlow" + r.operationGroup = "" + r.pathPattern = "/api/flows/{flowId}" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/approvals" + + if l := len("/approvals"); len(elem) >= l && elem[0:l] == "/approvals" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = ApproveFlowOperation + r.summary = "" + r.operationID = "approveFlow" + r.operationGroup = "" + r.pathPattern = "/api/flows/{flowId}/approvals" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } + + } + + case 'h': // Prefix: "health" + + if l := len("health"); len(elem) >= l && elem[0:l] == "health" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "GET": + r.name = GetHealthOperation + r.summary = "" + r.operationID = "getHealth" + r.operationGroup = "" + r.pathPattern = "/api/health" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + + } + + } + } + return r, false +} diff --git a/internal/api/generated/oas_schemas_gen.go b/internal/api/generated/oas_schemas_gen.go new file mode 100644 index 0000000..9b33ac5 --- /dev/null +++ b/internal/api/generated/oas_schemas_gen.go @@ -0,0 +1,347 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "github.com/go-faster/errors" +) + +// Ref: #/components/schemas/ApprovalRequest +type ApprovalRequest struct { + Approved ApprovalRequestApproved `json:"approved"` +} + +// GetApproved returns the value of Approved. +func (s *ApprovalRequest) GetApproved() ApprovalRequestApproved { + return s.Approved +} + +// SetApproved sets the value of Approved. +func (s *ApprovalRequest) SetApproved(val ApprovalRequestApproved) { + s.Approved = val +} + +type ApprovalRequestApproved bool + +const ( + ApprovalRequestApprovedTrue ApprovalRequestApproved = true +) + +// AllValues returns all ApprovalRequestApproved values. +func (ApprovalRequestApproved) AllValues() []ApprovalRequestApproved { + return []ApprovalRequestApproved{ + ApprovalRequestApprovedTrue, + } +} + +type ApproveFlowBadRequest ErrorResponse + +func (*ApproveFlowBadRequest) approveFlowRes() {} + +type ApproveFlowConflict ErrorResponse + +func (*ApproveFlowConflict) approveFlowRes() {} + +type ApproveFlowNotFound ErrorResponse + +func (*ApproveFlowNotFound) approveFlowRes() {} + +type ApproveFlowServiceUnavailable ErrorResponse + +func (*ApproveFlowServiceUnavailable) approveFlowRes() {} + +type CreateFlowBadRequest ErrorResponse + +func (*CreateFlowBadRequest) createFlowRes() {} + +// Ref: #/components/schemas/CreateFlowRequest +type CreateFlowRequest struct { + Title string `json:"title"` +} + +// GetTitle returns the value of Title. +func (s *CreateFlowRequest) GetTitle() string { + return s.Title +} + +// SetTitle sets the value of Title. +func (s *CreateFlowRequest) SetTitle(val string) { + s.Title = val +} + +type CreateFlowServiceUnavailable ErrorResponse + +func (*CreateFlowServiceUnavailable) createFlowRes() {} + +// Ref: #/components/schemas/ErrorResponse +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} + +// GetError returns the value of Error. +func (s *ErrorResponse) GetError() string { + return s.Error +} + +// GetMessage returns the value of Message. +func (s *ErrorResponse) GetMessage() string { + return s.Message +} + +// SetError sets the value of Error. +func (s *ErrorResponse) SetError(val string) { + s.Error = val +} + +// SetMessage sets the value of Message. +func (s *ErrorResponse) SetMessage(val string) { + s.Message = val +} + +// Ref: #/components/schemas/FlowView +type FlowView struct { + FlowId string `json:"flowId"` + Title string `json:"title"` + State ProcessState `json:"state"` + ReminderCount int64 `json:"reminderCount"` + Result OptString `json:"result"` +} + +// GetFlowId returns the value of FlowId. +func (s *FlowView) GetFlowId() string { + return s.FlowId +} + +// GetTitle returns the value of Title. +func (s *FlowView) GetTitle() string { + return s.Title +} + +// GetState returns the value of State. +func (s *FlowView) GetState() ProcessState { + return s.State +} + +// GetReminderCount returns the value of ReminderCount. +func (s *FlowView) GetReminderCount() int64 { + return s.ReminderCount +} + +// GetResult returns the value of Result. +func (s *FlowView) GetResult() OptString { + return s.Result +} + +// SetFlowId sets the value of FlowId. +func (s *FlowView) SetFlowId(val string) { + s.FlowId = val +} + +// SetTitle sets the value of Title. +func (s *FlowView) SetTitle(val string) { + s.Title = val +} + +// SetState sets the value of State. +func (s *FlowView) SetState(val ProcessState) { + s.State = val +} + +// SetReminderCount sets the value of ReminderCount. +func (s *FlowView) SetReminderCount(val int64) { + s.ReminderCount = val +} + +// SetResult sets the value of Result. +func (s *FlowView) SetResult(val OptString) { + s.Result = val +} + +func (*FlowView) approveFlowRes() {} +func (*FlowView) createFlowRes() {} +func (*FlowView) getFlowRes() {} + +type GetFlowBadRequest ErrorResponse + +func (*GetFlowBadRequest) getFlowRes() {} + +type GetFlowNotFound ErrorResponse + +func (*GetFlowNotFound) getFlowRes() {} + +type GetFlowServiceUnavailable ErrorResponse + +func (*GetFlowServiceUnavailable) getFlowRes() {} + +// Ref: #/components/schemas/HealthResponse +type HealthResponse struct { + Status HealthResponseStatus `json:"status"` +} + +// GetStatus returns the value of Status. +func (s *HealthResponse) GetStatus() HealthResponseStatus { + return s.Status +} + +// SetStatus sets the value of Status. +func (s *HealthResponse) SetStatus(val HealthResponseStatus) { + s.Status = val +} + +type HealthResponseStatus string + +const ( + HealthResponseStatusOk HealthResponseStatus = "ok" +) + +// AllValues returns all HealthResponseStatus values. +func (HealthResponseStatus) AllValues() []HealthResponseStatus { + return []HealthResponseStatus{ + HealthResponseStatusOk, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s HealthResponseStatus) MarshalText() ([]byte, error) { + switch s { + case HealthResponseStatusOk: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *HealthResponseStatus) UnmarshalText(data []byte) error { + switch HealthResponseStatus(data) { + case HealthResponseStatusOk: + *s = HealthResponseStatusOk + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// NewOptString returns new OptString with value set to v. +func NewOptString(v string) OptString { + return OptString{ + Value: v, + Set: true, + } +} + +// OptString is optional string. +type OptString struct { + Value string + Set bool +} + +// IsSet returns true if OptString was set. +func (o OptString) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptString) Reset() { + var v string + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptString) SetTo(v string) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptString) Get() (v string, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptString) Or(d string) string { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// Ref: #/components/schemas/ProcessState +type ProcessState string + +const ( + ProcessStateStarted ProcessState = "started" + ProcessStateValidated ProcessState = "validated" + ProcessStateWaitingForApproval ProcessState = "waiting_for_approval" + ProcessStateReminderEmitted ProcessState = "reminder_emitted" + ProcessStateApproved ProcessState = "approved" + ProcessStateExecuting ProcessState = "executing" + ProcessStateCompleted ProcessState = "completed" +) + +// AllValues returns all ProcessState values. +func (ProcessState) AllValues() []ProcessState { + return []ProcessState{ + ProcessStateStarted, + ProcessStateValidated, + ProcessStateWaitingForApproval, + ProcessStateReminderEmitted, + ProcessStateApproved, + ProcessStateExecuting, + ProcessStateCompleted, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ProcessState) MarshalText() ([]byte, error) { + switch s { + case ProcessStateStarted: + return []byte(s), nil + case ProcessStateValidated: + return []byte(s), nil + case ProcessStateWaitingForApproval: + return []byte(s), nil + case ProcessStateReminderEmitted: + return []byte(s), nil + case ProcessStateApproved: + return []byte(s), nil + case ProcessStateExecuting: + return []byte(s), nil + case ProcessStateCompleted: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ProcessState) UnmarshalText(data []byte) error { + switch ProcessState(data) { + case ProcessStateStarted: + *s = ProcessStateStarted + return nil + case ProcessStateValidated: + *s = ProcessStateValidated + return nil + case ProcessStateWaitingForApproval: + *s = ProcessStateWaitingForApproval + return nil + case ProcessStateReminderEmitted: + *s = ProcessStateReminderEmitted + return nil + case ProcessStateApproved: + *s = ProcessStateApproved + return nil + case ProcessStateExecuting: + *s = ProcessStateExecuting + return nil + case ProcessStateCompleted: + *s = ProcessStateCompleted + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} diff --git a/internal/api/generated/oas_server_gen.go b/internal/api/generated/oas_server_gen.go new file mode 100644 index 0000000..2511cde --- /dev/null +++ b/internal/api/generated/oas_server_gen.go @@ -0,0 +1,46 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "context" +) + +// Handler handles operations described by OpenAPI v3 specification. +type Handler interface { + // ApproveFlow implements approveFlow operation. + // + // POST /api/flows/{flowId}/approvals + ApproveFlow(ctx context.Context, req *ApprovalRequest, params ApproveFlowParams) (ApproveFlowRes, error) + // CreateFlow implements createFlow operation. + // + // POST /api/flows + CreateFlow(ctx context.Context, req *CreateFlowRequest) (CreateFlowRes, error) + // GetFlow implements getFlow operation. + // + // GET /api/flows/{flowId} + GetFlow(ctx context.Context, params GetFlowParams) (GetFlowRes, error) + // GetHealth implements getHealth operation. + // + // GET /api/health + GetHealth(ctx context.Context) (*HealthResponse, error) +} + +// Server implements http server based on OpenAPI v3 specification and +// calls Handler to handle requests. +type Server struct { + h Handler + baseServer +} + +// NewServer creates new Server. +func NewServer(h Handler, opts ...ServerOption) (*Server, error) { + s, err := newServerConfig(opts...).baseServer() + if err != nil { + return nil, err + } + return &Server{ + h: h, + baseServer: s, + }, nil +} diff --git a/internal/api/generated/oas_unimplemented_gen.go b/internal/api/generated/oas_unimplemented_gen.go new file mode 100644 index 0000000..e13ca66 --- /dev/null +++ b/internal/api/generated/oas_unimplemented_gen.go @@ -0,0 +1,42 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "context" + + ht "github.com/ogen-go/ogen/http" +) + +// UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented. +type UnimplementedHandler struct{} + +var _ Handler = UnimplementedHandler{} + +// ApproveFlow implements approveFlow operation. +// +// POST /api/flows/{flowId}/approvals +func (UnimplementedHandler) ApproveFlow(ctx context.Context, req *ApprovalRequest, params ApproveFlowParams) (r ApproveFlowRes, _ error) { + return r, ht.ErrNotImplemented +} + +// CreateFlow implements createFlow operation. +// +// POST /api/flows +func (UnimplementedHandler) CreateFlow(ctx context.Context, req *CreateFlowRequest) (r CreateFlowRes, _ error) { + return r, ht.ErrNotImplemented +} + +// GetFlow implements getFlow operation. +// +// GET /api/flows/{flowId} +func (UnimplementedHandler) GetFlow(ctx context.Context, params GetFlowParams) (r GetFlowRes, _ error) { + return r, ht.ErrNotImplemented +} + +// GetHealth implements getHealth operation. +// +// GET /api/health +func (UnimplementedHandler) GetHealth(ctx context.Context) (r *HealthResponse, _ error) { + return r, ht.ErrNotImplemented +} diff --git a/internal/api/generated/oas_validators_gen.go b/internal/api/generated/oas_validators_gen.go new file mode 100644 index 0000000..ae85f64 --- /dev/null +++ b/internal/api/generated/oas_validators_gen.go @@ -0,0 +1,302 @@ +// Code generated by ogen, DO NOT EDIT. + +package generated + +import ( + "github.com/go-faster/errors" + "github.com/ogen-go/ogen/validate" +) + +func (s *ApprovalRequest) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Approved.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "approved", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s ApprovalRequestApproved) Validate() error { + switch s { + case true: + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *ApproveFlowBadRequest) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *ApproveFlowConflict) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *ApproveFlowNotFound) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *ApproveFlowServiceUnavailable) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *CreateFlowBadRequest) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *CreateFlowRequest) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.String{ + MinLength: 1, + MinLengthSet: true, + MaxLength: 120, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Title)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "title", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *CreateFlowServiceUnavailable) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *ErrorResponse) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 0, + MaxLengthSet: false, + Email: false, + Hostname: false, + Regex: regexMap["^[a-z][a-z0-9_]*$"], + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Error)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "error", + Error: err, + }) + } + if err := func() error { + if err := (validate.String{ + MinLength: 0, + MinLengthSet: false, + MaxLength: 3000, + MaxLengthSet: true, + Email: false, + Hostname: false, + Regex: nil, + MinNumeric: 0, + MinNumericSet: false, + MaxNumeric: 0, + MaxNumericSet: false, + }).Validate(string(s.Message)); err != nil { + return errors.Wrap(err, "string") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "message", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *FlowView) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.State.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "state", + Error: err, + }) + } + if err := func() error { + if err := (validate.Int{ + MinSet: true, + Min: 0, + MaxSet: false, + Max: 0, + MinExclusive: false, + MaxExclusive: false, + MultipleOfSet: false, + MultipleOf: 0, + Pattern: nil, + }).Validate(int64(s.ReminderCount)); err != nil { + return errors.Wrap(err, "int") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "reminderCount", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *GetFlowBadRequest) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *GetFlowNotFound) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *GetFlowServiceUnavailable) Validate() error { + alias := (*ErrorResponse)(s) + if err := alias.Validate(); err != nil { + return err + } + return nil +} + +func (s *HealthResponse) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Status.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s HealthResponseStatus) Validate() error { + switch s { + case "ok": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s ProcessState) Validate() error { + switch s { + case "started": + return nil + case "validated": + return nil + case "waiting_for_approval": + return nil + case "reminder_emitted": + return nil + case "approved": + return nil + case "executing": + return nil + case "completed": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..d4ad395 --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/superdurable/dex-template-basic-process/internal/api/generated" + "github.com/superdurable/dex-template-basic-process/internal/process" +) + +type Handler struct{ processes *process.Service } + +func NewHandler(processes *process.Service) (*generated.Server, error) { + handler := &Handler{processes: processes} + return generated.NewServer(handler, + generated.WithErrorHandler(writeGeneratedError), + generated.WithNotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "unknown API route") + }), + generated.WithMethodNotAllowed(func(w http.ResponseWriter, _ *http.Request, allowed string) { + w.Header().Set("Allow", allowed) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method is not allowed") + }), + ) +} + +func (handler *Handler) GetHealth(context.Context) (*generated.HealthResponse, error) { + return &generated.HealthResponse{Status: generated.HealthResponseStatusOk}, nil +} + +func (handler *Handler) CreateFlow(ctx context.Context, request *generated.CreateFlowRequest) (generated.CreateFlowRes, error) { + view, err := handler.processes.Start(ctx, request.Title) + if err != nil { + response := generated.CreateFlowServiceUnavailable(errorResponse("flow_start_failed", "unable to start flow")) + return &response, nil + } + return generatedView(view), nil +} + +func (handler *Handler) GetFlow(ctx context.Context, params generated.GetFlowParams) (generated.GetFlowRes, error) { + view, err := handler.processes.Get(ctx, params.FlowId) + switch { + case err == nil: + return generatedView(view), nil + case errors.Is(err, process.ErrUnknownFlow): + response := generated.GetFlowNotFound(errorResponse("unknown_flow", "flow was not found")) + return &response, nil + default: + response := generated.GetFlowServiceUnavailable(errorResponse("flow_operation_failed", "flow operation failed")) + return &response, nil + } +} + +func (handler *Handler) ApproveFlow(ctx context.Context, _ *generated.ApprovalRequest, params generated.ApproveFlowParams) (generated.ApproveFlowRes, error) { + view, err := handler.processes.Approve(ctx, params.FlowId) + switch { + case err == nil: + return generatedView(view), nil + case errors.Is(err, process.ErrUnknownFlow): + response := generated.ApproveFlowNotFound(errorResponse("unknown_flow", "flow was not found")) + return &response, nil + case errors.Is(err, process.ErrDuplicateApproval): + response := generated.ApproveFlowConflict(errorResponse("duplicate_approval", "approval is not pending")) + return &response, nil + default: + response := generated.ApproveFlowServiceUnavailable(errorResponse("flow_operation_failed", "flow operation failed")) + return &response, nil + } +} + +func generatedView(view process.View) *generated.FlowView { + response := &generated.FlowView{FlowId: view.FlowID, Title: view.Title, State: generated.ProcessState(view.State), ReminderCount: view.ReminderCount} + if view.Result != "" { + response.Result = generated.NewOptString(view.Result) + } + return response +} + +func errorResponse(code, message string) generated.ErrorResponse { + return generated.ErrorResponse{Error: code, Message: message} +} + +func writeGeneratedError(_ context.Context, w http.ResponseWriter, _ *http.Request, err error) { + _ = err + writeError(w, http.StatusBadRequest, "invalid_request", "request does not match the OpenAPI contract") +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(generated.ErrorResponse{Error: code, Message: message}) +} diff --git a/internal/process/flow.go b/internal/process/flow.go new file mode 100644 index 0000000..f0a5770 --- /dev/null +++ b/internal/process/flow.go @@ -0,0 +1,246 @@ +package process + +import ( + "fmt" + "strings" + "time" + + "github.com/superdurable/dex/sdk-go/dex" +) + +const ( + ReminderInterval = 15 * time.Minute + ReminderTimerConditionID = "approval-reminder" + completionMessage = "approved automation completed" + duplicateApprovalDetail = "approval is not pending" +) + +type State string + +const ( + StateStarted State = "started" + StateValidated State = "validated" + StateWaitingForApproval State = "waiting_for_approval" + StateReminderEmitted State = "reminder_emitted" + StateApproved State = "approved" + StateExecuting State = "executing" + StateCompleted State = "completed" +) + +type Input struct { + Title string `json:"title"` +} + +type Result struct { + Title string `json:"title"` + State State `json:"state"` + ReminderCount int64 `json:"reminderCount"` + Message string `json:"message"` +} + +type Snapshot struct { + Title string `json:"title"` + State State `json:"state"` + ReminderCount int64 `json:"reminderCount"` +} + +var ( + ProcessTitle = dex.DefineAttribute[string]("process-title") + ProcessState = dex.DefineAttribute[State]("process-state") + ProcessReminderCount = dex.DefineAttribute[int64]("process-reminder-count") + ProcessApprovals = dex.DefineChannel[bool]("process-approvals") +) + +type StartProcess struct { + dex.StepDefaultsNoWaitFor[Input] +} + +func (StartProcess) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + if err := ProcessTitle.Set(ctx, strings.TrimSpace(input.Title)); err != nil { + return nil, err + } + if err := ProcessReminderCount.Set(ctx, 0); err != nil { + return nil, err + } + if err := ProcessState.Set(ctx, StateStarted); err != nil { + return nil, err + } + return dex.GoTo(ValidateRequest{}, input), nil +} + +type ValidateRequest struct { + dex.StepDefaultsNoWaitFor[Input] +} + +func (ValidateRequest) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + title := strings.TrimSpace(input.Title) + if title == "" || len(title) > 120 { + return nil, fmt.Errorf("title must contain between 1 and 120 bytes") + } + if err := ProcessState.Set(ctx, StateValidated); err != nil { + return nil, err + } + return dex.GoTo(WaitForApproval{}, input), nil +} + +type WaitForApproval struct{ dex.StepDefaults } + +func (WaitForApproval) WaitFor(ctx dex.Context, _ Input) (*dex.Wait, error) { + if err := ProcessState.Set(ctx, StateWaitingForApproval); err != nil { + return nil, err + } + return dex.AnyOf( + ProcessApprovals.ForOne(), + dex.Timer(ReminderInterval, dex.WithConditionID(ReminderTimerConditionID)), + ), nil +} + +func (WaitForApproval) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + approvals, err := ProcessApprovals.GetConditionResults(ctx) + if err != nil { + return nil, err + } + if len(approvals) == 1 && approvals[0] { + if err := ProcessState.Set(ctx, StateApproved); err != nil { + return nil, err + } + return dex.GoTo(ExecuteApprovedAutomation{}, input), nil + } + if ctx.HasTimerFired() { + return dex.GoTo(EmitReminder{}, input), nil + } + return nil, fmt.Errorf("approval wait completed without approval or reminder") +} + +type EmitReminder struct { + dex.StepDefaultsNoWaitFor[Input] +} + +func (EmitReminder) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + count, err := ProcessReminderCount.Get(ctx) + if err != nil { + return nil, err + } + if err := ProcessReminderCount.Set(ctx, count+1); err != nil { + return nil, err + } + if err := ProcessState.Set(ctx, StateReminderEmitted); err != nil { + return nil, err + } + return dex.GoTo(WaitForApproval{}, input), nil +} + +type ExecuteApprovedAutomation struct { + dex.StepDefaultsNoWaitFor[Input] +} + +func (ExecuteApprovedAutomation) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + if err := ProcessState.Set(ctx, StateExecuting); err != nil { + return nil, err + } + return dex.GoTo(CompleteProcess{}, input), nil +} + +type CompleteProcess struct { + dex.StepDefaultsNoWaitFor[Input] +} + +func (CompleteProcess) Execute(ctx dex.Context, input Input) (*dex.StepDecision, error) { + count, err := ProcessReminderCount.Get(ctx) + if err != nil { + return nil, err + } + if err := ProcessState.Set(ctx, StateCompleted); err != nil { + return nil, err + } + return dex.GracefulComplete(Result{ + Title: strings.TrimSpace(input.Title), State: StateCompleted, + ReminderCount: count, Message: completionMessage, + }), nil +} + +type BasicProcessFlow struct{ dex.FlowDefaults } + +func (BasicProcessFlow) GetSteps() []dex.StepDef { + return []dex.StepDef{ + dex.DefineStartStep(StartProcess{}), + dex.DefineStep(ValidateRequest{}), + dex.DefineStep(WaitForApproval{}), + dex.DefineStep(EmitReminder{}), + dex.DefineStep(ExecuteApprovedAutomation{}), + dex.DefineStep(CompleteProcess{}), + } +} + +func (flow BasicProcessFlow) GetRPCs() []dex.RPCDef { + return []dex.RPCDef{ + dex.DefineRPC(flow.DescribeProcess, &dex.RPCOptions{}), + dex.DefineRPC(flow.ApproveProcess, &dex.RPCOptions{ + IsTransactional: true, + LockAttributes: []dex.AttributeLock{ + dex.LockAttribute(ProcessState), + dex.LockAttribute(ProcessReminderCount), + }, + }), + } +} + +func (BasicProcessFlow) GetPersistenceSchema() dex.PersistenceSchema { + return dex.PersistenceSchema{ + Attributes: []dex.AttributeDef{ProcessTitle, ProcessState, ProcessReminderCount}, + Channels: []dex.ChannelDef{ProcessApprovals}, + } +} + +func (BasicProcessFlow) DescribeProcess(ctx dex.Context, _ dex.None) (*dex.RPCResult[Snapshot], error) { + title, err := ProcessTitle.Get(ctx) + if err != nil { + return nil, err + } + state, err := ProcessState.Get(ctx) + if err != nil { + return nil, err + } + count, err := ProcessReminderCount.Get(ctx) + if err != nil { + return nil, err + } + return &dex.RPCResult[Snapshot]{Output: Snapshot{Title: title, State: state, ReminderCount: count}}, nil +} + +func (BasicProcessFlow) ApproveProcess(ctx dex.Context, approved bool) (*dex.RPCResult[Snapshot], error) { + state, err := ProcessState.Get(ctx) + if err != nil { + return nil, err + } + if !approved || (state != StateWaitingForApproval && state != StateReminderEmitted) { + return nil, fmt.Errorf("%s", duplicateApprovalDetail) + } + title, err := ProcessTitle.Get(ctx) + if err != nil { + return nil, err + } + count, err := ProcessReminderCount.Get(ctx) + if err != nil { + return nil, err + } + if err := ProcessState.Set(ctx, StateApproved); err != nil { + return nil, err + } + if err := ProcessApprovals.Publish(ctx, true); err != nil { + return nil, err + } + return &dex.RPCResult[Snapshot]{Output: Snapshot{Title: title, State: StateApproved, ReminderCount: count}}, nil +} + +var BasicProcess = BasicProcessFlow{} + +var _ dex.Flow = BasicProcess +var _ dex.Step[Input] = StartProcess{} +var _ dex.Step[Input] = ValidateRequest{} +var _ dex.Step[Input] = WaitForApproval{} +var _ dex.Step[Input] = EmitReminder{} +var _ dex.Step[Input] = ExecuteApprovedAutomation{} +var _ dex.Step[Input] = CompleteProcess{} +var _ dex.RPC[dex.None, Snapshot] = BasicProcess.DescribeProcess +var _ dex.RPC[bool, Snapshot] = BasicProcess.ApproveProcess diff --git a/internal/process/flow_test.go b/internal/process/flow_test.go new file mode 100644 index 0000000..46bdce0 --- /dev/null +++ b/internal/process/flow_test.go @@ -0,0 +1,26 @@ +package process + +import ( + "testing" + + "github.com/superdurable/dex/sdk-go/dex" +) + +func TestBasicProcessDefinitionRegistersEveryDurablePrimitive(t *testing.T) { + registry, err := dex.NewRegistry([]dex.Flow{BasicProcess}) + if err != nil { + t.Fatalf("register Basic Process Flow: %v", err) + } + if registry == nil { + t.Fatal("registry is nil") + } + if got := len(BasicProcess.GetSteps()); got != 6 { + t.Fatalf("step count = %d, want 6", got) + } + if got := len(BasicProcess.GetPersistenceSchema().Attributes); got != 3 { + t.Fatalf("attribute count = %d, want 3", got) + } + if got := len(BasicProcess.GetPersistenceSchema().Channels); got != 1 { + t.Fatalf("channel count = %d, want 1", got) + } +} diff --git a/internal/process/integration_test.go b/internal/process/integration_test.go new file mode 100644 index 0000000..63af163 --- /dev/null +++ b/internal/process/integration_test.go @@ -0,0 +1,156 @@ +//go:build integration + +package process_test + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/process" + appRuntime "github.com/superdurable/dex-template-basic-process/internal/runtime" +) + +func TestReminderWorkerRestartApprovalAndTerminalRejection(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + runtime := startRuntime(t, logger) + t.Log("first Worker started") + view, err := runtime.Processes.Start(ctx, "Review the launch checklist") + if err != nil { + t.Fatalf("start Flow: %v", err) + } + t.Logf("Flow started: %s", view.FlowID) + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + return getErr == nil && current.State == process.StateWaitingForApproval + }) + t.Log("Flow is waiting for approval") + + server := os.Getenv("DEX_FLOW_SERVICE_ADDRESS") + command := exec.CommandContext(ctx, "dexcli", "flow", "skip-timer", view.FlowID, + "-server", server, "-step-type", "process.WaitForApproval", "-condition-id", process.ReminderTimerConditionID, "-yes") + if output, skipErr := command.CombinedOutput(); skipErr != nil { + t.Fatalf("skip reminder Timer: %v\n%s", skipErr, output) + } + t.Log("reminder Timer skipped") + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + return getErr == nil && current.ReminderCount == 1 && current.State == process.StateWaitingForApproval + }) + t.Log("reminder observed") + + if err := runtime.Close(); err != nil { + t.Fatalf("stop first Worker: %v", err) + } + t.Log("first Worker stopped") + runtime = startRuntime(t, logger) + t.Log("replacement Worker started") + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + return getErr == nil && current.State == process.StateWaitingForApproval + }) + approved, err := runtime.Processes.Approve(ctx, view.FlowID) + if err != nil { + t.Fatalf("approve after Worker restart: %v", err) + } + if approved.ReminderCount != 1 { + t.Fatalf("reminder count = %d, want 1", approved.ReminderCount) + } + t.Log("approval accepted") + lastObservation := "" + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + observation := fmt.Sprintf("state=%s result=%q error=%v", current.State, current.Result, getErr) + if observation != lastObservation { + t.Log(observation) + lastObservation = observation + } + return getErr == nil && current.State == process.StateCompleted && current.Result != "" + }) + t.Log("completion observed") + if _, err := runtime.Processes.Approve(ctx, view.FlowID); err != process.ErrDuplicateApproval { + t.Fatalf("terminal approval error = %v", err) + } + if err := runtime.Close(); err != nil { + t.Fatalf("stop replacement Worker: %v", err) + } +} + +func TestDirectApprovalCompletesWithoutReminder(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + runtime := startRuntime(t, slog.New(slog.NewTextHandler(os.Stderr, nil))) + defer func() { + if err := runtime.Close(); err != nil { + t.Errorf("close runtime: %v", err) + } + }() + view, err := runtime.Processes.Start(ctx, "Approve without a reminder") + if err != nil { + t.Fatalf("start Flow: %v", err) + } + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + return getErr == nil && current.State == process.StateWaitingForApproval + }) + if _, err := runtime.Processes.Approve(ctx, view.FlowID); err != nil { + t.Fatalf("approve Flow: %v", err) + } + waitFor(t, ctx, func(attemptCtx context.Context) bool { + current, getErr := runtime.Processes.Get(attemptCtx, view.FlowID) + return getErr == nil && current.State == process.StateCompleted && current.ReminderCount == 0 + }) +} + +func startRuntime(t *testing.T, logger *slog.Logger) *appRuntime.Runtime { + t.Helper() + runtime, err := appRuntime.New(logger) + if err != nil { + t.Fatalf("create runtime: %v", err) + } + workerResult := runtime.StartWorker() + deadline := time.NewTimer(15 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + connection, dialErr := net.DialTimeout("tcp", os.Getenv("DEX_WORKER_TARGET"), 100*time.Millisecond) + if dialErr == nil { + _ = connection.Close() + return runtime + } + select { + case err := <-workerResult: + t.Fatalf("start Worker: %v", err) + case <-deadline.C: + t.Fatalf("wait for Worker listener: %v", dialErr) + case <-ticker.C: + } + } +} + +func waitFor(t *testing.T, ctx context.Context, condition func(context.Context) bool) { + t.Helper() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + attemptCtx, cancel := context.WithTimeout(ctx, time.Second) + matched := condition(attemptCtx) + cancel() + if matched { + return + } + select { + case <-ctx.Done(): + t.Fatal(fmt.Errorf("deadline waiting for Flow convergence: %w", ctx.Err())) + case <-ticker.C: + } + } +} diff --git a/internal/process/service.go b/internal/process/service.go new file mode 100644 index 0000000..d8a0579 --- /dev/null +++ b/internal/process/service.go @@ -0,0 +1,105 @@ +package process + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/superdurable/dex/sdk-go/dex" +) + +var ( + ErrUnknownFlow = errors.New("unknown flow") + ErrDuplicateApproval = errors.New("approval is not pending") +) + +type View struct { + FlowID string + Title string + State State + ReminderCount int64 + Result string +} + +type Service struct { + client *dex.Client + flow BasicProcessFlow +} + +func NewService(client *dex.Client) *Service { + return &Service{client: client, flow: BasicProcess} +} + +func (service *Service) Start(ctx context.Context, title string) (View, error) { + flowID := "process-" + uuid.NewString() + input := Input{Title: title} + if _, err := service.client.StartFlow(ctx, service.flow, flowID, input, dex.StartFlowOptions{IDReusePolicy: dex.IDReuseDisallow}); err != nil { + return View{}, err + } + return View{FlowID: flowID, Title: title, State: StateStarted}, nil +} + +func (service *Service) Get(ctx context.Context, flowID string) (View, error) { + var snapshot Snapshot + err := service.client.InvokeRPC(ctx, flowID, service.flow.DescribeProcess, nil, &snapshot) + if err == nil { + return snapshotView(flowID, snapshot), nil + } + var inactive *dex.FlowNotActiveError + if !errors.As(err, &inactive) { + var missing *dex.FlowNotFoundError + if errors.As(err, &missing) { + return View{}, ErrUnknownFlow + } + return View{}, err + } + result, err := service.client.WaitForFlow(ctx, flowID, dex.WaitForFlowOptions{NeedsResults: true}) + if err != nil { + var missing *dex.FlowNotFoundError + if errors.As(err, &missing) { + return View{}, ErrUnknownFlow + } + return View{}, err + } + if result.Status != dex.FlowCompleted { + return View{}, fmt.Errorf("flow ended with status %s", result.Status) + } + var output Result + if err := result.DecodeSingleOutput(&output); err != nil { + return View{}, err + } + return View{FlowID: flowID, Title: output.Title, State: output.State, ReminderCount: output.ReminderCount, Result: output.Message}, nil +} + +func (service *Service) Approve(ctx context.Context, flowID string) (View, error) { + var snapshot Snapshot + err := service.client.InvokeRPC(ctx, flowID, service.flow.ApproveProcess, true, &snapshot) + if err == nil { + return snapshotView(flowID, snapshot), nil + } + var inactive *dex.FlowNotActiveError + if errors.As(err, &inactive) { + view, getErr := service.Get(ctx, flowID) + if errors.Is(getErr, ErrUnknownFlow) { + return View{}, ErrUnknownFlow + } + if getErr == nil && view.State == StateCompleted { + return View{}, ErrDuplicateApproval + } + return View{}, err + } + var worker *dex.WorkerInvocationError + if errors.As(err, &worker) && worker.Detail == duplicateApprovalDetail { + return View{}, ErrDuplicateApproval + } + return View{}, err +} + +func snapshotView(flowID string, snapshot Snapshot) View { + view := View{FlowID: flowID, Title: snapshot.Title, State: snapshot.State, ReminderCount: snapshot.ReminderCount} + if snapshot.State == StateCompleted { + view.Result = completionMessage + } + return view +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 0000000..063b77c --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,65 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/superdurable/dex-template-basic-process/internal/process" + "github.com/superdurable/dex/blob-cache-go/blobcache" + "github.com/superdurable/dex/sdk-go/dex" +) + +type Runtime struct { + Processes *process.Service + worker *dex.Worker + client *dex.Client + cache *blobcache.Cache +} + +func New(logger *slog.Logger) (*Runtime, error) { + registry, err := dex.NewRegistry([]dex.Flow{process.BasicProcess}) + if err != nil { + return nil, fmt.Errorf("register Basic Process Flow: %w", err) + } + cache, err := blobcache.New(&blobcache.Config{Dir: environment("DEX_BLOB_CACHE_DIR", filepath.Join(os.TempDir(), "basic-process-blobs")), MaxBytes: 256 << 20, Logger: logger}) + if err != nil { + return nil, fmt.Errorf("create Dex blob cache: %w", err) + } + flowServiceAddress := environment("DEX_FLOW_SERVICE_ADDRESS", "127.0.0.1:8801") + worker, err := dex.NewWorker(registry, cache, dex.WorkerOptions{BindAddress: environment("DEX_WORKER_BIND_ADDRESS", "127.0.0.1:8811"), WorkerTarget: dex.WorkerTarget{Address: environment("DEX_WORKER_TARGET", "127.0.0.1:8811")}, FlowServiceAddress: flowServiceAddress, Logger: logger}) + if err != nil { + _ = cache.Close() + return nil, fmt.Errorf("create Dex Worker: %w", err) + } + client, err := dex.NewClient(registry, cache, dex.ClientOptions{FlowServiceAddress: flowServiceAddress, WorkerTarget: worker.WorkerTarget(), Logger: logger}) + if err != nil { + _ = worker.Stop(context.Background()) + _ = cache.Close() + return nil, fmt.Errorf("create Dex Client: %w", err) + } + return &Runtime{Processes: process.NewService(client), worker: worker, client: client, cache: cache}, nil +} + +func (runtime *Runtime) StartWorker() <-chan error { + result := make(chan error, 1) + go func() { result <- runtime.worker.Start() }() + return result +} + +func (runtime *Runtime) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return errors.Join(runtime.worker.Stop(ctx), runtime.client.Close(), runtime.cache.Close()) +} + +func environment(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/internal/templatecontract/contract_test.go b/internal/templatecontract/contract_test.go new file mode 100644 index 0000000..f5644a4 --- /dev/null +++ b/internal/templatecontract/contract_test.go @@ -0,0 +1,75 @@ +package templatecontract_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type manifest struct { + SchemaVersion int `json:"schemaVersion"` + BuildProfile string `json:"buildProfile"` + MinimumSandboxRuntimeContractRevision int `json:"minimumSandboxRuntimeContractRevision"` + OpenAPISpec string `json:"openapiSpec"` + AgentInstructions string `json:"agentInstructions"` + DexSkill string `json:"dexSkill"` + Commands map[string]string `json:"commands"` +} + +func TestTemplateContract(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..")) + manifestBytes, err := os.ReadFile(filepath.Join(root, ".superverse", "template.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var contract manifest + if err := json.Unmarshal(manifestBytes, &contract); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if contract.SchemaVersion != 1 || contract.BuildProfile != "go-react-v1" || contract.MinimumSandboxRuntimeContractRevision != 2 { + t.Fatalf("unexpected template identity: %+v", contract) + } + for _, path := range []string{contract.OpenAPISpec, contract.AgentInstructions, contract.DexSkill} { + if _, err := os.Stat(filepath.Join(root, path)); err != nil { + t.Errorf("manifest path %q: %v", path, err) + } + } + makefile := readFile(t, filepath.Join(root, "Makefile")) + agents := readFile(t, filepath.Join(root, contract.AgentInstructions)) + for _, command := range contract.Commands { + target := strings.TrimPrefix(command, "make ") + if !strings.Contains(makefile, "\n"+target+":") && !strings.HasPrefix(makefile, target+":") { + t.Errorf("Makefile does not define %q", target) + } + if !strings.Contains(agents, command) { + t.Errorf("AGENTS.md does not mention %q", command) + } + } + gitmodules := readFile(t, filepath.Join(root, ".gitmodules")) + if !strings.Contains(gitmodules, "path = .agents/skills/dex-developer/upstream") || + !strings.Contains(gitmodules, "url = https://github.com/superdurable/skill-dex-developer.git") { + t.Fatal("Dex skill submodule path or public HTTPS URL is not allowlisted") + } + command := exec.Command("git", "ls-files", "--stage", ".agents/skills/dex-developer/upstream") + command.Dir = root + output, err := command.Output() + if err != nil { + t.Fatalf("read Dex skill submodule pin: %v", err) + } + fields := strings.Fields(string(output)) + if len(fields) < 2 || fields[0] != "160000" || len(fields[1]) != 40 { + t.Fatalf("Dex skill is not pinned as a gitlink: %q", output) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(contents) +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..846b034 --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,170 @@ +openapi: 3.0.3 +info: + title: Dex Basic Process API + version: 1.0.0 +paths: + /api/health: + get: + operationId: getHealth + responses: + '200': + description: Application is healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + /api/flows: + post: + operationId: createFlow + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFlowRequest' + responses: + '201': + description: Flow created. + content: + application/json: + schema: + $ref: '#/components/schemas/FlowView' + '400': + $ref: '#/components/responses/BadRequest' + '503': + $ref: '#/components/responses/Unavailable' + /api/flows/{flowId}: + parameters: + - $ref: '#/components/parameters/FlowId' + get: + operationId: getFlow + responses: + '200': + description: Current durable Flow view. + content: + application/json: + schema: + $ref: '#/components/schemas/FlowView' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/Unavailable' + /api/flows/{flowId}/approvals: + parameters: + - $ref: '#/components/parameters/FlowId' + post: + operationId: approveFlow + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApprovalRequest' + responses: + '200': + description: Approval accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/FlowView' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/Unavailable' +components: + parameters: + FlowId: + name: flowId + in: path + required: true + schema: + type: string + pattern: '^process-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + schemas: + ProcessState: + type: string + enum: [started, validated, waiting_for_approval, reminder_emitted, approved, executing, completed] + CreateFlowRequest: + type: object + additionalProperties: false + required: [title] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + ApprovalRequest: + type: object + additionalProperties: false + required: [approved] + properties: + approved: + type: boolean + enum: [true] + FlowView: + type: object + additionalProperties: false + required: [flowId, title, state, reminderCount] + properties: + flowId: + type: string + title: + type: string + state: + $ref: '#/components/schemas/ProcessState' + reminderCount: + type: integer + format: int64 + minimum: 0 + result: + type: string + HealthResponse: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + enum: [ok] + ErrorResponse: + type: object + additionalProperties: false + required: [error, message] + properties: + error: + type: string + pattern: '^[a-z][a-z0-9_]*$' + message: + type: string + maxLength: 3000 + responses: + BadRequest: + description: Invalid request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Flow not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Conflict: + description: Request conflicts with Flow state. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unavailable: + description: Dex operation is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/scripts/check-generated.sh b/scripts/check-generated.sh new file mode 100755 index 0000000..e61eb81 --- /dev/null +++ b/scripts/check-generated.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/dex-basic-process-codegen.XXXXXX")" +trap 'rm -rf -- "${temporary_directory}"' EXIT +go -C "${root_directory}/tools/openapi" tool ogen --target "${temporary_directory}/go" --package generated ../../openapi/openapi.yaml +(cd "${root_directory}/web" && OPENAPI_OUTPUT="${temporary_directory}/web" npm run generate) +diff -ru "${root_directory}/internal/api/generated" "${temporary_directory}/go" +diff -ru "${root_directory}/web/src/api/generated" "${temporary_directory}/web" diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100755 index 0000000..3f1e5eb --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +npm --prefix web run build +app_log="${DEX_TEST_ARTIFACT_DIR:-${TMPDIR:-/tmp}}/application.log" +PORT="${E2E_PORT:-18080}" go run ./cmd/server >"${app_log}" 2>&1 & +app_pid=$! +cleanup() { exit_code=$?; trap - EXIT INT TERM; kill "${app_pid}" 2>/dev/null || true; wait "${app_pid}" 2>/dev/null || true; exit "${exit_code}"; } +trap cleanup EXIT INT TERM +deadline=$((SECONDS + 45)) +until curl --fail --silent "http://127.0.0.1:${E2E_PORT:-18080}/api/health" >/dev/null; do + if (( SECONDS >= deadline )); then cat "${app_log}"; exit 1; fi + sleep 0.1 +done +E2E_BASE_URL="http://127.0.0.1:${E2E_PORT:-18080}" npm --prefix web run test:e2e diff --git a/scripts/with-dex.sh b/scripts/with-dex.sh new file mode 100755 index 0000000..b037a2f --- /dev/null +++ b/scripts/with-dex.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +test_directory="$(mktemp -d "${TMPDIR:-/tmp}/dex-basic-process.XXXXXX")" +free_port() { + python3 -c 'import socket; server = socket.socket(); server.bind(("127.0.0.1", 0)); print(server.getsockname()[1]); server.close()' +} +dex_port="${DEX_TEST_PORT:-$(free_port)}" +web_port="${DEX_TEST_WEB_PORT:-$(free_port)}" +worker_port="${DEX_TEST_WORKER_PORT:-$(free_port)}" +export E2E_PORT="${E2E_PORT:-$(free_port)}" +export DEX_FLOW_SERVICE_ADDRESS="127.0.0.1:${dex_port}" +export DEX_BLOB_CACHE_DIR="${test_directory}/application-blobs" +export DEX_WORKER_BIND_ADDRESS="127.0.0.1:${worker_port}" +export DEX_WORKER_TARGET="127.0.0.1:${worker_port}" +export DEX_TEST_ARTIFACT_DIR="${test_directory}" + +cleanup() { + exit_code=$? + trap - EXIT INT TERM + if [[ -n "${dex_pid:-}" ]]; then kill "${dex_pid}" 2>/dev/null || true; wait "${dex_pid}" 2>/dev/null || true; fi + if [[ "${exit_code}" -eq 0 ]]; then + rm -rf -- "${test_directory}" + else + cat "${test_directory}/dex.log" >&2 || true + echo "Dex test artifacts: ${test_directory}" >&2 + fi + exit "${exit_code}" +} +trap cleanup EXIT INT TERM + +dexcli dev -open=false -dex-port "${dex_port}" -web-port "${web_port}" -blob-store-dir "${test_directory}/dex-blobs" -sqlite-db-filename "${test_directory}/dex.sqlite" -server-log-folder "${test_directory}/logs" >"${test_directory}/dex.log" 2>&1 & +dex_pid=$! +deadline=$((SECONDS + 45)) +until dexcli health -server "${DEX_FLOW_SERVICE_ADDRESS}" -timeout 1s >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then cat "${test_directory}/dex.log"; exit 1; fi + sleep 0.1 +done +"$@" diff --git a/superverse.yaml b/superverse.yaml new file mode 100644 index 0000000..1d55e6d --- /dev/null +++ b/superverse.yaml @@ -0,0 +1,10 @@ +apiVersion: superverse.dev/v1alpha1 +kind: Application +build: + profile: go-react-v1 + profileVersion: 2 +entrypoints: + api: cmd/server + web: web +runtime: + port: 8080 diff --git a/tools/openapi/go.mod b/tools/openapi/go.mod new file mode 100644 index 0000000..fd1aa8d --- /dev/null +++ b/tools/openapi/go.mod @@ -0,0 +1,31 @@ +module github.com/superdurable/dex-template-basic-process/tools/openapi + +go 1.25.0 + +tool github.com/ogen-go/ogen/cmd/ogen + +require ( + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.8.0 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ogen-go/ogen v1.24.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/tools/openapi/go.sum b/tools/openapi/go.sum new file mode 100644 index 0000000..cc99149 --- /dev/null +++ b/tools/openapi/go.sum @@ -0,0 +1,67 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ogen-go/ogen v1.24.0 h1:NehBG/8s0JeM6jYHPJeFJntBG3cE/xQgZd1q8BchPNM= +github.com/ogen-go/ogen v1.24.0/go.mod h1:hcg4aTzLcu/MS1SGGahUriPBEOZB3c82RGIC6dYcdFU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/web/e2e/basic-process.spec.ts b/web/e2e/basic-process.spec.ts new file mode 100644 index 0000000..974eabe --- /dev/null +++ b/web/e2e/basic-process.spec.ts @@ -0,0 +1,21 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from '@playwright/test'; + +test('runs approval automation through a durable reminder', async ({ page }) => { + await page.goto('/'); + await page.getByLabel('Automation request').fill('Approve the release'); + await page.getByRole('button', { name: 'Start process' }).click(); + const processPanel = page.locator('[data-flow-id]'); + await expect(processPanel).toBeVisible(); + const flowId = await processPanel.getAttribute('data-flow-id'); + expect(flowId).toBeTruthy(); + await expect.poll(() => { + try { + execFileSync('dexcli', ['flow', 'skip-timer', flowId!, '-server', process.env.DEX_FLOW_SERVICE_ADDRESS!, '-step-type', 'process.WaitForApproval', '-condition-id', 'approval-reminder', '-yes']); + return true; + } catch { return false; } + }, { timeout: 20_000 }).toBe(true); + await expect(page.getByTestId('reminder-count')).toHaveText('1'); + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByTestId('result')).toHaveText('approved automation completed'); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..60602c5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Dex Basic Process + + +
+ + + diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts new file mode 100644 index 0000000..259e2fa --- /dev/null +++ b/web/openapi-ts.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@hey-api/openapi-ts'; + +export default defineConfig({ + input: '../openapi/openapi.yaml', + output: { + path: process.env.OPENAPI_OUTPUT ?? 'src/api/generated', + }, + plugins: ['@hey-api/client-fetch', '@hey-api/typescript', '@hey-api/sdk'], +}); diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..30c9046 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2869 @@ +{ + "name": "dex-basic-process-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dex-basic-process-web", + "version": "1.0.0", + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@hey-api/openapi-ts": "0.99.0", + "@playwright/test": "1.63.0", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@types/node": "24.10.1", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "jsdom": "27.2.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.1" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@hey-api/codegen-core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.9.1.tgz", + "integrity": "sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "c12": "3.3.4", + "color-support": "1.1.3" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.4.4.tgz", + "integrity": "sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "7.1.3", + "@types/json-schema": "7.0.15", + "js-yaml": "4.2.0" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", + "integrity": "sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/shared": "0.5.0", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "@lukeed/ms": "2.0.2", + "ansi-colors": "4.1.3", + "color-support": "1.1.3", + "commander": "15.0.0", + "get-tsconfig": "4.14.0" + }, + "bin": { + "openapi-ts": "bin/run.js" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" + } + }, + "node_modules/@hey-api/shared": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.5.0.tgz", + "integrity": "sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "0.9.1", + "@hey-api/json-schema-ref-parser": "1.4.4", + "@hey-api/spec-types": "0.2.0", + "@hey-api/types": "0.1.4", + "ansi-colors": "4.1.3", + "cross-spawn": "7.0.6", + "open": "11.0.0", + "semver": "7.8.4" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/spec-types": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.2.0.tgz", + "integrity": "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/types": "0.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/types": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", + "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", + "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc9": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.7", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5c5e32d --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "dex-basic-process-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "generate": "openapi-ts --file openapi-ts.config.ts", + "test": "vitest run", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit && vite build" + }, + "dependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@hey-api/openapi-ts": "0.99.0", + "@playwright/test": "1.63.0", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@types/node": "24.10.1", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "jsdom": "27.2.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.1" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..6d8d9b7 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8080', + browserName: 'chromium', + launchOptions: executablePath + ? { executablePath, args: ['--no-sandbox', '--disable-dev-shm-usage'] } + : undefined, + }, + reporter: [['list']], +}); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..31e6bdb --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { App } from './App'; + +describe('App', () => { + it('introduces the runnable approval automation', () => { + render(); + expect(screen.getByRole('heading', { name: /approval automation/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /start process/i })).toBeEnabled(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..ca0a164 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,102 @@ +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { approveFlow, createFlow, getFlow } from './api/generated/sdk.gen'; +import type { FlowView, ProcessState } from './api/generated/types.gen'; + +const steps: Array<{ state: ProcessState; label: string }> = [ + { state: 'started', label: 'Start process' }, + { state: 'validated', label: 'Validate request' }, + { state: 'waiting_for_approval', label: 'Wait for approval' }, + { state: 'reminder_emitted', label: 'Emit reminder' }, + { state: 'executing', label: 'Execute automation' }, + { state: 'completed', label: 'Complete process' }, +]; + +const stateRank: Record = { + started: 0, + validated: 1, + waiting_for_approval: 2, + reminder_emitted: 3, + approved: 4, + executing: 4, + completed: 5, +}; + +export function App() { + const [title, setTitle] = useState('Review the launch checklist'); + const [flow, setFlow] = useState(); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!flow || flow.state === 'completed') return; + const timer = window.setInterval(async () => { + const response = await getFlow({ path: { flowId: flow.flowId } }); + if (response.data) setFlow(response.data); + }, 750); + return () => window.clearInterval(timer); + }, [flow]); + + const activeRank = useMemo(() => (flow ? stateRank[flow.state] : -1), [flow]); + + async function start(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setError(''); + const response = await createFlow({ body: { title } }); + if (response.data) setFlow(response.data); + else setError(response.error?.message ?? 'Unable to start the automation.'); + setBusy(false); + } + + async function approve() { + if (!flow) return; + setBusy(true); + setError(''); + const response = await approveFlow({ path: { flowId: flow.flowId }, body: { approved: true } }); + if (response.data) setFlow(response.data); + else setError(response.error?.message ?? 'Unable to approve the automation.'); + setBusy(false); + } + + return ( +
+
+

SUPERDURABLE DEX

+

Approval automation that survives everything.

+

A complete six-step durable process with human approval and recurring reminders.

+
+ +
+
+ +
+ setTitle(event.target.value)} /> + +
+
+ {error &&

{error}

} +
+ + {flow && ( +
+
+

LIVE FLOW

{flow.title}

+ {flow.state.replaceAll('_', ' ')} +
+
    + {steps.map((step, index) => ( +
  1. + {index + 1}{step.label} +
  2. + ))} +
+
+

Reminders emitted: {flow.reminderCount}

+ {flow.state !== 'completed' && } +
+ {flow.result &&

{flow.result}

} +
+ )} +
+ ); +} diff --git a/web/src/api/generated/client.gen.ts b/web/src/api/generated/client.gen.ts new file mode 100644 index 0000000..47828b7 --- /dev/null +++ b/web/src/api/generated/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client: Client = createClient(createConfig()); diff --git a/web/src/api/generated/client/client.gen.ts b/web/src/api/generated/client/client.gen.ts new file mode 100644 index 0000000..fc3f037 --- /dev/null +++ b/web/src/api/generated/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/web/src/api/generated/client/index.ts b/web/src/api/generated/client/index.ts new file mode 100644 index 0000000..8c69331 --- /dev/null +++ b/web/src/api/generated/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/web/src/api/generated/client/types.gen.ts b/web/src/api/generated/client/types.gen.ts new file mode 100644 index 0000000..193646c --- /dev/null +++ b/web/src/api/generated/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/web/src/api/generated/client/utils.gen.ts b/web/src/api/generated/client/utils.gen.ts new file mode 100644 index 0000000..d4a7284 --- /dev/null +++ b/web/src/api/generated/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/web/src/api/generated/core/auth.gen.ts b/web/src/api/generated/core/auth.gen.ts new file mode 100644 index 0000000..c663664 --- /dev/null +++ b/web/src/api/generated/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/web/src/api/generated/core/bodySerializer.gen.ts b/web/src/api/generated/core/bodySerializer.gen.ts new file mode 100644 index 0000000..67daca6 --- /dev/null +++ b/web/src/api/generated/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/web/src/api/generated/core/params.gen.ts b/web/src/api/generated/core/params.gen.ts new file mode 100644 index 0000000..5e8908f --- /dev/null +++ b/web/src/api/generated/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/web/src/api/generated/core/pathSerializer.gen.ts b/web/src/api/generated/core/pathSerializer.gen.ts new file mode 100644 index 0000000..fab1ed4 --- /dev/null +++ b/web/src/api/generated/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/web/src/api/generated/core/queryKeySerializer.gen.ts b/web/src/api/generated/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..773b065 --- /dev/null +++ b/web/src/api/generated/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/web/src/api/generated/core/serverSentEvents.gen.ts b/web/src/api/generated/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..ddf3c4d --- /dev/null +++ b/web/src/api/generated/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/web/src/api/generated/core/types.gen.ts b/web/src/api/generated/core/types.gen.ts new file mode 100644 index 0000000..c657c85 --- /dev/null +++ b/web/src/api/generated/core/types.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/web/src/api/generated/core/utils.gen.ts b/web/src/api/generated/core/utils.gen.ts new file mode 100644 index 0000000..af56e07 --- /dev/null +++ b/web/src/api/generated/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/web/src/api/generated/index.ts b/web/src/api/generated/index.ts new file mode 100644 index 0000000..56e0cbc --- /dev/null +++ b/web/src/api/generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { approveFlow, createFlow, getFlow, getHealth, type Options } from './sdk.gen'; +export type { ApprovalRequest, ApproveFlowData, ApproveFlowError, ApproveFlowErrors, ApproveFlowResponse, ApproveFlowResponses, ClientOptions, CreateFlowData, CreateFlowError, CreateFlowErrors, CreateFlowRequest, CreateFlowResponse, CreateFlowResponses, ErrorResponse, FlowId, FlowView, GetFlowData, GetFlowError, GetFlowErrors, GetFlowResponse, GetFlowResponses, GetHealthData, GetHealthResponse, GetHealthResponses, HealthResponse, ProcessState } from './types.gen'; diff --git a/web/src/api/generated/sdk.gen.ts b/web/src/api/generated/sdk.gen.ts new file mode 100644 index 0000000..563b9df --- /dev/null +++ b/web/src/api/generated/sdk.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; +import { client } from './client.gen'; +import type { ApproveFlowData, ApproveFlowErrors, ApproveFlowResponses, CreateFlowData, CreateFlowErrors, CreateFlowResponses, GetFlowData, GetFlowErrors, GetFlowResponses, GetHealthData, GetHealthResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +export const getHealth = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/health', ...options }); + +export const createFlow = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/flows', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const getFlow = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/flows/{flowId}', ...options }); + +export const approveFlow = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/api/flows/{flowId}/approvals', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/web/src/api/generated/types.gen.ts b/web/src/api/generated/types.gen.ts new file mode 100644 index 0000000..95aadb1 --- /dev/null +++ b/web/src/api/generated/types.gen.ts @@ -0,0 +1,153 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; + +export type ProcessState = 'started' | 'validated' | 'waiting_for_approval' | 'reminder_emitted' | 'approved' | 'executing' | 'completed'; + +export type CreateFlowRequest = { + title: string; +}; + +export type ApprovalRequest = { + approved: true; +}; + +export type FlowView = { + flowId: string; + title: string; + state: ProcessState; + reminderCount: number; + result?: string; +}; + +export type HealthResponse = { + status: 'ok'; +}; + +export type ErrorResponse = { + error: string; + message: string; +}; + +export type FlowId = string; + +export type GetHealthData = { + body?: never; + path?: never; + query?: never; + url: '/api/health'; +}; + +export type GetHealthResponses = { + /** + * Application is healthy. + */ + 200: HealthResponse; +}; + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses]; + +export type CreateFlowData = { + body: CreateFlowRequest; + path?: never; + query?: never; + url: '/api/flows'; +}; + +export type CreateFlowErrors = { + /** + * Invalid request. + */ + 400: ErrorResponse; + /** + * Dex operation is unavailable. + */ + 503: ErrorResponse; +}; + +export type CreateFlowError = CreateFlowErrors[keyof CreateFlowErrors]; + +export type CreateFlowResponses = { + /** + * Flow created. + */ + 201: FlowView; +}; + +export type CreateFlowResponse = CreateFlowResponses[keyof CreateFlowResponses]; + +export type GetFlowData = { + body?: never; + path: { + flowId: string; + }; + query?: never; + url: '/api/flows/{flowId}'; +}; + +export type GetFlowErrors = { + /** + * Invalid request. + */ + 400: ErrorResponse; + /** + * Flow not found. + */ + 404: ErrorResponse; + /** + * Dex operation is unavailable. + */ + 503: ErrorResponse; +}; + +export type GetFlowError = GetFlowErrors[keyof GetFlowErrors]; + +export type GetFlowResponses = { + /** + * Current durable Flow view. + */ + 200: FlowView; +}; + +export type GetFlowResponse = GetFlowResponses[keyof GetFlowResponses]; + +export type ApproveFlowData = { + body: ApprovalRequest; + path: { + flowId: string; + }; + query?: never; + url: '/api/flows/{flowId}/approvals'; +}; + +export type ApproveFlowErrors = { + /** + * Invalid request. + */ + 400: ErrorResponse; + /** + * Flow not found. + */ + 404: ErrorResponse; + /** + * Request conflicts with Flow state. + */ + 409: ErrorResponse; + /** + * Dex operation is unavailable. + */ + 503: ErrorResponse; +}; + +export type ApproveFlowError = ApproveFlowErrors[keyof ApproveFlowErrors]; + +export type ApproveFlowResponses = { + /** + * Approval accepted. + */ + 200: FlowView; +}; + +export type ApproveFlowResponse = ApproveFlowResponses[keyof ApproveFlowResponses]; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..ed3b67f --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..2768ad1 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,24 @@ +:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #e8edf8; background: #07111f; font-synthesis: none; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 80% 0%, #18385a 0, transparent 35%), #07111f; } +main { width: min(980px, calc(100% - 40px)); margin: 0 auto; padding: 72px 0; } +header { max-width: 760px; margin-bottom: 40px; } +h1 { margin: 8px 0 16px; font-size: clamp(2.6rem, 7vw, 5.4rem); line-height: .96; letter-spacing: -.06em; } +h2 { margin: 4px 0; } +.eyebrow { margin: 0; color: #6ae4c4; font-size: .72rem; font-weight: 800; letter-spacing: .2em; } +.lede { color: #aab8cc; font-size: 1.15rem; } +.panel { margin-top: 18px; padding: 24px; border: 1px solid #29415d; border-radius: 20px; background: rgba(12, 28, 47, .88); box-shadow: 0 24px 80px rgba(0,0,0,.25); } +label { display: block; margin-bottom: 10px; font-size: .82rem; font-weight: 700; color: #aab8cc; } +.form-row, .actions, .process-heading { display: flex; gap: 12px; align-items: center; justify-content: space-between; } +input { flex: 1; min-width: 0; padding: 14px 16px; border: 1px solid #36506d; border-radius: 12px; color: white; background: #091727; font: inherit; } +button { padding: 13px 18px; border: 0; border-radius: 12px; background: #6ae4c4; color: #06131f; font: inherit; font-weight: 800; cursor: pointer; } +button:disabled { opacity: .45; cursor: wait; } +.status { padding: 7px 10px; border-radius: 999px; color: #6ae4c4; background: #11372f; font-size: .75rem; text-transform: uppercase; } +.timeline { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 28px 0; padding: 0; list-style: none; } +.timeline li { min-height: 94px; padding: 12px; border: 1px solid #253b54; border-radius: 12px; color: #6d7c90; } +.timeline li.reached { border-color: #4ea991; color: #e8edf8; background: #102e2b; } +.timeline span { display: block; margin-bottom: 16px; color: #6ae4c4; font-size: .75rem; } +.timeline strong { font-size: .8rem; } +.result { padding: 14px; border-radius: 12px; color: #06131f; background: #6ae4c4; font-weight: 800; } +.error { color: #ff9d9d; } +@media (max-width: 760px) { .timeline { grid-template-columns: repeat(2, 1fr); } .form-row { align-items: stretch; flex-direction: column; } } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..b704f5b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "vite.config.ts", "playwright.config.ts", "e2e"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1117b23 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + setupFiles: './src/test/setup.ts', + }, +}); From 62c0837735deb2711e0b52732b4ee167204738a1 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Fri, 18 Sep 2026 14:03:48 -0700 Subject: [PATCH 2/2] Install Temporal CLI in template CI --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67045da..e741e7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,16 @@ jobs: node-version: 22.22.0 cache: npm cache-dependency-path: web/package-lock.json + - name: Install Temporal CLI 1.8.1 + run: | + curl --fail --silent --show-error --location \ + https://github.com/temporalio/cli/releases/download/v1.8.1/temporal_cli_1.8.1_linux_amd64.tar.gz \ + --output /tmp/temporal-cli.tar.gz + echo "b94417b9a8760b30217f4b881dabce4b16a76a38b5e99e2eca3ce358b8030f06 /tmp/temporal-cli.tar.gz" | sha256sum --check + mkdir -p "$HOME/.local/bin" + tar -xzf /tmp/temporal-cli.tar.gz -C "$HOME/.local/bin" temporal + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/temporal" --version - name: Install dexcli run: | curl --fail --silent --show-error --location \