diff --git a/.agents/skills/formance-fctl-plugin/SKILL.md b/.agents/skills/formance-fctl-plugin/SKILL.md new file mode 100644 index 00000000..09595da8 --- /dev/null +++ b/.agents/skills/formance-fctl-plugin/SKILL.md @@ -0,0 +1,102 @@ +--- +name: formance-fctl-plugin +description: "Create or extend embedded or external Go service plugins in fctl v4: manifests, declarative forms, API operations and contract tests. Use for plugin development rather than operating the CLI." +--- + +# fctl plugin development + +Build service commands that work through both the CLI and a standalone SDK +caller. Keep service behavior in the plugin and terminal/authentication behavior +in the host. Embedded and external plugins use the same public contract. The Ledger pilot +owns its implementation in the Ledger repository's `fctl-plugin` module; fctl +embeds that package or selects an installed executable. + +## Establish the contract + +Locate the requested fctl checkout and read its `go.mod`, `docs/plugins.md`, +`pkg/pluginsdk/sdk.go` and `pkg/pluginsdk/interaction.go`. These files take +precedence over assumptions about older fctl releases. If the checkout lacks +this SDK, establish the intended version before creating another architecture. + +Read the service's published client or API definitions at the requested branch +or version. Use the small Auth plugin for SDK-backed operations, Ledger for +precise JSON and keyset pagination, and Cloud for multi-service commands. Pick +the relevant example rather than copying the whole tree. For Ledger, consult +the current source contract in `docs/validation.md` and the requested release +branch; a deployed beta can differ from that contract. + +Map each requested command to its method, versioned route, payload, response, +pagination, and failure behavior. Distinguish missing CLI behavior from an API +capability absent in the deployed service. + +## Implement the plugin + +For an embedded service, create or extend `plugins/`. For an external +service, create a separate module in the product repository and depend on +`github.com/formancehq/fctl/pkg/pluginsdk`; do not depend on the fctl core module. +Expose `New(*http.Client) pluginsdk.Plugin`, +`GetManifest(context.Context)` and `Execute(context.Context, ExecuteRequest)`. +Metadata must be available with a nil client and without network access. + +Normalize every execution with `pluginsdk.NormalizeRequest`, then validate +identifiers and the service payload before issuing a request. Preserve +`ChangedFlags` when omitted values and explicit false/empty values differ. +Return a useful error when execution lacks an injected HTTP client. + +Consume the injected client, endpoint and non-secret context. Use the published +service client when it implements the needed contract, or the public +`pkg/pluginsdk/httpclient` boundary for missing operations. Keep authentication +and endpoint selection in the host. Plugin packages depend on public SDK/client +packages, not Cobra, UI libraries, fctl `internal` packages or server commands. + +Preserve `json.RawMessage` or `json.Number` for quantities and identifiers. +Pass raw IDs to `httpclient.Path`; it escapes each segment once, including a +slash or literal percent sign. Avoid pre-escaping these inputs. Validate empty +IDs and dot-segment IDs before building service routes. Keep +mutation retries explicit; the shared HTTP boundary performs one attempt. +Return partial response data alongside an error when the API contract requires it. + +## Describe the CLI + +Declare commands, argument counts, flags and help in the manifest. Use inherited +`Target` for identity/organization/stack selection, and `Service` overrides for +different host connection boundaries. Declare destructive operations with +`Confirm: true` and a boolean `confirm` flag defaulting to false. + +For missing-input forms, resource selectors or a payload editor, read +[declarative inputs](references/inputs.md). Put serializable input metadata on +runnable leaves; the core renders the UI and validates the accepted values. + +For authorized embedded integration, register the factory in the existing +`cmd/root.go` registry and give its root command the appropriate help group. +For an isolated evaluation, use a temporary host harness and leave the requested +checkout unchanged. A new service may also need host +endpoint, scope and connection configuration support: inspect the current +resolver rather than assuming a new manifest service is automatically usable. +Keep these changes at the connection boundary; routes and payloads remain in +the plugin. Existing service extensions usually need no core changes. + +## External executable and distribution + +Read `docs/plugin-distribution.md` and the SDK `README.md`. The executable calls +`transport.Serve(factory)`. Supply the exact service version in its manifest; +keep plugin revision separate. Use Ledger's executable and publisher as the +example. Build with the product release, publish public OCI artifacts before +advertising catalogue entries, and generate manifests from the actual binary. +The host owns credentials, HTTP callbacks, checksums, target locks and cached +help. Do not pass tokens in arguments, environment or request context. + +Test an actual subprocess through `fctl plugins install` and test a registry +download through `plugins sync`. Cover version drift before mutations, offline +help/completion, both explicit flags and forms, and exact JSON responses. + +## Verify and deliver + +Read [contract and CLI checks](references/verification.md) for the checks that +apply to the new behavior. A plugin is ready when its API behavior passes local +fixtures, its manifest registers, a standalone caller runs without core/UI +dependencies, and the requested CLI path works in interactive and explicit modes. + +Use authorized disposable resources for live verification when requested, then +read back changes and clean up owned fixtures. Report fixture coverage, real +service results, unsupported backend behavior and unverified paths separately. diff --git a/.agents/skills/formance-fctl-plugin/agents/openai.yaml b/.agents/skills/formance-fctl-plugin/agents/openai.yaml new file mode 100644 index 00000000..9fd558e6 --- /dev/null +++ b/.agents/skills/formance-fctl-plugin/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "fctl Plugin Development" + short_description: "Create decoupled fctl plugins with forms and tests" + default_prompt: "Use $formance-fctl-plugin to add a service plugin to fctl v4 with declarative forms and contract tests." diff --git a/.agents/skills/formance-fctl-plugin/references/inputs.md b/.agents/skills/formance-fctl-plugin/references/inputs.md new file mode 100644 index 00000000..0b598f46 --- /dev/null +++ b/.agents/skills/formance-fctl-plugin/references/inputs.md @@ -0,0 +1,97 @@ +# Declarative forms and selectors + +Read the checkout's `docs/interaction.md` and the existing service's +`interaction.go` before choosing fields. `InputSpec` expresses UI intent; it does +not replace argument, flag or payload validation in direct SDK execution. + +## Bindings + +Each input binds exactly one of `Flag`, `Argument`, `BodyPointer` or `Context`. +`Argument` is a zero-based index represented by `*int`; zero must not disappear +during JSON serialization. `BodyPointer` is an RFC 6901 pointer into the request. +`Context` is reserved for host organization/stack IDs. + +Supported kinds are `input`, `text`, `select` and `confirm`; the value type is +`string`, `json`, `number` or `bool`. Choose types from the actual service +contract. A numeric input does not expand the supported `FlagSpec.Type` set. +Declare every bound flag and keep it consistent with the input's value type. +`number` checks JSON numeric syntax, not an integer range or sign. Enforce +integer, nonnegative and size constraints in plugin payload validation according +to the service contract. Validate exact tokens or `json.Number` with integer +arithmetic instead of converting through `float64`; preserve the API's allowed +numeric notation and bounds. + +For example, a leaf creating an object can declare: + +```go +pluginsdk.CommandSpec{ + Use: "create", Short: "Create an item", Runnable: true, + Flags: []pluginsdk.FlagSpec{{ + Name: "data", Type: "string", Body: true, Required: true, + Usage: "JSON object, @file or - for stdin", + }}, + Inputs: []pluginsdk.InputSpec{ + {Title: "Item name", Kind: "input", BodyPointer: "/name", + ValueType: "string", Required: true}, + {Title: "Metadata", Kind: "text", BodyPointer: "/metadata", + ValueType: "json", Description: "Optional JSON object"}, + }, +} +``` + +The host reads inline/file/stdin body flags; SDK callers provide `Body` directly. +An explicit body bypasses JSON-pointer fields. Use `AlternativeArgument` for a +positional equivalent of a flag/context input. Use `AlternativeFlag` only for a +real declared alternative, such as `data` bypassing a payload-related flag. + +Explicit arguments, flags, JSON fields, environment settings and saved targets +take precedence. Without explicit body input, missing required values open the +form. An explicit body bypasses the form even when incomplete: the plugin then +rejects missing required fields. Complete invocations run directly. Optional +blank fields stay omitted. Update forms should preserve +existing options unless the caller explicitly changes or clears them. + +## Discover choices + +Declare the read operation in the same plugin, then reference it with a full +`CommandPath`. The host injects a GET-only client for choice discovery. +For a runnable `show ITEM_ID` leaf with exactly one argument: + +```go +pluginsdk.InputSpec{ + Title: "Item", Kind: "select", Argument: new(0), + ValueType: "string", Required: true, + Source: &pluginsdk.ChoiceSource{ + CommandPath: []string{"inventory", "list"}, + ValueField: "id", LabelFields: []string{"name", "id"}, + EmptyMessage: "No items are available; create one first", + }, +} +``` + +`ValueField` is the identifier passed to execution; labels are display only. +Source arguments and flags can refer to previously resolved `$arg0`, `$flag`, +`$organization` or `$stack`. Propagate any required experimental opt-in flag. +Secret fields use `Secret: true`, kind `input`, a string value type and no +default. Choice labels must not expose credentials. + +Return the service's supported list envelope, such as `data` or `cursor.data`. +For cursor pagination preserve `hasMore` and `next`. For keyset pagination use +`AfterField` with the exact final-row identifier and declare the list's +`page-size` flag as `uint32` and `after` as `string`. Cursor lists need a +declared string `cursor` flag for subsequent pages. The core enumerates pages and +deduplicates choices; the plugin still implements the actual pagination API. + +`ExcludeTrueFields` hides entries with a true boolean field. `MatchFields` ANDs +the declared fields and accepts any listed value per field. These filters can +restrict eligible lifecycle states. Pagination advances from original rows +before filtering, so an ineligible full page must not terminate discovery. +`PreferredPrefix` orders suggestions; it does not authorize selecting a fallback. + +## Host behavior to preserve + +Forms require terminal stdin and stderr and write to stderr; results write to +stdout. `--no-input`, `FCTL_NO_INPUT` and `CI` disable interaction. The host owns +cancellation, styles, keyboard navigation and confirmation defaulting to No. +Use metadata to extend these behaviors instead of adding prompts or output +calls to service code. `--confirm=false` and cancellation must cause no mutation. diff --git a/.agents/skills/formance-fctl-plugin/references/verification.md b/.agents/skills/formance-fctl-plugin/references/verification.md new file mode 100644 index 00000000..fc21cf57 --- /dev/null +++ b/.agents/skills/formance-fctl-plugin/references/verification.md @@ -0,0 +1,78 @@ +# Verify a plugin independently and through fctl + +Use local HTTP fixtures for API behavior before relying on a real stack. Read +the checkout's test conventions and `justfile`; use its pinned Go/Nix toolchain. + +## API and manifest evidence + +- Call `GetManifest` with a nil HTTP client and marshal/unmarshal the manifest. + Register it through the existing registry tests to catch invalid bindings, + command paths, choice sources and inherited flags. +- Execute each added leaf through the public SDK against `httptest.Server`. + Check method, escaped route, query, body and relevant headers. Verify wrong + argument counts, unknown flags, invalid payloads and refused confirmations + fail before HTTP. Include malformed/error responses relevant to the API. +- Exercise omitted update options versus explicit false/empty values when + supported. Verify `9007199254740993` survives a request and a response exactly + when the operation carries quantities, metadata or numeric IDs. +- Check cancellation and write count for mutations. Add idempotency/partial + result checks when those behaviors belong to the service contract. + +Use the public API, not implementation-mirroring assertions. A standalone +consumer can use a temporary module with a local `replace` for +the public SDK and product plugin modules (or `github.com/formancehq/fctl/v4` +for an embedded service), then import the plugin and call `New`, +`GetManifest` and `Execute`. Use `GOWORK=off` so workspace settings do not hide +dependencies. Inspect its dependency closure for Cobra, pflag, UI and fctl core +packages; none belongs to the plugin's public execution path. Do not treat the +host CLI's own dependency closure as the plugin closure. + +Registry and Runner integration checks belong in the fctl module, because those +host packages are `internal`. For an external plugin module, keep its SDK tests +independent and place host integration tests in fctl or an isolated host checkout. +The standalone consumer must exercise the public dependency boundary separately. + +## Forms and scripting + +Use the core's injected `interactive.Runner` in adapter fixtures to cover: + +- Missing values bind to the expected argument/flag/body/context without an + extra write; cancellation and default-No confirmation perform no mutation. +- Explicit values and a complete `Body` skip forms, including inline JSON, + `@file` and stdin input. Disabled interaction returns an actionable error. +- Selectors return actual IDs, handle empty lists and use GET-only discovery. + If paginated, test a second page and repeated/missing continuations; if filtered, + test a full ineligible page followed by an eligible page. + +Use a real PTY when adding a new interactive path. Check one complete form, +resource selection and cancellation at ordinary terminal dimensions. Capture +stdout separately from stderr and parse `-o json` even with forced color. Check +human tables with `NO_COLOR`. Runner fixtures cannot verify visual rendering or +actual keyboard navigation. + +## Repository checks and live work + +For authorized new plugins or host integration, run the repository checks, +for example: + +```bash +nix develop --impure --command just pc +nix develop --impure --command just tests +``` + +Inspect changes from formatter/generator recipes before committing. An isolated +SDK consumer needs its own `go test -race ./...`; the fctl suite does not discover +tests in an external module automatically. When adding a host service boundary, +cover Cloud, anonymous and client-credentials modes in connection fixtures. + +For a read-only or temporary evaluation, run formatting, race tests, vet and +dependency checks only in the temporary module or host copy. `just pc` writes +module, generated, lint-fix and completion files; it belongs in the writable +integration checkout, not a read-only source checkout. Report this smaller scope. + +When live testing is in scope, use the selected sandbox and newly owned fixtures. +Read back mutations, retain user-owned resources and verify cleanup. Report the +selected stack version and actual service version separately. Deployed betas +can lack fields present in a release branch; fixture success proves the client +contract, while live readback proves deployed behavior. CLI work does not grant +permission to publish releases, send invitations or modify unrelated resources. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 241b46e0..3d0d7921 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,8 +23,6 @@ jobs: Dirty: uses: formancehq/ci/.github/workflows/go-dirty.yml@v1 - secrets: - SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} Tests: uses: formancehq/ci/.github/workflows/go-test.yml@v1 @@ -40,6 +38,5 @@ jobs: build-target: release-local secrets: NUMARY_GITHUB_TOKEN: ${{ secrets.NUMARY_GITHUB_TOKEN }} - SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} FURY_TOKEN: ${{ secrets.FURY_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 76213aaa..dc2504d8 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -14,6 +14,5 @@ jobs: attestations: write secrets: NUMARY_GITHUB_TOKEN: ${{ secrets.NUMARY_GITHUB_TOKEN }} - SPEAKEASY_API_KEY: ${{ secrets.SPEAKEASY_API_KEY }} FURY_TOKEN: ${{ secrets.FURY_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} diff --git a/.golangci.yml b/.golangci.yml index e9156c13..d0236c6e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,48 +19,52 @@ run: timeout: 5m linters: + default: none enable: - - unused + - bodyclose + - contextcheck + - errcheck + - errorlint + - exhaustive + - gocognit - gosec + - govet + - ineffassign + - modernize + - nilerr + - noctx + - nolintlint + - revive + - staticcheck + - unused + - usetesting settings: errcheck: - exclude-functions: - - fmt.Fprintf - - fmt.Fprintln - - (io.Closer).Close - - (*github.com/formancehq/go-libs/v3/publish/circuit_breaker.CircuitBreaker).Close - staticcheck: - checks: - - -ST1001 # Use fmt.Errorf for error construction using Sprintf - gosec: - # To select a subset of rules to run. - # Available rules: https://github.com/securego/gosec#available-rules - includes: - - G103 # Audit the use of unsafe block - - G104 # Audit errors not checked - - G106 # Audit the use of ssh.InsecureIgnoreHostKey - - G108 # Profiling endpoint automatically exposed on /debug/pprof - - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - - G110 # Potential DoS vulnerability via decompression bomb - - G111 # Potential directory traversal - - G112 # Potential slowloris attack - #- G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) - - G201 # SQL query construction using format string - - G202 # SQL query construction using string concatenation - - G203 # Use of unescaped data in HTML templates - - G204 # Audit use of command execution - - G301 # Poor file permissions used when creating a directory - - G302 # Poor file permissions used with chmod - - G303 # Creating tempfile using a predictable path - - G304 # File path provided as taint input - - G305 # File traversal when extracting zip/tar archive - - G306 # Poor file permissions used when writing to a new file - - G307 # Poor file permissions used when creating a file with os.Create - - G401 # Detect the usage of DES, RC4, MD5 or SHA1 - - G403 # Ensure minimum RSA key length of 2048 bits - - G501 # Import blocklist: crypto/md5 - - G502 # Import blocklist: crypto/des - - G503 # Import blocklist: crypto/rc4 - - G504 # Import blocklist: net/http/cgi - - G505 # Import blocklist: crypto/sha1 - - G602 # Slice access out of bounds + check-blank: true + check-type-assertions: true + disable-default-exclusions: true + exhaustive: + # A default branch must not hide a newly added enum value. + default-signifies-exhaustive: false + gocognit: + min-complexity: 15 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: empty-block + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: increment-decrement + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: var-naming diff --git a/.goreleaser.yml b/.goreleaser.yml index c089ae1c..35257cb9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -10,9 +10,9 @@ builds: - binary: fctl id: fctl ldflags: - - -X github.com/formancehq/fctl/v3/cmd/version.BuildDate={{ .Date }} - - -X github.com/formancehq/fctl/v3/cmd/version.Version=v{{ .Version }} - - -X github.com/formancehq/fctl/v3/cmd/version.Commit={{ .ShortCommit }} + - -X github.com/formancehq/fctl/v4/cmd/version.BuildDate={{ .Date }} + - -X github.com/formancehq/fctl/v4/cmd/version.Version=v{{ .Version }} + - -X github.com/formancehq/fctl/v4/cmd/version.Commit={{ .ShortCommit }} - -s - -w - -extldflags "-static" @@ -28,6 +28,24 @@ builds: - amd64 - arm64 + - binary: fctl-plugin-legacy + id: fctl-plugin-legacy + dir: misc/fctl-plugin + main: ./cmd/fctl-plugin-legacy + flags: + - -trimpath + ldflags: + - -s -w + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + archives: - id: "{{.ProjectName}}" ids: @@ -38,13 +56,37 @@ archives: formats: [tar.gz] name_template: "{{.ProjectName}}_{{.Os}}-{{.Arch}}" files: - - completions/* + - completions/* + - .agents/skills/**/* + - docs/agent-skills.md + - LICENSE + + - id: fctl-plugin-legacy + ids: + - fctl-plugin-legacy + name_template: "fctl-plugin-legacy_{{.Os}}-{{.Arch}}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - misc/fctl-plugin/README.md -brews: +homebrew_casks: - name: fctl + ids: + - fctl alternative_names: - fctl@{{ .Major }} - url_template: https://brew.formance.com/{{ .Tag }}/{{ .ArtifactName }} + binaries: + - fctl + completions: + bash: completions/fctl.bash + zsh: completions/fctl.zsh + fish: completions/fctl.fish + url: + template: https://brew.formance.com/{{ .Tag }}/{{ .ArtifactName }} repository: owner: formancehq name: homebrew-tap @@ -54,34 +96,16 @@ brews: owner: formancehq name: homebrew-tap branch: main - directory: Formula + directory: Casks homepage: https://formance.com + description: Formance Control CLI skip_upload: 'false' - test: | - system "#{bin}/fctl version" - install: | - bin.install "fctl" - bash_completion.install "completions/fctl.bash" => "fctl" - zsh_completion.install "completions/fctl.zsh" => "_fctl" - fish_completion.install "completions/fctl.fish" - custom_block: | - on_macos do - if Hardware::CPU.intel? - mirror "https://github.com/formancehq/fctl/releases/download/{{ .Tag }}/fctl_darwin-amd64.tar.gz" - end - if Hardware::CPU.arm? - mirror "https://github.com/formancehq/fctl/releases/download/{{ .Tag }}/fctl_darwin-arm64.tar.gz" - end - end - - on_linux do - if Hardware::CPU.intel? && Hardware::CPU.is_64_bit? - mirror "https://github.com/formancehq/fctl/releases/download/{{ .Tag }}/fctl_linux-amd64.tar.gz" - end - if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? - mirror "https://github.com/formancehq/fctl/releases/download/{{ .Tag }}/fctl_linux-arm64.tar.gz" - end - end + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/fctl"] + end nfpms: - id: packages diff --git a/Justfile b/Justfile index 4f962f89..33ac3097 100644 --- a/Justfile +++ b/Justfile @@ -7,28 +7,28 @@ pre-commit: tidy generate lint pc: pre-commit completions lint: + cd misc/fctl-plugin && golangci-lint run --config ../../.golangci.yml --fix --timeout 5m golangci-lint run --fix --timeout 5m + cd pkg/pluginsdk && golangci-lint run --config ../../.golangci.yml --fix --timeout 5m tidy: + cd misc/fctl-plugin && go mod tidy + cd pkg/pluginsdk && go mod tidy go mod tidy -generate: generate-deploy-server-client generate-membership-client +generate: + @cd misc/fctl-plugin && go generate ./... + @cd pkg/pluginsdk && go generate ./... @go generate ./... g: generate -[group('generate')] -generate-deploy-server-client: - @cd internal/deployserverclient && speakeasy run --skip-versioning --frozen-workflow-lockfile - -generate-membership-client: - @cd internal/membershipclient && speakeasy run --skip-versioning --frozen-workflow-lockfile - - install: go install -v . tests: - go test ./... + cd misc/fctl-plugin && go test -race ./... + cd pkg/pluginsdk && go test -race ./... + go test -race ./... release-local: @goreleaser release --nightly --skip=publish --clean @@ -43,4 +43,7 @@ completions: generate mkdir -p ./completions go run main.go completion bash > "./completions/fctl.bash" go run main.go completion zsh > "./completions/fctl.zsh" - go run main.go completion fish > "./completions/fctl.fish" \ No newline at end of file + go run main.go completion fish > "./completions/fctl.fish" + +build-legacy-plugin: + cd misc/fctl-plugin && go build -trimpath -o ../../build/fctl-plugin-legacy ./cmd/fctl-plugin-legacy diff --git a/README.md b/README.md index 3f6ff1d4..0a1fb575 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,95 @@ -# Formance Control CLI (fctl) +# Formance Control CLI (fctl v4) -Command-line interface for managing and interacting with Formance services. +`fctl` is the Formance command-line interface (repository profile: CLI). +The v4 implementation embeds Cloud and loads Auth and Ledger as external +plugins, with a shared connection boundary for local services, OAuth2 client +credentials and Cloud login. Connectivity commands are absent while their +independent plugin is prepared. +The Go module is `github.com/formancehq/fctl/v4`. -## Overview - -`fctl` is the official CLI tool for Formance, providing a comprehensive set of commands to interact with Formance services, including: - -- Ledger management -- Payments processing -- Wallets -- Reconciliation -- Orchestration -- Authentication -- Stack management -- Cloud resources -- Webhooks configuration - -## Installation - -### Using Homebrew (macOS/Linux) - -```bash -brew install formancehq/tap/fctl -``` - -### Manual Installation - -Download the latest binary for your platform from the [Releases page](https://github.com/formancehq/fctl/releases). - -## Getting Started - -### Authentication +## Current commands ```bash -# Login to Formance -fctl login - -# Configure profiles -fctl profiles list -fctl profiles create --endpoint +go run . --help +go run . version +go run . --version +go run . completion bash +go run . profiles --help +go run . login +go run . cloud organizations list +go run . cloud stack list --organization ORG_ID +go run . --organization ORG_ID --stack STACK_ID plugins sync --service auth +go run . --organization ORG_ID --stack STACK_ID auth clients list ``` -### Basic Usage +Development builds report `v4.0.0-dev`. Release builds receive their version, +commit and build date through GoReleaser. No v4 release is published by this +source change. + +The independently published Auth plugin owns its API client and command contract. +Auth 2.5.2 revision 1 is available through the official catalogue. Targets must +run that exact service version. Ledger retains its external loader, but has no +embedded provider or advertised official release yet; install a trusted +product-built executable matching the deployed version before using its commands. +See [module commands](docs/modules.md), [native plugin distribution](docs/plugin-distribution.md) +and [profiles and authentication](docs/profiles.md). + +Cloud management lives under `cloud`, with stack administration under +`cloud stack`. See [Cloud command boundaries](docs/cloud.md) for proxy, MCP, +token helpers and experimental Deploy Apps. See [Connectivity](docs/connectivity.md) +for the deferred plugin migration and preserved connection settings. + +The v3 command tree and profile store are not migrated automatically. Users of +v3 scripts must adapt commands and request schemas or retain a v3 binary. +The v4 profile store is separate and direct service access needs no Cloud login. + +`fctl login` defaults to the public Cloud, opens the browser and saves a Cloud +profile after successful authentication. Use `--no-browser` for a headless +session or `--issuer` for another environment. Login does not require a stack; +service commands resolve their target from flags, saved defaults, unique access or +interactive selection. + +Output defaults to readable tables on a terminal and JSON in pipes or files. +Use `-o json` for all fields and automation; table lists summarize scalar columns +without repeated output reminders. `--color auto|always|never` controls styles. +See [output and diagnostics](docs/output.md), including `NO_COLOR` and `-d`. + +Commands offer forms and searchable selections when required input is missing. +Supply arguments and flags for direct execution, or use `--no-input` for scripts. +See [interactive command input](docs/interaction.md). + +Plugins expose manifests and execution through a public SDK. The core adapts +their manifests to Cobra; service plugins do not depend on Cobra or core internals. +See the [plugin contract](docs/plugins.md) and [native plugin distribution](docs/plugin-distribution.md). This +CLI uses explicit composition without Fx. Proxy and MCP commands run until +cancellation; they use the host's connection and authentication boundary. + +## Development + +Use the Go version and toolchain declared in `go.mod`, or the repository's Nix +development shell: ```bash -# Get version information -fctl version - -# Get help for any command -fctl --help -fctl --help - -# Use the interactive mode -fctl prompt +nix develop --impure +just pre-commit +just tests +go build ./... ``` -## Features +`just tests` runs with the race detector. `just generate` runs Go generators; +the removed Membership and deployment SDKs are no longer generated. +`just completions` updates the shell scripts shipped in release archives. -- **Multiple Output Formats**: Support for plain text and JSON output -- **Profile Management**: Create and switch between different configuration profiles -- **Interactive Mode**: Use the prompt mode for interactive command execution -- **Comprehensive API Coverage**: Access to all Formance services and features +The repository includes the `formance-fctl-plugin` agent skill for developing +service plugins through the public SDK. Its sources are versioned under +`.agents/skills/`, where Codex discovers them for this repository, and included +in release archives. See [agent skill usage and installation](docs/agent-skills.md). -## Configuration +## Documentation -Configuration is stored in `~/.formance/fctl.config` by default. You can specify a different configuration file using the `-c` flag. - -## Options - -- `--profile, -p`: Configuration profile to use -- `--config, -c`: Path to configuration file -- `--debug, -d`: Enable debug mode -- `--output, -o`: Output format (plain, json) -- `--insecure-tls`: Allow insecure TLS connections -- `--telemetry`: Enable telemetry +- [Technical documentation](docs/README.md) +- [Formance documentation](https://docs.formance.com) ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Links - -- [Formance Documentation](https://docs.formance.com) -- [Formance GitHub](https://github.com/formancehq) +MIT License. See [LICENSE](LICENSE). diff --git a/cmd/auth/clients/create.go b/cmd/auth/clients/create.go deleted file mode 100644 index db2782ac..00000000 --- a/cmd/auth/clients/create.go +++ /dev/null @@ -1,142 +0,0 @@ -package clients - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateClient struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsPublic string `json:"isPublic"` - RedirectUri string `json:"redirectUri"` - PostLogoutRedirectUri string `json:"postLogoutRedirectUri"` - Scopes []string `json:"scopes"` -} - -type CreateStore struct { - Client *CreateClient `json:"client"` -} -type CreateController struct { - store *CreateStore - publicFlag string - trustedFlag string - descriptionFlag string - redirectUriFlag string - postLogoutRedirectUriFlag string - scopes string -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - publicFlag: "public", - trustedFlag: "trusted", - descriptionFlag: "description", - redirectUriFlag: "redirect-uri", - postLogoutRedirectUriFlag: "post-logout-redirect-uri", - scopes: "scopes", - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create ", - fctl.WithAliases("c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithConfirmFlag(), - fctl.WithBoolFlag(c.publicFlag, false, "Mark the client as public"), - fctl.WithBoolFlag(c.trustedFlag, false, "Mark the client as trusted"), - fctl.WithStringFlag(c.descriptionFlag, "", "Client description"), - fctl.WithStringSliceFlag(c.redirectUriFlag, []string{}, "Redirect URIS"), - fctl.WithStringSliceFlag(c.postLogoutRedirectUriFlag, []string{}, "Post logout redirect uris"), - fctl.WithStringSliceFlag(c.scopes, []string{""}, "Scopes"), - fctl.WithShortDescription("Create a client"), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new OAuth2 client") { - return nil, fctl.ErrMissingApproval - } - - public := fctl.GetBool(cmd, c.publicFlag) - trusted := fctl.GetBool(cmd, c.trustedFlag) - description := fctl.GetString(cmd, c.descriptionFlag) - - request := auth.ClientOptions1{ - Public: &public, - RedirectUris: fctl.GetStringSlice(cmd, c.redirectUriFlag), - Description: &description, - Name: args[0], - Trusted: &trusted, - PostLogoutRedirectUris: fctl.GetStringSlice(cmd, c.postLogoutRedirectUriFlag), - Scopes: fctl.GetStringSlice(cmd, c.scopes), - } - response, err := stackClient.Auth.V1.CreateClient(cmd.Context(), &request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Client = &CreateClient{ - ID: response.CreateClientResponse.ClientOptions.ID, - Name: response.CreateClientResponse.ClientOptions.Name, - Description: fctl.StringPointerToString(response.CreateClientResponse.ClientOptions.Description), - IsPublic: fctl.BoolPointerToString(response.CreateClientResponse.ClientOptions.Public), - RedirectUri: strings.Join(response.CreateClientResponse.ClientOptions.RedirectUris, ","), - PostLogoutRedirectUri: strings.Join(response.CreateClientResponse.ClientOptions.PostLogoutRedirectUris, ","), - Scopes: response.CreateClientResponse.ClientOptions.Scopes, - } - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Client.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Client.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Description"), c.store.Client.Description}) - tableData = append(tableData, []string{pterm.LightCyan("Public"), c.store.Client.IsPublic}) - tableData = append(tableData, []string{pterm.LightCyan("Redirect URIs"), c.store.Client.RedirectUri}) - tableData = append(tableData, []string{pterm.LightCyan("Post logout redirect URIs"), c.store.Client.PostLogoutRedirectUri}) - tableData = append(tableData, []string{pterm.LightCyan("Scopes"), strings.Join(c.store.Client.Scopes, " ")}) - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/auth/clients/delete.go b/cmd/auth/clients/delete.go deleted file mode 100644 index c202bc04..00000000 --- a/cmd/auth/clients/delete.go +++ /dev/null @@ -1,84 +0,0 @@ -package clients - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - Success bool `json:"success"` -} -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("d", "del"), - fctl.WithShortDescription("Delete a client"), - fctl.WithController[*DeleteStore](NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete an OAuth2 client") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeleteClientRequest{ - ClientID: args[0], - } - response, err := stackClient.Auth.V1.DeleteClient(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Client deleted!") - return nil -} diff --git a/cmd/auth/clients/list.go b/cmd/auth/clients/list.go deleted file mode 100644 index 883e6fb1..00000000 --- a/cmd/auth/clients/list.go +++ /dev/null @@ -1,112 +0,0 @@ -package clients - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Client struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsPublic string `json:"isPublic"` - Scopes []string `json:"scopes"` -} - -type ListStore struct { - Clients []Client `json:"clients"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List clients"), - fctl.WithController[*ListStore](NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - clients, err := stackClient.Auth.V1.ListClients(cmd.Context()) - if err != nil { - return nil, err - } - - if clients.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", clients.StatusCode) - } - - c.store.Clients = fctl.Map(clients.ListClientsResponse.Data, func(o auth.ClientOptions) Client { - return Client{ - ID: o.ID, - Name: o.Name, - Description: func() string { - if o.Description == nil { - return "" - } - return "" - }(), - Scopes: o.Scopes, - IsPublic: fctl.BoolPointerToString(o.Public), - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Clients, func(o Client) []string { - return []string{ - o.ID, - o.Name, - o.Description, - o.IsPublic, - fmt.Sprintf("%d", len(o.Scopes)), - } - }) - - tableData = fctl.Prepend(tableData, []string{"ID", "Name", "Description", "Public", "Permissions"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/auth/clients/root.go b/cmd/auth/clients/root.go deleted file mode 100644 index 1b372d13..00000000 --- a/cmd/auth/clients/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package clients - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/auth/clients/secrets" - "github.com/formancehq/fctl/v3/cmd/auth/users" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("clients", - fctl.WithAliases("client", "c"), - fctl.WithShortDescription("Manage clients"), - fctl.WithChildCommands( - NewListCommand(), - NewCreateCommand(), - NewDeleteCommand(), - NewUpdateCommand(), - NewShowCommand(), - secrets.NewCommand(), - users.NewCommand(), - ), - ) -} diff --git a/cmd/auth/clients/secrets/create.go b/cmd/auth/clients/secrets/create.go deleted file mode 100644 index 95a391ee..00000000 --- a/cmd/auth/clients/secrets/create.go +++ /dev/null @@ -1,99 +0,0 @@ -package secrets - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - SecretId string `json:"secretId"` - Name string `json:"name"` - Clear string `json:"clear"` -} -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand("create ", - fctl.WithAliases("c"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithShortDescription("Create a secret"), - fctl.WithConfirmFlag(), - fctl.WithController[*CreateStore](NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new client secret") { - return nil, fctl.ErrMissingApproval - } - - request := operations.CreateSecretRequest{ - ClientID: args[0], - SecretOptions: &auth.SecretOptions{ - Name: args[1], - Metadata: nil, - }, - } - response, err := stackClient.Auth.V1.CreateSecret(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.SecretId = response.CreateSecretResponse.SecretOptions.ID - c.store.Name = response.CreateSecretResponse.SecretOptions.Name - c.store.Clear = response.CreateSecretResponse.SecretOptions.Clear - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.SecretId}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Clear"), c.store.Clear}) - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/auth/clients/secrets/delete.go b/cmd/auth/clients/secrets/delete.go deleted file mode 100644 index 4d1b5dba..00000000 --- a/cmd/auth/clients/secrets/delete.go +++ /dev/null @@ -1,88 +0,0 @@ -package secrets - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - Success bool `json:"success"` - SecretId string `json:"secretId"` -} -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithAliases("d"), - fctl.WithShortDescription("Delete a secret"), - fctl.WithConfirmFlag(), - fctl.WithController[*DeleteStore](NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete a client secret") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeleteSecretRequest{ - ClientID: args[0], - SecretID: args[1], - } - response, err := stackClient.Auth.V1.DeleteSecret(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.SecretId = args[1] - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Secret %s successfully deleted!", c.store.SecretId) - - return nil - -} diff --git a/cmd/auth/clients/secrets/root.go b/cmd/auth/clients/secrets/root.go deleted file mode 100644 index 29682b4b..00000000 --- a/cmd/auth/clients/secrets/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package secrets - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("secrets", - fctl.WithAliases("sec"), - fctl.WithShortDescription("Manage secrets"), - fctl.WithChildCommands( - NewCreateCommand(), - NewDeleteCommand(), - ), - ) -} diff --git a/cmd/auth/clients/show.go b/cmd/auth/clients/show.go deleted file mode 100644 index 7ce8f854..00000000 --- a/cmd/auth/clients/show.go +++ /dev/null @@ -1,116 +0,0 @@ -package clients - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/auth/clients/views" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Client *auth.ClientOptions `json:"client,omitempty"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("s"), - fctl.WithShortDescription("Show a client"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ReadClientRequest{ - ClientID: args[0], - } - response, err := stackClient.Auth.V1.ReadClient(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Client = response.ReadClientResponse.ClientOptions - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - - if err := views.PrintClient(cmd.OutOrStdout(), c.store.Client); err != nil { - return err - } - - if len(c.store.Client.RedirectUris) > 0 { - fctl.BasicTextCyan.WithWriter(cmd.OutOrStdout()).Printfln("Redirect URIs :") - if err := pterm.DefaultBulletList.WithWriter(cmd.OutOrStdout()).WithItems(fctl.Map(c.store.Client.RedirectUris, func(redirectURI string) pterm.BulletListItem { - return pterm.BulletListItem{ - Text: redirectURI, - TextStyle: pterm.NewStyle(pterm.FgDefault), - BulletStyle: pterm.NewStyle(pterm.FgLightCyan), - } - })).Render(); err != nil { - return err - } - } - - if len(c.store.Client.PostLogoutRedirectUris) > 0 { - fctl.BasicTextCyan.WithWriter(cmd.OutOrStdout()).Printfln("Post logout redirect URIs :") - if err := pterm.DefaultBulletList.WithWriter(cmd.OutOrStdout()).WithItems(fctl.Map(c.store.Client.PostLogoutRedirectUris, func(redirectURI string) pterm.BulletListItem { - return pterm.BulletListItem{ - Text: redirectURI, - TextStyle: pterm.NewStyle(pterm.FgDefault), - BulletStyle: pterm.NewStyle(pterm.FgLightCyan), - } - })).Render(); err != nil { - return err - } - } - - if len(c.store.Client.Secrets) > 0 { - if err := views.PrintSecrets(cmd.OutOrStdout(), c.store.Client.Secrets); err != nil { - return err - } - } - return nil -} diff --git a/cmd/auth/clients/update.go b/cmd/auth/clients/update.go deleted file mode 100644 index 958a737d..00000000 --- a/cmd/auth/clients/update.go +++ /dev/null @@ -1,151 +0,0 @@ -package clients - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -// TODO: This command is a copy/paste of the create command -// We should handle membership side the patch of the client OR -// We should get the client before updating it to get replace informations - -type UpdateClient struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsPublic string `json:"isPublic"` - RedirectUri string `json:"redirectUri"` - PostLogoutRedirectUri string `json:"postLogoutRedirectUri"` - Scopes []string `json:"scopes"` -} - -type UpdateStore struct { - Client *UpdateClient `json:"client"` -} -type UpdateController struct { - store *UpdateStore - publicFlag string - trustedFlag string - descriptionFlag string - redirectUriFlag string - postLogoutRedirectUriFlag string - scopes string -} - -var _ fctl.Controller[*UpdateStore] = (*UpdateController)(nil) - -func NewDefaultUpdateStore() *UpdateStore { - return &UpdateStore{ - Client: &UpdateClient{}, - } -} - -func NewUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultUpdateStore(), - publicFlag: "public", - trustedFlag: "trusted", - descriptionFlag: "description", - redirectUriFlag: "redirect-uri", - postLogoutRedirectUriFlag: "post-logout-redirect-uri", - scopes: "scopes", - } -} - -func NewUpdateCommand() *cobra.Command { - c := NewUpdateController() - return fctl.NewCommand("update ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("Update a client"), - fctl.WithAliases("u", "upd"), - fctl.WithConfirmFlag(), - fctl.WithBoolFlag(c.publicFlag, false, "Mark the client as public"), - fctl.WithBoolFlag(c.trustedFlag, false, "Mark the client as trusted"), - fctl.WithStringFlag(c.descriptionFlag, "", "Client description"), - fctl.WithStringSliceFlag(c.redirectUriFlag, []string{}, "Redirect URIS"), - fctl.WithStringSliceFlag(c.postLogoutRedirectUriFlag, []string{}, "Post logout redirect uris"), - fctl.WithStringSliceFlag(c.scopes, []string{}, "Scopes"), - fctl.WithController[*UpdateStore](c), - ) -} - -func (c *UpdateController) GetStore() *UpdateStore { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to update an OAuth2 client") { - return nil, fctl.ErrMissingApproval - } - - public := fctl.GetBool(cmd, c.publicFlag) - trusted := fctl.GetBool(cmd, c.trustedFlag) - description := fctl.GetString(cmd, c.descriptionFlag) - - request := operations.UpdateClientRequest{ - ClientID: args[0], - ClientOptions: &auth.ClientOptions1{ - Public: &public, - RedirectUris: fctl.GetStringSlice(cmd, c.redirectUriFlag), - Description: &description, - Name: args[0], - Trusted: &trusted, - PostLogoutRedirectUris: fctl.GetStringSlice(cmd, c.postLogoutRedirectUriFlag), - Scopes: fctl.GetStringSlice(cmd, c.scopes), - }, - } - response, err := stackClient.Auth.V1.UpdateClient(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Client.ID = response.CreateClientResponse.ClientOptions.ID - c.store.Client.Name = response.CreateClientResponse.ClientOptions.Name - c.store.Client.Description = fctl.StringPointerToString(response.CreateClientResponse.ClientOptions.Description) - c.store.Client.IsPublic = fctl.BoolPointerToString(response.CreateClientResponse.ClientOptions.Public) - c.store.Client.RedirectUri = strings.Join(response.CreateClientResponse.ClientOptions.RedirectUris, ",") - c.store.Client.PostLogoutRedirectUri = strings.Join(response.CreateClientResponse.ClientOptions.PostLogoutRedirectUris, ",") - c.store.Client.Scopes = response.CreateClientResponse.ClientOptions.Scopes - - return c, nil -} - -func (c *UpdateController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Client.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Client.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Description"), c.store.Client.Description}) - tableData = append(tableData, []string{pterm.LightCyan("Public"), c.store.Client.IsPublic}) - tableData = append(tableData, []string{pterm.LightCyan("Redirect URIs"), c.store.Client.RedirectUri}) - tableData = append(tableData, []string{pterm.LightCyan("Post logout redirect URIs"), c.store.Client.PostLogoutRedirectUri}) - tableData = append(tableData, []string{pterm.LightCyan("Scopes"), strings.Join(c.store.Client.Scopes, " ")}) - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/auth/clients/views/client.go b/cmd/auth/clients/views/client.go deleted file mode 100644 index b8254a71..00000000 --- a/cmd/auth/clients/views/client.go +++ /dev/null @@ -1,36 +0,0 @@ -package views - -import ( - "fmt" - "io" - "strings" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintClient(out io.Writer, client *auth.ClientOptions) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), client.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), client.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Description"), fctl.StringPointerToString(client.Description)}) - tableData = append(tableData, []string{pterm.LightCyan("Public"), fctl.BoolPointerToString(client.Public)}) - if len(client.Scopes) > 0 { - tableData = append(tableData, []string{pterm.LightCyan("Scopes"), strings.Join(client.Scopes, " ")}) - } - - fctl.Section.WithWriter(out).Println("Information :") - if err := pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - _, err := fmt.Fprintln(out, "") - - return err -} diff --git a/cmd/auth/clients/views/secrets.go b/cmd/auth/clients/views/secrets.go deleted file mode 100644 index 3c973738..00000000 --- a/cmd/auth/clients/views/secrets.go +++ /dev/null @@ -1,28 +0,0 @@ -package views - -import ( - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintSecrets(out io.Writer, secrets []auth.ClientSecret) error { - fctl.Section.WithWriter(out).Println("Secrets :") - - return pterm.DefaultTable. - WithWriter(out). - WithHasHeader(true). - WithData(fctl.Prepend( - fctl.Map(secrets, func(secret auth.ClientSecret) []string { - return []string{ - secret.ID, secret.Name, secret.LastDigits, - } - }), - []string{"ID", "Name", "Last digits"}, - )). - Render() -} diff --git a/cmd/auth/root.go b/cmd/auth/root.go deleted file mode 100644 index 75f7986b..00000000 --- a/cmd/auth/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package auth - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/auth/clients" - "github.com/formancehq/fctl/v3/cmd/auth/users" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("auth", - fctl.WithShortDescription("Manage the auth server"), - fctl.WithChildCommands( - clients.NewCommand(), - users.NewCommand(), - ), - ) -} diff --git a/cmd/auth/users/list.go b/cmd/auth/users/list.go deleted file mode 100644 index fbbca60b..00000000 --- a/cmd/auth/users/list.go +++ /dev/null @@ -1,104 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/auth" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type User struct { - ID string `json:"id,omitempty"` - Subject string `json:"subject,omitempty"` - Email string `json:"email,omitempty"` -} - -type ListStore struct { - Users []User `json:"users,omitempty"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List users"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithController[*ListStore](NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - listUsersResponse, err := stackClient.Auth.V1.ListUsers(cmd.Context()) - if err != nil { - return nil, err - } - - if listUsersResponse.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", listUsersResponse.StatusCode) - } - - c.store.Users = fctl.Map(listUsersResponse.ListUsersResponse.Data, func(o auth.User) User { - return User{ - ID: *o.ID, - Subject: *o.Subject, - Email: *o.Email, - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Users) == 0 { - fctl.Println("No users found.") - return nil - } - - tableData := fctl.Map(c.store.Users, func(o User) []string { - return []string{ - o.ID, - o.Subject, - o.Email, - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Subject", "Email"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/auth/users/root.go b/cmd/auth/users/root.go deleted file mode 100644 index 085a0dbb..00000000 --- a/cmd/auth/users/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package users - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("users", - fctl.WithShortDescription("Manage users"), - fctl.WithAliases("u", "user"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/auth/users/show.go b/cmd/auth/users/show.go deleted file mode 100644 index 74616b88..00000000 --- a/cmd/auth/users/show.go +++ /dev/null @@ -1,90 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - User *User `json:"user,omitempty"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithAliases("s"), - fctl.WithShortDescription("Show a user"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ReadUserRequest{ - UserID: args[0], - } - readUserResponse, err := stackClient.Auth.V1.ReadUser(cmd.Context(), request) - if err != nil { - return nil, err - } - - if readUserResponse.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", readUserResponse.StatusCode) - } - - c.store.User = &User{ - ID: *readUserResponse.ReadUserResponse.User.ID, - Subject: *readUserResponse.ReadUserResponse.User.Subject, - Email: *readUserResponse.ReadUserResponse.User.Email, - } - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.User.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Membership ID"), c.store.User.Subject}) - tableData = append(tableData, []string{pterm.LightCyan("Email"), c.store.User.Email}) - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/apps/bind_manifest.go b/cmd/cloud/apps/bind_manifest.go deleted file mode 100644 index d415bdb8..00000000 --- a/cmd/cloud/apps/bind_manifest.go +++ /dev/null @@ -1,80 +0,0 @@ -package apps - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type BindManifest struct { - AppID string `json:"appId"` - ManifestID string `json:"manifestId"` -} - -type BindManifestCtrl struct { - store *BindManifest -} - -var _ fctl.Controller[*BindManifest] = (*BindManifestCtrl)(nil) - -func NewBindManifestCtrl() *BindManifestCtrl { - return &BindManifestCtrl{store: &BindManifest{}} -} - -func NewBindManifest() *cobra.Command { - return fctl.NewCommand("bind-manifest", - fctl.WithShortDescription("Bind a manifest to an app"), - fctl.WithStringFlag("app-id", "", "App ID"), - fctl.WithStringFlag("manifest-id", "", "Manifest ID to bind"), - fctl.WithController(NewBindManifestCtrl()), - ) -} - -func (c *BindManifestCtrl) GetStore() *BindManifest { return c.store } - -func (c *BindManifestCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - appID := fctl.GetString(cmd, "app-id") - if appID == "" { - return nil, fmt.Errorf("app-id is required") - } - manifestID := fctl.GetString(cmd, "manifest-id") - if manifestID == "" { - return nil, fmt.Errorf("manifest-id is required") - } - - if _, err := apiClient.AttachAppManifest(cmd.Context(), appID, components.AttachManifestRequest{ - ManifestID: manifestID, - }); err != nil { - return nil, err - } - - c.store.AppID = appID - c.store.ManifestID = manifestID - return c, nil -} - -func (c *BindManifestCtrl) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Printfln("Manifest %s bound to app %s", c.store.ManifestID, c.store.AppID) - return nil -} diff --git a/cmd/cloud/apps/bind_manifest_test.go b/cmd/cloud/apps/bind_manifest_test.go deleted file mode 100644 index 740ca1e3..00000000 --- a/cmd/cloud/apps/bind_manifest_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package apps - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestBindManifest_JSONOutput_LowercaseKeys(t *testing.T) { - store := &BindManifest{ - AppID: "app-abc", - ManifestID: "mnf-xyz", - } - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok) - require.Equal(t, "app-abc", data["appId"]) - require.Equal(t, "mnf-xyz", data["manifestId"]) - - for _, capitalized := range []string{"AppID", "ManifestID"} { - _, present := data[capitalized] - require.False(t, present, "must not surface Go-cased key %q", capitalized) - } -} - -func TestUnbindManifest_JSONOutput_LowercaseKeys(t *testing.T) { - store := &UnbindManifest{AppID: "app-abc"} - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok) - require.Equal(t, "app-abc", data["appId"]) - _, capitalized := data["AppID"] - require.False(t, capitalized) -} diff --git a/cmd/cloud/apps/create.go b/cmd/cloud/apps/create.go deleted file mode 100644 index 36ea7bf8..00000000 --- a/cmd/cloud/apps/create.go +++ /dev/null @@ -1,101 +0,0 @@ -package apps - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Create struct { - components.App -} - -type CreateCtrl struct { - store *Create -} - -var _ fctl.Controller[*Create] = (*CreateCtrl)(nil) - -func newCreateStore() *Create { - return &Create{ - App: components.App{}, - } -} - -func NewCreateCtrl() *CreateCtrl { - return &CreateCtrl{ - store: newCreateStore(), - } -} - -func NewCreate() *cobra.Command { - return fctl.NewCommand("create", - fctl.WithShortDescription("Create an app"), - fctl.WithStringFlag("name", "", "App name"), - fctl.WithStringFlag("stack-id", "", "Optional existing stack ID to claim"), - fctl.WithController(NewCreateCtrl()), - ) -} - -func (c *CreateCtrl) GetStore() *Create { - return c.store -} - -func (c *CreateCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - name := fctl.GetString(cmd, "name") - if name == "" { - return nil, fmt.Errorf("name is required") - } - - req := components.CreateAppRequest{ - Name: name, - } - if stackID := fctl.GetString(cmd, "stack-id"); stackID != "" { - req.StackID = &stackID - } - - apps, err := apiClient.CreateApp(cmd.Context(), req) - if err != nil { - return nil, err - } - - c.store.App = apps.AppResponse.Data - - return c, nil -} - -func (c *CreateCtrl) Render(cmd *cobra.Command, args []string) error { - if err := pterm. - DefaultTable. - WithHasHeader(). - WithData([][]string{ - {"ID", "Name"}, - {c.store.App.ID, c.store.App.Name}, - }). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/delete.go b/cmd/cloud/apps/delete.go deleted file mode 100644 index 209f8fb8..00000000 --- a/cmd/cloud/apps/delete.go +++ /dev/null @@ -1,97 +0,0 @@ -package apps - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Delete struct { - ID string `json:"id"` - DestroyDeploymentID *string `json:"destroyDeploymentId,omitempty"` - Waited bool `json:"waited"` -} - -type DeleteCtrl struct { - store *Delete -} - -var _ fctl.Controller[*Delete] = (*DeleteCtrl)(nil) - -func newDeleteStore() *Delete { - return &Delete{} -} - -func NewDeleteCtrl() *DeleteCtrl { - return &DeleteCtrl{ - store: newDeleteStore(), - } -} - -func NewDelete() *cobra.Command { - return fctl.NewCommand("delete", - fctl.WithShortDescription("Soft-delete an app and enqueue a destroy deployment"), - fctl.WithStringFlag("id", "", "App ID"), - fctl.WithBoolFlag("wait", false, "Block until the destroy deployment reaches a terminal status"), - fctl.WithController(NewDeleteCtrl()), - ) -} - -func (c *DeleteCtrl) GetStore() *Delete { - return c.store -} - -func (c *DeleteCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - wait := fctl.GetBool(cmd, "wait") - resp, err := apiClient.DeleteApp(cmd.Context(), id, &wait) - if err != nil { - return nil, err - } - - c.store.ID = id - c.store.Waited = wait - if resp.DeleteAppResponse != nil { - c.store.DestroyDeploymentID = resp.DeleteAppResponse.DestroyDeploymentID - } - - return c, nil -} - -func (c *DeleteCtrl) Render(cmd *cobra.Command, args []string) error { - switch { - case c.store.DestroyDeploymentID != nil && *c.store.DestroyDeploymentID != "": - if c.store.Waited { - pterm.Success.Printfln("App %s deleted (destroy deployment %s reached terminal status)", - c.store.ID, *c.store.DestroyDeploymentID) - } else { - pterm.Success.Printfln("App %s soft-deleted; destroy deployment %s enqueued (poll with `fctl cloud app deployments show --id %s`)", - c.store.ID, *c.store.DestroyDeploymentID, *c.store.DestroyDeploymentID) - } - default: - pterm.Success.Printfln("App %s deleted (no destroy deployment was needed)", c.store.ID) - } - return nil -} diff --git a/cmd/cloud/apps/deployments/create.go b/cmd/cloud/apps/deployments/create.go deleted file mode 100644 index 2781504b..00000000 --- a/cmd/cloud/apps/deployments/create.go +++ /dev/null @@ -1,302 +0,0 @@ -package deployments - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -// Deployment status values polled by the wait loop. Mirrors the strings the -// deploy server emits — keep this list in sync with the server's status enum -// (terraform-hcp-proxy `internal/storage/models/deployment.go`). The server -// does not yet expose these as a typed enum in the OpenAPI spec; lift this -// block once it does so a rename produces a compile error instead of a -// silent polling deadlock. -const ( - statusApplied = "applied" - statusPlannedAndFinished = "planned_and_finished" - statusErrored = "errored" -) - -type Create struct { - *components.DeploymentResource -} - -type CreateCtrl struct { - store *Create - wait func(context.Context, time.Duration) error -} - -var _ fctl.Controller[*Create] = (*CreateCtrl)(nil) - -func newCreateStore() *Create { - return &Create{} -} - -func NewCreateCtrl() *CreateCtrl { - return &CreateCtrl{ - store: newCreateStore(), - wait: waitForDeploymentPoll, - } -} - -func NewCreate() *cobra.Command { - return newCreate(NewCreateCtrl()) -} - -func newCreate(ctrl *CreateCtrl) *cobra.Command { - return fctl.NewCommand("create", - fctl.WithShortDescription("Create a deployment (deploy an app)"), - fctl.WithStringFlag("app-id", "", "App ID"), - fctl.WithStringFlag("manifest-id", "", "Manifest ID to deploy"), - fctl.WithIntFlag("manifest-version", 0, "Manifest version to deploy (required, >= 1)"), - fctl.WithBoolFlag("wait", true, "Wait for the deployment to complete"), - fctl.WithStringFlag("wait-timeout", "30m", "Max duration to wait for the deployment when --wait is set"), - fctl.WithController(ctrl), - ) -} - -func (c *CreateCtrl) GetStore() *Create { - return c.store -} - -func (c *CreateCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - appID := fctl.GetString(cmd, "app-id") - if appID == "" { - return nil, fmt.Errorf("app-id is required") - } - manifestID := fctl.GetString(cmd, "manifest-id") - if manifestID == "" { - return nil, fmt.Errorf("manifest-id is required") - } - manifestVersion := fctl.GetInt(cmd, "manifest-version") - if manifestVersion <= 0 { - return nil, fmt.Errorf("manifest-version is required (>= 1)") - } - - req := components.CreateDeploymentRequest{ - AppID: appID, - ManifestID: manifestID, - ManifestVersion: int64(manifestVersion), - } - - cmd.SilenceUsage = true - deployment, err := apiClient.CreateDeployment(cmd.Context(), req) - if err != nil { - return nil, err - } - c.store.DeploymentResource = &deployment.DeploymentResponse.Data - - if fctl.GetBool(cmd, "wait") { - if err := c.waitDeploymentCompletion(cmd); err != nil { - return nil, err - } - } else if c.store.Status == statusErrored { - return nil, fmt.Errorf("deployment failed: %s", c.store.ID) - } - - return c, nil -} - -func waitForDeploymentPoll(ctx context.Context, delay time.Duration) error { - if err := ctx.Err(); err != nil { - return err - } - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return ctx.Err() - } -} - -func (c *CreateCtrl) waitDeploymentCompletion(cmd *cobra.Command) (err error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return err - } - spinner := pterm.DefaultSpinner.WithWriter(io.Discard) - // Do not animate when the command output is captured or redirected. - animate := false - if file, ok := cmd.ErrOrStderr().(*os.File); ok { - info, err := file.Stat() - animate = err == nil && info.Mode()&os.ModeCharDevice != 0 - } - - if s := fctl.GetString(cmd, "output"); s == "plain" && animate { - var err error - spinner, err = spinner.WithWriter(cmd.ErrOrStderr()).Start("Waiting for deployment to complete...") - if err != nil { - return err - } - defer func() { - if err := spinner.Stop(); err != nil { - pterm.Error.Println(err) - } - }() - } - - timeout := 30 * time.Minute - if v := fctl.GetString(cmd, "wait-timeout"); v != "" { - parsed, err := time.ParseDuration(v) - if err != nil { - return fmt.Errorf("invalid --wait-timeout: %w", err) - } - timeout = parsed - } - ctx, cancel := context.WithTimeout(cmd.Context(), timeout) - defer cancel() - defer func() { - if errors.Is(err, context.DeadlineExceeded) { - err = fmt.Errorf("timed out after %s waiting for deployment %s (last status: %s): %w", - timeout, c.store.ID, c.store.Status, err) - } - }() - - waitFor := 0 * time.Second - for { - if err := c.wait(ctx, waitFor); err != nil { - return err - } - // Backoff: 2s, 4s, 8s capped at 15s. A long-running terraform - // apply doesn't need sub-second polling. - if waitFor == 0 { - waitFor = 2 * time.Second - } else if waitFor < 15*time.Second { - waitFor *= 2 - if waitFor > 15*time.Second { - waitFor = 15 * time.Second - } - } - r, err := apiClient.ReadDeployment(ctx, c.store.ID, nil) - if err != nil { - return err - } - c.store.DeploymentResource = &r.DeploymentResponse.Data - - spinner.UpdateText(fmt.Sprintf("Deployment status: %s", r.DeploymentResponse.Data.Status)) - switch r.DeploymentResponse.Data.Status { - case statusApplied: - spinner.UpdateText("Deployment completed successfully") - return nil - case statusPlannedAndFinished: - spinner.UpdateText("Deployment completed successfully, no changes to apply") - return nil - case statusErrored: - l, err := apiClient.ReadDeploymentLogs(ctx, c.store.ID) - if err != nil { - return fmt.Errorf("deployment failed: %s (could not read logs: %w)", c.store.ID, err) - } - - var diagnostics strings.Builder - for _, entry := range l.ReadLogsResponse.Data { - if entry.Diagnostic != nil { - fmt.Fprintf(&diagnostics, "\n%s: %s\n%s", entry.Diagnostic.Severity, entry.Diagnostic.Summary, entry.Diagnostic.Detail) - } else if entry.Message != "" { - fmt.Fprintf(&diagnostics, "\n%s", entry.Message) - } - } - return fmt.Errorf("deployment failed: %s%s", c.store.ID, diagnostics.String()) - default: - continue - } - } -} - -func (c *CreateCtrl) Render(cmd *cobra.Command, args []string) error { - pterm.Info.Println("App Deployment accepted", c.store.ID) - wait := fctl.GetBool(cmd, "wait") - if !wait { - return nil - } - - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return err - } - - profile, profileName, err := fctl.LoadCurrentProfile(cmd, *cfg) - if err != nil { - return err - } - - relyingParty, err := fctl.GetAuthRelyingParty(cmd.Context(), fctl.GetHttpClient(cmd), profile.MembershipURI) - if err != nil { - return err - } - - organizationID, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return err - } - - appID := fctl.GetString(cmd, "app-id") - appResp, err := apiClient.ReadApp(cmd.Context(), appID, []operations.ReadAppInclude{operations.ReadAppIncludeState}) - if err != nil { - return err - } - - if state := appResp.AppResponse.Data.State; state != nil && state.Stack != nil { - membershipClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return err - } - - info, err := membershipClient.GetServerInfo(cmd.Context()) - if err != nil { - return err - } - - if info.ServerInfo.ConsoleURL != nil { - pterm.Success.Printfln("View stack in console: %s/%s/%s?region=%s", *info.ServerInfo.ConsoleURL, organizationID, state.Stack["id"], state.Stack["region_id"]) - } - } - return nil -} diff --git a/cmd/cloud/apps/deployments/create_test.go b/cmd/cloud/apps/deployments/create_test.go deleted file mode 100644 index 2373e905..00000000 --- a/cmd/cloud/apps/deployments/create_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package deployments - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - "time" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -// TestNewCreate_RequiredFlags asserts the cobra command exposes the flags -// required by the new {appId, manifestId, manifestVersion} payload shape. -// Until d063441 landed on the server, the deploy endpoint accepted inline -// YAML and an optional manifest version. The new shape requires a -// manifest version >= 1 — the validation happens at Run() time, but the -// flag must at least exist on the command tree. -func TestNewCreate_RequiredFlags(t *testing.T) { - cmd := NewCreate() - for _, name := range []string{"app-id", "manifest-id", "manifest-version", "wait", "wait-timeout"} { - require.NotNil(t, cmd.Flags().Lookup(name), "flag %q must be defined", name) - } -} - -func TestNewCreate_WaitTimeoutDefault(t *testing.T) { - cmd := NewCreate() - f := cmd.Flags().Lookup("wait-timeout") - require.NotNil(t, f) - require.Equal(t, "30m", f.DefValue, - "a sensible default keeps the previous unbounded-poll behaviour from coming back") -} - -// Status constants must stay in sync with the server's deployment status -// enum (terraform-hcp-proxy `internal/storage/models/deployment.go`). -// Renaming any of these on the server without lifting the constants here -// would silently break the wait loop — these assertions are the canary. -func TestStatusConstants(t *testing.T) { - require.Equal(t, "applied", statusApplied) - require.Equal(t, "planned_and_finished", statusPlannedAndFinished) - require.Equal(t, "errored", statusErrored) -} - -func TestCreateWaitErrored(t *testing.T) { - for _, output := range []string{"json", "plain"} { - t.Run(output, func(t *testing.T) { - cmd, stdout, _ := newTestCreateCommand(t, NewCreateCtrl(), deploymentAPI(t, []string{"errored"}, `{"data":[{"message":"apply failed","timestamp":"2026-01-01T00:00:00Z","module":"test","diagnostic":{"severity":"error","summary":"Invalid resource","detail":"Synthetic failure"}}]}`)) - require.NoError(t, cmd.Flags().Set("output", output)) - err := cmd.Execute() - require.EqualError(t, err, "deployment failed: deployment-test\nerror: Invalid resource\nSynthetic failure") - require.Empty(t, stdout.String(), "a failed deployment must not emit a success payload") - }) - } -} - -func TestCreateWaitSuccess(t *testing.T) { - for _, status := range []string{"applied", "planned_and_finished"} { - t.Run(status, func(t *testing.T) { - ctrl := NewCreateCtrl() - var delays []time.Duration - ctrl.wait = func(ctx context.Context, delay time.Duration) error { - delays = append(delays, delay) - return ctx.Err() - } - cmd, stdout, stderr := newTestCreateCommand(t, ctrl, deploymentAPI(t, - []string{"pending", "planning", "planned", "applying", "applying", status}, "")) - require.NoError(t, cmd.Execute()) - require.Equal(t, status, ctrl.store.Status) - require.Equal(t, []time.Duration{0, 2 * time.Second, 4 * time.Second, 8 * time.Second, 15 * time.Second, 15 * time.Second}, delays) - var output struct { - Data struct { - ID string `json:"id"` - Status string `json:"status"` - } `json:"data"` - } - require.NoError(t, json.Unmarshal(stdout.Bytes(), &output)) - require.Equal(t, "deployment-test", output.Data.ID) - require.Equal(t, status, output.Data.Status) - require.Empty(t, stderr.String()) - }) - } -} - -func TestCreateWaitFailureDiagnostics(t *testing.T) { - for _, tc := range []struct { - name string - logs string - want string - }{ - {"empty logs", `{"data":[]}`, "deployment failed: deployment-test"}, - {"message without diagnostic", `{"data":[{"message":"apply failed","timestamp":"2026-01-01T00:00:00Z","module":"test"}]}`, "deployment failed: deployment-test\napply failed"}, - {"logs unavailable", "", "deployment failed: deployment-test (could not read logs:"}, - } { - t.Run(tc.name, func(t *testing.T) { - cmd, stdout, _ := newTestCreateCommand(t, NewCreateCtrl(), deploymentAPI(t, []string{"errored"}, tc.logs)) - require.ErrorContains(t, cmd.Execute(), tc.want) - require.Empty(t, stdout.String()) - }) - } -} - -func TestCreateWaitInterrupted(t *testing.T) { - for _, interruption := range []error{context.DeadlineExceeded, context.Canceled} { - t.Run(interruption.Error(), func(t *testing.T) { - ctrl := NewCreateCtrl() - ctrl.wait = func(ctx context.Context, delay time.Duration) error { - if delay != 0 { - return interruption - } - return ctx.Err() - } - cmd, stdout, _ := newTestCreateCommand(t, ctrl, deploymentAPI(t, []string{"applying"}, "")) - err := cmd.Execute() - require.ErrorIs(t, err, interruption) - if interruption == context.DeadlineExceeded { - require.ErrorContains(t, err, "timed out after 30m0s waiting for deployment deployment-test (last status: applying)") - } - require.Empty(t, stdout.String()) - }) - } -} - -func TestCreateWaitExpiredDeadline(t *testing.T) { - cmd, stdout, _ := newTestCreateCommand(t, NewCreateCtrl(), deploymentAPI(t, nil, "")) - require.NoError(t, cmd.Flags().Set("wait-timeout", "0s")) - err := cmd.Execute() - require.ErrorIs(t, err, context.DeadlineExceeded) - require.ErrorContains(t, err, "timed out after 0s waiting for deployment deployment-test (last status: pending)") - require.Empty(t, stdout.String()) -} - -func TestCreateWaitCanceledDuringRequest(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - api := deploymentAPI(t, nil, "") - cmd, stdout, _ := newTestCreateCommand(t, NewCreateCtrl(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && r.URL.Path == "/deployments/deployment-test" { - cancel() - <-r.Context().Done() - return - } - api.ServeHTTP(w, r) - })) - require.ErrorIs(t, cmd.ExecuteContext(ctx), context.Canceled) - require.Empty(t, stdout.String()) -} - -func TestCreateWithoutWait(t *testing.T) { - for _, output := range []string{"json", "plain"} { - for _, status := range []string{"pending", "errored"} { - t.Run(output+"/"+status, func(t *testing.T) { - ctrl := NewCreateCtrl() - ctrl.wait = func(context.Context, time.Duration) error { - t.Fatal("--wait=false must not poll") - return nil - } - cmd, stdout, _ := newTestCreateCommand(t, ctrl, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/deployments" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, `{"data":{"id":"deployment-test","appId":"app-test","status":%q}}`, status) - })) - require.NoError(t, cmd.Flags().Set("output", output)) - require.NoError(t, cmd.Flags().Set("wait", "false")) - err := cmd.Execute() - if status == "errored" { - require.EqualError(t, err, "deployment failed: deployment-test") - require.Empty(t, stdout.String()) - } else { - require.NoError(t, err) - } - require.Equal(t, status, ctrl.store.Status) - }) - } - } -} - -func TestWaitForDeploymentPoll(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - require.ErrorIs(t, waitForDeploymentPoll(ctx, time.Hour), context.Canceled) - require.NoError(t, waitForDeploymentPoll(context.Background(), 0)) -} - -func deploymentAPI(t *testing.T, statuses []string, logs string) http.Handler { - t.Helper() - var reads atomic.Int64 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.Method + " " + r.URL.Path { - case "POST /deployments": - w.WriteHeader(http.StatusCreated) - fmt.Fprintln(w, `{"data":{"id":"deployment-test","appId":"app-test","status":"pending"}}`) - case "GET /deployments/deployment-test": - index := int(reads.Add(1)) - 1 - if index >= len(statuses) { - t.Errorf("unexpected deployment poll %d", index+1) - http.NotFound(w, r) - return - } - fmt.Fprintf(w, `{"data":{"id":"deployment-test","appId":"app-test","status":%q}}`, statuses[index]) - case "GET /deployments/deployment-test/logs": - if logs == "" { - http.Error(w, `{"errorCode":"FORBIDDEN","errorMessage":"logs unavailable"}`, http.StatusForbidden) - return - } - fmt.Fprintln(w, logs) - default: - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - http.NotFound(w, r) - } - }) -} - -func newTestCreateCommand(t *testing.T, ctrl *CreateCtrl, handler http.Handler) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { - t.Helper() - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - cmd := newCreate(ctrl) - cmd.SilenceErrors = true - cmd.SilenceUsage = true - cmd.Flags().String(fctl.ConfigDir, t.TempDir(), "") - cmd.Flags().String(fctl.ProfileFlag, "test", "") - cmd.Flags().String(fctl.OrganizationFlag, "organization-test", "") - cmd.Flags().String(fctl.DeployAppAliasFlag, "deploy", "") - cmd.Flags().StringP(fctl.OutputFlag, "o", "json", "") - cmd.Flags().Bool(fctl.DebugFlag, false, "") - cmd.Flags().Bool(fctl.InsecureTlsFlag, false, "") - cmd.Flags().Bool(fctl.HTTPCloseOnErrorFlag, false, "") - cmd.Flags().Bool(fctl.TelemetryFlag, false, "") - cmd.SetArgs([]string{"--app-id=app-test", "--manifest-id=manifest-test", "--manifest-version=1"}) - stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) - cmd.SetOut(stdout) - cmd.SetErr(stderr) - - profile := fctl.Profile{MembershipURI: server.URL, RootTokens: &fctl.Tokens{}} - profile.RootTokens.ID.Claims.Organizations = []fctl.OrganizationAccess{{ - ID: "organization-test", Applications: []fctl.ApplicationAccess{{Alias: "deploy"}}, - }} - require.NoError(t, fctl.WriteProfile(cmd, "test", profile)) - var token fctl.AccessToken - require.NoError(t, json.Unmarshal([]byte(fmt.Sprintf(`{"token":"synthetic-token","claims":{"exp":4102444800,"aud":[%q],"organization_id":"organization-test"}}`, server.URL)), &token)) - require.NoError(t, fctl.WriteAppToken(cmd, "test", "deploy", token)) - return cmd, stdout, stderr -} diff --git a/cmd/cloud/apps/deployments/download.go b/cmd/cloud/apps/deployments/download.go deleted file mode 100644 index 56f37884..00000000 --- a/cmd/cloud/apps/deployments/download.go +++ /dev/null @@ -1,82 +0,0 @@ -package deployments - -import ( - "fmt" - "io" - "os" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewDownload() *cobra.Command { - return fctl.NewCommand("download", - fctl.WithShortDescription("Download a deployment's resolved manifest as YAML"), - fctl.WithStringFlag("id", "", "Deployment ID"), - fctl.WithStringFlag("out", "", "Output file path (defaults to stdout)"), - fctl.WithRunE(runDeploymentDownload), - ) -} - -func runDeploymentDownload(cmd *cobra.Command, _ []string) error { - id := fctl.GetString(cmd, "id") - if id == "" { - return fmt.Errorf("id is required") - } - - out := fctl.GetString(cmd, "out") - - cmd.SilenceUsage = true - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return err - } - - resp, err := apiClient.ReadDeployment( - cmd.Context(), - id, - nil, - operations.WithAcceptHeaderOverride(operations.AcceptHeaderEnumApplicationYaml), - ) - if err != nil { - return err - } - - if resp.ResponseStream == nil { - return fmt.Errorf("server returned no YAML payload for deployment %s", id) - } - defer resp.ResponseStream.Close() - - if out == "" { - if _, err := io.Copy(cmd.OutOrStdout(), resp.ResponseStream); err != nil { - return fmt.Errorf("write deployment payload: %w", err) - } - return nil - } - - f, err := os.Create(out) // #nosec G304 -- user-specified output path on a CLI flag - if err != nil { - return fmt.Errorf("create output file: %w", err) - } - defer func() { _ = f.Close() }() - - if _, err := io.Copy(f, resp.ResponseStream); err != nil { - return fmt.Errorf("write deployment payload: %w", err) - } - return f.Close() -} diff --git a/cmd/cloud/apps/deployments/list.go b/cmd/cloud/apps/deployments/list.go deleted file mode 100644 index ebee4e45..00000000 --- a/cmd/cloud/apps/deployments/list.go +++ /dev/null @@ -1,123 +0,0 @@ -package deployments - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - components.ListDeploymentsResponseCursor -} - -type ListCtrl struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListCtrl)(nil) - -func newDefaultStore() *List { - return &List{ - ListDeploymentsResponseCursor: components.ListDeploymentsResponseCursor{}, - } -} - -func NewListCtrl() *ListCtrl { - return &ListCtrl{ - store: newDefaultStore(), - } -} - -func NewList() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithShortDescription("List deployments"), - fctl.WithStringFlag("app-id", "", "Filter by app ID"), - fctl.WithIntFlag("page-size", 100, "Page size"), - fctl.WithStringFlag("cursor", "", "Opaque cursor token for the next page"), - fctl.WithController(NewListCtrl()), - ) -} - -func (c *ListCtrl) GetStore() *List { - return c.store -} - -func (c *ListCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - var appID *string - if id := fctl.GetString(cmd, "app-id"); id != "" { - appID = &id - } - - var cursor *string - if v := fctl.GetString(cmd, "cursor"); v != "" { - cursor = &v - } - - deployments, err := apiClient.ListDeployments( - cmd.Context(), - appID, - pointer.For(int64(fctl.GetInt(cmd, "page-size"))), - cursor, - ) - if err != nil { - return nil, err - } - - c.store.ListDeploymentsResponseCursor = deployments.ListDeploymentsResponse.Cursor - - return c, nil -} - -func (c *ListCtrl) Render(cmd *cobra.Command, _ []string) error { - data := [][]string{ - {"ID", "App ID", "Status", "Manifest ID", "Created At"}, - } - - for _, d := range c.store.Data { - data = append(data, []string{ - d.ID, - d.AppID, - d.Status, - func() string { - if d.ManifestID != nil { - return *d.ManifestID - } - return "inline" - }(), - fmt.Sprint(d.CreatedAt), - }) - } - - if err := pterm. - DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/deployments/logs.go b/cmd/cloud/apps/deployments/logs.go deleted file mode 100644 index eb15d2dd..00000000 --- a/cmd/cloud/apps/deployments/logs.go +++ /dev/null @@ -1,77 +0,0 @@ -package deployments - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - "github.com/formancehq/fctl/v3/cmd/cloud/apps/printer" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Logs []components.Log -type LogsCtrl struct { - store Logs -} - -var _ fctl.Controller[Logs] = (*LogsCtrl)(nil) - -func newLogStore() Logs { - return Logs{} -} - -func NewLogsCtrl() *LogsCtrl { - return &LogsCtrl{ - store: newLogStore(), - } -} - -func NewLogs() *cobra.Command { - return fctl.NewCommand("logs", - fctl.WithShortDescription("Read logs for a deployment"), - fctl.WithStringFlag("id", "", "Deployment ID"), - fctl.WithController(NewLogsCtrl()), - ) -} - -func (c *LogsCtrl) GetStore() Logs { - return c.store -} - -func (c *LogsCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - logs, err := apiClient.ReadDeploymentLogs(cmd.Context(), id) - if err != nil { - return nil, err - } - - c.store = Logs(logs.ReadLogsResponse.Data) - - return c, nil -} - -func (c *LogsCtrl) Render(cmd *cobra.Command, _ []string) error { - return printer.RenderLogs(cmd.OutOrStdout(), c.store) -} diff --git a/cmd/cloud/apps/deployments/root.go b/cmd/cloud/apps/deployments/root.go deleted file mode 100644 index 26b1a37a..00000000 --- a/cmd/cloud/apps/deployments/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package deployments - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("deployments", - fctl.WithShortDescription("Manage deployments"), - fctl.WithAliases("deploy", "dep"), - fctl.WithChildCommands( - NewCreate(), - NewList(), - NewShow(), - NewLogs(), - NewDownload(), - ), - ) -} diff --git a/cmd/cloud/apps/deployments/show.go b/cmd/cloud/apps/deployments/show.go deleted file mode 100644 index 57f028d3..00000000 --- a/cmd/cloud/apps/deployments/show.go +++ /dev/null @@ -1,139 +0,0 @@ -package deployments - -import ( - "fmt" - "sort" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Show struct { - components.DeploymentResource -} - -type ShowCtrl struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowCtrl)(nil) - -func newShowStore() *Show { - return &Show{} -} - -func NewShowCtrl() *ShowCtrl { - return &ShowCtrl{ - store: newShowStore(), - } -} - -func NewShow() *cobra.Command { - return fctl.NewCommand("show", - fctl.WithShortDescription("Show deployment details"), - fctl.WithStringFlag("id", "", "Deployment ID"), - fctl.WithBoolFlag("include-state", false, "Include Terraform state"), - fctl.WithController(NewShowCtrl()), - ) -} - -func (c *ShowCtrl) GetStore() *Show { - return c.store -} - -func (c *ShowCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - var includes []operations.ReadDeploymentInclude - if fctl.GetBool(cmd, "include-state") { - includes = append(includes, operations.ReadDeploymentIncludeState) - } - - deployment, err := apiClient.ReadDeployment(cmd.Context(), id, includes) - if err != nil { - return nil, err - } - - c.store.DeploymentResource = deployment.DeploymentResponse.Data - - return c, nil -} - -func (c *ShowCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.DefaultSection.Println("Deployment") - - items := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("ID: %s", c.store.ID)}, - {Level: 0, Text: fmt.Sprintf("App ID: %s", c.store.AppID)}, - {Level: 0, Text: fmt.Sprintf("Status: %s", c.store.Status)}, - {Level: 0, Text: fmt.Sprintf("Created At: %s", c.store.CreatedAt)}, - {Level: 0, Text: fmt.Sprintf("Updated At: %s", c.store.UpdatedAt)}, - } - - if c.store.ManifestID != nil { - items = append(items, pterm.BulletListItem{Level: 0, Text: fmt.Sprintf("Manifest ID: %s", *c.store.ManifestID)}) - } - if c.store.ManifestVersion != nil { - items = append(items, pterm.BulletListItem{Level: 0, Text: fmt.Sprintf("Manifest Version: %d", *c.store.ManifestVersion)}) - } - - if c.store.State != nil && len(c.store.State.Stack) > 0 { - items = append(items, pterm.BulletListItem{Level: 0, Text: "State"}) - items = append(items, renderStackItems(c.store.State.Stack)...) - } - - if err := pterm. - DefaultBulletList. - WithItems(items). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - - return nil -} - -func renderStackItems(stack map[string]any) []pterm.BulletListItem { - keys := make([]string, 0, len(stack)) - for k := range stack { - keys = append(keys, k) - } - sort.Strings(keys) - - items := make([]pterm.BulletListItem, 0, len(stack)) - for _, k := range keys { - v := stack[k] - if v == nil { - continue - } - items = append(items, pterm.BulletListItem{ - Level: 1, - Text: fmt.Sprintf("%s: %v", k, v), - }) - } - return items -} diff --git a/cmd/cloud/apps/list.go b/cmd/cloud/apps/list.go deleted file mode 100644 index b15e38e7..00000000 --- a/cmd/cloud/apps/list.go +++ /dev/null @@ -1,113 +0,0 @@ -package apps - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - components.ListAppsResponseCursor -} - -type ListCtrl struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListCtrl)(nil) - -func newDefaultStore() *List { - return &List{ - ListAppsResponseCursor: components.ListAppsResponseCursor{}, - } -} - -func NewListCtrl() *ListCtrl { - return &ListCtrl{ - store: newDefaultStore(), - } -} - -func NewList() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithIntFlag("page-size", 100, "Page size"), - fctl.WithStringFlag("cursor", "", "Opaque cursor token for the next page"), - fctl.WithShortDescription("List apps"), - fctl.WithController(NewListCtrl()), - ) -} - -func (c *ListCtrl) GetStore() *List { - return c.store -} - -func (c *ListCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - var cursor *string - if v := fctl.GetString(cmd, "cursor"); v != "" { - cursor = &v - } - - apps, err := apiClient.ListApps( - cmd.Context(), - pointer.For(int64(fctl.GetInt(cmd, "page-size"))), - cursor, - ) - if err != nil { - return nil, err - } - - c.store.ListAppsResponseCursor = apps.ListAppsResponse.Cursor - - return c, nil -} - -func (c *ListCtrl) Render(cmd *cobra.Command, _ []string) error { - data := [][]string{ - {"Name", "ID", "Stack ID"}, - } - - for _, w := range c.store.Data { - data = append(data, []string{ - w.Name, - w.ID, - func() string { - if w.StackID != nil { - return *w.StackID - } - return "N/A" - }(), - }) - } - - if err := pterm. - DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/manifests/create.go b/cmd/cloud/apps/manifests/create.go deleted file mode 100644 index a5ccab16..00000000 --- a/cmd/cloud/apps/manifests/create.go +++ /dev/null @@ -1,96 +0,0 @@ -package manifests - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Create struct { - ID string `json:"id"` - Name string `json:"name"` - Version int64 `json:"version"` -} - -type CreateCtrl struct { - store *Create -} - -var _ fctl.Controller[*Create] = (*CreateCtrl)(nil) - -func newCreateStore() *Create { - return &Create{} -} - -func NewCreateCtrl() *CreateCtrl { - return &CreateCtrl{ - store: newCreateStore(), - } -} - -func NewCreate() *cobra.Command { - return fctl.NewCommand("create", - fctl.WithShortDescription("Create a new manifest"), - fctl.WithStringFlag("name", "", "Manifest name"), - fctl.WithStringFlag("path", "", "Path to YAML manifest file"), - fctl.WithController(NewCreateCtrl()), - ) -} - -func (c *CreateCtrl) GetStore() *Create { - return c.store -} - -func (c *CreateCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - name := fctl.GetString(cmd, "name") - if name == "" { - return nil, fmt.Errorf("name is required") - } - - path := fctl.GetString(cmd, "path") - if path == "" { - return nil, fmt.Errorf("path is required") - } - - data, err := os.ReadFile(filepath.Clean(path)) - if err != nil { - return nil, err - } - - resp, err := apiClient.CreateManifestRaw(cmd.Context(), name, data) - if err != nil { - return nil, err - } - - c.store.ID = resp.CreateManifestResponse.Data.ID - c.store.Name = resp.CreateManifestResponse.Data.Name - c.store.Version = resp.CreateManifestResponse.Data.Version - - return c, nil -} - -func (c *CreateCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.Printfln("Manifest created: %s (version %d)", c.store.ID, c.store.Version) - return nil -} diff --git a/cmd/cloud/apps/manifests/create_test.go b/cmd/cloud/apps/manifests/create_test.go deleted file mode 100644 index 0bfa61fb..00000000 --- a/cmd/cloud/apps/manifests/create_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package manifests - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -// TestCreate_JSONOutput_LowercaseKeys verifies the Create store serializes -// to JSON with lowercase, idiomatic keys (`id`, `name`, `version`) under -// `data`. Without explicit json tags Go marshals exported fields as-is -// (Go-cased), which would produce `{"data":{"ID":..., "Name":..., "Version":...}}` -// and break any consumer (script, dashboard) reading `.data.id`. The -// provision-example-apps.sh script tripped on this exact bug. -func TestCreate_JSONOutput_LowercaseKeys(t *testing.T) { - store := &Create{ - ID: "mnf-abc123", - Name: "my-manifest", - Version: 1, - } - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok, "data should be an object") - - require.Equal(t, "mnf-abc123", data["id"], "expected lowercase 'id' key") - require.Equal(t, "my-manifest", data["name"], "expected lowercase 'name' key") - require.EqualValues(t, 1, data["version"], "expected lowercase 'version' key") - - // Negative: capital aliases must NOT exist (would indicate missing tags). - for _, capitalized := range []string{"ID", "Name", "Version"} { - _, ok := data[capitalized] - require.False(t, ok, "JSON output should not contain Go-cased key %q", capitalized) - } -} diff --git a/cmd/cloud/apps/manifests/delete.go b/cmd/cloud/apps/manifests/delete.go deleted file mode 100644 index dda23735..00000000 --- a/cmd/cloud/apps/manifests/delete.go +++ /dev/null @@ -1,79 +0,0 @@ -package manifests - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Delete struct { - ID string `json:"id"` -} - -type DeleteCtrl struct { - store *Delete -} - -var _ fctl.Controller[*Delete] = (*DeleteCtrl)(nil) - -func newDeleteStore() *Delete { - return &Delete{} -} - -func NewDeleteCtrl() *DeleteCtrl { - return &DeleteCtrl{ - store: newDeleteStore(), - } -} - -func NewDelete() *cobra.Command { - return fctl.NewCommand("delete", - fctl.WithShortDescription("Delete a manifest"), - fctl.WithStringFlag("id", "", "Manifest ID"), - fctl.WithController(NewDeleteCtrl()), - ) -} - -func (c *DeleteCtrl) GetStore() *Delete { - return c.store -} - -func (c *DeleteCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - _, err = apiClient.DeleteManifest(cmd.Context(), id) - if err != nil { - return nil, err - } - - c.store.ID = id - - return c, nil -} - -func (c *DeleteCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.Println("Manifest deleted", c.store.ID) - return nil -} diff --git a/cmd/cloud/apps/manifests/delete_test.go b/cmd/cloud/apps/manifests/delete_test.go deleted file mode 100644 index a1e2c865..00000000 --- a/cmd/cloud/apps/manifests/delete_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package manifests - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestDelete_JSONOutput_LowercaseKeys(t *testing.T) { - store := &Delete{ID: "mnf-abc123"} - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok) - require.Equal(t, "mnf-abc123", data["id"]) - _, capitalized := data["ID"] - require.False(t, capitalized) -} diff --git a/cmd/cloud/apps/manifests/download.go b/cmd/cloud/apps/manifests/download.go deleted file mode 100644 index 572c8d63..00000000 --- a/cmd/cloud/apps/manifests/download.go +++ /dev/null @@ -1,88 +0,0 @@ -package manifests - -import ( - "fmt" - "io" - "os" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewDownload() *cobra.Command { - return fctl.NewCommand("download", - fctl.WithShortDescription("Download a manifest version's raw YAML content"), - fctl.WithStringFlag("id", "", "Manifest ID"), - fctl.WithStringFlag("version", "latest", "Version number or \"latest\""), - fctl.WithStringFlag("out", "", "Output file path (defaults to stdout)"), - fctl.WithRunE(runManifestDownload), - ) -} - -func runManifestDownload(cmd *cobra.Command, _ []string) error { - id := fctl.GetString(cmd, "id") - if id == "" { - return fmt.Errorf("id is required") - } - - version := fctl.GetString(cmd, "version") - if version == "" { - return fmt.Errorf("version is required") - } - - out := fctl.GetString(cmd, "out") - - cmd.SilenceUsage = true - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return err - } - - resp, err := apiClient.ReadManifestVersion( - cmd.Context(), - id, - version, - operations.WithAcceptHeaderOverride(operations.AcceptHeaderEnumApplicationXYaml), - ) - if err != nil { - return err - } - - if resp.ResponseStream == nil { - return fmt.Errorf("server returned no YAML payload for manifest %s version %s", id, version) - } - defer resp.ResponseStream.Close() - - if out == "" { - if _, err := io.Copy(cmd.OutOrStdout(), resp.ResponseStream); err != nil { - return fmt.Errorf("write manifest payload: %w", err) - } - return nil - } - - f, err := os.Create(out) // #nosec G304 -- user-specified output path on a CLI flag - if err != nil { - return fmt.Errorf("create output file: %w", err) - } - defer func() { _ = f.Close() }() - - if _, err := io.Copy(f, resp.ResponseStream); err != nil { - return fmt.Errorf("write manifest payload: %w", err) - } - return f.Close() -} diff --git a/cmd/cloud/apps/manifests/list.go b/cmd/cloud/apps/manifests/list.go deleted file mode 100644 index 2aa0b7cd..00000000 --- a/cmd/cloud/apps/manifests/list.go +++ /dev/null @@ -1,111 +0,0 @@ -package manifests - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - components.ListManifestsResponseCursor -} - -type ListCtrl struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListCtrl)(nil) - -func newDefaultStore() *List { - return &List{ - ListManifestsResponseCursor: components.ListManifestsResponseCursor{}, - } -} - -func NewListCtrl() *ListCtrl { - return &ListCtrl{ - store: newDefaultStore(), - } -} - -func NewList() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithShortDescription("List manifests"), - fctl.WithIntFlag("page-size", 100, "Page size"), - fctl.WithStringFlag("cursor", "", "Opaque cursor token for the next page"), - fctl.WithController(NewListCtrl()), - ) -} - -func (c *ListCtrl) GetStore() *List { - return c.store -} - -func (c *ListCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - var cursor *string - if v := fctl.GetString(cmd, "cursor"); v != "" { - cursor = &v - } - - manifests, err := apiClient.ListManifests( - cmd.Context(), - pointer.For(int64(fctl.GetInt(cmd, "page-size"))), - cursor, - ) - if err != nil { - return nil, err - } - - c.store.ListManifestsResponseCursor = manifests.ListManifestsResponse.Cursor - - return c, nil -} - -func (c *ListCtrl) Render(cmd *cobra.Command, _ []string) error { - data := [][]string{ - {"ID", "Name", "Latest Version", "Created At"}, - } - - for _, m := range c.store.Data { - data = append(data, []string{ - m.ID, - m.Name, - strconv.FormatInt(m.LatestVersion, 10), - fmt.Sprint(m.CreatedAt), - }) - } - - if err := pterm. - DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/manifests/root.go b/cmd/cloud/apps/manifests/root.go deleted file mode 100644 index 1f70d504..00000000 --- a/cmd/cloud/apps/manifests/root.go +++ /dev/null @@ -1,24 +0,0 @@ -package manifests - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/cloud/apps/manifests/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("manifests", - fctl.WithShortDescription("Manage manifests"), - fctl.WithAliases("manifest", "mf"), - fctl.WithChildCommands( - NewList(), - NewShow(), - NewCreate(), - NewDelete(), - NewUpdate(), - NewDownload(), - versions.NewCommand(), - ), - ) -} diff --git a/cmd/cloud/apps/manifests/show.go b/cmd/cloud/apps/manifests/show.go deleted file mode 100644 index f382b984..00000000 --- a/cmd/cloud/apps/manifests/show.go +++ /dev/null @@ -1,98 +0,0 @@ -package manifests - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Show struct { - components.Manifest -} - -type ShowCtrl struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowCtrl)(nil) - -func newShowStore() *Show { - return &Show{} -} - -func NewShowCtrl() *ShowCtrl { - return &ShowCtrl{ - store: newShowStore(), - } -} - -func NewShow() *cobra.Command { - return fctl.NewCommand("show", - fctl.WithShortDescription("Show manifest details"), - fctl.WithStringFlag("id", "", "Manifest ID"), - fctl.WithController(NewShowCtrl()), - ) -} - -func (c *ShowCtrl) GetStore() *Show { - return c.store -} - -func (c *ShowCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - manifest, err := apiClient.ReadManifest(cmd.Context(), id, nil) - if err != nil { - return nil, err - } - - c.store.Manifest = manifest.ManifestResponse.Data - - return c, nil -} - -func (c *ShowCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.DefaultSection.Println("Manifest") - - items := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("ID: %s", c.store.ID)}, - {Level: 0, Text: fmt.Sprintf("Name: %s", c.store.Name)}, - {Level: 0, Text: fmt.Sprintf("Latest Version: %d", c.store.LatestVersion)}, - {Level: 0, Text: fmt.Sprintf("Created At: %s", c.store.CreatedAt)}, - {Level: 0, Text: fmt.Sprintf("Updated At: %s", c.store.UpdatedAt)}, - } - - if err := pterm. - DefaultBulletList. - WithItems(items). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/cloud/apps/manifests/update.go b/cmd/cloud/apps/manifests/update.go deleted file mode 100644 index 3d0beb76..00000000 --- a/cmd/cloud/apps/manifests/update.go +++ /dev/null @@ -1,89 +0,0 @@ -package manifests - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Update struct { - components.Manifest -} - -type UpdateCtrl struct { - store *Update -} - -var _ fctl.Controller[*Update] = (*UpdateCtrl)(nil) - -func newUpdateStore() *Update { - return &Update{} -} - -func NewUpdateCtrl() *UpdateCtrl { - return &UpdateCtrl{ - store: newUpdateStore(), - } -} - -func NewUpdate() *cobra.Command { - return fctl.NewCommand("update", - fctl.WithShortDescription("Update manifest metadata"), - fctl.WithStringFlag("id", "", "Manifest ID"), - fctl.WithStringFlag("name", "", "New name for the manifest"), - fctl.WithController(NewUpdateCtrl()), - ) -} - -func (c *UpdateCtrl) GetStore() *Update { - return c.store -} - -func (c *UpdateCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - name := fctl.GetString(cmd, "name") - if name == "" { - return nil, fmt.Errorf("name is required") - } - - resp, err := apiClient.UpdateManifest(cmd.Context(), id, components.UpdateManifestRequest{ - Name: name, - }) - if err != nil { - return nil, err - } - - c.store.Manifest = resp.ManifestResponse.Data - - return c, nil -} - -func (c *UpdateCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.Printfln("Manifest updated: %s", c.store.ID) - return nil -} diff --git a/cmd/cloud/apps/manifests/versions/list.go b/cmd/cloud/apps/manifests/versions/list.go deleted file mode 100644 index fa1108dc..00000000 --- a/cmd/cloud/apps/manifests/versions/list.go +++ /dev/null @@ -1,117 +0,0 @@ -package versions - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - components.ListManifestVersionsResponseCursor -} - -type ListCtrl struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListCtrl)(nil) - -func newDefaultStore() *List { - return &List{ - ListManifestVersionsResponseCursor: components.ListManifestVersionsResponseCursor{}, - } -} - -func NewListCtrl() *ListCtrl { - return &ListCtrl{ - store: newDefaultStore(), - } -} - -func NewList() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithShortDescription("List manifest versions"), - fctl.WithStringFlag("manifest-id", "", "Manifest ID"), - fctl.WithIntFlag("page-size", 100, "Page size"), - fctl.WithStringFlag("cursor", "", "Opaque cursor token for the next page"), - fctl.WithController(NewListCtrl()), - ) -} - -func (c *ListCtrl) GetStore() *List { - return c.store -} - -func (c *ListCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - manifestID := fctl.GetString(cmd, "manifest-id") - if manifestID == "" { - return nil, fmt.Errorf("manifest-id is required") - } - - var cursor *string - if v := fctl.GetString(cmd, "cursor"); v != "" { - cursor = &v - } - - versions, err := apiClient.ListManifestVersions( - cmd.Context(), - manifestID, - pointer.For(int64(fctl.GetInt(cmd, "page-size"))), - cursor, - ) - if err != nil { - return nil, err - } - - c.store.ListManifestVersionsResponseCursor = versions.ListManifestVersionsResponse.Cursor - - return c, nil -} - -func (c *ListCtrl) Render(cmd *cobra.Command, _ []string) error { - data := [][]string{ - {"Manifest ID", "Version", "Created At"}, - } - - for _, v := range c.store.Data { - data = append(data, []string{ - v.ManifestID, - strconv.FormatInt(v.Version, 10), - fmt.Sprint(v.CreatedAt), - }) - } - - if err := pterm. - DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/manifests/versions/push.go b/cmd/cloud/apps/manifests/versions/push.go deleted file mode 100644 index 2838d8bb..00000000 --- a/cmd/cloud/apps/manifests/versions/push.go +++ /dev/null @@ -1,94 +0,0 @@ -package versions - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Push struct { - ManifestID string `json:"manifestId"` - Version int64 `json:"version"` -} - -type PushCtrl struct { - store *Push -} - -var _ fctl.Controller[*Push] = (*PushCtrl)(nil) - -func newPushStore() *Push { - return &Push{} -} - -func NewPushCtrl() *PushCtrl { - return &PushCtrl{ - store: newPushStore(), - } -} - -func NewPush() *cobra.Command { - return fctl.NewCommand("push", - fctl.WithShortDescription("Push a new version of a manifest"), - fctl.WithStringFlag("manifest-id", "", "Manifest ID"), - fctl.WithStringFlag("path", "", "Path to YAML manifest file"), - fctl.WithController(NewPushCtrl()), - ) -} - -func (c *PushCtrl) GetStore() *Push { - return c.store -} - -func (c *PushCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - manifestID := fctl.GetString(cmd, "manifest-id") - if manifestID == "" { - return nil, fmt.Errorf("manifest-id is required") - } - - path := fctl.GetString(cmd, "path") - if path == "" { - return nil, fmt.Errorf("path is required") - } - - data, err := os.ReadFile(filepath.Clean(path)) - if err != nil { - return nil, err - } - - resp, err := apiClient.PushManifestVersionRaw(cmd.Context(), manifestID, data) - if err != nil { - return nil, err - } - - c.store.ManifestID = resp.ManifestVersionResponse.Data.ManifestID - c.store.Version = resp.ManifestVersionResponse.Data.Version - - return c, nil -} - -func (c *PushCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.Printfln("Manifest version %d pushed for %s", c.store.Version, c.store.ManifestID) - return nil -} diff --git a/cmd/cloud/apps/manifests/versions/push_test.go b/cmd/cloud/apps/manifests/versions/push_test.go deleted file mode 100644 index 66522b16..00000000 --- a/cmd/cloud/apps/manifests/versions/push_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package versions - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestPush_JSONOutput_LowercaseKeys(t *testing.T) { - store := &Push{ManifestID: "mnf-abc", Version: 4} - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok) - require.Equal(t, "mnf-abc", data["manifestId"]) - require.EqualValues(t, 4, data["version"]) - - for _, capitalized := range []string{"ManifestID", "Version"} { - _, present := data[capitalized] - require.False(t, present, "must not surface Go-cased key %q", capitalized) - } -} diff --git a/cmd/cloud/apps/manifests/versions/root.go b/cmd/cloud/apps/manifests/versions/root.go deleted file mode 100644 index fb949d0b..00000000 --- a/cmd/cloud/apps/manifests/versions/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package versions - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("versions", - fctl.WithShortDescription("Manage manifest versions"), - fctl.WithAliases("ver", "v"), - fctl.WithChildCommands( - NewList(), - NewShow(), - NewPush(), - ), - ) -} diff --git a/cmd/cloud/apps/manifests/versions/show.go b/cmd/cloud/apps/manifests/versions/show.go deleted file mode 100644 index 2ac45b20..00000000 --- a/cmd/cloud/apps/manifests/versions/show.go +++ /dev/null @@ -1,112 +0,0 @@ -package versions - -import ( - "encoding/base64" - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Show struct { - components.ManifestVersion -} - -type ShowCtrl struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowCtrl)(nil) - -func newShowStore() *Show { - return &Show{} -} - -func NewShowCtrl() *ShowCtrl { - return &ShowCtrl{ - store: newShowStore(), - } -} - -func NewShow() *cobra.Command { - return fctl.NewCommand("show", - fctl.WithShortDescription("Show a specific manifest version"), - fctl.WithStringFlag("manifest-id", "", "Manifest ID"), - fctl.WithStringFlag("version", "latest", "Version number or 'latest'"), - fctl.WithController(NewShowCtrl()), - ) -} - -func (c *ShowCtrl) GetStore() *Show { - return c.store -} - -func (c *ShowCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - manifestID := fctl.GetString(cmd, "manifest-id") - if manifestID == "" { - return nil, fmt.Errorf("manifest-id is required") - } - - version := fctl.GetString(cmd, "version") - - resp, err := apiClient.ReadManifestVersion(cmd.Context(), manifestID, version) - if err != nil { - return nil, err - } - - c.store.ManifestVersion = resp.ManifestVersionResponse.Data - - return c, nil -} - -func (c *ShowCtrl) Render(cmd *cobra.Command, _ []string) error { - pterm.DefaultSection.Println("Manifest Version") - - items := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("Manifest ID: %s", c.store.ManifestID)}, - {Level: 0, Text: fmt.Sprintf("Version: %s", strconv.FormatInt(c.store.Version, 10))}, - {Level: 0, Text: fmt.Sprintf("Created At: %s", c.store.CreatedAt)}, - } - - if err := pterm. - DefaultBulletList. - WithItems(items). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - - if c.store.Content != nil { - pterm.DefaultSection.WithWriter(cmd.OutOrStdout()).Println("Content") - - decoded, err := base64.StdEncoding.DecodeString(*c.store.Content) - if err != nil { - fmt.Fprintln(cmd.OutOrStdout(), *c.store.Content) - } else { - fmt.Fprintln(cmd.OutOrStdout(), string(decoded)) - } - } - - return nil -} diff --git a/cmd/cloud/apps/printer/logs.go b/cmd/cloud/apps/printer/logs.go deleted file mode 100644 index ce4544f5..00000000 --- a/cmd/cloud/apps/printer/logs.go +++ /dev/null @@ -1,40 +0,0 @@ -package printer - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -func RenderLogs(out io.Writer, logs []components.Log) error { - for i, entry := range logs { - pterm.DefaultSection.Print(fmt.Sprintf("%d - Diagnostic ", i+1)) - data := [][]string{ - { - "Timestamp", - entry.Timestamp.String(), - }, { - "Severity", - entry.Diagnostic.Severity, - }, { - "Summary", - entry.Diagnostic.Summary, - }, { - "Details", - entry.Diagnostic.Detail, - }, - } - if err := pterm. - DefaultTable. - WithData(data). - WithWriter(out). - Render(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/cloud/apps/root.go b/cmd/cloud/apps/root.go deleted file mode 100644 index b98403f9..00000000 --- a/cmd/cloud/apps/root.go +++ /dev/null @@ -1,47 +0,0 @@ -package apps - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/cloud/apps/deployments" - "github.com/formancehq/fctl/v3/cmd/cloud/apps/manifests" - "github.com/formancehq/fctl/v3/cmd/cloud/apps/variables" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - cmd := fctl.NewMembershipCommand("apps", - fctl.WithShortDescription("Manage app manifests"), - fctl.WithPersistentBoolFlag("experimental", false, "Enable experimental commands"), - fctl.WithPersistentStringFlag(fctl.FrameworkURIFlag, "https://deploy.formance.cloud", "Framework URI"), - fctl.WithPersistentStringFlag(fctl.DeployAppAliasFlag, fctl.DefaultDeployAppAlias, "Membership application alias used to authenticate against the deploy server"), - fctl.WithPersistentPreRunE(func(cmd *cobra.Command, args []string) error { - ok, err := cmd.Flags().GetBool("experimental") - if err != nil { - return err - } - - if !ok { - return fmt.Errorf("the apps command is experimental, please use the --experimental flag to enable it") - } - - return nil - }), - fctl.WithAliases("app"), - fctl.WithChildCommands( - NewList(), - NewCreate(), - NewDelete(), - NewShow(), - NewBindManifest(), - NewUnbindManifest(), - deployments.NewCommand(), - manifests.NewCommand(), - variables.NewCommand(), - ), - ) - - return cmd -} diff --git a/cmd/cloud/apps/show.go b/cmd/cloud/apps/show.go deleted file mode 100644 index 510df228..00000000 --- a/cmd/cloud/apps/show.go +++ /dev/null @@ -1,200 +0,0 @@ -package apps - -import ( - "fmt" - "slices" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func divergenceHint(k components.Kind) string { - switch k { - case components.KindUndeployed: - return "manifest bound but no successful deployment yet" - case components.KindSynced: - return "deployed (manifestId, version) matches the bound manifest's latest version" - case components.KindBehind: - return "deployed manifest matches the bound manifest but on an older version" - case components.KindRebound: - return "the app was rebound; deployed manifest is a different manifest" - default: - return "" - } -} - -type Show struct { - components.App -} - -type ShowCtrl struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowCtrl)(nil) - -func newShowStore() *Show { - return &Show{ - App: components.App{}, - } -} - -func NewShowCtrl() *ShowCtrl { - return &ShowCtrl{ - store: newShowStore(), - } -} - -func NewShow() *cobra.Command { - return fctl.NewCommand("show", - fctl.WithShortDescription("Show an app"), - fctl.WithStringFlag("id", "", "App ID"), - fctl.WithController(NewShowCtrl()), - ) -} - -func (c *ShowCtrl) GetStore() *Show { - return c.store -} - -func (c *ShowCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - app, err := apiClient.ReadApp(cmd.Context(), id, []operations.ReadAppInclude{operations.ReadAppIncludeState}) - if err != nil { - return nil, err - } - c.store.App = app.AppResponse.Data - - return c, nil -} - -func (c *ShowCtrl) Render(cmd *cobra.Command, args []string) error { - - if c.store.App.State != nil && c.store.App.State.Stack != nil { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return err - } - info, err := fctl.MembershipServerInfo(cmd.Context(), apiClient) - if err != nil { - return err - } - - if consoleURL := info.GetConsoleURL(); consoleURL != nil { - pterm.Info.Printfln("View stack in console: %s/%s/%s?region=%s", *consoleURL, organizationID, c.store.App.State.Stack["id"], c.store.App.State.Stack["region_id"]) - } - } - - pterm.DefaultSection.Println("App") - - items := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("ID: %s", c.store.App.ID)}, - {Level: 0, Text: fmt.Sprintf("Name: %s", c.store.App.Name)}, - } - - if c.store.App.StackID != nil { - items = append(items, pterm.BulletListItem{Level: 0, Text: fmt.Sprintf("Stack ID: %s", *c.store.App.StackID)}) - } - - if err := pterm. - DefaultBulletList. - WithItems(items). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - - if cm := c.store.App.CurrentManifest; cm != nil { - pterm.DefaultSection.Println("Manifest") - manifestItems := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("ID: %s", cm.ID)}, - {Level: 0, Text: fmt.Sprintf("Name: %s", cm.Name)}, - {Level: 0, Text: fmt.Sprintf("Latest Version: %d", cm.LatestVersion)}, - } - if d := cm.Divergence; d != nil { - manifestItems = append(manifestItems, pterm.BulletListItem{ - Level: 0, - Text: fmt.Sprintf("Status: %s — %s", d.Kind, divergenceHint(d.Kind)), - }) - if d.DeployedManifestID != nil { - manifestItems = append(manifestItems, pterm.BulletListItem{ - Level: 1, - Text: fmt.Sprintf("Deployed manifest: %s", *d.DeployedManifestID), - }) - } - if d.DeployedVersion != nil { - manifestItems = append(manifestItems, pterm.BulletListItem{ - Level: 1, - Text: fmt.Sprintf("Deployed version: %d", *d.DeployedVersion), - }) - } - manifestItems = append(manifestItems, pterm.BulletListItem{ - Level: 1, - Text: fmt.Sprintf("Latest version: %d", d.LatestVersion), - }) - } - if err := pterm. - DefaultBulletList. - WithItems(manifestItems). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - } - - if c.store.App.State != nil && c.store.App.State.Stack != nil { - pterm.DefaultSection.Println("State") - - stateItems := []pterm.BulletListItem{} - - for k, v := range c.store.App.State.Stack { - if v == nil { - continue - } - stateItems = append(stateItems, pterm.BulletListItem{Level: 0, Text: fmt.Sprintf("%s: %s", k, v)}) - } - - slices.SortFunc(stateItems, func(a, b pterm.BulletListItem) int { - return strings.Compare(a.Text, b.Text) - }) - if err := pterm. - DefaultBulletList. - WithItems(stateItems). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/cloud/apps/show_test.go b/cmd/cloud/apps/show_test.go deleted file mode 100644 index 64d682d4..00000000 --- a/cmd/cloud/apps/show_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package apps - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestShow_JSONOutput_PreservesSDKShape(t *testing.T) { - stackID := "stk-abc" - deployedManifestID := "mnf-old" - deployedVersion := int64(1) - - store := &Show{ - App: components.App{ - ID: "app-abc", - Name: "my-app", - StackID: &stackID, - CurrentManifest: &components.AppCurrentManifest{ - ID: "mnf-new", - Name: "my-manifest", - LatestVersion: 3, - Divergence: &components.AppManifestDivergence{ - Kind: components.KindRebound, - DeployedManifestID: &deployedManifestID, - DeployedVersion: &deployedVersion, - LatestVersion: 3, - }, - }, - }, - } - - out, err := json.Marshal(fctl.ExportedData{Data: store}) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(out, &raw)) - - data, ok := raw["data"].(map[string]any) - require.True(t, ok) - require.Equal(t, "app-abc", data["id"]) - require.Equal(t, "stk-abc", data["stackId"]) - - cm, ok := data["currentManifest"].(map[string]any) - require.True(t, ok, "currentManifest must be present and named lowercase") - require.Equal(t, "mnf-new", cm["id"]) - require.EqualValues(t, 3, cm["latestVersion"]) - - div, ok := cm["divergence"].(map[string]any) - require.True(t, ok, "divergence must be present") - require.Equal(t, "rebound", div["kind"]) - require.Equal(t, "mnf-old", div["deployedManifestId"]) - require.EqualValues(t, 1, div["deployedVersion"]) -} - -func TestShow_PlainRender_IncludesDivergenceSection(t *testing.T) { - deployedManifestID := "mnf-old" - deployedVersion := int64(1) - - c := NewShowCtrl() - c.store.App = components.App{ - ID: "app-abc", - Name: "my-app", - CurrentManifest: &components.AppCurrentManifest{ - ID: "mnf-new", - Name: "my-manifest", - LatestVersion: 3, - Divergence: &components.AppManifestDivergence{ - Kind: components.KindBehind, - DeployedManifestID: &deployedManifestID, - DeployedVersion: &deployedVersion, - LatestVersion: 3, - }, - }, - } - - cmd := &cobra.Command{} - buf := &bytes.Buffer{} - cmd.SetOut(buf) - - require.NoError(t, c.Render(cmd, nil)) - - // pterm.DefaultSection writes to os.Stdout directly; only the bullet - // list goes through cmd.Out. So we assert on bullet content, not the - // section header. - got := stripANSI(buf.String()) - require.Contains(t, got, "ID: mnf-new", "should print bound manifest id") - require.Contains(t, got, "Name: my-manifest", "should print bound manifest name") - require.Contains(t, got, "Latest Version: 3", "should print latest version of bound manifest") - require.Contains(t, got, "behind", "should print divergence kind") - require.Contains(t, got, "Deployed manifest: mnf-old") - require.Contains(t, got, "Deployed version: 1") - require.Contains(t, got, "Latest version: 3") -} - -func TestShow_PlainRender_OmitsDivergenceWhenAbsent(t *testing.T) { - c := NewShowCtrl() - c.store.App = components.App{ID: "app-abc", Name: "my-app"} - - cmd := &cobra.Command{} - buf := &bytes.Buffer{} - cmd.SetOut(buf) - - require.NoError(t, c.Render(cmd, nil)) - - got := stripANSI(buf.String()) - require.NotContains(t, got, "rebound") - require.NotContains(t, got, "Deployed manifest") -} - -func TestDivergenceHint_CoversAllKinds(t *testing.T) { - for _, k := range []components.Kind{ - components.KindUndeployed, - components.KindSynced, - components.KindBehind, - components.KindRebound, - } { - require.NotEmpty(t, divergenceHint(k), "missing hint for kind %q", k) - } - require.Empty(t, divergenceHint(components.Kind("unknown"))) -} - -// stripANSI removes pterm color/escape sequences so assertions can match on -// the raw text content without coupling to terminal formatting. -func stripANSI(s string) string { - var b strings.Builder - inEsc := false - for _, r := range s { - switch { - case r == 0x1b: - inEsc = true - case inEsc && (r == 'm' || r == 'K' || r == 'A' || r == 'B' || r == 'C' || r == 'D'): - inEsc = false - case inEsc: - // swallow CSI body - default: - b.WriteRune(r) - } - } - return b.String() -} diff --git a/cmd/cloud/apps/unbind_manifest.go b/cmd/cloud/apps/unbind_manifest.go deleted file mode 100644 index 7607e5eb..00000000 --- a/cmd/cloud/apps/unbind_manifest.go +++ /dev/null @@ -1,69 +0,0 @@ -package apps - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UnbindManifest struct { - AppID string `json:"appId"` -} - -type UnbindManifestCtrl struct { - store *UnbindManifest -} - -var _ fctl.Controller[*UnbindManifest] = (*UnbindManifestCtrl)(nil) - -func NewUnbindManifestCtrl() *UnbindManifestCtrl { - return &UnbindManifestCtrl{store: &UnbindManifest{}} -} - -func NewUnbindManifest() *cobra.Command { - return fctl.NewCommand("unbind-manifest", - fctl.WithShortDescription("Unbind the manifest from an app"), - fctl.WithStringFlag("app-id", "", "App ID"), - fctl.WithController(NewUnbindManifestCtrl()), - ) -} - -func (c *UnbindManifestCtrl) GetStore() *UnbindManifest { return c.store } - -func (c *UnbindManifestCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - - appID := fctl.GetString(cmd, "app-id") - if appID == "" { - return nil, fmt.Errorf("app-id is required") - } - - if _, err := apiClient.DetachAppManifest(cmd.Context(), appID); err != nil { - return nil, err - } - - c.store.AppID = appID - return c, nil -} - -func (c *UnbindManifestCtrl) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Printfln("Manifest unbound from app %s", c.store.AppID) - return nil -} diff --git a/cmd/cloud/apps/variables/create.go b/cmd/cloud/apps/variables/create.go deleted file mode 100644 index 90f81213..00000000 --- a/cmd/cloud/apps/variables/create.go +++ /dev/null @@ -1,106 +0,0 @@ -package variables - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Create struct { - components.Variable -} - -type CreateCtrl struct { - store *Create -} - -var _ fctl.Controller[*Create] = (*CreateCtrl)(nil) - -func newCreateStore() *Create { - return &Create{ - Variable: components.Variable{}, - } -} - -func NewCreateCtrl() *CreateCtrl { - return &CreateCtrl{ - store: newCreateStore(), - } -} - -func NewCreate() *cobra.Command { - return fctl.NewCommand("create", - fctl.WithShortDescription("Create a new variable for an app"), - fctl.WithStringFlag("id", "", "App ID"), - fctl.WithStringFlag("key", "", "Variable key"), - fctl.WithStringFlag("value", "", "Variable value"), - fctl.WithStringFlag("description", "", "Variable description"), - fctl.WithController(NewCreateCtrl()), - ) -} - -func (c *CreateCtrl) GetStore() *Create { - return c.store -} - -func (c *CreateCtrl) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - v, err := apiClient.CreateAppVariable(cmd.Context(), fctl.GetString(cmd, "id"), components.CreateVariableRequest{ - Variable: components.VariableData{ - Key: fctl.GetString(cmd, "key"), - Value: fctl.GetString(cmd, "value"), - Description: func() *string { s := fctl.GetString(cmd, "description"); return &s }(), - }, - }) - if err != nil { - return nil, err - } - - c.store.Variable = v.CreateVariableResponse.Data - - return c, nil -} - -func (c *CreateCtrl) Render(cmd *cobra.Command, args []string) error { - pterm.DefaultSection.Println("Variable") - - items := []pterm.BulletListItem{ - {Level: 0, Text: fmt.Sprintf("ID: %s", c.store.Variable.ID)}, - {Level: 0, Text: fmt.Sprintf("Key: %s", c.store.Variable.Key)}, - {Level: 0, Text: fmt.Sprintf("Value: %s", c.store.Variable.Value)}, - {Level: 0, Text: fmt.Sprintf("Description: %s", func() string { - if c.store.Variable.Description == nil { - return "N/A" - } - return *c.store.Variable.Description - }())}, - } - - if err := pterm. - DefaultBulletList. - WithItems(items). - WithWriter(cmd.OutOrStdout()). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/variables/delete.go b/cmd/cloud/apps/variables/delete.go deleted file mode 100644 index 20adc654..00000000 --- a/cmd/cloud/apps/variables/delete.go +++ /dev/null @@ -1,82 +0,0 @@ -package variables - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Delete struct { - ID string -} - -type DeleteCtrl struct { - store *Delete -} - -var _ fctl.Controller[*Delete] = (*DeleteCtrl)(nil) - -func newDeleteStore() *Delete { - return &Delete{} -} - -func NewDeleteCtrl() *DeleteCtrl { - return &DeleteCtrl{ - store: newDeleteStore(), - } -} - -func NewDelete() *cobra.Command { - return fctl.NewCommand("delete", - fctl.WithShortDescription("Delete a variable"), - fctl.WithStringFlag("id", "", "Variable ID"), - fctl.WithStringFlag("app-id", "", "App ID"), - fctl.WithController(NewDeleteCtrl()), - ) -} - -func (c *DeleteCtrl) GetStore() *Delete { - return c.store -} - -func (c *DeleteCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - appID := fctl.GetString(cmd, "app-id") - if appID == "" { - return nil, fmt.Errorf("app-id is required") - } - _, err = apiClient.DeleteAppVariable(cmd.Context(), appID, id) - if err != nil { - return nil, err - } - - c.store.ID = id - - return c, nil -} - -func (c *DeleteCtrl) Render(cmd *cobra.Command, args []string) error { - pterm.Success.Println("Variable deleted", c.store.ID) - return nil -} diff --git a/cmd/cloud/apps/variables/list.go b/cmd/cloud/apps/variables/list.go deleted file mode 100644 index 01afe831..00000000 --- a/cmd/cloud/apps/variables/list.go +++ /dev/null @@ -1,120 +0,0 @@ -package variables - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - components.ReadVariablesResponseCursor -} - -type ListCtrl struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListCtrl)(nil) - -func newDefaultStore() *List { - return &List{ - ReadVariablesResponseCursor: components.ReadVariablesResponseCursor{}, - } -} - -func NewListCtrl() *ListCtrl { - return &ListCtrl{ - store: newDefaultStore(), - } -} - -func NewList() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithShortDescription("List variables for an app"), - fctl.WithStringFlag("id", "", "App ID"), - fctl.WithIntFlag("page-size", 100, "Page size"), - fctl.WithStringFlag("cursor", "", "Opaque cursor token for the next page"), - fctl.WithController(NewListCtrl()), - ) -} - -func (c *ListCtrl) GetStore() *List { - return c.store -} - -func (c *ListCtrl) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewAppDeployClientFromFlags( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - ) - if err != nil { - return nil, err - } - id := fctl.GetString(cmd, "id") - if id == "" { - return nil, fmt.Errorf("id is required") - } - - var cursor *string - if v := fctl.GetString(cmd, "cursor"); v != "" { - cursor = &v - } - - vars, err := apiClient.ReadAppVariables( - cmd.Context(), - id, - pointer.For(int64(fctl.GetInt(cmd, "page-size"))), - cursor, - ) - if err != nil { - return nil, err - } - - c.store.ReadVariablesResponseCursor = vars.ReadVariablesResponse.Cursor - - return c, nil -} - -func (c *ListCtrl) Render(cmd *cobra.Command, args []string) error { - data := [][]string{ - {"Id", "Key", "Value", "Description"}, - } - - for _, variable := range c.store.Data { - data = append(data, []string{ - variable.ID, - variable.Key, - variable.Value, - func() string { - if variable.Description == nil { - return "" - } - return *variable.Description - }(), - }) - } - if err := pterm. - DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/cloud/apps/variables/root.go b/cmd/cloud/apps/variables/root.go deleted file mode 100644 index 64038c4d..00000000 --- a/cmd/cloud/apps/variables/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package variables - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("variables", - fctl.WithShortDescription("Manage app variables"), - fctl.WithAliases("var", "vars"), - fctl.WithChildCommands( - NewList(), - NewCreate(), - NewDelete(), - ), - ) -} diff --git a/cmd/cloud/generate_personal_token.go b/cmd/cloud/generate_personal_token.go deleted file mode 100644 index fa1303a5..00000000 --- a/cmd/cloud/generate_personal_token.go +++ /dev/null @@ -1,84 +0,0 @@ -package cloud - -import ( - "fmt" - - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type GeneratePersonalTokenStore struct { - Token string `json:"token"` -} -type GeneratePersonalTokenController struct { - store *GeneratePersonalTokenStore -} - -var _ fctl.Controller[*GeneratePersonalTokenStore] = (*GeneratePersonalTokenController)(nil) - -func NewDefaultGeneratePersonalTokenStore() *GeneratePersonalTokenStore { - return &GeneratePersonalTokenStore{} -} - -func NewGeneratePersonalTokenController() *GeneratePersonalTokenController { - return &GeneratePersonalTokenController{ - store: NewDefaultGeneratePersonalTokenStore(), - } -} - -func NewGeneratePersonalTokenCommand() *cobra.Command { - return fctl.NewStackCommand("generate-personal-token", - fctl.WithAliases("gpt"), - fctl.WithShortDescription("Generate a personal bearer token"), - fctl.WithDescription("Generate a personal bearer token"), - fctl.WithController[*GeneratePersonalTokenStore](NewGeneratePersonalTokenController()), - ) -} - -func (c *GeneratePersonalTokenController) GetStore() *GeneratePersonalTokenStore { - return c.store -} - -func (c *GeneratePersonalTokenController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - access, _, err := fctl.EnsureStackAccess( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - organizationID, - stackID, - ) - if err != nil { - return nil, err - } - stackAccess := profile.RootTokens.ID.Claims. - GetOrganizationAccess(organizationID). - GetStackAccess(stackID) - - token, err := fctl.FetchStackToken(cmd.Context(), relyingParty.HttpClient(), stackAccess.URI, access.Token) - if err != nil { - return nil, err - } - - c.store.Token = token.AccessToken - - return c, nil -} - -func (c *GeneratePersonalTokenController) Render(cmd *cobra.Command, _ []string) error { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), c.store.Token) - return nil -} diff --git a/cmd/cloud/me/info.go b/cmd/cloud/me/info.go deleted file mode 100644 index a3bc1b39..00000000 --- a/cmd/cloud/me/info.go +++ /dev/null @@ -1,76 +0,0 @@ -package me - -import ( - "errors" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InfoStore struct { - Subject string `json:"subject"` - Email string `json:"email"` -} -type InfoController struct { - store *InfoStore -} - -var _ fctl.Controller[*InfoStore] = (*InfoController)(nil) - -func NewDefaultInfoStore() *InfoStore { - return &InfoStore{} -} - -func NewInfoController() *InfoController { - return &InfoController{ - store: NewDefaultInfoStore(), - } -} - -func NewInfoCommand() *cobra.Command { - return fctl.NewCommand("info", - fctl.WithAliases("i", "in"), - fctl.WithShortDescription("Display user information"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithController[*InfoStore](NewInfoController()), - ) -} - -func (c *InfoController) GetStore() *InfoStore { - return c.store -} - -func (c *InfoController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, _, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - if !profile.IsConnected() { - return nil, errors.New("not logged. use 'login' command before") - } - - userInfo, err := fctl.UserInfo(cmd, relyingParty, profile.RootTokens.Access) - if err != nil { - return nil, err - } - - c.store.Subject = userInfo.Subject - c.store.Email = userInfo.Email - - return c, nil -} - -func (c *InfoController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Subject"), c.store.Subject}) - tableData = append(tableData, []string{pterm.LightCyan("Email"), c.store.Email}) - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/me/invitations/accept.go b/cmd/cloud/me/invitations/accept.go deleted file mode 100644 index ea2c6178..00000000 --- a/cmd/cloud/me/invitations/accept.go +++ /dev/null @@ -1,80 +0,0 @@ -package invitations - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type AcceptStore struct { - Success bool `json:"success"` - InvitationId string `json:"invitationId"` -} -type AcceptController struct { - store *AcceptStore -} - -var _ fctl.Controller[*AcceptStore] = (*AcceptController)(nil) - -func NewDefaultAcceptStore() *AcceptStore { - return &AcceptStore{} -} - -func NewAcceptController() *AcceptController { - return &AcceptController{ - store: NewDefaultAcceptStore(), - } -} - -func NewAcceptCommand() *cobra.Command { - return fctl.NewCommand("accept ", - fctl.WithAliases("a"), - fctl.WithShortDescription("Accept an invitation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithController[*AcceptStore](NewAcceptController()), - ) -} - -func (c *AcceptController) GetStore() *AcceptStore { - return c.store -} - -func (c *AcceptController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClient(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to accept an invitation") { - return nil, fctl.ErrMissingApproval - } - - request := operations.AcceptInvitationRequest{ - InvitationID: args[0], - } - - _, err = apiClient.AcceptInvitation(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.InvitationId = args[0] - c.store.Success = true - - return c, nil -} - -func (c *AcceptController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Invitation %s accepted!", c.store.InvitationId) - return nil - -} diff --git a/cmd/cloud/me/invitations/decline.go b/cmd/cloud/me/invitations/decline.go deleted file mode 100644 index baaff956..00000000 --- a/cmd/cloud/me/invitations/decline.go +++ /dev/null @@ -1,80 +0,0 @@ -package invitations - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeclineStore struct { - Success bool `json:"success"` - InvitationId string `json:"invitationId"` -} -type DeclineController struct { - store *DeclineStore -} - -var _ fctl.Controller[*DeclineStore] = (*DeclineController)(nil) - -func NewDefaultDeclineStore() *DeclineStore { - return &DeclineStore{} -} - -func NewDeclineController() *DeclineController { - return &DeclineController{ - store: NewDefaultDeclineStore(), - } -} - -func NewDeclineCommand() *cobra.Command { - return fctl.NewCommand("decline ", - fctl.WithAliases("dec", "d"), - fctl.WithShortDescription("Decline an invitation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithController[*DeclineStore](NewDeclineController()), - ) -} - -func (c *DeclineController) GetStore() *DeclineStore { - return c.store -} - -func (c *DeclineController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClient(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to decline an invitation") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeclineInvitationRequest{ - InvitationID: args[0], - } - - _, err = apiClient.DeclineInvitation(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.InvitationId = args[0] - c.store.Success = true - - return c, nil -} - -func (c *DeclineController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Invitation %s declined.", c.store.InvitationId) - return nil -} diff --git a/cmd/cloud/me/invitations/list.go b/cmd/cloud/me/invitations/list.go deleted file mode 100644 index 670fc6d4..00000000 --- a/cmd/cloud/me/invitations/list.go +++ /dev/null @@ -1,122 +0,0 @@ -package invitations - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Invitations struct { - Id string `json:"id"` - UserEmail string `json:"userEmail"` - Status string `json:"status"` - CreationDate time.Time `json:"creationDate"` -} - -type ListStore struct { - Invitations []Invitations `json:"invitations"` -} -type ListController struct { - store *ListStore - statusFlag string - organizationFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - statusFlag: "status", - organizationFlag: "organization", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List invitations"), - fctl.WithStringFlag(c.statusFlag, "", "Filter invitations by status"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithStringFlag(c.organizationFlag, "", "Filter invitations by organization"), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClient(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - status := fctl.GetString(cmd, c.statusFlag) - organization := fctl.GetString(cmd, c.organizationFlag) - - request := operations.ListInvitationsRequest{} - if status != "" { - request.Status = &status - } - if organization != "" { - request.Organization = &organization - } - - response, err := apiClient.ListInvitations(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListInvitationsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Invitations = fctl.Map(response.ListInvitationsResponse.GetData(), func(i components.Invitation) Invitations { - return Invitations{ - Id: i.GetID(), - UserEmail: i.GetUserEmail(), - Status: string(i.GetStatus()), - CreationDate: i.GetCreationDate(), - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Invitations, func(i Invitations) []string { - return []string{ - i.Id, - i.UserEmail, - i.Status, - i.CreationDate.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Email", "Status", "CreationDate"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/me/invitations/root.go b/cmd/cloud/me/invitations/root.go deleted file mode 100644 index e61fb626..00000000 --- a/cmd/cloud/me/invitations/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package invitations - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("invitations", - fctl.WithShortDescription("Manage invitations"), - fctl.WithAliases("invit", "inv", "i"), - fctl.WithChildCommands( - NewListCommand(), - NewAcceptCommand(), - NewDeclineCommand(), - ), - ) -} diff --git a/cmd/cloud/me/root.go b/cmd/cloud/me/root.go deleted file mode 100644 index 0a575781..00000000 --- a/cmd/cloud/me/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package me - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/cloud/me/invitations" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("me", - fctl.WithShortDescription("Manage current user settings"), - fctl.WithChildCommands( - invitations.NewCommand(), - NewInfoCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/applications/list.go b/cmd/cloud/organizations/applications/list.go deleted file mode 100644 index c05b161d..00000000 --- a/cmd/cloud/organizations/applications/list.go +++ /dev/null @@ -1,154 +0,0 @@ -package applications - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Applications []components.Application `json:"applications"` - Cursor *components.Cursor `json:"cursor"` -} - -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{ - Applications: []components.Application{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand(`list`, - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List applications available for the organization"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithIntFlag("page", 0, "Page number"), - fctl.WithIntFlag("page-size", 15, "Page size"), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - page := fctl.GetInt(cmd, "page") - pageSize := fctl.GetInt(cmd, "page-size") - - request := operations.ListOrganizationApplicationsRequest{ - OrganizationID: organizationID, - Page: pointer.For(int64(page)), - PageSize: pointer.For(int64(pageSize)), - } - - response, err := apiClient.ListOrganizationApplications(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListApplicationsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - cursor := response.ListApplicationsResponse.GetCursor() - if cursor == nil { - return nil, fmt.Errorf("unexpected response: no cursor data") - } - - c.store.Applications = cursor.GetData() - c.store.Cursor = cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - if len(c.store.Applications) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No applications found.") - return nil - } - - header := []string{"ID", "Name", "Alias", "URL", "Description", "Created At", "Updated At"} - tableData := fctl.Map(c.store.Applications, func(app components.Application) []string { - return []string{ - app.GetID(), - app.GetName(), - app.GetAlias(), - app.GetURL(), - func() string { - if desc := app.GetDescription(); desc != nil { - return *desc - } - return "" - }(), - time.Time{Time: app.GetCreatedAt()}.String(), - time.Time{Time: app.GetUpdatedAt()}.String(), - } - }) - - tableData = fctl.Prepend(tableData, header) - - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - // Display cursor information if available - if c.store.Cursor != nil { - cursorInfo := [][]string{ - {"Has More", fctl.BoolToString(c.store.Cursor.GetHasMore())}, - {"Page Size", fmt.Sprintf("%d", c.store.Cursor.GetPageSize())}, - } - if next := c.store.Cursor.GetNext(); next != nil { - cursorInfo = append(cursorInfo, []string{"Next", *next}) - } - if previous := c.store.Cursor.GetPrevious(); previous != nil { - cursorInfo = append(cursorInfo, []string{"Previous", *previous}) - } - - fmt.Fprintln(cmd.OutOrStdout(), "") - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(cursorInfo). - Render(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/cloud/organizations/applications/root.go b/cmd/cloud/organizations/applications/root.go deleted file mode 100644 index 38de68c7..00000000 --- a/cmd/cloud/organizations/applications/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package applications - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("applications", - fctl.WithAliases("apps", "app"), - fctl.WithShortDescription("Manage applications"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/applications/show.go b/cmd/cloud/organizations/applications/show.go deleted file mode 100644 index 16170618..00000000 --- a/cmd/cloud/organizations/applications/show.go +++ /dev/null @@ -1,121 +0,0 @@ -package applications - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Application *components.ApplicationWithScope `json:"application"` -} - -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand(`show `, - fctl.WithAliases("s", "sh"), - fctl.WithShortDescription("Show application details"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.GetOrganizationApplicationRequest{ - OrganizationID: organizationID, - ApplicationID: args[0], - } - - response, err := apiClient.GetOrganizationApplication(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.GetApplicationResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Application = response.GetApplicationResponse.GetData() - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - if c.store.Application == nil { - return fmt.Errorf("no application data") - } - - app := c.store.Application - - data := [][]string{ - {"ID", app.GetID()}, - {"Name", app.GetName()}, - {"Alias", app.GetAlias()}, - {"URL", app.GetURL()}, - {"Description", func() string { - if desc := app.GetDescription(); desc != nil { - return *desc - } - return "" - }()}, - {"Created At", time.Time{Time: app.GetCreatedAt()}.String()}, - {"Updated At", time.Time{Time: app.GetUpdatedAt()}.String()}, - } - - if scopes := app.GetScopes(); len(scopes) > 0 { - data = append(data, []string{"Scopes", ""}) - for _, scope := range scopes { - data = append(data, []string{ - "", - fmt.Sprintf(" - %s (ID: %d)", scope.GetLabel(), scope.GetID()), - }) - if desc := scope.GetDescription(); desc != nil && *desc != "" { - data = append(data, []string{"", fmt.Sprintf(" Description: %s", *desc)}) - } - } - } - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render() -} diff --git a/cmd/cloud/organizations/authentication-provider/configure.go b/cmd/cloud/organizations/authentication-provider/configure.go deleted file mode 100644 index beb68155..00000000 --- a/cmd/cloud/organizations/authentication-provider/configure.go +++ /dev/null @@ -1,143 +0,0 @@ -package authentication_provider - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Configure struct { - RedirectURI string `json:"redirectURI"` -} -type ConfigureController struct { - store *Configure -} - -var _ fctl.Controller[*Configure] = (*ConfigureController)(nil) - -func NewDefaultConfigure() *Configure { - return &Configure{} -} - -func NewConfigureController() *ConfigureController { - return &ConfigureController{ - store: NewDefaultConfigure(), - } -} - -func NewConfigureCommand() *cobra.Command { - return fctl.NewCommand(`configure `, - fctl.WithValidArgs("github", "google", "microsoft", "oidc"), - fctl.WithArgs(cobra.ExactArgs(4)), - fctl.WithShortDescription("Configure the authorization provider for the organization"), - fctl.WithController(NewConfigureController()), - fctl.WithStringFlag("oidc-issuer", "", "OIDC issuer URL (used when type is 'oidc')"), - fctl.WithStringFlag("microsoft-tenant", "tenant", "Microsoft tenant ID (used when type is 'microsoft')"), - ) -} - -func (c *ConfigureController) GetStore() *Configure { - return c.store -} - -func (c *ConfigureController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var requestData components.UpsertAuthenticationProviderRequest - - switch args[0] { - case "github": - requestData = components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGithubIDPConfig(components.UpsertAuthenticationProviderRequestGithubIDPConfig{ - Name: args[1], - ClientID: args[2], - ClientSecret: args[3], - Config: components.UpsertAuthenticationProviderRequestGithubIDPConfigConfig{}, - }) - case "google": - requestData = components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGoogleIDPConfig(components.UpsertAuthenticationProviderRequestGoogleIDPConfig{ - Name: args[1], - ClientID: args[2], - ClientSecret: args[3], - Config: components.UpsertAuthenticationProviderRequestGoogleIDPConfigConfig{}, - }) - case "microsoft": - requestData = components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestMicrosoftIDPConfig(components.UpsertAuthenticationProviderRequestMicrosoftIDPConfig{ - Name: args[1], - ClientID: args[2], - ClientSecret: args[3], - Config: components.UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig{ - Tenant: pointer.For(fctl.GetString(cmd, "microsoft-tenant")), - }, - }) - case "oidc": - requestData = components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestOIDCConfig(components.UpsertAuthenticationProviderRequestOIDCConfig{ - Name: args[1], - ClientID: args[2], - ClientSecret: args[3], - Config: components.UpsertAuthenticationProviderRequestOIDCConfigConfig{ - Issuer: fctl.GetString(cmd, "oidc-issuer"), - }, - }) - default: - return nil, fmt.Errorf("unknown provider type: %s", args[0]) - } - - request := operations.UpsertAuthenticationProviderRequest{ - OrganizationID: organizationID, - Body: &requestData, - } - - response, err := apiClient.UpsertAuthenticationProvider(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.AuthenticationProviderResponse == nil || response.AuthenticationProviderResponse.GetData() == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - data := response.AuthenticationProviderResponse.GetData() - switch data.Type { - case components.DataTypeAuthenticationProviderResponseGithubIDPConfig: - if p := data.AuthenticationProviderResponseGithubIDPConfig; p != nil { - c.store.RedirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseGoogleIDPConfig: - if p := data.AuthenticationProviderResponseGoogleIDPConfig; p != nil { - c.store.RedirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - if p := data.AuthenticationProviderResponseMicrosoftIDPConfig; p != nil { - c.store.RedirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseOIDCConfig: - if p := data.AuthenticationProviderResponseOIDCConfig; p != nil { - c.store.RedirectURI = p.GetRedirectURI() - } - } - - return c, nil -} - -func (c *ConfigureController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Authorization provider configured successfully") - pterm.Info.Println(fmt.Sprintf("Redirect URI: %s", c.store.RedirectURI)) - - return nil -} diff --git a/cmd/cloud/organizations/authentication-provider/delete.go b/cmd/cloud/organizations/authentication-provider/delete.go deleted file mode 100644 index 287a43cf..00000000 --- a/cmd/cloud/organizations/authentication-provider/delete.go +++ /dev/null @@ -1,68 +0,0 @@ -package authentication_provider - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Delete struct{} -type DeleteController struct { - store *Delete -} - -var _ fctl.Controller[*Delete] = (*DeleteController)(nil) - -func NewDefaultDelete() *Delete { - return &Delete{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDelete(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand(`delete`, - fctl.WithShortDescription("Delete the authorization provider for the organization"), - fctl.WithController(NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *Delete { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.DeleteAuthenticationProviderRequest{ - OrganizationID: organizationID, - } - - _, err = apiClient.DeleteAuthenticationProvider(cmd.Context(), request) - if err != nil { - return nil, err - } - - return c, nil -} - -func (c *DeleteController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Authorization provider deleted successfully") - - return nil -} diff --git a/cmd/cloud/organizations/authentication-provider/root.go b/cmd/cloud/organizations/authentication-provider/root.go deleted file mode 100644 index 8cd8e9cd..00000000 --- a/cmd/cloud/organizations/authentication-provider/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package authentication_provider - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("authentication-provider", - fctl.WithShortDescription("Manage the authentication provider"), - fctl.WithChildCommands( - NewConfigureCommand(), - NewDeleteCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/authentication-provider/show.go b/cmd/cloud/organizations/authentication-provider/show.go deleted file mode 100644 index d009ad46..00000000 --- a/cmd/cloud/organizations/authentication-provider/show.go +++ /dev/null @@ -1,136 +0,0 @@ -package authentication_provider - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Show struct { - provider *components.Data -} -type ShowController struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowController)(nil) - -func NewDefaultShow() *Show { - return &Show{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShow(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand(`show`, - fctl.WithShortDescription("Show the authorization provider for the organization"), - fctl.WithController(NewShowController()), - ) -} - -func (c *ShowController) GetStore() *Show { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ReadAuthenticationProviderRequest{ - OrganizationID: organizationID, - } - - response, err := apiClient.ReadAuthenticationProvider(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.AuthenticationProviderResponse == nil || response.AuthenticationProviderResponse.GetData() == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.provider = response.AuthenticationProviderResponse.GetData() - - return c, nil -} - -func (c *ShowController) Render(_ *cobra.Command, _ []string) error { - var providerType, name, clientID, clientSecret, redirectURI string - var createdAt, updatedAt interface{} - - if c.store.provider == nil { - return fmt.Errorf("no provider data") - } - - switch c.store.provider.Type { - case components.DataTypeAuthenticationProviderResponseGithubIDPConfig: - if p := c.store.provider.AuthenticationProviderResponseGithubIDPConfig; p != nil { - providerType = string(p.GetType()) - name = p.GetName() - clientID = p.GetClientID() - clientSecret = p.GetClientSecret() - createdAt = p.GetCreatedAt() - updatedAt = p.GetUpdatedAt() - redirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseGoogleIDPConfig: - if p := c.store.provider.AuthenticationProviderResponseGoogleIDPConfig; p != nil { - providerType = string(p.GetType()) - name = p.GetName() - clientID = p.GetClientID() - clientSecret = p.GetClientSecret() - createdAt = p.GetCreatedAt() - updatedAt = p.GetUpdatedAt() - redirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - if p := c.store.provider.AuthenticationProviderResponseMicrosoftIDPConfig; p != nil { - providerType = string(p.GetType()) - name = p.GetName() - clientID = p.GetClientID() - clientSecret = p.GetClientSecret() - createdAt = p.GetCreatedAt() - updatedAt = p.GetUpdatedAt() - redirectURI = p.GetRedirectURI() - } - case components.DataTypeAuthenticationProviderResponseOIDCConfig: - if p := c.store.provider.AuthenticationProviderResponseOIDCConfig; p != nil { - providerType = string(p.GetType()) - name = p.GetName() - clientID = p.GetClientID() - clientSecret = p.GetClientSecret() - createdAt = p.GetCreatedAt() - updatedAt = p.GetUpdatedAt() - redirectURI = p.GetRedirectURI() - } - } - - data := [][]string{ - {"Provider", providerType}, - {"Name", name}, - {"Client ID", clientID}, - {"Client secret", clientSecret}, - {"Created at", fmt.Sprintf("%v", createdAt)}, - {"Updated at", fmt.Sprintf("%v", updatedAt)}, - {"Redirect URI", redirectURI}, - } - return pterm.DefaultTable.WithHasHeader().WithData(data).Render() -} diff --git a/cmd/cloud/organizations/create.go b/cmd/cloud/organizations/create.go deleted file mode 100644 index 9f7494c4..00000000 --- a/cmd/cloud/organizations/create.go +++ /dev/null @@ -1,97 +0,0 @@ -package organizations - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/go-libs/v4/pointer" - - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - Organization *components.OrganizationExpanded `json:"organization"` -} -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand(`create --default-stack-role "ADMIN" --default-organization-role "ADMIN"`, - fctl.WithAliases("cr", "c"), - fctl.WithShortDescription("Create an organization"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithIntFlag("default-policy-id", 0, "Default policy ID"), - fctl.WithStringFlag("domain", "", "Organization domain"), - fctl.WithConfirmFlag(), - fctl.WithController(NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClient(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to create a new organization") { - return nil, fctl.ErrMissingApproval - } - - defaultPolicyID := fctl.GetInt(cmd, "default-policy-id") - domain := fctl.GetString(cmd, "domain") - - orgData := components.CreateOrganizationRequest{ - Name: args[0], - } - - if defaultPolicyID != 0 { - orgData.DefaultPolicyID = pointer.For(int64(defaultPolicyID)) - } - - if domain != "" { - orgData.Domain = pointer.For(domain) - } - - response, err := apiClient.CreateOrganization(cmd.Context(), &orgData) - if err != nil { - return nil, err - } - - if response.CreateOrganizationResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Organization = response.CreateOrganizationResponse.GetData() - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintOrganization(c.store.Organization) -} diff --git a/cmd/cloud/organizations/delete.go b/cmd/cloud/organizations/delete.go deleted file mode 100644 index 3f5fa451..00000000 --- a/cmd/cloud/organizations/delete.go +++ /dev/null @@ -1,83 +0,0 @@ -package organizations - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - OrganizationId string `json:"organizationId"` - Success bool `json:"success"` -} -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithAliases("del", "d"), - fctl.WithShortDescription("Delete an organization"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.OrganizationCompletion), - fctl.WithConfirmFlag(), - fctl.WithController(NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, args[0]) - if err != nil { - return nil, err - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to delete an organization") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeleteOrganizationRequest{ - OrganizationID: args[0], - } - - _, err = apiClient.DeleteOrganization(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.OrganizationId = args[0] - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Organization '%s' deleted", c.store.OrganizationId) - - return nil -} diff --git a/cmd/cloud/organizations/describe.go b/cmd/cloud/organizations/describe.go deleted file mode 100644 index cdd4a0c5..00000000 --- a/cmd/cloud/organizations/describe.go +++ /dev/null @@ -1,83 +0,0 @@ -package organizations - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DescribeStore struct { - *components.OrganizationExpanded -} -type DescribeController struct { - store *DescribeStore -} - -var _ fctl.Controller[*DescribeStore] = (*DescribeController)(nil) - -func NewDefaultDescribeStore() *DescribeStore { - return &DescribeStore{} -} - -func NewDescribeController() *DescribeController { - return &DescribeController{ - store: NewDefaultDescribeStore(), - } -} - -func NewDescribeCommand() *cobra.Command { - return fctl.NewCommand("describe ", - fctl.WithShortDescription("Describe an organization"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithBoolFlag("expand", false, "Expand the organization"), - fctl.WithValidArgsFunction(fctl.OrganizationCompletion), - fctl.WithController(NewDescribeController()), - ) -} - -func (c *DescribeController) GetStore() *DescribeStore { - return c.store -} - -func (c *DescribeController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, args[0]) - if err != nil { - return nil, err - } - - expand := fctl.GetBool(cmd, "expand") - request := operations.ReadOrganizationRequest{ - OrganizationID: args[0], - Expand: pointer.For(expand), - } - - response, err := apiClient.ReadOrganization(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ReadOrganizationResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.OrganizationExpanded = response.ReadOrganizationResponse.GetData() - return c, nil -} - -func (c *DescribeController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintOrganization(c.store.OrganizationExpanded) -} diff --git a/cmd/cloud/organizations/history.go b/cmd/cloud/organizations/history.go deleted file mode 100644 index a71f04ae..00000000 --- a/cmd/cloud/organizations/history.go +++ /dev/null @@ -1,142 +0,0 @@ -package organizations - -import ( - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" - "github.com/formancehq/fctl/v3/pkg/printer" -) - -const ( - pageSizeFlag = "page-size" - cursorFlag = "cursor" - - actionFlag = "action" - dataFlag = "data" - userIdFlag = "user-id" - displayDataFlag = "display-data" -) - -type HistoryStore struct { - Cursor *components.LogCursorData `json:"cursor"` -} - -type HistoryController struct { - store *HistoryStore -} - -var _ fctl.Controller[*HistoryStore] = (*HistoryController)(nil) - -func NewDefaultHistoryStore() *HistoryStore { - return &HistoryStore{ - Cursor: &components.LogCursorData{}, - } -} -func NewHistoryController() *HistoryController { - return &HistoryController{ - store: NewDefaultHistoryStore(), - } -} - -func NewHistoryCommand() *cobra.Command { - return fctl.NewMembershipCommand("history [organization-id]", - fctl.WithShortDescription("Query organization history"), - fctl.WithAliases("hist"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.OrganizationCompletion), - fctl.WithStringFlag(actionFlag, "", "Filter by action"), - fctl.WithStringFlag(userIdFlag, "", "Filter by user ID (use SYSTEM for system logs)"), - fctl.WithStringFlag(dataFlag, "", "Filter by modified data (format: key=value, key is a JSONB text path)"), - - fctl.WithBoolFlag(displayDataFlag, false, "Display data"), - - fctl.WithStringFlag(cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(pageSizeFlag, 10, "Page size"), - fctl.WithController(NewHistoryController()), - ) -} -func (c *HistoryController) GetStore() *HistoryStore { - return c.store -} - -func (c *HistoryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, args[0]) - if err != nil { - return nil, err - } - pageSize := fctl.GetInt(cmd, pageSizeFlag) - orgId := args[0] - - cursor := fctl.GetString(cmd, cursorFlag) - userID := fctl.GetString(cmd, userIdFlag) - action := fctl.GetString(cmd, actionFlag) - data := fctl.GetString(cmd, dataFlag) - - if cursor != "" { - if userID != "" || action != "" || data != "" { - return nil, errors.New("cursor can't be used with other flags") - } - } - - if orgId == "" && cursor == "" { - return nil, errors.New("org-id or cursor is required") - } - - request := operations.ListLogsRequest{ - OrganizationID: orgId, - PageSize: pointer.For(int64(pageSize)), - } - - if cursor != "" { - request.Cursor = &cursor - } - - if userID != "" { - request.UserID = &userID - } - - if action != "" { - actionEnum := components.Action(action) - request.Action = &actionEnum - } - - if data != "" { - keyVal := strings.Split(data, "=") - if len(keyVal) != 2 { - return nil, errors.New("data filter must be in the form key=value") - } - request.Key = &keyVal[0] - request.Value = &keyVal[1] - } - - response, err := apiClient.ListLogs(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.LogCursor == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - cursorData := response.LogCursor.GetData() - c.store.Cursor = &cursorData - return c, nil -} - -func (c *HistoryController) Render(cmd *cobra.Command, args []string) error { - return printer.LogCursor(cmd.OutOrStdout(), c.store.Cursor, fctl.GetBool(cmd, displayDataFlag)) -} diff --git a/cmd/cloud/organizations/internal/print.go b/cmd/cloud/organizations/internal/print.go deleted file mode 100644 index 7f554889..00000000 --- a/cmd/cloud/organizations/internal/print.go +++ /dev/null @@ -1,45 +0,0 @@ -package internal - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -func PrintOrganization(organization *components.OrganizationExpanded) error { - pterm.DefaultSection.Println("Organization") - - data := [][]string{ - {"ID", organization.ID}, - {"Name", organization.Name}, - {"Domain", func() string { - if organization.Domain == nil { - return "" - } - return *organization.Domain - }()}, - {"Default Policy", func() string { - if organization.DefaultPolicyID == nil { - return "None" - } - return fmt.Sprintf("%d", *organization.DefaultPolicyID) - }()}, - } - - if organization.Owner != nil { - data = append(data, []string{"Owner", organization.Owner.Email}) - } - - if organization.TotalUsers != nil { - data = append(data, []string{"Total Users", strconv.Itoa(int(*organization.TotalUsers))}) - } - - if organization.TotalStacks != nil { - data = append(data, []string{"Total Stacks", strconv.Itoa(int(*organization.TotalStacks))}) - } - - return pterm.DefaultTable.WithHasHeader().WithData(data).Render() -} diff --git a/cmd/cloud/organizations/invitations/delete.go b/cmd/cloud/organizations/invitations/delete.go deleted file mode 100644 index 47a7326b..00000000 --- a/cmd/cloud/organizations/invitations/delete.go +++ /dev/null @@ -1,85 +0,0 @@ -package invitations - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - Success bool `json:"success"` - OrganizationID string `json:"organizationID"` -} -type DeleteController struct { - store *DeleteStore - endpointFlag string - defaultEndpoint string -} - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{ - Success: false, - OrganizationID: "", - } -} -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - endpointFlag: "endpoint", - defaultEndpoint: "https://api.sandbox.mangopay.com", - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithShortDescription("Delete an invitation"), - fctl.WithAliases("del"), - fctl.WithConfirmFlag(), - fctl.WithController[*DeleteStore](NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to delete an invitation") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeleteInvitationRequest{ - OrganizationID: organizationID, - InvitationID: args[0], - } - - _, err = apiClient.DeleteInvitation(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = true - c.store.OrganizationID = organizationID - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Invitation %s deleted", args[0]) - return nil -} diff --git a/cmd/cloud/organizations/invitations/list.go b/cmd/cloud/organizations/invitations/list.go deleted file mode 100644 index fbe80870..00000000 --- a/cmd/cloud/organizations/invitations/list.go +++ /dev/null @@ -1,118 +0,0 @@ -package invitations - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Invitations struct { - Id string `json:"id"` - UserEmail string `json:"userEmail"` - Status string `json:"status"` - CreationDate time.Time `json:"creationDate"` -} - -type ListStore struct { - Invitations []Invitations `json:"invitations"` -} -type ListController struct { - store *ListStore - statusFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - statusFlag: "status", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithShortDescription("List invitations"), - fctl.WithAliases("s"), - fctl.WithStringFlag(c.statusFlag, "", "Filter invitations by status"), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - status := fctl.GetString(cmd, c.statusFlag) - request := operations.ListOrganizationInvitationsRequest{ - OrganizationID: organizationID, - } - if status != "" { - request.Status = &status - } - - response, err := apiClient.ListOrganizationInvitations(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListInvitationsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Invitations = fctl.Map(response.ListInvitationsResponse.GetData(), func(i components.Invitation) Invitations { - return Invitations{ - Id: i.GetID(), - UserEmail: i.GetUserEmail(), - Status: string(i.GetStatus()), - CreationDate: i.GetCreationDate(), - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - tableData := fctl.Map(c.store.Invitations, func(i Invitations) []string { - return []string{ - i.Id, - i.UserEmail, - i.Status, - i.CreationDate.Format(time.RFC3339), - } - }) - - tableData = fctl.Prepend(tableData, []string{"ID", "Email", "Status", "Creation date", "Org claim"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/organizations/invitations/root.go b/cmd/cloud/organizations/invitations/root.go deleted file mode 100644 index 22441a6c..00000000 --- a/cmd/cloud/organizations/invitations/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package invitations - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("invitations", - fctl.WithAliases("invit", "inv", "i", "invitation"), - fctl.WithShortDescription("Manage invitations"), - fctl.WithChildCommands( - NewSendCommand(), - NewListCommand(), - NewDeleteCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/invitations/send.go b/cmd/cloud/organizations/invitations/send.go deleted file mode 100644 index c2e73ef6..00000000 --- a/cmd/cloud/organizations/invitations/send.go +++ /dev/null @@ -1,92 +0,0 @@ -package invitations - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InvitationSend struct { - Email string `json:"email"` -} - -type SendStore struct { - Invitation InvitationSend `json:"invitation"` -} -type SendController struct { - store *SendStore -} - -var _ fctl.Controller[*SendStore] = (*SendController)(nil) - -func NewDefaultSendStore() *SendStore { - return &SendStore{ - Invitation: InvitationSend{}, - } -} - -func NewSendController() *SendController { - return &SendController{ - store: NewDefaultSendStore(), - } -} - -func NewSendCommand() *cobra.Command { - return fctl.NewCommand("send ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithShortDescription("Invite a user by email"), - fctl.WithAliases("s"), - fctl.WithConfirmFlag(), - fctl.WithController[*SendStore](NewSendController()), - ) -} - -func (c *SendController) GetStore() *SendStore { - return c.store -} - -func (c *SendController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to send an invitation") { - return nil, fctl.ErrMissingApproval - } - - request := operations.CreateInvitationRequest{ - OrganizationID: organizationID, - Email: args[0], - } - - response, err := apiClient.CreateInvitation(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.CreateInvitationResponse == nil || response.CreateInvitationResponse.GetData() == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Invitation.Email = response.CreateInvitationResponse.GetData().GetUserEmail() - - return c, nil -} - -func (c *SendController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Invitation sent to %s", c.store.Invitation.Email) - return nil - -} diff --git a/cmd/cloud/organizations/list.go b/cmd/cloud/organizations/list.go deleted file mode 100644 index cb07524a..00000000 --- a/cmd/cloud/organizations/list.go +++ /dev/null @@ -1,124 +0,0 @@ -package organizations - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type OrgRow struct { - ID string `json:"id"` - Name string `json:"name"` - OwnerID string `json:"ownerId"` - OwnerEmail string `json:"ownerEmail"` - Domain string `json:"domain"` - IsMine string `json:"isMine"` -} - -type ListStore struct { - Organizations []*OrgRow `json:"organizations"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List organizations"), - fctl.WithBoolFlag("expand", true, "Expand the organization"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClient(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - expand := fctl.GetBool(cmd, "expand") - - request := operations.ListOrganizationsRequest{ - Expand: pointer.For(expand), - } - - response, err := apiClient.ListOrganizations(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListOrganizationExpandedResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - claims, err := profile.GetClaims() - if err != nil { - return nil, err - } - - c.store.Organizations = fctl.Map(response.ListOrganizationExpandedResponse.GetData(), func(o components.OrganizationExpanded) *OrgRow { - isMine := fctl.BoolToString(o.GetOwnerID() == claims.Subject) - return &OrgRow{ - ID: o.GetID(), - Name: o.GetName(), - OwnerID: o.GetOwnerID(), - OwnerEmail: func() string { - if owner := o.GetOwner(); owner != nil { - return owner.GetEmail() - } - return "" - }(), - Domain: func() string { - if domain := o.GetDomain(); domain != nil { - return *domain - } - return "" - }(), - IsMine: isMine, - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - OrgMap := fctl.Map(c.store.Organizations, func(o *OrgRow) []string { - return []string{o.ID, o.Name, o.OwnerID, o.OwnerEmail, o.Domain, o.IsMine} - }) - - tableData := fctl.Prepend(OrgMap, []string{"ID", "Name", "Owner ID", "Owner email", "Domain", "Is mine?"}) - - return pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(tableData).Render() -} diff --git a/cmd/cloud/organizations/oauth-clients/create.go b/cmd/cloud/organizations/oauth-clients/create.go deleted file mode 100644 index 6fc8f985..00000000 --- a/cmd/cloud/organizations/oauth-clients/create.go +++ /dev/null @@ -1,108 +0,0 @@ -package oauth_clients - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -var ( - descriptionFlag = "description" - nameFlag = "name" -) - -type Create struct { - Client components.OrganizationClient `json:"organizationClient"` -} -type CreateController struct { - store *Create -} - -var _ fctl.Controller[*Create] = (*CreateController)(nil) - -func NewDefaultCreate() *Create { - return &Create{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreate(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand(`create`, - fctl.WithShortDescription("Create an organization OAuth client"), - fctl.WithConfirmFlag(), - fctl.WithStringFlag(descriptionFlag, "", "Description of the OAuth client usage"), - fctl.WithStringFlag(nameFlag, "", "Name of the OAuth client"), - fctl.WithController(NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *Create { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to create a new organization OAuth client") { - return nil, fctl.ErrMissingApproval - } - - description, err := cmd.Flags().GetString(descriptionFlag) - if err != nil { - return nil, err - } - - name, err := cmd.Flags().GetString(nameFlag) - if err != nil { - return nil, err - } - - reqBody := components.CreateOrganizationClientRequest{} - if description != "" { - reqBody.Description = pointer.For(description) - } - - if name != "" { - reqBody.Name = pointer.For(name) - } - - request := operations.OrganizationClientCreateRequest{ - OrganizationID: organizationID, - Body: &reqBody, - } - - response, err := apiClient.OrganizationClientCreate(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.CreateOrganizationClientResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Client = response.CreateOrganizationClientResponse.GetData() - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - return onCreateShow(cmd.OutOrStdout(), c.store.Client) -} diff --git a/cmd/cloud/organizations/oauth-clients/delete.go b/cmd/cloud/organizations/oauth-clients/delete.go deleted file mode 100644 index b6cafb89..00000000 --- a/cmd/cloud/organizations/oauth-clients/delete.go +++ /dev/null @@ -1,78 +0,0 @@ -package oauth_clients - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Delete struct{} -type DeleteController struct { - store *Delete -} - -var _ fctl.Controller[*Delete] = (*DeleteController)(nil) - -func NewDefaultDelete() *Delete { - return &Delete{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDelete(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand(`delete `, - fctl.WithShortDescription("Delete an organization OAuth client"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController(NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *Delete { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to delete an OAuth client") { - return nil, fctl.ErrMissingApproval - } - - clientID := args[0] - if clientID == "" { - return nil, ErrMissingClientID - } - - request := operations.OrganizationClientDeleteRequest{ - OrganizationID: organizationID, - ClientID: clientID, - } - - _, err = apiClient.OrganizationClientDelete(cmd.Context(), request) - if err != nil { - return nil, err - } - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.Println("Organization client deleted successfully") - return nil -} diff --git a/cmd/cloud/organizations/oauth-clients/errors.go b/cmd/cloud/organizations/oauth-clients/errors.go deleted file mode 100644 index e8c1629a..00000000 --- a/cmd/cloud/organizations/oauth-clients/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package oauth_clients - -import "fmt" - -var ( - ErrMissingClientID = fmt.Errorf("client_id must be provided") -) diff --git a/cmd/cloud/organizations/oauth-clients/list.go b/cmd/cloud/organizations/oauth-clients/list.go deleted file mode 100644 index f0029b36..00000000 --- a/cmd/cloud/organizations/oauth-clients/list.go +++ /dev/null @@ -1,98 +0,0 @@ -package oauth_clients - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type List struct { - Cursor components.ReadOrganizationClientsResponseData `json:"cursor"` -} - -type ListController struct { - store *List -} - -var _ fctl.Controller[*List] = (*ListController)(nil) - -func NewDefaultList() *List { - return &List{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultList(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand(`list`, - fctl.WithShortDescription("List organization OAuth clients"), - fctl.WithPageSizeFlag(), - fctl.WithCursorFlag(), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *List { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - - request := operations.OrganizationClientsReadRequest{ - OrganizationID: organizationID, - } - - if pageSize > 0 { - pageSizeInt64 := int64(pageSize) - request.PageSize = &pageSizeInt64 - } - - if cursor != "" { - request.Cursor = &cursor - } - - response, err := apiClient.OrganizationClientsRead(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ReadOrganizationClientsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Cursor = response.ReadOrganizationClientsResponse.GetData() - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - return showOrganizationClients(cmd.OutOrStdout(), c.store.Cursor) -} diff --git a/cmd/cloud/organizations/oauth-clients/print.go b/cmd/cloud/organizations/oauth-clients/print.go deleted file mode 100644 index 1e33c953..00000000 --- a/cmd/cloud/organizations/oauth-clients/print.go +++ /dev/null @@ -1,77 +0,0 @@ -package oauth_clients - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func onCreateShow(writer io.Writer, client components.OrganizationClient) error { - data := [][]string{ - {"Client ID", fmt.Sprintf("organization_%s", client.GetID())}, - {"Name", client.GetName()}, - {"Secret", func() string { - secret := client.GetSecret() - if clear := secret.GetClear(); clear != nil { - return *clear - } - return "" - }()}, - {"Secret last digits", func() string { - secret := client.GetSecret() - return secret.GetLastDigits() - }()}, - {"Description", client.GetDescription()}, - {"CreatedAt", client.GetCreatedAt().String()}, - } - return pterm.DefaultTable.WithHasHeader().WithWriter(writer).WithData(data).Render() -} - -func showOrganizationClient(writer io.Writer, client components.OrganizationClient) error { - data := [][]string{ - {"Client ID", fmt.Sprintf("organization_%s", client.GetID())}, - {"Name", client.GetName()}, - {"Secret last digits", func() string { - secret := client.GetSecret() - return secret.GetLastDigits() - }()}, - {"Description", client.GetDescription()}, - {"CreatedAt", client.GetCreatedAt().String()}, - {"UpdatedAt", client.GetUpdatedAt().String()}, - } - return pterm.DefaultTable.WithHasHeader().WithWriter(writer).WithData(data).Render() -} - -func showOrganizationClients(writer io.Writer, clientsCursor components.ReadOrganizationClientsResponseData) error { - cursor := fctl.Cursor{ - HasMore: clientsCursor.GetHasMore(), - PageSize: clientsCursor.GetPageSize(), - Next: clientsCursor.GetNext(), - Previous: clientsCursor.GetPrevious(), - } - - if err := fctl.RenderCursor(writer, cursor); err != nil { - return err - } - - data := [][]string{ - {"Client ID", "Name", "Secret last digits", "Description", "CreatedAt", "UpdatedAt"}, - } - for _, client := range clientsCursor.GetData() { - secret := client.GetSecret() - data = append(data, []string{ - fmt.Sprintf("organization_%s", client.GetID()), - client.GetName(), - secret.GetLastDigits(), - client.GetDescription(), - client.GetCreatedAt().String(), - client.GetUpdatedAt().String(), - }) - } - return pterm.DefaultTable.WithHasHeader().WithWriter(writer).WithData(data).Render() -} diff --git a/cmd/cloud/organizations/oauth-clients/root.go b/cmd/cloud/organizations/oauth-clients/root.go deleted file mode 100644 index ee834a73..00000000 --- a/cmd/cloud/organizations/oauth-clients/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package oauth_clients - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("oauth-clients", - fctl.WithShortDescription("Manage OAuth clients"), - fctl.WithChildCommands( - NewCreateCommand(), - NewListCommand(), - NewShowCommand(), - NewDeleteCommand(), - NewUpdateCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/oauth-clients/show.go b/cmd/cloud/organizations/oauth-clients/show.go deleted file mode 100644 index 90f5c7a4..00000000 --- a/cmd/cloud/organizations/oauth-clients/show.go +++ /dev/null @@ -1,83 +0,0 @@ -package oauth_clients - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Show struct { - Client components.OrganizationClient `json:"organizationClient"` -} -type ShowController struct { - store *Show -} - -var _ fctl.Controller[*Show] = (*ShowController)(nil) - -func NewDefaultShow() *Show { - return &Show{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShow(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand(`show `, - fctl.WithShortDescription("Show an organization OAuth client"), - fctl.WithController(NewShowController()), - fctl.WithArgs(cobra.ExactArgs(1)), - ) -} - -func (c *ShowController) GetStore() *Show { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - clientID := args[0] - if clientID == "" { - return nil, ErrMissingClientID - } - - request := operations.OrganizationClientReadRequest{ - OrganizationID: organizationID, - ClientID: clientID, - } - - response, err := apiClient.OrganizationClientRead(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ReadOrganizationClientResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Client = response.ReadOrganizationClientResponse.GetData() - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - return showOrganizationClient(cmd.OutOrStdout(), c.store.Client) -} diff --git a/cmd/cloud/organizations/oauth-clients/update.go b/cmd/cloud/organizations/oauth-clients/update.go deleted file mode 100644 index 89144e6e..00000000 --- a/cmd/cloud/organizations/oauth-clients/update.go +++ /dev/null @@ -1,139 +0,0 @@ -package oauth_clients - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Update struct { - Client components.OrganizationClient `json:"organizationClient"` -} -type UpdateController struct { - store *Update -} - -var _ fctl.Controller[*Update] = (*UpdateController)(nil) - -func NewDefaultUpdate() *Update { - return &Update{} -} - -func NewUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultUpdate(), - } -} - -func NewUpdateCommand() *cobra.Command { - return fctl.NewCommand(`update `, - fctl.WithShortDescription("Update an organization OAuth client"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithStringFlag(descriptionFlag, "", "Description of the OAuth client usage"), - fctl.WithStringFlag(nameFlag, "", "Name of the OAuth client"), - fctl.WithController(NewUpdateController()), - ) -} - -func (c *UpdateController) GetStore() *Update { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - if len(args) != 1 { - return nil, fmt.Errorf("client_id is required") - } - description, err := cmd.Flags().GetString(descriptionFlag) - if err != nil { - return nil, err - } - - name, err := cmd.Flags().GetString(nameFlag) - if err != nil { - return nil, err - } - - clientId := args[0] - clientId = strings.TrimPrefix(clientId, "organization_") - if clientId == "" { - return nil, fmt.Errorf("invalid client_id: %s", args[0]) - } - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to update an existing organization OAuth client") { - return nil, fctl.ErrMissingApproval - } - - readRequest := operations.OrganizationClientReadRequest{ - OrganizationID: organizationID, - ClientID: clientId, - } - - actualClientResponse, err := apiClient.OrganizationClientRead(cmd.Context(), readRequest) - if err != nil { - return nil, fmt.Errorf("failed to read organization client: %w", err) - } - - if actualClientResponse.ReadOrganizationClientResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - actualClient := actualClientResponse.ReadOrganizationClientResponse.GetData() - - reqBody := components.UpdateOrganizationClientRequest{} - if description != "" { - reqBody.Description = pointer.For(description) - } else { - reqBody.Description = pointer.For(actualClient.GetDescription()) - } - - if name != "" { - reqBody.Name = name - } else { - reqBody.Name = actualClient.GetName() - } - - updateRequest := operations.OrganizationClientUpdateRequest{ - OrganizationID: organizationID, - ClientID: clientId, - Body: &reqBody, - } - - _, err = apiClient.OrganizationClientUpdate(cmd.Context(), updateRequest) - if err != nil { - return nil, err - } - - updatedClientResponse, err := apiClient.OrganizationClientRead(cmd.Context(), readRequest) - if err != nil { - return nil, fmt.Errorf("failed to read organization client: %w", err) - } - - if updatedClientResponse.ReadOrganizationClientResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Client = updatedClientResponse.ReadOrganizationClientResponse.GetData() - - return c, nil -} - -func (c *UpdateController) Render(cmd *cobra.Command, args []string) error { - return showOrganizationClient(cmd.OutOrStdout(), c.store.Client) -} diff --git a/cmd/cloud/organizations/policies/add-scope.go b/cmd/cloud/organizations/policies/add-scope.go deleted file mode 100644 index 5d0caa96..00000000 --- a/cmd/cloud/organizations/policies/add-scope.go +++ /dev/null @@ -1,95 +0,0 @@ -package policies - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type AddScopeStore struct { - Success bool `json:"success"` -} - -type AddScopeController struct { - store *AddScopeStore -} - -var _ fctl.Controller[*AddScopeStore] = (*AddScopeController)(nil) - -func NewDefaultAddScopeStore() *AddScopeStore { - return &AddScopeStore{} -} - -func NewAddScopeController() *AddScopeController { - return &AddScopeController{ - store: NewDefaultAddScopeStore(), - } -} - -func NewAddScopeCommand() *cobra.Command { - return fctl.NewCommand(`add-scope `, - fctl.WithAliases("add", "a"), - fctl.WithShortDescription("Add a scope to a policy"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewAddScopeController()), - ) -} - -func (c *AddScopeController) GetStore() *AddScopeStore { - return c.store -} - -func (c *AddScopeController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid policy ID: %w", err) - } - - scopeID, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid scope ID: %w", err) - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to add a scope to a policy") { - return nil, fctl.ErrMissingApproval - } - - request := operations.AddScopeToPolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - ScopeID: scopeID, - } - - _, err = apiClient.AddScopeToPolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *AddScopeController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Scope added to policy successfully") - return nil -} diff --git a/cmd/cloud/organizations/policies/create.go b/cmd/cloud/organizations/policies/create.go deleted file mode 100644 index 390942e4..00000000 --- a/cmd/cloud/organizations/policies/create.go +++ /dev/null @@ -1,98 +0,0 @@ -package policies - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - Policy *components.Policy `json:"policy"` -} - -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand(`create `, - fctl.WithAliases("c", "cr"), - fctl.WithShortDescription("Create a policy"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithStringFlag("description", "", "Policy description"), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to create a new policy") { - return nil, fctl.ErrMissingApproval - } - - description := fctl.GetString(cmd, "description") - policyData := components.CreatePolicyRequest{ - Name: args[0], - } - if description != "" { - policyData.Description = pointer.For(description) - } - - request := operations.CreatePolicyRequest{ - OrganizationID: organizationID, - Body: &policyData, - } - - response, err := apiClient.CreatePolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.CreatePolicyResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Policy = response.CreatePolicyResponse.GetData() - - return c, nil -} - -func (c *CreateController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Policy created successfully") - return nil -} diff --git a/cmd/cloud/organizations/policies/delete.go b/cmd/cloud/organizations/policies/delete.go deleted file mode 100644 index 1067f6d5..00000000 --- a/cmd/cloud/organizations/policies/delete.go +++ /dev/null @@ -1,89 +0,0 @@ -package policies - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - Success bool `json:"success"` -} - -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand(`delete `, - fctl.WithAliases("del", "d"), - fctl.WithShortDescription("Delete a policy"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid policy ID: %w", err) - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to delete a policy") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeletePolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - } - - _, err = apiClient.DeletePolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Policy deleted successfully") - return nil -} diff --git a/cmd/cloud/organizations/policies/list.go b/cmd/cloud/organizations/policies/list.go deleted file mode 100644 index 257d73cd..00000000 --- a/cmd/cloud/organizations/policies/list.go +++ /dev/null @@ -1,112 +0,0 @@ -package policies - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Policies []components.Policy `json:"policies"` -} - -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{ - Policies: []components.Policy{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand(`list`, - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List organization policies"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ListPoliciesRequest{ - OrganizationID: organizationID, - } - - response, err := apiClient.ListPolicies(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListPoliciesResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Policies = response.ListPoliciesResponse.GetData() - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - if len(c.store.Policies) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No policies found.") - return nil - } - - header := []string{"ID", "Name", "Description", "Protected", "Created At", "Updated At"} - tableData := fctl.Map(c.store.Policies, func(policy components.Policy) []string { - return []string{ - fmt.Sprintf("%d", policy.GetID()), - policy.GetName(), - func() string { - if desc := policy.GetDescription(); desc != nil { - return *desc - } - return "" - }(), - fctl.BoolToString(policy.GetProtected()), - time.Time{Time: policy.GetCreatedAt()}.String(), - time.Time{Time: policy.GetUpdatedAt()}.String(), - } - }) - - tableData = fctl.Prepend(tableData, header) - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/cloud/organizations/policies/remove-scope.go b/cmd/cloud/organizations/policies/remove-scope.go deleted file mode 100644 index b4aa5f0f..00000000 --- a/cmd/cloud/organizations/policies/remove-scope.go +++ /dev/null @@ -1,95 +0,0 @@ -package policies - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RemoveScopeStore struct { - Success bool `json:"success"` -} - -type RemoveScopeController struct { - store *RemoveScopeStore -} - -var _ fctl.Controller[*RemoveScopeStore] = (*RemoveScopeController)(nil) - -func NewDefaultRemoveScopeStore() *RemoveScopeStore { - return &RemoveScopeStore{} -} - -func NewRemoveScopeController() *RemoveScopeController { - return &RemoveScopeController{ - store: NewDefaultRemoveScopeStore(), - } -} - -func NewRemoveScopeCommand() *cobra.Command { - return fctl.NewCommand(`remove-scope `, - fctl.WithAliases("remove", "rm", "r"), - fctl.WithShortDescription("Remove a scope from a policy"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewRemoveScopeController()), - ) -} - -func (c *RemoveScopeController) GetStore() *RemoveScopeStore { - return c.store -} - -func (c *RemoveScopeController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid policy ID: %w", err) - } - - scopeID, err := strconv.ParseInt(args[1], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid scope ID: %w", err) - } - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to remove a scope from a policy") { - return nil, fctl.ErrMissingApproval - } - - request := operations.RemoveScopeFromPolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - ScopeID: scopeID, - } - - _, err = apiClient.RemoveScopeFromPolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *RemoveScopeController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Scope removed from policy successfully") - return nil -} diff --git a/cmd/cloud/organizations/policies/root.go b/cmd/cloud/organizations/policies/root.go deleted file mode 100644 index f559f784..00000000 --- a/cmd/cloud/organizations/policies/root.go +++ /dev/null @@ -1,22 +0,0 @@ -package policies - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("policies", - fctl.WithShortDescription("Manage policies"), - fctl.WithChildCommands( - NewListCommand(), - NewCreateCommand(), - NewShowCommand(), - NewUpdateCommand(), - NewDeleteCommand(), - NewAddScopeCommand(), - NewRemoveScopeCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/policies/show.go b/cmd/cloud/organizations/policies/show.go deleted file mode 100644 index 5007234a..00000000 --- a/cmd/cloud/organizations/policies/show.go +++ /dev/null @@ -1,129 +0,0 @@ -package policies - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Policy *components.Policy `json:"policy"` -} - -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand(`show `, - fctl.WithAliases("s", "sh"), - fctl.WithShortDescription("Show policy details"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid policy ID: %w", err) - } - - request := operations.ReadPolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - } - - response, err := apiClient.ReadPolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ReadPolicyResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Policy = response.ReadPolicyResponse.GetData() - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - if c.store.Policy == nil { - return fmt.Errorf("no policy data") - } - - policy := c.store.Policy - - data := [][]string{ - {"ID", fmt.Sprintf("%d", policy.GetID())}, - {"Name", policy.GetName()}, - {"Description", func() string { - if desc := policy.GetDescription(); desc != nil { - return *desc - } - return "" - }()}, - {"Protected", fctl.BoolToString(policy.GetProtected())}, - {"Created At", time.Time{Time: policy.GetCreatedAt()}.String()}, - {"Updated At", time.Time{Time: policy.GetUpdatedAt()}.String()}, - } - - if scopes := policy.GetScopes(); len(scopes) > 0 { - data = append(data, []string{"Scopes", ""}) - for _, scope := range scopes { - data = append(data, []string{ - "", - fmt.Sprintf(" - %s (ID: %d)", scope.GetLabel(), scope.GetID()), - }) - if app := scope.GetApplicationID(); app != nil { - data = append(data, []string{"", fmt.Sprintf(" Application: %s", *app)}) - } - if desc := scope.GetDescription(); desc != nil && *desc != "" { - data = append(data, []string{"", fmt.Sprintf(" Description: %s", *desc)}) - } - } - } - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(data). - Render() -} diff --git a/cmd/cloud/organizations/policies/update.go b/cmd/cloud/organizations/policies/update.go deleted file mode 100644 index 30684c8a..00000000 --- a/cmd/cloud/organizations/policies/update.go +++ /dev/null @@ -1,135 +0,0 @@ -package policies - -import ( - "fmt" - "strconv" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateStore struct { - Policy *components.Policy `json:"policy"` -} - -type UpdateController struct { - store *UpdateStore -} - -var _ fctl.Controller[*UpdateStore] = (*UpdateController)(nil) - -func NewDefaultUpdateStore() *UpdateStore { - return &UpdateStore{} -} - -func NewUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultUpdateStore(), - } -} - -func NewUpdateCommand() *cobra.Command { - return fctl.NewCommand(`update `, - fctl.WithAliases("u", "up"), - fctl.WithShortDescription("Update a policy"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithStringFlag("name", "", "Policy name"), - fctl.WithStringFlag("description", "", "Policy description"), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewUpdateController()), - ) -} - -func (c *UpdateController) GetStore() *UpdateStore { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID, err := strconv.ParseInt(args[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid policy ID: %w", err) - } - - // Read current policy to get existing values - readRequest := operations.ReadPolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - } - - readResponse, err := apiClient.ReadPolicy(cmd.Context(), readRequest) - if err != nil { - return nil, err - } - - if readResponse.ReadPolicyResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - currentPolicy := readResponse.ReadPolicyResponse.GetData() - - if !fctl.CheckOrganizationApprobation(cmd, "You are about to update a policy") { - return nil, fctl.ErrMissingApproval - } - - // Prepare update data - name := fctl.GetString(cmd, "name") - if name == "" { - name = currentPolicy.GetName() - } - - description := fctl.GetString(cmd, "description") - policyData := components.CreatePolicyRequest{ - Name: name, - } - if description != "" || cmd.Flags().Changed("description") { - if description != "" { - policyData.Description = pointer.For(description) - } else { - policyData.Description = currentPolicy.GetDescription() - } - } else { - policyData.Description = currentPolicy.GetDescription() - } - - updateRequest := operations.UpdatePolicyRequest{ - OrganizationID: organizationID, - PolicyID: policyID, - Body: &policyData, - } - - response, err := apiClient.UpdatePolicy(cmd.Context(), updateRequest) - if err != nil { - return nil, err - } - - if response.UpdatePolicyResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Policy = response.UpdatePolicyResponse.GetData() - - return c, nil -} - -func (c *UpdateController) Render(_ *cobra.Command, _ []string) error { - pterm.Success.Println("Policy updated successfully") - return nil -} diff --git a/cmd/cloud/organizations/root.go b/cmd/cloud/organizations/root.go deleted file mode 100644 index dd108853..00000000 --- a/cmd/cloud/organizations/root.go +++ /dev/null @@ -1,34 +0,0 @@ -package organizations - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/applications" - authorization_provider "github.com/formancehq/fctl/v3/cmd/cloud/organizations/authentication-provider" - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/invitations" - oauth_clients "github.com/formancehq/fctl/v3/cmd/cloud/organizations/oauth-clients" - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/policies" - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/users" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("organizations", - fctl.WithAliases("org", "o", "organization"), - fctl.WithShortDescription("Manage organizations"), - fctl.WithChildCommands( - NewListCommand(), - NewCreateCommand(), - NewDeleteCommand(), - NewUpdateCommand(), - NewDescribeCommand(), - NewHistoryCommand(), - users.NewCommand(), - invitations.NewCommand(), - oauth_clients.NewCommand(), - authorization_provider.NewCommand(), - policies.NewCommand(), - applications.NewCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/update.go b/cmd/cloud/organizations/update.go deleted file mode 100644 index 4e1fb04c..00000000 --- a/cmd/cloud/organizations/update.go +++ /dev/null @@ -1,123 +0,0 @@ -package organizations - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - "github.com/formancehq/fctl/v3/cmd/cloud/organizations/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateController struct { - store *DescribeStore -} - -var _ fctl.Controller[*DescribeStore] = (*UpdateController)(nil) - -func NewDefaultUpdateStore() *DescribeStore { - return &DescribeStore{} -} - -func NewUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultUpdateStore(), - } -} - -func NewUpdateCommand() *cobra.Command { - return fctl.NewCommand("update --name --default-policy-id ", - fctl.WithAliases("update"), - fctl.WithShortDescription("Update an organization"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.OrganizationCompletion), - fctl.WithConfirmFlag(), - fctl.WithStringFlag("name", "", "Organization name"), - fctl.WithIntFlag("default-policy-id", 0, "Default policy ID"), - fctl.WithStringFlag("domain", "", "Organization domain"), - fctl.WithController(NewUpdateController()), - ) -} - -func (c *UpdateController) GetStore() *DescribeStore { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, args[0]) - if err != nil { - return nil, err - } - if !fctl.CheckOrganizationApprobation(cmd, "You are about to update an organization") { - return nil, fctl.ErrMissingApproval - } - - readRequest := operations.ReadOrganizationRequest{ - OrganizationID: args[0], - } - - org, err := apiClient.ReadOrganization(cmd.Context(), readRequest) - if err != nil { - return nil, err - } - - if org.ReadOrganizationResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - orgData := org.ReadOrganizationResponse.GetData() - - preparedData := components.OrganizationData{ - Name: func() string { - if cmd.Flags().Changed("name") { - return cmd.Flag("name").Value.String() - } - return orgData.GetName() - }(), - DefaultPolicyID: func() *int64 { - if cmd.Flags().Changed("default-policy-id") { - return pointer.For(int64(fctl.GetInt(cmd, "default-policy-id"))) - } - return orgData.GetDefaultPolicyID() - }(), - Domain: func() *string { - str := fctl.GetString(cmd, "domain") - if str != "" { - return &str - } - return orgData.GetDomain() - }(), - } - - updateRequest := operations.UpdateOrganizationRequest{ - OrganizationID: args[0], - Body: &preparedData, - } - - response, err := apiClient.UpdateOrganization(cmd.Context(), updateRequest) - if err != nil { - return nil, err - } - - if response.ReadOrganizationResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.OrganizationExpanded = response.ReadOrganizationResponse.GetData() - - return c, nil -} - -func (c *UpdateController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintOrganization(c.store.OrganizationExpanded) -} diff --git a/cmd/cloud/organizations/users/link.go b/cmd/cloud/organizations/users/link.go deleted file mode 100644 index b27a0722..00000000 --- a/cmd/cloud/organizations/users/link.go +++ /dev/null @@ -1,98 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type LinkStore struct { - Id string `json:"id"` - Email string `json:"email"` - IsAdmin bool `json:"is_admin"` -} -type LinkController struct { - store *LinkStore -} - -var _ fctl.Controller[*LinkStore] = (*LinkController)(nil) - -func NewDefaultLinkStore() *LinkStore { - return &LinkStore{} -} - -func NewLinkController() *LinkController { - return &LinkController{ - store: NewDefaultLinkStore(), - } -} - -func NewLinkCommand() *cobra.Command { - return fctl.NewCommand("link ", - fctl.WithIntFlag("policy-id", 0, "Policy ID"), - fctl.WithShortDescription("Link user to an organization with properties"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewLinkController()), - ) -} - -func (c *LinkController) GetStore() *LinkStore { - return c.store -} - -func (c *LinkController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - policyID := fctl.GetInt(cmd, "policy-id") - if policyID == 0 { - return nil, fmt.Errorf("policy id is required") - } - - reqBody := components.UpdateOrganizationUserRequest{ - PolicyID: pointer.For(int64(policyID)), - } - - request := operations.UpsertOrganizationUserRequest{ - OrganizationID: organizationID, - UserID: args[0], - Body: &reqBody, - } - - response, err := apiClient.UpsertOrganizationUser(cmd.Context(), request) - if err != nil { - return nil, err - } - - // Vérifier s'il y a une erreur dans la réponse - if response.Error != nil { - errMsg := "unknown error" - if msg := response.Error.GetErrorMessage(); msg != nil { - errMsg = *msg - } - return nil, fmt.Errorf("error updating user: %s", errMsg) - } - - return c, nil -} - -func (c *LinkController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("User added.") - return nil -} diff --git a/cmd/cloud/organizations/users/list.go b/cmd/cloud/organizations/users/list.go deleted file mode 100644 index 7e7d040a..00000000 --- a/cmd/cloud/organizations/users/list.go +++ /dev/null @@ -1,106 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type User struct { - ID string `json:"id"` - Email string `json:"email"` - PolicyID int64 `json:"policyID"` -} - -type ListStore struct { - Users []User `json:"users"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List users"), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ListUsersOfOrganizationRequest{ - OrganizationID: organizationID, - } - - response, err := apiClient.ListUsersOfOrganization(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListUsersResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Users = fctl.Map(response.ListUsersResponse.GetData(), func(i components.OrganizationUser) User { - return User{ - ID: i.GetID(), - Email: i.GetEmail(), - PolicyID: i.GetPolicyID(), - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - - usersRow := fctl.Map(c.store.Users, func(i User) []string { - return []string{ - i.ID, - i.Email, - fmt.Sprint(i.PolicyID), - } - }) - - tableData := fctl.Prepend(usersRow, []string{"ID", "Email", "Policy ID"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/organizations/users/root.go b/cmd/cloud/organizations/users/root.go deleted file mode 100644 index e9e9a50a..00000000 --- a/cmd/cloud/organizations/users/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package users - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("users", - fctl.WithAliases("u", "user"), - fctl.WithShortDescription("Manage users"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewLinkCommand(), - NewUnlinkCommand(), - ), - ) -} diff --git a/cmd/cloud/organizations/users/show.go b/cmd/cloud/organizations/users/show.go deleted file mode 100644 index dc78af07..00000000 --- a/cmd/cloud/organizations/users/show.go +++ /dev/null @@ -1,97 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Id string `json:"id"` - Email string `json:"email"` - PolicyID int64 `json:"policyID"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithAliases("s"), - fctl.WithShortDescription("Show a user by ID"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ReadUserOfOrganizationRequest{ - OrganizationID: organizationID, - UserID: args[0], - } - - response, err := apiClient.ReadUserOfOrganization(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ReadOrganizationUserResponse == nil || response.ReadOrganizationUserResponse.GetData() == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - data := response.ReadOrganizationUserResponse.GetData() - c.store.Id = data.GetID() - c.store.Email = data.GetEmail() - c.store.PolicyID = data.GetPolicyID() - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Id}) - tableData = append(tableData, []string{pterm.LightCyan("Email"), c.store.Email}) - tableData = append(tableData, []string{ - pterm.LightCyan("Role"), - pterm.LightCyan(c.store.PolicyID), - }) - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/organizations/users/unlink.go b/cmd/cloud/organizations/users/unlink.go deleted file mode 100644 index 29df8e8a..00000000 --- a/cmd/cloud/organizations/users/unlink.go +++ /dev/null @@ -1,79 +0,0 @@ -package users - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UnlinkStore struct { - OrganizationID string `json:"organizationId"` - UserID string `json:"userId"` -} -type DeleteController struct { - store *UnlinkStore -} - -var _ fctl.Controller[*UnlinkStore] = (*DeleteController)(nil) - -func NewDefaultUnlinkStore() *UnlinkStore { - return &UnlinkStore{} -} - -func NewUnlinkController() *DeleteController { - return &DeleteController{ - store: NewDefaultUnlinkStore(), - } -} - -func NewUnlinkCommand() *cobra.Command { - return fctl.NewCommand("unlink ", - fctl.WithAliases("u", "un"), - fctl.WithShortDescription("Unlink a user from the organization"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewUnlinkController()), - ) -} - -func (c *DeleteController) GetStore() *UnlinkStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.DeleteUserFromOrganizationRequest{ - OrganizationID: organizationID, - UserID: args[0], - } - - _, err = apiClient.DeleteUserFromOrganization(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.OrganizationID = organizationID - c.store.UserID = args[0] - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("User '%s' removed from organization '%s'.", c.store.UserID, c.store.OrganizationID) - - return nil - -} diff --git a/cmd/cloud/regions/create.go b/cmd/cloud/regions/create.go deleted file mode 100644 index 84c737f1..00000000 --- a/cmd/cloud/regions/create.go +++ /dev/null @@ -1,106 +0,0 @@ -package regions - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - RegionId string `json:"regionId"` - Secret string `json:"secret"` -} -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand("create [name]", - fctl.WithAliases("sh", "s"), - fctl.WithShortDescription("Create a region"), - fctl.WithArgs(cobra.RangeArgs(0, 1)), - fctl.WithController[*CreateStore](NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - name := "" - if len(args) > 0 { - name = args[0] - } else { - name, err = pterm.DefaultInteractiveTextInput.WithMultiLine(false).Show("Enter a name") - if err != nil { - return nil, err - } - } - - reqBody := components.CreatePrivateRegionRequest{ - Name: name, - } - - request := operations.CreatePrivateRegionRequest{ - OrganizationID: organizationID, - Body: &reqBody, - } - - response, err := apiClient.CreatePrivateRegion(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.CreatedPrivateRegionResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - region := response.CreatedPrivateRegionResponse.GetData() - c.store.RegionId = region.GetID() - - secret := region.GetSecret() - if secret != nil && secret.GetClear() != nil { - c.store.Secret = *secret.GetClear() - } - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln( - "Region created successfully with ID: %s", c.store.RegionId) - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln( - "Secret (shown once — store it safely): %s", c.store.Secret) - - return nil -} diff --git a/cmd/cloud/regions/delete.go b/cmd/cloud/regions/delete.go deleted file mode 100644 index 19c7e6c5..00000000 --- a/cmd/cloud/regions/delete.go +++ /dev/null @@ -1,76 +0,0 @@ -package regions - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - Success bool `json:"success"` -} -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDefaultDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDefaultDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithAliases("del", "d"), - fctl.WithShortDescription("Delete a private region"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*DeleteStore](NewDeleteController()), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.DeleteRegionRequest{ - OrganizationID: organizationID, - RegionID: args[0], - } - - _, err = apiClient.DeleteRegion(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Region deleted successfully!") - - return nil - -} diff --git a/cmd/cloud/regions/list.go b/cmd/cloud/regions/list.go deleted file mode 100644 index 5eb65d68..00000000 --- a/cmd/cloud/regions/list.go +++ /dev/null @@ -1,112 +0,0 @@ -package regions - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Regions []components.AnyRegion `json:"regions"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List regions"), - fctl.WithController[*ListStore](NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ListRegionsRequest{ - OrganizationID: organizationID, - } - - response, err := apiClient.ListRegions(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListRegionsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Regions = response.ListRegionsResponse.GetData() - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Regions, func(i components.AnyRegion) []string { - return []string{ - i.GetID(), - i.GetName(), - i.GetBaseURL(), - fctl.BoolToString(i.GetPublic()), - fctl.BoolToString(i.GetActive()), - func() string { - if ping := i.GetLastPing(); ping != nil { - return ping.Format(time.RFC3339) - } - return "" - }(), - func() string { - if creator := i.GetCreator(); creator != nil { - return creator.GetEmail() - } - return "Formance Cloud" - }(), - func() string { - if version := i.GetVersion(); version != nil { - return *version - } - return "" - }(), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Name", "Base url", "Public", "Active", "Last ping", "Owner", "Version"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/cloud/regions/root.go b/cmd/cloud/regions/root.go deleted file mode 100644 index cbd9e0e8..00000000 --- a/cmd/cloud/regions/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package regions - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("regions", - fctl.WithAliases("region", "reg"), - fctl.WithShortDescription("Manage regions"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewCreateCommand(), - NewDeleteCommand(), - ), - ) -} diff --git a/cmd/cloud/regions/show.go b/cmd/cloud/regions/show.go deleted file mode 100644 index dc70c071..00000000 --- a/cmd/cloud/regions/show.go +++ /dev/null @@ -1,139 +0,0 @@ -package regions - -import ( - "fmt" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Region components.AnyRegion `json:"region"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithAliases("sh", "s"), - fctl.WithShortDescription("Show region details"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.GetRegionRequest{ - OrganizationID: organizationID, - RegionID: args[0], - } - - response, err := apiClient.GetRegion(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.GetRegionResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Region = response.GetRegionResponse.GetData() - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) (err error) { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Region.GetID()}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Region.GetName()}) - tableData = append(tableData, []string{pterm.LightCyan("Base URL"), c.store.Region.GetBaseURL()}) - tableData = append(tableData, []string{pterm.LightCyan("Active"), fctl.BoolToString(c.store.Region.GetActive())}) - tableData = append(tableData, []string{pterm.LightCyan("Public"), fctl.BoolToString(c.store.Region.GetPublic())}) - - if version := c.store.Region.GetVersion(); version != nil { - tableData = append(tableData, []string{pterm.LightCyan("Version"), *version}) - } - - if creator := c.store.Region.GetCreator(); creator != nil { - tableData = append(tableData, []string{pterm.LightCyan("Creator"), creator.GetEmail()}) - } - if lastPing := c.store.Region.GetLastPing(); lastPing != nil { - tableData = append(tableData, []string{pterm.LightCyan("Last ping"), lastPing.Format(time.RFC3339)}) - } - - err = pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - if err != nil { - return - } - - tableData = pterm.TableData{} - capabilities, err := fctl.StructToMap(c.store.Region.GetCapabilities()) - if err != nil { - return - } - if len(capabilities) > 0 { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Capabilities") - } - for key, value := range capabilities { - data := []string{ - pterm.LightCyan(key), - } - - var v []string - if value != nil { - c, ok := value.([]interface{}) - if ok { - for _, val := range c { - v = append(v, fmt.Sprintf("%v", val)) - } - } - } - data = append(data, strings.Join(v, ", ")) - tableData = append(tableData, data) - } - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/cloud/root.go b/cmd/cloud/root.go deleted file mode 100644 index 5021bfc8..00000000 --- a/cmd/cloud/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package cloud - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/cloud/apps" - "github.com/formancehq/fctl/v3/cmd/cloud/me" - "github.com/formancehq/fctl/v3/cmd/cloud/organizations" - "github.com/formancehq/fctl/v3/cmd/cloud/regions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("cloud", - fctl.WithAliases("c"), - fctl.WithShortDescription("Cloud management"), - fctl.WithChildCommands( - organizations.NewCommand(), - me.NewCommand(), - regions.NewCommand(), - NewGeneratePersonalTokenCommand(), - apps.NewCommand(), - ), - ) -} diff --git a/cmd/cloudtools/cloudtools.go b/cmd/cloudtools/cloudtools.go new file mode 100644 index 00000000..93a09e44 --- /dev/null +++ b/cmd/cloudtools/cloudtools.go @@ -0,0 +1,140 @@ +// Package cloudtools attaches hosted utilities to the manifest-built Cloud tree. +package cloudtools + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/stacktools" +) + +type resolver func(context.Context, *cobra.Command, string) (*api.Client, error) +type tokenReader func(context.Context, *http.Client) (string, error) + +// AddTo requires the Cloud and stack groups to have been installed by the registry. +func AddTo(root *cobra.Command, settings *connection.Settings) error { + if settings == nil { + return fmt.Errorf("connection settings are required") + } + return addTo(root, settings.Client, cloud.StackAccessToken) +} + +func addTo(root *cobra.Command, resolve resolver, token tokenReader) error { + cloudCommand := child(root, "cloud") + stack := child(cloudCommand, "stack") + if cloudCommand == nil || stack == nil { + return fmt.Errorf("cloud and stack commands must be installed before hosted utilities") + } + for _, name := range []string{"proxy", "mcp"} { + if child(stack, name) != nil { + return fmt.Errorf("stack command %s already exists", name) + } + } + if child(cloudCommand, "generate-personal-token") != nil || child(cloudCommand, "gpt") != nil { + return fmt.Errorf("personal token command already exists") + } + stack.AddCommand(proxyCommand(resolve), mcpCommand(resolve)) + cloudCommand.AddCommand(tokenCommand(resolve, token)) + return nil +} + +func child(parent *cobra.Command, name string) *cobra.Command { + if parent == nil { + return nil + } + for _, candidate := range parent.Commands() { + if candidate.Name() == name || candidate.HasAlias(name) { + return candidate + } + } + return nil +} + +func proxyCommand(resolve resolver) *cobra.Command { + var port uint32 + var origins []string + cmd := &cobra.Command{Use: "proxy", Short: "Serve an authenticated stack proxy on loopback", Args: cobra.NoArgs} + cmd.Flags().Uint32Var(&port, "port", 55001, "Loopback port (0 chooses an available port)") + cmd.Flags().StringSliceVar(&origins, "allowed-origins", nil, "Allowed browser origins (comma-separated)") + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + if port > 65535 { + return fmt.Errorf("port must be between 0 and 65535") + } + timeout, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + if timeout <= 0 { + return fmt.Errorf("timeout must be positive") + } + client, err := resolve(cmd.Context(), cmd, "stack") + if err != nil { + return err + } + options := stacktools.ProxyOptions{Endpoint: client.Endpoint(), Client: client.HTTPClient(), Port: port, AllowedOrigins: origins, Timeout: timeout} + return stacktools.ServeProxy(cmd.Context(), options, func(address string) error { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Stack proxy listening on http://%s\n", address) + return err + }) + } + return cmd +} + +func mcpCommand(resolve resolver) *cobra.Command { + group := &cobra.Command{Use: "mcp", Short: "Stack MCP integrations"} + var transport string + serve := &cobra.Command{Use: "serve", Short: "Bridge stack MCP over stdio", Args: cobra.NoArgs} + serve.Flags().StringVar(&transport, "transport", "stdio", "MCP transport (stdio)") + serve.RunE = func(cmd *cobra.Command, _ []string) error { + if transport != "stdio" { + return fmt.Errorf("unsupported MCP transport: only stdio is supported") + } + timeout, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + if timeout <= 0 { + return fmt.Errorf("timeout must be positive") + } + client, err := resolve(cmd.Context(), cmd, "stack") + if err != nil { + return err + } + return stacktools.ServeMCP(cmd.Context(), stacktools.MCPOptions{Endpoint: client.Endpoint(), Client: client.HTTPClient(), Timeout: timeout, Input: cmd.InOrStdin(), Output: cmd.OutOrStdout()}) + } + group.AddCommand(serve) + return group +} + +func tokenCommand(resolve resolver, read tokenReader) *cobra.Command { + cmd := &cobra.Command{Use: "generate-personal-token", Aliases: []string{"gpt"}, Short: "Print the selected stack's bearer token", Args: cobra.NoArgs} + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := resolve(cmd.Context(), cmd, "stack") + if err != nil { + return err + } + value, err := read(cmd.Context(), client.HTTPClient()) + if err != nil { + return err + } + data, err := json.Marshal(struct { + Token string `json:"token"` + }{Token: value}) + if err != nil { + return err + } + if err := command.ConfigureOutput(cmd, "json", "never"); err != nil { + return err + } + return command.WriteJSON(cmd.OutOrStdout(), data) + } + return cmd +} diff --git a/cmd/cloudtools/cloudtools_test.go b/cmd/cloudtools/cloudtools_test.go new file mode 100644 index 00000000..fbebfcbf --- /dev/null +++ b/cmd/cloudtools/cloudtools_test.go @@ -0,0 +1,240 @@ +package cloudtools + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/connection" +) + +func utilityRoot(t *testing.T, resolve resolver, read tokenReader) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "fctl", SilenceUsage: true, SilenceErrors: true} + settings := &connection.Settings{} + settings.Bind(root) + cloudCommand := &cobra.Command{Use: "cloud"} + cloudCommand.AddCommand(&cobra.Command{Use: "stack"}) + root.AddCommand(cloudCommand) + if err := addTo(root, resolve, read); err != nil { + t.Fatal(err) + } + return root +} + +func TestHostedHelpAndValidationDoNotAuthenticate(t *testing.T) { + t.Parallel() + tests := [][]string{ + {"cloud", "stack", "proxy", "--help"}, + {"cloud", "stack", "mcp", "serve", "--help"}, + {"cloud", "generate-personal-token", "--help"}, + {"cloud", "stack", "proxy", "--port", "65536"}, + {"cloud", "stack", "proxy", "--timeout", "0s"}, + {"cloud", "stack", "mcp", "serve", "--timeout", "0s"}, + {"cloud", "stack", "mcp", "serve", "--transport", "http"}, + {"cloud", "generate-personal-token", "extra"}, + } + for _, args := range tests { + t.Run(strings.Join(args, " "), func(t *testing.T) { + root := utilityRoot(t, func(context.Context, *cobra.Command, string) (*api.Client, error) { + t.Error("unexpected authentication") + return nil, errors.New("unexpected authentication") + }, func(context.Context, *http.Client) (string, error) { + t.Error("unexpected token access") + return "", nil + }) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + err := root.ExecuteContext(t.Context()) + help := args[len(args)-1] == "--help" + if help && err != nil { + t.Fatal(err) + } + if !help && err == nil { + t.Fatal("invalid input accepted") + } + }) + } +} + +func TestHostedTokenIsExplicitJSON(t *testing.T) { + t.Parallel() + httpClient := &http.Client{} + client, err := api.New("http://127.0.0.1", httpClient) + if err != nil { + t.Fatal(err) + } + root := utilityRoot(t, func(_ context.Context, _ *cobra.Command, service string) (*api.Client, error) { + if service != "stack" { + t.Error("incorrect service") + } + return client, nil + }, func(_ context.Context, got *http.Client) (string, error) { + if got != httpClient { + t.Error("authenticated client replaced") + } + return "synthetic-explicit-token", nil + }) + var out, stderr bytes.Buffer + root.SetOut(&out) + root.SetErr(&stderr) + root.SetArgs([]string{"--output", "table", "cloud", "generate-personal-token"}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if out.String() != "{\n \"token\": \"synthetic-explicit-token\"\n}\n" || stderr.Len() != 0 { + t.Fatalf("token output %q stderr %q", out.String(), stderr.String()) + } +} + +func TestHostedMCPUsesCommandStreams(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/prefix/api/mcp" { + t.Errorf("path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, `{"jsonrpc":"2.0","id":"tool","result":{"tools":[]}}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL+"/prefix", server.Client()) + if err != nil { + t.Fatal(err) + } + root := utilityRoot(t, func(context.Context, *cobra.Command, string) (*api.Client, error) { return client, nil }, nil) + root.SetIn(strings.NewReader(`{"jsonrpc":"2.0","id":"tool","method":"tools/list"}`)) + var out, stderr bytes.Buffer + root.SetOut(&out) + root.SetErr(&stderr) + root.SetArgs([]string{"cloud", "stack", "mcp", "serve"}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if out.String() != "{\"jsonrpc\":\"2.0\",\"id\":\"tool\",\"result\":{\"tools\":[]}}\n" || stderr.Len() != 0 { + t.Fatalf("protocol output %q stderr %q", out.String(), stderr.String()) + } +} + +func TestAttachmentIsAtomic(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "fctl"} + cloudCommand := &cobra.Command{Use: "cloud"} + stack := &cobra.Command{Use: "stack"} + stack.AddCommand(&cobra.Command{Use: "proxy"}) + cloudCommand.AddCommand(stack) + root.AddCommand(cloudCommand) + if err := addTo(root, nil, nil); err == nil { + t.Fatal("collision accepted") + } + if child(stack, "mcp") != nil || child(cloudCommand, "generate-personal-token") != nil { + t.Fatal("partial attachment") + } + if err := AddTo(root, nil); err == nil { + t.Fatal("missing settings accepted") + } + if err := AddTo(&cobra.Command{Use: "fctl"}, &connection.Settings{Timeout: time.Second}); err == nil { + t.Fatal("missing groups accepted") + } +} + +type failedWriter struct{ err error } + +func (w failedWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestTokenWriterFailure(t *testing.T) { + t.Parallel() + sentinel := errors.New("writer failed") + client, err := api.New("http://127.0.0.1", &http.Client{}) + if err != nil { + t.Fatal(err) + } + root := utilityRoot(t, func(context.Context, *cobra.Command, string) (*api.Client, error) { return client, nil }, func(context.Context, *http.Client) (string, error) { return "synthetic-explicit-token", nil }) + root.SetOut(failedWriter{err: sentinel}) + root.SetArgs([]string{"cloud", "generate-personal-token"}) + if err := root.ExecuteContext(t.Context()); !errors.Is(err, sentinel) { + t.Fatalf("writer error %v", err) + } +} + +type proxyReadyWriter struct{ ready chan<- string } + +func (writer proxyReadyWriter) Write(data []byte) (int, error) { + address := strings.TrimSpace(strings.TrimPrefix(string(data), "Stack proxy listening on http://")) + writer.ready <- address + return len(data), nil +} + +func TestHostedProxyUsesStackClientAndCancels(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/base/api/ledger/v3/ledgers" { + t.Errorf("path %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL+"/base", server.Client()) + if err != nil { + t.Fatal(err) + } + root := utilityRoot(t, func(_ context.Context, _ *cobra.Command, service string) (*api.Client, error) { + if service != "stack" { + t.Error("wrong proxy service") + } + return client, nil + }, nil) + ready := make(chan string, 1) + root.SetErr(proxyReadyWriter{ready: ready}) + root.SetOut(io.Discard) + root.SetArgs([]string{"cloud", "stack", "proxy", "--port", "0"}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- root.ExecuteContext(ctx) }() + assertHostedProxyRequest(t, ready) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancel %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("command did not stop") + } +} + +func assertHostedProxyRequest(t *testing.T, ready <-chan string) { + t.Helper() + var address string + select { + case address = <-ready: + case <-time.After(3 * time.Second): + t.Fatal("proxy command did not start") + } + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+address+"/api/ledger/v3/ledgers", nil) + if err != nil { + t.Fatal(err) + } + response, err := (&http.Client{Timeout: time.Second}).Do(request) + if err != nil { + t.Fatal(err) + } + if err := response.Body.Close(); err != nil { + t.Error(err) + } + if response.StatusCode != http.StatusNoContent { + t.Fatalf("proxy status %d", response.StatusCode) + } +} diff --git a/cmd/factory/legacy.go b/cmd/factory/legacy.go new file mode 100644 index 00000000..52441c23 --- /dev/null +++ b/cmd/factory/legacy.go @@ -0,0 +1,212 @@ +package factory + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + + "github.com/spf13/cobra" + + legacy "github.com/formancehq/fctl/misc/fctl-plugin" + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginhost" + "github.com/formancehq/fctl/v4/internal/pluginmanager" + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +func legacyFactory(service string) plugin.Factory { + return func(client *http.Client) pluginsdk.Plugin { return legacy.NewService(service, client) } +} + +type providerPreparation struct { + root *cobra.Command + settings *connection.Settings + service string + directory string + modern bool + execution bool +} + +func prepareProvider(ctx context.Context, root *cobra.Command, settings *connection.Settings, args []string, service string) (plugin.Factory, bool, error) { + selected, metadataOnly, err := pluginhost.SelectedCommand(root, args) + if err != nil { + return nil, false, err + } + modern := service == "auth" || service == "ledger" + if modern && os.Getenv("FCTL_PLUGIN_CATALOGUE") != "" { + if err := command.ValidateOutputOptions(settings.Output, settings.Color); err != nil { + return nil, false, err + } + provider, err := pluginhost.PrepareService(ctx, root, settings, args, service) + return provider, false, err + } + dir, err := settings.DirectoryPath() + if err != nil { + return nil, false, err + } + p := providerPreparation{root: root, settings: settings, service: service, directory: filepath.Join(dir, "plugins"), modern: modern, execution: legacyServiceCommand(ctx, selected, service) && !metadataOnly} + return p.prepare(ctx, args) +} + +func (p providerPreparation) selection(ctx context.Context) (pluginselection.Selection, error) { + if p.execution { + return p.inspect(ctx) + } + target, err := pluginhost.SelectionTarget(p.settings, p.root, p.service) + if err != nil { + return pluginselection.Selection{}, err + } + return pluginselection.Load(p.directory, target, p.service) +} + +func (p providerPreparation) inspect(ctx context.Context) (pluginselection.Selection, error) { + snapshot, err := pluginhost.InspectService(ctx, p.root, p.settings, p.service) + if err != nil { + return snapshot, err + } + historical, err := p.useLegacy(snapshot) + if err != nil { + return snapshot, err + } + if historical { + snapshot.Provider = "legacy" + snapshot.PluginVersion = legacy.Version + } else { + snapshot.Provider = "modern" + } + err = pluginselection.Save(p.directory, snapshot) + return snapshot, err +} + +func (p providerPreparation) useLegacy(snapshot pluginselection.Selection) (bool, error) { + if !legacy.Supported(p.service, snapshot.ServiceVersion, snapshot.StackVersion) { + return false, nil + } + if !p.modern || snapshot.StackVersion != "" { + return true, nil + } + _, err := pluginhost.Show(p.settings, p.root, p.service) + if errors.Is(err, pluginmanager.ErrNotInstalled) { + return true, nil + } + if err != nil { + return false, err + } + return false, nil +} + +// LegacyBundle exposes explicit legacy commands through the same SDK. The +// request resolver checks the Cloud compatibility line before every operation. +func LegacyBundle() plugin.Factory { return legacy.New } + +func (p providerPreparation) checkModernMetadata(selected pluginselection.Selection) error { + lock, err := pluginhost.Show(p.settings, p.root, p.service) + if errors.Is(err, pluginmanager.ErrNotInstalled) { + return nil + } + if err != nil { + return err + } + if lock.ServiceVersion != selected.ServiceVersion { + return fmt.Errorf("%s %s was observed but its plugin is not prepared; run fctl plugins sync --service %s", p.service, selected.ServiceVersion, p.service) + } + return nil +} + +func legacyServiceCommand(ctx context.Context, selected, service string) bool { + if selected == service { + return true + } + manifest, err := legacy.NewService(service, nil).GetManifest(ctx) + return err == nil && slices.Contains(manifest.Root.Aliases, selected) +} + +func (p providerPreparation) modernProvider(ctx context.Context, args []string, selected pluginselection.Selection, observed bool) (plugin.Factory, bool, error) { + if observed && !p.execution { + if err := p.checkModernMetadata(selected); err != nil { + return nil, false, err + } + } + provider, err := pluginhost.PrepareService(ctx, p.root, p.settings, args, p.service) + return provider, false, err +} + +func (p providerPreparation) prepare(ctx context.Context, args []string) (plugin.Factory, bool, error) { + if p.execution { + if err := command.ValidateOutputOptions(p.settings.Output, p.settings.Color); err != nil { + return nil, false, err + } + provider, handled, err := p.installedModern(ctx, args) + if handled || err != nil { + return provider, false, err + } + } + return p.selectProvider(ctx, args) +} +func (p providerPreparation) selectProvider(ctx context.Context, args []string) (plugin.Factory, bool, error) { + selectedProvider, err := p.selection(ctx) + if err != nil && !errors.Is(err, pluginselection.ErrNotSelected) { + return nil, false, err + } + if err == nil && selectedProvider.Provider == "legacy" { + if !legacy.Supported(p.service, selectedProvider.ServiceVersion, selectedProvider.StackVersion) { + return nil, false, fmt.Errorf("cached legacy selection is outside the declared compatibility table") + } + return legacyFactory(p.service), true, nil + } + if p.modern { + return p.modernProvider(ctx, args, selectedProvider, err == nil) + } + if p.execution { + return nil, false, fmt.Errorf("%s has no modern plugin; legacy commands require a supported service on a stack before v4", p.service) + } + return nil, false, nil +} + +// A prepared modern provider stays modern after version drift. Its existing +// verifier/discovery performs the check with the execution transport, avoiding +// a second OAuth grant and preserving verification-before-network behavior. +func (p providerPreparation) installedModern(ctx context.Context, args []string) (plugin.Factory, bool, error) { + if !p.modern { + return nil, false, nil + } + options, _, _, _, err := p.settings.Resolve(p.root) + if err != nil { + return nil, false, err + } + if options.AuthMode == "cloud" { + return nil, false, nil + } + _, err = pluginhost.Show(p.settings, p.root, p.service) + if errors.Is(err, pluginmanager.ErrNotInstalled) { + return nil, false, nil + } + if err != nil { + return nil, true, err + } + provider, _, err := p.modernProvider(ctx, args, pluginselection.Selection{}, false) + if err != nil || provider == nil { + return provider, true, err + } + err = p.saveModernMetadata(ctx, provider) + return provider, true, err +} +func (p providerPreparation) saveModernMetadata(ctx context.Context, provider plugin.Factory) error { + manifest, err := provider(nil).GetManifest(ctx) + if err != nil { + return err + } + target, err := pluginhost.SelectionTarget(p.settings, p.root, p.service) + if err != nil { + return err + } + return pluginselection.Save(p.directory, pluginselection.Selection{Target: target, Service: p.service, ServiceVersion: manifest.Version, Provider: "modern"}) +} diff --git a/cmd/factory/legacy_test.go b/cmd/factory/legacy_test.go new file mode 100644 index 00000000..ca84f33d --- /dev/null +++ b/cmd/factory/legacy_test.go @@ -0,0 +1,106 @@ +package factory + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +func TestLegacySelectionIsExplicitAndOffline(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + fixture := newLegacySelectionFixture(t) + root, settings, directory := fixture.root, fixture.settings, fixture.directory + provider, _, err := prepareProvider(t.Context(), root, settings, []string{"ledger", "list"}, "ledger") + if err != nil || provider == nil { + t.Fatalf("prepare legacy: %v", err) + } + manifest, err := provider(nil).GetManifest(t.Context()) + if err != nil || manifest.Version != "1.0.0" || manifest.Name != "ledger" { + t.Fatalf("legacy metadata: %#v %v", manifest, err) + } + selection, err := pluginselection.Load(filepath.Join(directory, "plugins"), pluginselection.Target{Endpoint: fixture.server.URL}, "ledger") + if err != nil || selection.Provider != "legacy" || selection.ServiceVersion != "2.4.15" { + t.Fatalf("selection %#v %v", selection, err) + } + before := fixture.callCount() + provider, _, err = prepareProvider(t.Context(), root, settings, []string{"ledger", "--help"}, "ledger") + if err != nil || provider == nil { + t.Fatal(err) + } + after := fixture.callCount() + fixture.setVersion("3.0.0-beta.10") + if before != after { + t.Fatal("help accessed remote service") + } + // A changed service family must never retain the historical provider when + // modern publication fails. The unavailable catalogue is an explicit override. + t.Setenv("FCTL_PLUGIN_CATALOGUE", fixture.server.URL+"/missing") + if provider, _, err = prepareProvider(t.Context(), root, settings, []string{"ledger", "list"}, "ledger"); err == nil || provider != nil { + t.Fatal("missing modern catalogue fell back to legacy") + } +} + +func TestLegacyBundleMetadataHasNoHTTP(t *testing.T) { + provider := LegacyBundle() + registryRoot := &cobra.Command{Use: "fctl"} + registryRoot.SetContext(t.Context()) + settings := &connection.Settings{} + settings.Bind(registryRoot) + manifest, err := provider(nil).GetManifest(t.Context()) + if err != nil || manifest.Name != "legacy" || len(manifest.Root.Subcommands) != 7 { + t.Fatalf("bundle metadata %#v %v", manifest, err) + } +} + +type legacySelectionFixture struct { + mu sync.Mutex + calls int + version string + server *httptest.Server + root *cobra.Command + settings *connection.Settings + directory string +} + +func newLegacySelectionFixture(t *testing.T) *legacySelectionFixture { + t.Helper() + f := &legacySelectionFixture{version: "2.4.15", root: &cobra.Command{Use: "fctl"}, settings: &connection.Settings{}, directory: t.TempDir()} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if r.URL.Path == "/missing" { + w.WriteHeader(http.StatusNotFound) + return + } + if r.URL.Path != "/_info" { + t.Errorf("unexpected route %s", r.URL.Path) + } + if err := json.NewEncoder(w).Encode(map[string]any{"data": map[string]string{"version": f.version}}); err != nil { + t.Error(err) + } + })) + t.Cleanup(f.server.Close) + f.root.SetContext(t.Context()) + f.settings.Bind(f.root) + for name, value := range map[string]string{"config-dir": f.directory, "auth-mode": "none", "ledger-url": f.server.URL} { + if err := f.root.PersistentFlags().Set(name, value); err != nil { + t.Fatal(err) + } + } + return f +} +func (f *legacySelectionFixture) callCount() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls } +func (f *legacySelectionFixture) setVersion(version string) { + f.mu.Lock() + defer f.mu.Unlock() + f.version = version +} diff --git a/cmd/factory/provider_boundaries_test.go b/cmd/factory/provider_boundaries_test.go new file mode 100644 index 00000000..6daaa383 --- /dev/null +++ b/cmd/factory/provider_boundaries_test.go @@ -0,0 +1,298 @@ +package factory + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + legacy "github.com/formancehq/fctl/misc/fctl-plugin" + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginhost" + "github.com/formancehq/fctl/v4/internal/pluginmanager" + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +func TestProviderBoundaryFirstLegacyAliasInvocation(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + t.Setenv("FCTL_PROFILE", "") + f := newLegacySelectionFixture(t) + directory := filepath.Join(f.directory, "plugins") + target := pluginselection.Target{Endpoint: f.server.URL} + if _, err := pluginselection.Load(directory, target, "ledger"); !errors.Is(err, pluginselection.ErrNotSelected) { + t.Fatalf("expected a fresh target: %v", err) + } + provider, historical, err := prepareProvider(t.Context(), f.root, f.settings, []string{"l", "list"}, "ledger") + if err != nil || provider == nil || !historical { + t.Fatalf("first l list did not select legacy: provider=%v historical=%v err=%v", provider != nil, historical, err) + } + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), provider(nil), provider); err != nil { + t.Fatal(err) + } + if err := plugin.NewCommandWithRequest(registry, serviceRequestResolver(f.root, f.settings, map[string]bool{"ledger": historical})).AddTo(f.root); err != nil { + t.Fatal(err) + } + cmd, remaining, err := f.root.Find([]string{"l", "list"}) + if err != nil || len(remaining) != 0 || cmd.CommandPath() != "fctl ledger list" { + t.Fatalf("legacy alias was not registered: command=%s remaining=%v err=%v", cmd.CommandPath(), remaining, err) + } + selection, err := pluginselection.Load(directory, target, "ledger") + if err != nil || selection.Provider != "legacy" || selection.ServiceVersion != "2.4.15" || selection.StackVersion != "" || selection.PluginVersion != legacy.Version { + t.Fatalf("standalone alias selection: %+v err=%v", selection, err) + } + if f.callCount() != 1 { + t.Fatalf("first alias should inspect the service once, got %d requests", f.callCount()) + } +} + +func TestProviderBoundaryResolverFreezesDirectIdentity(t *testing.T) { + t.Setenv("FCTL_PROFILE", "") + for _, historical := range []bool{true, false} { + t.Run(map[bool]string{true: "legacy", false: "modern"}[historical], func(t *testing.T) { + testProviderBoundaryDirectIdentity(t, historical) + }) + } +} + +func testProviderBoundaryDirectIdentity(t *testing.T, historical bool) { + t.Helper() + f := newLegacySelectionFixture(t) + roots := map[string]bool{"ledger": historical} + resolver := serviceRequestResolver(f.root, f.settings, roots) + // Model a later tree builder and another CLI replacing the selection. + roots["ledger"] = !historical + selected := pluginselection.Selection{Target: pluginselection.Target{Endpoint: f.server.URL}, Service: "ledger", ServiceVersion: "2.4.15", Provider: "modern"} + if !historical { + selected.Provider, selected.PluginVersion = "legacy", legacy.Version + } + if err := pluginselection.Save(filepath.Join(f.directory, "plugins"), selected); err != nil { + t.Fatal(err) + } + client, err := resolver(t.Context(), "ledger", pluginsdk.ExecuteRequest{CommandPath: []string{"ledger", "list"}}) + if err != nil { + t.Fatal(err) + } + metadata := client.Context() + if (metadata["provider"] == "legacy") != historical { + t.Fatalf("mutable cache/map changed the registered provider: historical=%v metadata=%v", historical, metadata) + } + if historical && metadata["plugin-version"] != legacy.Version { + t.Fatalf("wrong historical plugin identity: %v", metadata) + } + if f.callCount() != 0 { + t.Fatal("resolving a direct client should not inspect a remote stack") + } +} + +func TestProviderBoundaryLegacyCloudV4GuardSurvivesCacheReplacement(t *testing.T) { + t.Setenv("FCTL_PROFILE", "") + f := newProviderBoundaryCloudFixture(t) + selected := pluginselection.Selection{Target: pluginselection.Target{Profile: "work", Organization: "org", Stack: "stack", Endpoint: f.server.URL + "/api"}, Service: "auth", ServiceVersion: "2.5.1", StackVersion: "3.2.0", Provider: "legacy", PluginVersion: legacy.Version} + directory := filepath.Join(f.directory, "plugins") + if err := pluginselection.Save(directory, selected); err != nil { + t.Fatal(err) + } + roots := map[string]bool{"auth": true, "legacy": true} + resolver := serviceRequestResolver(f.root, f.settings, roots) + delete(roots, "auth") + roots["legacy"] = false + selected.Provider, selected.PluginVersion, selected.StackVersion = "modern", "", "v4.0.0-beta.1" + if err := pluginselection.Save(directory, selected); err != nil { + t.Fatal(err) + } + for _, path := range [][]string{{"auth", "clients", "create"}, {"legacy", "auth", "clients", "create"}} { + client, err := resolver(t.Context(), "auth", pluginsdk.ExecuteRequest{CommandPath: path}) + if client != nil || err == nil || !strings.Contains(err.Error(), "legacy plugin "+legacy.Version) || !strings.Contains(err.Error(), "v4.0.0-beta.1") { + t.Fatalf("%v bypassed the Cloud v4 guard: client=%v err=%v", path, client != nil, err) + } + } + if f.stackReads.Load() != 2 || f.operations.Load() != 0 { + t.Fatalf("guard should read Membership before any operation: stack reads=%d operations=%d", f.stackReads.Load(), f.operations.Load()) + } +} + +func TestProviderBoundaryCorruptModernLockDoesNotFallBack(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + t.Setenv("FCTL_PROFILE", "") + for _, data := range []string{`{"schemaVersion":`, `{"schemaVersion":999}`} { + t.Run(data, func(t *testing.T) { + f := newLegacySelectionFixture(t) + installProviderBoundaryLock(t, f) + lockDir := filepath.Join(f.directory, "plugins", "targets") + entries, err := os.ReadDir(lockDir) + if err != nil || len(entries) != 1 { + t.Fatalf("expected one installed lock: entries=%v err=%v", entries, err) + } + if err := os.WriteFile(filepath.Join(lockDir, entries[0].Name()), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + provider, historical, err := prepareProvider(t.Context(), f.root, f.settings, []string{"ledger", "list"}, "ledger") + if err == nil || errors.Is(err, pluginmanager.ErrNotInstalled) || provider != nil || historical { + t.Fatalf("corrupt modern lock fell back: provider=%v historical=%v err=%v", provider != nil, historical, err) + } + if _, err := pluginselection.Load(filepath.Join(f.directory, "plugins"), pluginselection.Target{Endpoint: f.server.URL}, "ledger"); !errors.Is(err, pluginselection.ErrNotSelected) { + t.Fatalf("failed preparation published a selection: %v", err) + } + }) + } +} + +func TestProviderBoundaryModernVersionMismatchLeavesOfflineHelpUnprepared(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + t.Setenv("FCTL_PROFILE", "") + f := newLegacySelectionFixture(t) + installProviderBoundaryLock(t, f) + selected := pluginselection.Selection{Target: pluginselection.Target{Endpoint: f.server.URL}, Service: "ledger", ServiceVersion: "3.0.0-beta.10", Provider: "modern"} + if err := pluginselection.Save(filepath.Join(f.directory, "plugins"), selected); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"ledger", "--help"}, {"ledger"}, {"__complete", "ledger", ""}} { + provider, historical, err := prepareProvider(t.Context(), f.root, f.settings, args, "ledger") + if provider != nil || historical || err == nil || !strings.Contains(err.Error(), "ledger 3.0.0-beta.10 was observed but its plugin is not prepared") { + t.Fatalf("%v exposed a stale modern manifest: provider=%v historical=%v err=%v", args, provider != nil, historical, err) + } + } + lock, err := pluginhost.Show(f.settings, f.root, "ledger") + if err != nil || lock.ServiceVersion != "2.4.15" { + t.Fatalf("offline help changed the old lock: %+v err=%v", lock, err) + } + if f.callCount() != 0 { + t.Fatalf("offline help accessed the network %d times", f.callCount()) + } +} + +func installProviderBoundaryLock(t *testing.T, f *legacySelectionFixture) { + t.Helper() + path := filepath.Join(t.TempDir(), "metadata-only-plugin") + if err := os.WriteFile(path, []byte("offline metadata must not execute this file"), 0o600); err != nil { + t.Fatal(err) + } + manager, err := pluginmanager.New(filepath.Join(f.directory, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + manifest := pluginsdk.Manifest{Name: "ledger", Service: "ledger", Version: "2.4.15", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{Use: "ledger", Subcommands: []pluginsdk.CommandSpec{{Use: "probe", Runnable: true}}}} + if _, err := manager.InstallLocal(t.Context(), path, pluginmanager.Target{Endpoint: f.server.URL}, manifest); err != nil { + t.Fatal(err) + } + if _, err := pluginhost.Show(f.settings, f.root, "ledger"); err != nil { + t.Fatalf("fixture must start with a valid modern lock: %v", err) + } +} + +type providerBoundaryCloudFixture struct { + server *httptest.Server + directory string + root *cobra.Command + settings *connection.Settings + stackReads atomic.Int32 + operations atomic.Int32 + orgToken string +} + +func newProviderBoundaryCloudFixture(t *testing.T) *providerBoundaryCloudFixture { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &providerBoundaryCloudFixture{directory: t.TempDir(), root: &cobra.Command{Use: "fctl"}, settings: &connection.Settings{}} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.serve(t, key, w, r) })) + t.Cleanup(f.server.Close) + entry := f.entry(t, key) + if err := connection.Save(f.directory, connection.Store{Active: "work", Connections: map[string]connection.Entry{"work": entry}}); err != nil { + t.Fatal(err) + } + f.root.SetContext(t.Context()) + f.settings.Bind(f.root) + for name, value := range map[string]string{"config-dir": f.directory, "profile": "work", "auth-mode": "cloud", "issuer": f.server.URL + "/api", "client-id": "fctl", "organization": "org", "stack": "stack", "no-browser": "true"} { + if err := f.root.PersistentFlags().Set(name, value); err != nil { + t.Fatal(err) + } + } + return f +} + +func (f *providerBoundaryCloudFixture) entry(t *testing.T, key *rsa.PrivateKey) connection.Entry { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithHeader("kid", "key")) + if err != nil { + t.Fatal(err) + } + issuer := f.server.URL + "/api" + id := providerBoundaryClaims(t, signer, issuer, map[string]any{"org": []any{map[string]any{"id": "org", "scopes": []string{"organization:Read", "organization:ListStacks"}, "stacks": []any{map[string]any{"id": "stack", "uri": f.server.URL + "/stack", "scopes": []string{"stack:Read", "stack:Write"}}}}}}) + f.orgToken = providerBoundaryClaims(t, signer, issuer, map[string]any{"organization_id": "org", "scope": "organization:Read organization:ListStacks"}) + options := cloud.Options{Issuer: issuer, ClientID: "fctl"} + orgOptions := options + orgOptions.Organization = "org" + stackOptions := orgOptions + stackOptions.Stack = "stack" + entry := connection.NewEntry(connection.Options{AuthMode: "cloud", Issuer: issuer, ClientID: "fctl"}) + entry.Session = &cloud.Session{Options: options, IDToken: id, MembershipToken: providerBoundaryToken("root-access"), + Organizations: map[string]*cloud.Session{"org": {Options: orgOptions, IDToken: id, MembershipToken: providerBoundaryToken(f.orgToken)}}, + Targets: map[string]*cloud.Session{"org/stack": {Options: stackOptions, IDToken: id, MembershipToken: providerBoundaryToken("stack-membership"), StackURL: f.server.URL + "/stack", StackToken: providerBoundaryToken("stack-access")}}} + return entry +} + +func providerBoundaryClaims(t *testing.T, signer jose.Signer, issuer string, values map[string]any) string { + t.Helper() + values["iss"], values["aud"], values["sub"], values["exp"] = issuer, "fctl", "user", time.Now().Add(time.Hour).Unix() + raw, err := jwt.Signed(signer).Claims(values).Serialize() + if err != nil { + t.Fatal(err) + } + return raw +} + +func providerBoundaryToken(access string) *oauth2.Token { + return &oauth2.Token{AccessToken: access, TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} +} + +func (f *providerBoundaryCloudFixture) serve(t *testing.T, key *rsa.PrivateKey, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.Method != http.MethodGet { + f.operations.Add(1) + t.Errorf("guard made an unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var data any + switch r.URL.Path { + case "/api/.well-known/openid-configuration": + data = map[string]any{"issuer": f.server.URL + "/api", "authorization_endpoint": f.server.URL + "/api/authorize", "device_authorization_endpoint": f.server.URL + "/api/device", "token_endpoint": f.server.URL + "/api/token", "jwks_uri": f.server.URL + "/api/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}} + case "/api/jwks": + data = jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "key", Algorithm: "RS256", Use: "sig"}}} + case "/api/organizations/org/stacks/stack": + f.stackReads.Add(1) + if r.Header.Get("Authorization") != "Bearer "+f.orgToken { + t.Error("stack version did not use the organization Membership grant") + } + data = map[string]any{"data": map[string]string{"id": "stack", "organizationId": "org", "version": "v4.0.0-beta.1"}} + default: + f.operations.Add(1) + t.Errorf("guard accessed an unexpected route: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewEncoder(w).Encode(data); err != nil { + t.Error(err) + } +} diff --git a/cmd/factory/resolver.go b/cmd/factory/resolver.go new file mode 100644 index 00000000..86189a7e --- /dev/null +++ b/cmd/factory/resolver.go @@ -0,0 +1,51 @@ +package factory + +import ( + "context" + "maps" + + "github.com/spf13/cobra" + + legacy "github.com/formancehq/fctl/misc/fctl-plugin" + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginhost" +) + +func serviceRequestResolver(root *cobra.Command, settings *connection.Settings, legacyRoots map[string]bool) plugin.RequestResolver { + base := pluginhost.RequestResolver(root, settings) + providers := maps.Clone(legacyRoots) + return func(ctx context.Context, service string, request pluginsdk.ExecuteRequest) (*api.Client, error) { + client, err := base(ctx, service, request) + if err != nil { + return nil, err + } + if !legacyRequest(providers, request) { + return client, nil + } + line, err := settings.StackVersion(ctx, root) + if err != nil { + return nil, err + } + if line != "" && !legacy.LegacyStack(line) { + return nil, legacy.Unsupported(service, "", line) + } + metadata := client.Context() + if metadata == nil { + metadata = map[string]string{} + } + metadata["stack-version"] = line + metadata["provider"] = "legacy" + metadata["plugin-version"] = legacy.Version + return client.WithContext(metadata), nil + } +} + +// Bind provider identity to this command tree. A concurrent cache update must +// never remove the Stack guard from an already registered legacy factory. +func legacyRequest(providers map[string]bool, request pluginsdk.ExecuteRequest) bool { + return len(request.CommandPath) > 0 && providers[request.CommandPath[0]] +} diff --git a/cmd/factory/services.go b/cmd/factory/services.go new file mode 100644 index 00000000..fa68e280 --- /dev/null +++ b/cmd/factory/services.go @@ -0,0 +1,55 @@ +// Package factory assembles the service providers used by the root command. +package factory + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginhost" + cloudplugin "github.com/formancehq/fctl/v4/plugins/cloud" +) + +// Services holds providers and preparation failures for a single command tree. +type Services struct { + factories []plugin.Factory + missing []*cobra.Command + errors map[string]error + legacyRoots map[string]bool +} + +// Prepare selects service providers from the requested target and cached metadata. +func Prepare(ctx context.Context, root *cobra.Command, settings *connection.Settings, args []string) *Services { + services := &Services{factories: []plugin.Factory{cloudplugin.New, LegacyBundle()}, errors: map[string]error{}, legacyRoots: map[string]bool{"legacy": true}} + for _, service := range []string{"ledger", "auth", "payments", "orchestration", "reconciliation", "wallets", "webhooks"} { + provider, historical, err := prepareProvider(ctx, root, settings, args, service) + services.errors[service] = err + if provider != nil { + services.legacyRoots[service] = historical + services.factories = append(services.factories, provider) + } else if service == "ledger" || service == "auth" || err != nil { + services.missing = append(services.missing, pluginhost.UnpreparedServiceCommand(service, service, err)) + } + } + return services +} + +// PreparationError returns the failure for the service selected by a command. +func (s *Services) PreparationError(cmd *cobra.Command) error { + return s.errors[pluginhost.ServiceCommand(cmd)] +} + +// AddTo registers providers and attaches service commands to the root. + +func (s *Services) AddTo(root *cobra.Command, settings *connection.Settings) error { + root.AddCommand(s.missing...) + registry := &plugin.Registry{} + for _, provider := range s.factories { + if err := registry.Register(context.WithoutCancel(root.Context()), provider(nil), provider); err != nil { + return err + } + } + return plugin.NewCommandWithRequest(registry, serviceRequestResolver(root, settings, s.legacyRoots)).AddTo(root) +} diff --git a/cmd/ledger/accounts/delete_metadata.go b/cmd/ledger/accounts/delete_metadata.go deleted file mode 100644 index 8b4c2044..00000000 --- a/cmd/ledger/accounts/delete_metadata.go +++ /dev/null @@ -1,78 +0,0 @@ -package accounts - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteMetadataStore struct { - Success bool `json:"success"` -} -type DeleteMetadataController struct { - store *DeleteMetadataStore -} - -var _ fctl.Controller[*DeleteMetadataStore] = (*DeleteMetadataController)(nil) - -func NewDefaultDeleteMetadataStore() *DeleteMetadataStore { - return &DeleteMetadataStore{} -} - -func NewDeleteMetadataController() *DeleteMetadataController { - return &DeleteMetadataController{ - store: NewDefaultDeleteMetadataStore(), - } -} - -func NewDeleteMetadataCommand() *cobra.Command { - return fctl.NewCommand("delete-metadata
", - fctl.WithShortDescription("Delete metadata from an account (requires ledger v2 or later)"), - fctl.WithAliases("dm", "del-meta"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithController[*DeleteMetadataStore](NewDeleteMetadataController()), - ) -} - -func (c *DeleteMetadataController) GetStore() *DeleteMetadataStore { - return c.store -} - -func (c *DeleteMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete a metadata on account %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Ledger.V2.DeleteAccountMetadata(cmd.Context(), operations.V2DeleteAccountMetadataRequest{ - Address: args[0], - Key: args[1], - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - }) - if err != nil { - return nil, err - } - - c.store.Success = (response.StatusCode % 200) < 100 - return c, nil -} - -func (c *DeleteMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata deleted!") - return nil -} diff --git a/cmd/ledger/accounts/list.go b/cmd/ledger/accounts/list.go deleted file mode 100644 index fb75ccdc..00000000 --- a/cmd/ledger/accounts/list.go +++ /dev/null @@ -1,106 +0,0 @@ -package accounts - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Accounts []ledger.V2Account `json:"accounts"` -} -type ListController struct { - store *ListStore - metadataFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - metadataFlag: "metadata", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List accounts"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithStringSliceFlag(c.metadataFlag, []string{}, "Filter accounts with metadata"), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - body := make([]map[string]map[string]any, 0) - for key, value := range metadata { - body = append(body, map[string]map[string]any{ - "$match": { - "metadata[" + key + "]": value, - }, - }) - } - - request := operations.V2ListAccountsRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - RequestBody: map[string]any{ - "$and": body, - }, - } - rsp, err := stackClient.Ledger.V2.ListAccounts(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Accounts = rsp.V2AccountsCursorResponse.Cursor.Data - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - - tableData := fctl.Map(c.store.Accounts, func(account ledger.V2Account) []string { - return []string{ - account.Address, - fctl.MetadataAsShortString(account.Metadata), - } - }) - tableData = fctl.Prepend(tableData, []string{"Address", "Metadata"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/ledger/accounts/root.go b/cmd/ledger/accounts/root.go deleted file mode 100644 index 406b2809..00000000 --- a/cmd/ledger/accounts/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package accounts - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewLedgerAccountsCommand() *cobra.Command { - return fctl.NewCommand("accounts", - fctl.WithAliases("acc", "a", "ac", "account"), - fctl.WithShortDescription("Manage accounts"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewSetMetadataCommand(), - NewDeleteMetadataCommand(), - ), - ) -} diff --git a/cmd/ledger/accounts/set_metadata.go b/cmd/ledger/accounts/set_metadata.go deleted file mode 100644 index b4807443..00000000 --- a/cmd/ledger/accounts/set_metadata.go +++ /dev/null @@ -1,90 +0,0 @@ -package accounts - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetMetadataStore struct { - Success bool `json:"success"` -} -type SetMetadataController struct { - store *SetMetadataStore -} - -var _ fctl.Controller[*SetMetadataStore] = (*SetMetadataController)(nil) - -func NewDefaultSetMetadataStore() *SetMetadataStore { - return &SetMetadataStore{ - Success: false, - } -} - -func NewSetMetadataController() *SetMetadataController { - return &SetMetadataController{ - store: NewDefaultSetMetadataStore(), - } -} - -func NewSetMetadataCommand() *cobra.Command { - return fctl.NewCommand("set-metadata
[=...]", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Set metadata on an account"), - fctl.WithAliases("sm", "set-meta"), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithController[*SetMetadataStore](NewSetMetadataController()), - ) -} - -func (c *SetMetadataController) GetStore() *SetMetadataStore { - return c.store -} - -func (c *SetMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(args[1:]) - if err != nil { - return nil, err - } - - address := args[0] - - if !fctl.CheckStackApprobation(cmd, "You are about to set a metadata on address '%s'", address) { - return nil, fctl.ErrMissingApproval - } - - request := operations.AddMetadataToAccountRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - Address: address, - RequestBody: collectionutils.ConvertMap(metadata, collectionutils.ToAny[string]), - } - response, err := stackClient.Ledger.V1.AddMetadataToAccount(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = response.StatusCode == 204 - - return c, nil -} - -func (c *SetMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata added!") - return nil -} diff --git a/cmd/ledger/accounts/show.go b/cmd/ledger/accounts/show.go deleted file mode 100644 index 58631249..00000000 --- a/cmd/ledger/accounts/show.go +++ /dev/null @@ -1,100 +0,0 @@ -package accounts - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - - internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Account *ledger.AccountWithVolumesAndBalances `json:"account"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show
", - fctl.WithShortDescription("Show an account"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - ledger := fctl.GetString(cmd, internal.LedgerFlag) - response, err := stackClient.Ledger.V1.GetAccountLedger(cmd.Context(), operations.GetAccountLedgerRequest{ - Address: args[0], - Ledger: ledger, - }) - if err != nil { - return nil, err - } - - c.store.Account = &response.AccountResponse.AccountWithVolumesAndBalances - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - if c.store.Account.Volumes != nil && len(c.store.Account.Volumes) > 0 { - tableData := pterm.TableData{} - tableData = append(tableData, []string{"Asset", "Input", "Output"}) - for asset, volumes := range c.store.Account.Volumes { - input := volumes.Input - output := volumes.Output - tableData = append(tableData, []string{pterm.LightCyan(asset), fmt.Sprint(input), fmt.Sprint(output)}) - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - } else { - fctl.Println("No balances.") - } - - fmt.Fprintln(cmd.OutOrStdout()) - - return fctl.PrintMetadata(cmd.OutOrStdout(), - collectionutils.ConvertMap(c.store.Account.Metadata, collectionutils.ToFmtString)) -} diff --git a/cmd/ledger/create.go b/cmd/ledger/create.go deleted file mode 100644 index 96ea73bc..00000000 --- a/cmd/ledger/create.go +++ /dev/null @@ -1,112 +0,0 @@ -package ledger - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - ledgermodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - bucketNameFlag = "bucket" - featuresFlag = "features" -) - -type CreateStore struct{} - -type CreateController struct { - store *CreateStore - metadataFlag string - referenceFlag string -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - metadataFlag: "metadata", - referenceFlag: "reference", - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create ", - fctl.WithAliases("c", "cr"), - fctl.WithShortDescription("Create a new ledger (requires ledger v2 or later)"), - fctl.WithStringFlag(bucketNameFlag, "", "Bucket in which to install the new ledger"), - fctl.WithStringSliceFlag(featuresFlag, []string{}, `Experimental! Features to enable on the newly created ledger (default: all) -Starting from ledger v2.2`), - fctl.WithStringSliceFlag(c.metadataFlag, []string{}, "Metadata to apply on the newly created ledger"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new ledger") { - return nil, fctl.ErrMissingApproval - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - features := make(map[string]string) - for _, s := range fctl.GetStringSlice(cmd, featuresFlag) { - parts := strings.SplitN(s, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid feature flag format") - } - features[parts[0]] = parts[1] - } - - _, err = stackClient.Ledger.V2.CreateLedger(cmd.Context(), operations.V2CreateLedgerRequest{ - V2CreateLedgerRequest: ledgermodels.V2CreateLedgerRequest{ - Bucket: pointer.For(fctl.GetString(cmd, bucketNameFlag)), - V2Metadata: metadata, - Features: features, - }, - Ledger: args[0], - }) - if err != nil { - return nil, err - } - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Ledger created!") - return nil -} diff --git a/cmd/ledger/delete_metadata.go b/cmd/ledger/delete_metadata.go deleted file mode 100644 index 3e3bfce5..00000000 --- a/cmd/ledger/delete_metadata.go +++ /dev/null @@ -1,77 +0,0 @@ -package ledger - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteMetadataStore struct { - Success bool `json:"success"` -} -type DeleteMetadataController struct { - store *DeleteMetadataStore -} - -var _ fctl.Controller[*DeleteMetadataStore] = (*DeleteMetadataController)(nil) - -func NewDefaultDeleteMetadataStore() *DeleteMetadataStore { - return &DeleteMetadataStore{} -} - -func NewDeleteMetadataController() *DeleteMetadataController { - return &DeleteMetadataController{ - store: NewDefaultDeleteMetadataStore(), - } -} - -func NewDeleteMetadataCommand() *cobra.Command { - return fctl.NewCommand("delete-metadata ", - fctl.WithShortDescription("Delete metadata from a ledger (requires ledger v2 or later)"), - fctl.WithAliases("dm", "del-meta"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*DeleteMetadataStore](NewDeleteMetadataController()), - ) -} - -func (c *DeleteMetadataController) GetStore() *DeleteMetadataStore { - return c.store -} - -func (c *DeleteMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete a metadata on ledger %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Ledger.V2.DeleteLedgerMetadata(cmd.Context(), operations.V2DeleteLedgerMetadataRequest{ - Key: args[1], - Ledger: args[0], - }) - if err != nil { - return nil, err - } - - c.store.Success = (response.StatusCode % 200) < 100 - return c, nil -} - -func (c *DeleteMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata deleted!") - return nil -} diff --git a/cmd/ledger/export.go b/cmd/ledger/export.go deleted file mode 100644 index 57299a82..00000000 --- a/cmd/ledger/export.go +++ /dev/null @@ -1,90 +0,0 @@ -package ledger - -import ( - "context" - "io" - "net/http" - "os" - - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ExportStore struct { - response *http.Response -} -type ExportController struct { - store *ExportStore - outputFileFlag string -} - -var _ fctl.Controller[*ExportStore] = (*ExportController)(nil) - -func NewDefaultExportStore() *ExportStore { - return &ExportStore{} -} - -func NewExportController() *ExportController { - return &ExportController{ - store: NewDefaultExportStore(), - outputFileFlag: "file", - } -} - -func NewExportCommand() *cobra.Command { - c := NewExportController() - return fctl.NewCommand("export", - fctl.WithShortDescription("Export a ledger"), - fctl.WithStringFlag(c.outputFileFlag, "", "Export to file"), - fctl.WithController[*ExportStore](c), - ) -} - -func (c *ExportController) GetStore() *ExportStore { - return c.store -} - -func (c *ExportController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - ctx := cmd.Context() - out := fctl.GetString(cmd, "file") - if out != "" { - ctx = context.WithValue(ctx, "path", out) - } - - ret, err := stackClient.Ledger.V2.ExportLogs(ctx, operations.V2ExportLogsRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - }) - if err != nil { - return nil, err - } - - c.store.response = ret.RawResponse - - return c, nil -} - -func (c *ExportController) Render(cmd *cobra.Command, _ []string) error { - outFile := fctl.GetString(cmd, "file") - var out io.Writer - if outFile == "" { - out = os.Stdout - _, err := io.Copy(out, c.store.response.Body) - return err - } - return nil -} diff --git a/cmd/ledger/import.go b/cmd/ledger/import.go deleted file mode 100644 index ae1d5f33..00000000 --- a/cmd/ledger/import.go +++ /dev/null @@ -1,242 +0,0 @@ -package ledger - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "math/big" - "os" - "path/filepath" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ImportStore struct{} -type ImportController struct { - store *ImportStore - inputFileFlag string - resumeFromLastLog string -} - -var _ fctl.Controller[*ImportStore] = (*ImportController)(nil) - -func NewDefaultImportStore() *ImportStore { - return &ImportStore{} -} - -func NewImportController() *ImportController { - return &ImportController{ - store: NewDefaultImportStore(), - inputFileFlag: "file", - resumeFromLastLog: "resume-from-last-log", - } -} - -func NewImportCommand() *cobra.Command { - c := NewImportController() - return fctl.NewCommand("import ", - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithShortDescription("Import a ledger"), - fctl.WithStringFlag(c.inputFileFlag, "", "Import from stdin or file"), - fctl.WithBoolFlag(c.resumeFromLastLog, false, "Recover interrupted import"), - fctl.WithController[*ImportStore](c), - ) -} - -func (c *ImportController) GetStore() *ImportStore { - return c.store -} - -func (c *ImportController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - lastID := big.NewInt(-1) - resumeFromLastLog, err := cmd.Flags().GetBool(c.resumeFromLastLog) - if err != nil { - return nil, err - } - if resumeFromLastLog { - logs, err := stackClient.Ledger.V2.ListLogs(cmd.Context(), operations.V2ListLogsRequest{ - Ledger: args[0], - PageSize: pointer.For[int64](1), - }) - if err != nil { - return nil, err - } - if len(logs.V2LogsCursorResponse.Cursor.Data) > 0 { - lastID = logs.V2LogsCursorResponse.Cursor.Data[0].ID - } - } - - var ( - f *os.File - positionInBytes int - ) - if lastID.Cmp(big.NewInt(-1)) == 0 { - f, err = os.Open(args[1]) - } else { - f, positionInBytes, err = c.openFileWithOffset(args[1], lastID) - } - if err != nil { - return nil, err - } - defer func() { - _ = f.Close() - }() - - fileInfo, err := f.Stat() - if err != nil { - return nil, err - } - - fileSize := fileInfo.Size() - - progressBar, err := pterm.DefaultProgressbar. - WithTotal(int(fileSize)). - WithWriter(cmd.OutOrStdout()). - WithCurrent(positionInBytes). - WithRemoveWhenDone(true). - WithShowCount(false). - Start("Import") - if err != nil { - return nil, err - } - - err = importLogs(f, importBlockSize, func(n int) { - progressBar.Add(n) - }, func(buffer *bytes.Buffer) error { - _, err := stackClient.Ledger.V2.ImportLogs(cmd.Context(), operations.V2ImportLogsRequest{ - Ledger: args[0], - V2ImportLogsRequest: buffer, - }) - return err - }) - if err != nil { - return nil, err - } - - return c, nil -} - -func (c *ImportController) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Ledger imported!") - return nil -} - -func (c *ImportController) openFileWithOffset(filePath string, id *big.Int) (*os.File, int, error) { - f, err := os.Open(filepath.Clean(filePath)) - if err != nil { - return nil, 0, err - } - defer func() { - _ = f.Close() - }() - - reader := bufio.NewReader(f) - - type log struct { - ID *big.Int `json:"id"` - } - readBytes := 0 - for { - line, err := readLine(reader) - if errors.Is(err, io.EOF) { - return nil, 0, fmt.Errorf("log %s not found in %s", id, filePath) - } - if err != nil { - return nil, 0, fmt.Errorf("error reading file: %w", err) - } - l := &log{} - if err := json.Unmarshal(line, l); err != nil { - return nil, 0, err - } - - readBytes += len(line) - - if l.ID.Cmp(id) == 0 { - break - } - } - - ret, err := os.Open(filepath.Clean(filePath)) - if err != nil { - return nil, 0, err - } - - _, err = ret.Seek(int64(readBytes), 0) - if err != nil { - return nil, 0, err - } - - return ret, readBytes, nil -} - -const importBlockSize = 100 - -// importLogs reads newline-delimited logs from r and passes them to send in -// blocks of blockSize lines. onRead receives the number of bytes consumed. -func importLogs(r io.Reader, blockSize int, onRead func(int), send func(*bytes.Buffer) error) error { - var ( - reader = bufio.NewReader(r) - buffer = new(bytes.Buffer) - count = 0 - ) - for { - line, err := readLine(reader) - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return fmt.Errorf("error reading file: %w", err) - } - onRead(len(line)) - - buffer.Write(line) - if line[len(line)-1] != '\n' { - buffer.WriteByte('\n') - } - count++ - - if count == blockSize { - if err := send(buffer); err != nil { - return err - } - buffer.Reset() - count = 0 - } - } - - if buffer.Len() > 0 { - return send(buffer) - } - return nil -} - -// readLine returns the next line of r with its trailing newline, if any. -// Unlike bufio.Scanner it has no line length limit. It returns io.EOF only -// once no bytes are left. -func readLine(r *bufio.Reader) ([]byte, error) { - line, err := r.ReadBytes('\n') - if errors.Is(err, io.EOF) && len(line) > 0 { - return line, nil - } - return line, err -} diff --git a/cmd/ledger/import_test.go b/cmd/ledger/import_test.go deleted file mode 100644 index 5e8e83e7..00000000 --- a/cmd/ledger/import_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package ledger - -import ( - "bytes" - "errors" - "fmt" - "math/big" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestImportLogs(t *testing.T) { - t.Parallel() - - // Larger than bufio.Scanner's 64 KB default token size. - longLog := fmt.Sprintf(`{"id":1,"data":"%s"}`, strings.Repeat("a", 200*1024)) - input := `{"id":0}` + "\n" + longLog + "\n" + `{"id":2}` - - var ( - blocks []string - read int - ) - err := importLogs(strings.NewReader(input), 2, func(n int) { - read += n - }, func(buffer *bytes.Buffer) error { - blocks = append(blocks, buffer.String()) - return nil - }) - require.NoError(t, err) - require.Equal(t, len(input), read) - require.Equal(t, []string{ - `{"id":0}` + "\n" + longLog + "\n", - `{"id":2}` + "\n", - }, blocks) -} - -func TestImportLogsSendError(t *testing.T) { - t.Parallel() - - sendErr := errors.New("boom") - err := importLogs(strings.NewReader(`{"id":0}`+"\n"), 100, func(int) {}, func(*bytes.Buffer) error { - return sendErr - }) - require.ErrorIs(t, err, sendErr) -} - -func TestOpenFileWithOffset(t *testing.T) { - t.Parallel() - - longLog := fmt.Sprintf(`{"id":1,"data":"%s"}`, strings.Repeat("a", 200*1024)) - path := filepath.Join(t.TempDir(), "logs.jsonl") - require.NoError(t, os.WriteFile(path, []byte(`{"id":0}`+"\n"+longLog+"\n"+`{"id":2}`+"\n"), 0o600)) - - c := NewImportController() - - f, offset, err := c.openFileWithOffset(path, big.NewInt(1)) - require.NoError(t, err) - t.Cleanup(func() { _ = f.Close() }) - require.Equal(t, len(`{"id":0}`)+1+len(longLog)+1, offset) - - _, _, err = c.openFileWithOffset(path, big.NewInt(42)) - require.ErrorContains(t, err, "log 42 not found") -} diff --git a/cmd/ledger/internal/flags.go b/cmd/ledger/internal/flags.go deleted file mode 100644 index 8a8b2c3a..00000000 --- a/cmd/ledger/internal/flags.go +++ /dev/null @@ -1,5 +0,0 @@ -package internal - -const ( - LedgerFlag = "ledger" -) diff --git a/cmd/ledger/internal/print.go b/cmd/ledger/internal/print.go deleted file mode 100644 index 9dff6f8d..00000000 --- a/cmd/ledger/internal/print.go +++ /dev/null @@ -1,221 +0,0 @@ -package internal - -import ( - "encoding/json" - "fmt" - "io" - "math/big" - "time" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/go-libs/v4/collectionutils" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func printCommonInformation( - out io.Writer, - txID *big.Int, - reference string, - postings []ledger.Posting, - timestamp time.Time, -) error { - fctl.Section.WithWriter(out).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), fmt.Sprint(txID)}) - tableData = append(tableData, []string{pterm.LightCyan("Reference"), reference}) - tableData = append(tableData, []string{pterm.LightCyan("Date"), timestamp.Format(time.RFC3339)}) - - if err := pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - fmt.Fprintln(out, "") - fctl.Section.WithWriter(out).Println("Postings") - tableData = pterm.TableData{} - tableData = append(tableData, []string{"Source", "Destination", "Asset", "Amount"}) - for _, posting := range postings { - tableData = append(tableData, []string{ - posting.GetSource(), posting.GetDestination(), posting.GetAsset(), posting.GetAmount().String(), - }) - } - - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - fmt.Fprintln(out, "") - - return nil -} - -func PrintExpandedTransaction(out io.Writer, transaction ExpandedTransaction) error { - - if err := printCommonInformation( - out, - transaction.GetID(), - func() string { - if transaction.GetReference() == nil { - return "" - } - return *transaction.GetReference() - }(), - transaction.GetPostings(), - transaction.GetTimestamp(), - ); err != nil { - return err - } - - fctl.Section.WithWriter(out).Println("Summary") - tableData := pterm.TableData{} - tableData = append(tableData, []string{"Account", "Asset", "Movement", "Final balance"}) - for account, postCommitVolume := range transaction.GetPostCommitVolumes() { - for asset, volumes := range postCommitVolume { - movement := big.NewInt(0) - movement = movement.Sub(volumes.Balance, (transaction.GetPreCommitVolumes())[account][asset].Balance) - movementStr := fmt.Sprint(movement) - if movement.Cmp(big.NewInt(0)) > 0 { - movementStr = "+" + movementStr - } - tableData = append(tableData, []string{ - account, asset, movementStr, fmt.Sprint(*volumes.Balance), - }) - } - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - fmt.Fprintln(out, "") - if err := PrintMetadata(out, transaction.GetMetadata()); err != nil { - return err - } - return nil -} - -type Transaction interface { - GetReference() *string - GetID() *big.Int - GetPostings() []ledger.Posting - GetTimestamp() time.Time - GetMetadata() map[string]string -} - -type v2Transaction struct { - ledger.V2Transaction -} - -func (t v2Transaction) GetID() *big.Int { - return t.ID -} - -func (t v2Transaction) GetPostings() []ledger.Posting { - return collectionutils.Map(t.V2Transaction.GetPostings(), func(from ledger.V2Posting) ledger.Posting { - return ledger.Posting{ - Amount: from.GetAmount(), - Asset: from.GetAsset(), - Destination: from.GetDestination(), - Source: from.GetSource(), - } - }) -} - -func (t v2Transaction) GetMetadata() map[string]string { - return t.V2Transaction.GetV2Metadata() -} - -var _ Transaction = (*v2Transaction)(nil) - -func WrapV2Transaction(transaction ledger.V2Transaction) *v2Transaction { - return &v2Transaction{ - V2Transaction: transaction, - } -} - -type v1Transaction struct { - ledger.Transaction -} - -func (t v1Transaction) GetID() *big.Int { - return t.Transaction.GetTxid() -} - -func (t v1Transaction) GetMetadata() map[string]string { - return collectionutils.ConvertMap(t.Transaction.Metadata, collectionutils.ToFmtString) -} - -func (t v1Transaction) GetPreCommitVolumes() map[string]map[string]ledger.Volume { - return t.Transaction.GetAggregatedVolumes() -} - -func (t v1Transaction) GetPostCommitVolumes() map[string]map[string]ledger.Volume { - return t.Transaction.GetAggregatedVolumes1() -} - -var _ Transaction = (*v1Transaction)(nil) - -func WrapV1Transaction(transaction ledger.Transaction) *v1Transaction { - return &v1Transaction{ - Transaction: transaction, - } -} - -type ExpandedTransaction interface { - Transaction - GetPreCommitVolumes() map[string]map[string]ledger.Volume - GetPostCommitVolumes() map[string]map[string]ledger.Volume -} - -func PrintTransaction(out io.Writer, transaction Transaction) error { - - reference := "" - if transaction.GetReference() != nil { - reference = *transaction.GetReference() - } - if err := printCommonInformation( - out, - transaction.GetID(), - reference, - transaction.GetPostings(), - transaction.GetTimestamp(), - ); err != nil { - return err - } - - // collectionutils.ConvertMap(transaction.Metadata, collectionutils.ToFmtString[string]) - if err := PrintMetadata(out, transaction.GetMetadata()); err != nil { - return err - } - return nil -} -func PrintMetadata(out io.Writer, metadata map[string]string) error { - fctl.Section.WithWriter(out).Println("Metadata") - if len(metadata) == 0 { - fmt.Println("No metadata.") - return nil - } - tableData := pterm.TableData{} - for k, v := range metadata { - asJson, err := json.Marshal(v) - if err != nil { - return err - } - tableData = append(tableData, []string{pterm.LightCyan(k), string(asJson)}) - } - - return pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render() -} diff --git a/cmd/ledger/internal/transaction.go b/cmd/ledger/internal/transaction.go deleted file mode 100644 index 38d5dcab..00000000 --- a/cmd/ledger/internal/transaction.go +++ /dev/null @@ -1,55 +0,0 @@ -package internal - -import ( - "context" - "errors" - "fmt" - "math/big" - "strconv" - "strings" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" -) - -func TransactionIDOrLastN(ctx context.Context, ledgerClient *formance.Formance, ledger, id string) (*big.Int, error) { - if strings.HasPrefix(id, "last") { - id = strings.TrimPrefix(id, "last") - sub := int64(0) - if id != "" { - var err error - sub, err = strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, err - } - } - pageSize := int64(1) - request := operations.ListTransactionsRequest{ - Ledger: ledger, - PageSize: &pageSize, - } - response, err := ledgerClient.Ledger.V1.ListTransactions(ctx, request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if len(response.TransactionsCursorResponse.Cursor.Data) == 0 { - return nil, errors.New("no transaction found") - } - return response.TransactionsCursorResponse.Cursor.Data[0].Txid.Sub( - response.TransactionsCursorResponse.Cursor.Data[0].Txid, - big.NewInt(sub), - ), nil - } - - v, ok := big.NewInt(0).SetString(id, 10) - if !ok { - return nil, fmt.Errorf("invalid bigint: %s", id) - } - - return v, nil -} diff --git a/cmd/ledger/list.go b/cmd/ledger/list.go deleted file mode 100644 index b469ae33..00000000 --- a/cmd/ledger/list.go +++ /dev/null @@ -1,84 +0,0 @@ -package ledger - -import ( - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - ledgermodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Ledgers []ledgermodels.V2Ledger `json:"ledgers"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{ - Ledgers: []ledgermodels.V2Ledger{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("l", "ls"), - fctl.WithShortDescription("List ledgers (requires ledger v2 or later)"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ListStore](NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Ledger.V2.ListLedgers(cmd.Context(), operations.V2ListLedgersRequest{}) - if err != nil { - return nil, err - } - - c.store.Ledgers = response.V2LedgerListResponse.Cursor.Data - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - tableData := fctl.Map(c.store.Ledgers, func(ledger ledgermodels.V2Ledger) []string { - return []string{ - ledger.Name, ledger.AddedAt.Format(time.RFC3339Nano), fctl.MetadataAsShortString(ledger.V2Metadata), - } - }) - tableData = fctl.Prepend(tableData, []string{"Name", "Created at", "Metadata"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/ledger/root.go b/cmd/ledger/root.go deleted file mode 100644 index c4602e55..00000000 --- a/cmd/ledger/root.go +++ /dev/null @@ -1,35 +0,0 @@ -package ledger - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/ledger/accounts" - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - "github.com/formancehq/fctl/v3/cmd/ledger/schemas" - "github.com/formancehq/fctl/v3/cmd/ledger/transactions" - "github.com/formancehq/fctl/v3/cmd/ledger/volumes" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("ledger", - fctl.WithAliases("l"), - fctl.WithPersistentStringFlag(internal.LedgerFlag, "default", "Specific ledger"), - fctl.WithShortDescription("Manage ledgers"), - fctl.WithChildCommands( - NewCreateCommand(), - NewSendCommand(), - NewStatsCommand(), - NewServerInfoCommand(), - NewListCommand(), - NewSetMetadataCommand(), - NewDeleteMetadataCommand(), - NewExportCommand(), - NewImportCommand(), - schemas.NewLedgerSchemasCommand(), - transactions.NewLedgerTransactionsCommand(), - accounts.NewLedgerAccountsCommand(), - volumes.NewLedgerVolumesCommand(), - ), - ) -} diff --git a/cmd/ledger/schemas/get.go b/cmd/ledger/schemas/get.go deleted file mode 100644 index c29424fe..00000000 --- a/cmd/ledger/schemas/get.go +++ /dev/null @@ -1,110 +0,0 @@ -package schemas - -import ( - "encoding/json" - "fmt" - - "github.com/TylerBrock/colorjson" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type GetStore struct { - Schema ledger.V2SchemaData `json:"schema"` -} -type GetController struct { - store *GetStore - formatFlag string -} - -var _ fctl.Controller[*GetStore] = (*GetController)(nil) - -func NewDefaultGetStore() *GetStore { - return &GetStore{} -} - -func NewGetController() *GetController { - return &GetController{ - store: NewDefaultGetStore(), - formatFlag: "format", - } -} - -func NewGetCommand() *cobra.Command { - c := NewGetController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a schema for a ledger by version"), - fctl.WithAliases("g", "show"), - fctl.WithStringFlag(c.formatFlag, "json", "Output format of the schema (json, yaml)"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*GetStore](c), - ) -} - -func (c *GetController) GetStore() *GetStore { - return c.store -} - -func (c *GetController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Ledger.V2.GetSchema(cmd.Context(), operations.V2GetSchemaRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - Version: args[0], - }) - if err != nil { - return nil, err - } - - c.store.Schema = response.V2SchemaResponse.V2SchemaData - return c, nil -} - -func (c *GetController) Render(cmd *cobra.Command, _ []string) error { - out, err := json.Marshal(c.store.Schema) - if err != nil { - return err - } - - raw := make(map[string]any) - if err := json.Unmarshal(out, &raw); err != nil { - _, err = cmd.OutOrStdout().Write(out) - return err - } - - switch format := fctl.GetString(cmd, c.formatFlag); format { - case "yaml", "yml": - yamlOut, err := yaml.Marshal(raw) - if err != nil { - return err - } - _, err = cmd.OutOrStdout().Write(yamlOut) - return err - case "json": - f := colorjson.NewFormatter() - f.Indent = 2 - colorized, err := f.Marshal(raw) - if err != nil { - return err - } - _, err = cmd.OutOrStdout().Write(append(colorized, '\n')) - return err - default: - return fmt.Errorf("unsupported format %q (expected json or yaml)", format) - } -} diff --git a/cmd/ledger/schemas/insert.go b/cmd/ledger/schemas/insert.go deleted file mode 100644 index d3f5cacd..00000000 --- a/cmd/ledger/schemas/insert.go +++ /dev/null @@ -1,150 +0,0 @@ -package schemas - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const schemaFetchTimeout = 30 * time.Second - -type InsertStore struct { - Success bool `json:"success"` -} -type InsertController struct { - store *InsertStore -} - -var _ fctl.Controller[*InsertStore] = (*InsertController)(nil) - -func NewDefaultInsertStore() *InsertStore { - return &InsertStore{} -} - -func NewInsertController() *InsertController { - return &InsertController{ - store: NewDefaultInsertStore(), - } -} - -func NewInsertCommand() *cobra.Command { - return fctl.NewCommand("insert ", - fctl.WithShortDescription("Insert a schema for a ledger from a JSON/YAML file or URL"), - fctl.WithAliases("i", "create"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*InsertStore](NewInsertController()), - ) -} - -func (c *InsertController) GetStore() *InsertStore { - return c.store -} - -func (c *InsertController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - ledgerName := fctl.GetString(cmd, internal.LedgerFlag) - version := args[0] - - schemaData, err := loadSchemaData(cmd, args[1]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to insert schema version %s on ledger %s", version, ledgerName) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Ledger.V2.InsertSchema(cmd.Context(), operations.V2InsertSchemaRequest{ - Ledger: ledgerName, - Version: version, - V2SchemaData: *schemaData, - }) - if err != nil { - return nil, err - } - - c.store.Success = response.StatusCode == 204 - if !c.store.Success { - return nil, fmt.Errorf("unexpected status code %d while inserting schema", response.StatusCode) - } - return c, nil -} - -func (c *InsertController) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Schema inserted!") - return nil -} - -func loadSchemaData(cmd *cobra.Command, source string) (*ledger.V2SchemaDataInput, error) { - raw, err := readSource(cmd, source) - if err != nil { - return nil, err - } - - var intermediate any - if err := yaml.Unmarshal(raw, &intermediate); err != nil { - return nil, fmt.Errorf("parsing schema: %w", err) - } - - normalized, err := json.Marshal(intermediate) - if err != nil { - return nil, err - } - - schemaData := &ledger.V2SchemaDataInput{} - if err := json.Unmarshal(normalized, schemaData); err != nil { - return nil, err - } - - return schemaData, nil -} - -func readSource(cmd *cobra.Command, source string) ([]byte, error) { - if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { - req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, source, nil) - if err != nil { - return nil, err - } - client := fctl.GetHttpClient(cmd) - client.Timeout = schemaFetchTimeout - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("fetching schema from %s: unexpected status %s", source, resp.Status) - } - return io.ReadAll(resp.Body) - } - - return os.ReadFile(filepath.Clean(source)) -} diff --git a/cmd/ledger/schemas/list.go b/cmd/ledger/schemas/list.go deleted file mode 100644 index 8f699466..00000000 --- a/cmd/ledger/schemas/list.go +++ /dev/null @@ -1,117 +0,0 @@ -package schemas - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Schemas []ledger.V2SchemaData `json:"schemas"` - Cursor fctl.Cursor `json:"cursor"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{ - Schemas: []ledger.V2SchemaData{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List all schemas for a ledger"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - - req := operations.V2ListSchemasRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - } - if cursor != "" { - req.Cursor = fctl.Ptr(cursor) - } else { - req.PageSize = fctl.Ptr(int64(pageSize)) - } - - response, err := stackClient.Ledger.V2.ListSchemas(cmd.Context(), req) - if err != nil { - return nil, err - } - - cur := response.V2SchemasCursorResponse.V2SchemasCursor - c.store.Schemas = cur.Data - c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous} - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - tableData := fctl.Map(c.store.Schemas, func(schema ledger.V2SchemaData) []string { - return []string{ - schema.Version, - schema.CreatedAt.Format(time.RFC3339), - fmt.Sprintf("%d", len(schema.V2ChartOfAccounts)), - fmt.Sprintf("%d", len(schema.V2QueryTemplates)), - fmt.Sprintf("%d", len(schema.V2TransactionTemplates)), - } - }) - tableData = fctl.Prepend(tableData, []string{"Version", "Created at", "Chart", "Queries", "Transactions"}) - - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) -} diff --git a/cmd/ledger/schemas/root.go b/cmd/ledger/schemas/root.go deleted file mode 100644 index 25236981..00000000 --- a/cmd/ledger/schemas/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package schemas - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewLedgerSchemasCommand() *cobra.Command { - return fctl.NewCommand("schemas", - fctl.WithAliases("schema", "sc"), - fctl.WithShortDescription("Manage ledger schemas"), - fctl.WithChildCommands( - NewInsertCommand(), - NewGetCommand(), - NewListCommand(), - ), - ) -} diff --git a/cmd/ledger/send.go b/cmd/ledger/send.go deleted file mode 100644 index 775351d8..00000000 --- a/cmd/ledger/send.go +++ /dev/null @@ -1,124 +0,0 @@ -package ledger - -import ( - "fmt" - "math/big" - - "github.com/spf13/cobra" - - ledgermodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SendStore struct { - Transaction *ledgermodels.Transaction `json:"transaction"` -} -type SendController struct { - store *SendStore - metadataFlag string - referenceFlag string -} - -var _ fctl.Controller[*SendStore] = (*SendController)(nil) - -func NewDefaultSendStore() *SendStore { - return &SendStore{} -} - -func NewSendController() *SendController { - return &SendController{ - store: NewDefaultSendStore(), - metadataFlag: "metadata", - referenceFlag: "reference", - } -} - -func NewSendCommand() *cobra.Command { - c := NewSendController() - return fctl.NewCommand("send [] ", - fctl.WithAliases("s", "se"), - fctl.WithShortDescription("Send from one account to another"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.RangeArgs(3, 4)), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithStringFlag(c.referenceFlag, "", "Reference to add to the generated transaction"), - fctl.WithController[*SendStore](c), - ) -} - -func (c *SendController) GetStore() *SendStore { - return c.store -} - -func (c *SendController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new transaction") { - return nil, fctl.ErrMissingApproval - } - - var source, destination, asset, amountStr string - if len(args) == 3 { - source = "world" - destination = args[0] - amountStr = args[1] - asset = args[2] - } else { - source = args[0] - destination = args[1] - amountStr = args[2] - asset = args[3] - } - - amount, ok := big.NewInt(0).SetString(amountStr, 10) - if !ok { - return nil, fmt.Errorf("unable to parse '%s' as big int", amountStr) - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - reference := fctl.GetString(cmd, c.referenceFlag) - - response, err := stackClient.Ledger.V1.CreateTransaction(cmd.Context(), operations.CreateTransactionRequest{ - PostTransaction: ledgermodels.PostTransaction{ - Metadata: collectionutils.ConvertMap(metadata, collectionutils.ToAny[string]), - Postings: []ledgermodels.Posting{ - { - Amount: amount, - Asset: asset, - Destination: destination, - Source: source, - }, - }, - Reference: &reference, - }, - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - }) - if err != nil { - return nil, err - } - - c.store.Transaction = &response.TransactionsResponse.Data[0] - return c, nil -} - -// TODO: This need to use the ui.NewListModel -func (c *SendController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintTransaction(cmd.OutOrStdout(), internal.WrapV1Transaction(*c.store.Transaction)) -} diff --git a/cmd/ledger/serverinfo.go b/cmd/ledger/serverinfo.go deleted file mode 100644 index 18e29e06..00000000 --- a/cmd/ledger/serverinfo.go +++ /dev/null @@ -1,85 +0,0 @@ -package ledger - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ServerInfoStore struct { - Server string `json:"server"` - Version string `json:"version"` -} -type ServerInfoController struct { - store *ServerInfoStore -} - -var _ fctl.Controller[*ServerInfoStore] = (*ServerInfoController)(nil) - -func NewDefaultServerInfoStore() *ServerInfoStore { - return &ServerInfoStore{ - Server: "unknown", - Version: "unknown", - } -} - -func NewServerInfoController() *ServerInfoController { - return &ServerInfoController{ - store: NewDefaultServerInfoStore(), - } -} - -func NewServerInfoCommand() *cobra.Command { - return fctl.NewCommand("server-infos", - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("si"), - fctl.WithShortDescription("Read server info"), - fctl.WithController[*ServerInfoStore](NewServerInfoController()), - ) -} - -func (c *ServerInfoController) GetStore() *ServerInfoStore { - return c.store -} - -func (c *ServerInfoController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Ledger.V1.GetInfo(cmd.Context()) - if err != nil { - return nil, err - } - - c.store.Server = response.ConfigInfoResponse.ConfigInfo.Server - c.store.Version = response.ConfigInfoResponse.ConfigInfo.Version - - return c, nil -} - -func (c *ServerInfoController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Server"), fmt.Sprint(c.store.Server)}) - tableData = append(tableData, []string{pterm.LightCyan("Version"), fmt.Sprint(c.store.Version)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/ledger/set_metadata.go b/cmd/ledger/set_metadata.go deleted file mode 100644 index 43327651..00000000 --- a/cmd/ledger/set_metadata.go +++ /dev/null @@ -1,83 +0,0 @@ -package ledger - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetMetadataStore struct { - Success bool `json:"success"` -} -type SetMetadataController struct { - store *SetMetadataStore -} - -var _ fctl.Controller[*SetMetadataStore] = (*SetMetadataController)(nil) - -func NewDefaultSetMetadataStore() *SetMetadataStore { - return &SetMetadataStore{} -} - -func NewSetMetadataController() *SetMetadataController { - return &SetMetadataController{ - store: NewDefaultSetMetadataStore(), - } -} - -func NewSetMetadataCommand() *cobra.Command { - return fctl.NewCommand("set-metadata [=...]", - fctl.WithShortDescription("Set metadata on a ledger (requires ledger v2 or later)"), - fctl.WithAliases("sm", "set-meta"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*SetMetadataStore](NewSetMetadataController()), - ) -} - -func (c *SetMetadataController) GetStore() *SetMetadataStore { - return c.store -} - -func (c *SetMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(args[1:]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to set a metadata on ledger %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - - request := operations.V2UpdateLedgerMetadataRequest{ - Ledger: args[0], - RequestBody: metadata, - } - response, err := stackClient.Ledger.V2.UpdateLedgerMetadata(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = response.StatusCode == 204 - return c, nil -} - -func (c *SetMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata added!") - return nil -} diff --git a/cmd/ledger/stats.go b/cmd/ledger/stats.go deleted file mode 100644 index b58554c7..00000000 --- a/cmd/ledger/stats.go +++ /dev/null @@ -1,85 +0,0 @@ -package ledger - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - ledgermodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type StatsStore struct { - Stats ledgermodels.Stats `json:"stats"` -} -type StatsController struct { - store *StatsStore -} - -var _ fctl.Controller[*StatsStore] = (*StatsController)(nil) - -func NewDefaultStatsStore() *StatsStore { - return &StatsStore{} -} - -func NewStatsController() *StatsController { - return &StatsController{ - store: NewDefaultStatsStore(), - } -} - -func NewStatsCommand() *cobra.Command { - return fctl.NewCommand("stats", - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("st"), - fctl.WithShortDescription("Read ledger stats"), - fctl.WithController[*StatsStore](NewStatsController()), - ) -} - -func (c *StatsController) GetStore() *StatsStore { - return c.store -} - -func (c *StatsController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ReadStatsRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - } - response, err := stackClient.Ledger.V1.ReadStats(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Stats = response.StatsResponse.Stats - - return c, nil -} - -// TODO: This need to use the ui.NewListModel -func (c *StatsController) Render(cmd *cobra.Command, args []string) error { - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Transactions"), fmt.Sprint(c.store.Stats.Transactions)}) - tableData = append(tableData, []string{pterm.LightCyan("Accounts"), fmt.Sprint(c.store.Stats.Accounts)}) - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/ledger/transactions/delete_metadata.go b/cmd/ledger/transactions/delete_metadata.go deleted file mode 100644 index f4d23212..00000000 --- a/cmd/ledger/transactions/delete_metadata.go +++ /dev/null @@ -1,85 +0,0 @@ -package transactions - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteMetadataStore struct { - Success bool `json:"success"` -} -type DeleteMetadataController struct { - store *DeleteMetadataStore -} - -var _ fctl.Controller[*DeleteMetadataStore] = (*DeleteMetadataController)(nil) - -func NewDefaultDeleteMetadataStore() *DeleteMetadataStore { - return &DeleteMetadataStore{} -} - -func NewDeleteMetadataController() *DeleteMetadataController { - return &DeleteMetadataController{ - store: NewDefaultDeleteMetadataStore(), - } -} - -func NewDeleteMetadataCommand() *cobra.Command { - return fctl.NewCommand("delete-metadata ", - fctl.WithShortDescription("Delete metadata from a transaction (requires ledger v2 or later)"), - fctl.WithAliases("dm", "del-meta"), - fctl.WithConfirmFlag(), - fctl.WithValidArgs("last"), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithController[*DeleteMetadataStore](NewDeleteMetadataController()), - ) -} - -func (c *DeleteMetadataController) GetStore() *DeleteMetadataStore { - return c.store -} - -func (c *DeleteMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - transactionID, err := internal.TransactionIDOrLastN(cmd.Context(), stackClient, - fctl.GetString(cmd, internal.LedgerFlag), args[0]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete a metadata on transaction %d", transactionID) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Ledger.V2.DeleteTransactionMetadata(cmd.Context(), operations.V2DeleteTransactionMetadataRequest{ - ID: transactionID, - Key: args[1], - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - }) - if err != nil { - return nil, err - } - - c.store.Success = (response.StatusCode % 200) < 100 - return c, nil -} - -func (c *DeleteMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata deleted!") - return nil -} diff --git a/cmd/ledger/transactions/list.go b/cmd/ledger/transactions/list.go deleted file mode 100644 index 0d53f456..00000000 --- a/cmd/ledger/transactions/list.go +++ /dev/null @@ -1,164 +0,0 @@ -package transactions - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - "github.com/formancehq/go-libs/v4/pointer" - - internal "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Transaction ledger.TransactionsCursorResponseCursor `json:"transactionCursor"` -} -type ListController struct { - store *ListStore - pageSizeFlag string - metadataFlag string - referenceFlag string - accountFlag string - destinationFlag string - sourceFlag string - endTimeFlag string - startTimeFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - pageSizeFlag: "page-size", - metadataFlag: "metadata", - referenceFlag: "reference", - accountFlag: "account", - destinationFlag: "dst", - sourceFlag: "src", - endTimeFlag: "end", - startTimeFlag: "start", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List transactions"), - fctl.WithStringFlag(c.accountFlag, "", "Filter by account"), - fctl.WithStringFlag(c.destinationFlag, "", "Filter by destination account"), - fctl.WithStringFlag(c.endTimeFlag, "", "Include only transactions before this date"), - fctl.WithStringFlag(c.startTimeFlag, "", "Include only transactions after this date"), - fctl.WithStringFlag(c.sourceFlag, "", "Filter by source account"), - fctl.WithStringFlag(c.referenceFlag, "", "Filter by reference"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{}, "Filter transactions with metadata"), - fctl.WithIntFlag(c.pageSizeFlag, 5, "Page size"), - fctl.WithHiddenFlag(c.metadataFlag), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - ledger := fctl.GetString(cmd, internal.LedgerFlag) - req := operations.ListTransactionsRequest{ - Ledger: ledger, - PageSize: fctl.Ptr(int64(fctl.GetInt(cmd, c.pageSizeFlag))), - } - - if account := fctl.GetString(cmd, c.accountFlag); account != "" { - req.Account = pointer.For(account) - } - if source := fctl.GetString(cmd, c.sourceFlag); source != "" { - req.Source = pointer.For(source) - } - if destination := fctl.GetString(cmd, c.destinationFlag); destination != "" { - req.Destination = pointer.For(destination) - } - if reference := fctl.GetString(cmd, c.referenceFlag); reference != "" { - req.Reference = pointer.For(reference) - } - if startTime := fctl.GetString(cmd, c.startTimeFlag); startTime != "" { - t, err := time.Parse(time.RFC3339Nano, startTime) - if err != nil { - return nil, fmt.Errorf("parsing start time: %w", err) - } - req.StartTime = pointer.For(t) - } - if endTime := fctl.GetString(cmd, c.endTimeFlag); endTime != "" { - t, err := time.Parse(time.RFC3339Nano, endTime) - if err != nil { - return nil, fmt.Errorf("parsing end time: %w", err) - } - req.EndTime = pointer.For(t) - } - req.Metadata = collectionutils.ConvertMap(metadata, collectionutils.ToAny[string]) - - response, err := stackClient.Ledger.V1.ListTransactions(cmd.Context(), req) - if err != nil { - return nil, err - } - - c.store.Transaction = response.TransactionsCursorResponse.Cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Transaction.Data) == 0 { - fctl.Println("No transactions found.") - return nil - } - - tableData := fctl.Map(c.store.Transaction.Data, func(tx ledger.Transaction) []string { - return []string{ - fmt.Sprintf("%d", tx.Txid), - func() string { - if tx.Reference == nil { - return "" - } - return *tx.Reference - }(), - tx.Timestamp.Format(time.RFC3339), - fctl.MetadataAsShortString(tx.Metadata), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Reference", "Date", "Metadata"}) - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/ledger/transactions/num.go b/cmd/ledger/transactions/num.go deleted file mode 100644 index a792b8f4..00000000 --- a/cmd/ledger/transactions/num.go +++ /dev/null @@ -1,178 +0,0 @@ -package transactions - -import ( - "fmt" - "math/big" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type NumStore struct { - Transaction *ledger.Transaction `json:"transaction"` -} -type NumController struct { - store *NumStore - amountVarFlag string - portionVarFlag string - accountVarFlag string - metadataFlag string - referenceFlag string - timestampFlag string -} - -var _ fctl.Controller[*NumStore] = (*NumController)(nil) - -func NewDefaultNumStore() *NumStore { - return &NumStore{} -} - -func NewNumController() *NumController { - return &NumController{ - store: NewDefaultNumStore(), - amountVarFlag: "amount-var", - portionVarFlag: "portion-var", - accountVarFlag: "account-var", - metadataFlag: "metadata", - referenceFlag: "reference", - timestampFlag: "timestamp", - } -} - -func NewNumCommand() *cobra.Command { - c := NewNumController() - - return fctl.NewCommand("num -|", - fctl.WithShortDescription("Execute a Numscript program on a ledger"), - fctl.WithDescription(`More help on variables can be found here: https://docs.formance.com/oss/ledger/reference/numscript/variables`), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithStringSliceFlag(c.amountVarFlag, []string{""}, "Pass a variable of type 'amount'"), - fctl.WithStringSliceFlag(c.portionVarFlag, []string{""}, "Pass a variable of type 'portion'"), - fctl.WithStringSliceFlag(c.accountVarFlag, []string{""}, "Pass a variable of type 'account'"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithStringFlag(c.timestampFlag, "", "Timestamp to use (format RFC3339)"), - fctl.WithStringFlag(c.referenceFlag, "", "Reference to add to the generated transaction"), - fctl.WithController[*NumStore](c), - ) -} - -func (c *NumController) GetStore() *NumStore { - return c.store -} - -func (c *NumController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to apply a numscript") { - return nil, fctl.ErrMissingApproval - } - - vars := map[string]interface{}{} - for _, v := range fctl.GetStringSlice(cmd, c.accountVarFlag) { - parts := strings.SplitN(v, "=", 2) - if len(parts) == 1 { - return nil, fmt.Errorf("malformed var: %s", v) - } - vars[parts[0]] = parts[1] - } - for _, v := range fctl.GetStringSlice(cmd, c.portionVarFlag) { - parts := strings.SplitN(v, "=", 2) - if len(parts) == 1 { - return nil, fmt.Errorf("malformed var: %s", v) - } - vars[parts[0]] = parts[1] - } - for _, v := range fctl.GetStringSlice(cmd, c.amountVarFlag) { - parts := strings.SplitN(v, "=", 2) - if len(parts) == 1 { - return nil, fmt.Errorf("malformed var: %s", v) - } - - amountParts := strings.SplitN(parts[1], "/", 2) - if len(amountParts) != 2 { - return nil, fmt.Errorf("malformed var: %s", v) - } - - amount, ok := big.NewInt(0).SetString(amountParts[0], 10) - if !ok { - return nil, fmt.Errorf("unable to parse '%s' as big int", amountParts[0]) - } - - vars[parts[0]] = map[string]any{ - "amount": amount, - "asset": amountParts[1], - } - } - - timestampStr := fctl.GetString(cmd, c.timestampFlag) - var ( - timestamp time.Time - ) - if timestampStr != "" { - timestamp, err = time.Parse(time.RFC3339Nano, timestampStr) - if err != nil { - return nil, err - } - } - - reference := fctl.GetString(cmd, c.referenceFlag) - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - ledgerName := fctl.GetString(cmd, internal.LedgerFlag) - - response, err := stackClient.Ledger.V1.CreateTransaction(cmd.Context(), operations.CreateTransactionRequest{ - PostTransaction: ledger.PostTransaction{ - Metadata: collectionutils.ConvertMap(metadata, collectionutils.ToAny[string]), - Reference: &reference, - Script: &ledger.PostTransactionScript{ - Plain: script, - Vars: vars, - }, - Timestamp: func() *time.Time { - if timestamp.IsZero() { - return nil - } - return ×tamp - }(), - }, - Ledger: ledgerName, - }) - if err != nil { - return nil, err - } - - c.store.Transaction = &response.TransactionsResponse.Data[0] - - return c, nil -} - -func (c *NumController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintTransaction(cmd.OutOrStdout(), internal.WrapV1Transaction(*c.store.Transaction)) -} diff --git a/cmd/ledger/transactions/revert.go b/cmd/ledger/transactions/revert.go deleted file mode 100644 index b24f5f1f..00000000 --- a/cmd/ledger/transactions/revert.go +++ /dev/null @@ -1,106 +0,0 @@ -package transactions - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RevertStore struct { - Transaction internal.Transaction `json:"transaction"` -} -type RevertController struct { - store *RevertStore -} - -var _ fctl.Controller[*RevertStore] = (*RevertController)(nil) - -func NewDefaultRevertStore() *RevertStore { - return &RevertStore{} -} - -func NewRevertController() *RevertController { - return &RevertController{ - store: NewDefaultRevertStore(), - } -} - -func NewRevertCommand() *cobra.Command { - return fctl.NewCommand("revert ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Revert a transaction"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgs("last"), - fctl.WithBoolFlag("at-effective-date", false, "Use the original transaction's timestamp"), - fctl.WithBoolFlag("force", false, "Force the revert even if the account does not have enough funds"), - fctl.WithController[*RevertStore](NewRevertController()), - ) -} - -func (c *RevertController) GetStore() *RevertStore { - return c.store -} - -func (c *RevertController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to revert transaction %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - - ledger := fctl.GetString(cmd, internal.LedgerFlag) - txId, err := internal.TransactionIDOrLastN(cmd.Context(), stackClient, ledger, args[0]) - if err != nil { - return nil, err - } - - force := fctl.GetBool(cmd, "force") - - if fctl.GetBool(cmd, "at-effective-date") { - request := operations.V2RevertTransactionRequest{ - Ledger: ledger, - ID: txId, - AtEffectiveDate: pointer.For(true), - Force: &force, - } - - response, err := stackClient.Ledger.V2.RevertTransaction(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Transaction = internal.WrapV2Transaction(response.V2CreateTransactionResponse.V2Transaction) - } else { - request := operations.RevertTransactionRequest{ - Ledger: ledger, - Txid: txId, - DisableChecks: &force, - } - - response, err := stackClient.Ledger.V1.RevertTransaction(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Transaction = internal.WrapV1Transaction(response.TransactionResponse.Transaction) - } - - return c, nil -} - -func (c *RevertController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintTransaction(cmd.OutOrStdout(), c.store.Transaction) -} diff --git a/cmd/ledger/transactions/root.go b/cmd/ledger/transactions/root.go deleted file mode 100644 index ea818de7..00000000 --- a/cmd/ledger/transactions/root.go +++ /dev/null @@ -1,22 +0,0 @@ -package transactions - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewLedgerTransactionsCommand() *cobra.Command { - return fctl.NewCommand("transactions", - fctl.WithAliases("t", "txs", "tx"), - fctl.WithShortDescription("Manage transactions"), - fctl.WithChildCommands( - NewListCommand(), - NewNumCommand(), - NewRevertCommand(), - NewShowCommand(), - NewSetMetadataCommand(), - NewDeleteMetadataCommand(), - ), - ) -} diff --git a/cmd/ledger/transactions/set_metadata.go b/cmd/ledger/transactions/set_metadata.go deleted file mode 100644 index 14e0ad89..00000000 --- a/cmd/ledger/transactions/set_metadata.go +++ /dev/null @@ -1,93 +0,0 @@ -package transactions - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/collectionutils" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetMetadataStore struct { - Success bool `json:"success"` -} -type SetMetadataController struct { - store *SetMetadataStore -} - -var _ fctl.Controller[*SetMetadataStore] = (*SetMetadataController)(nil) - -func NewDefaultSetMetadataStore() *SetMetadataStore { - return &SetMetadataStore{} -} - -func NewSetMetadataController() *SetMetadataController { - return &SetMetadataController{ - store: NewDefaultSetMetadataStore(), - } -} - -func NewSetMetadataCommand() *cobra.Command { - return fctl.NewCommand("set-metadata [=...]", - fctl.WithShortDescription("Set metadata on a transaction"), - fctl.WithAliases("sm", "set-meta"), - fctl.WithConfirmFlag(), - fctl.WithValidArgs("last"), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithController[*SetMetadataStore](NewSetMetadataController()), - ) -} - -func (c *SetMetadataController) GetStore() *SetMetadataStore { - return c.store -} - -func (c *SetMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(args[1:]) - if err != nil { - return nil, err - } - - transactionID, err := internal.TransactionIDOrLastN(cmd.Context(), stackClient, - fctl.GetString(cmd, internal.LedgerFlag), args[0]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to set a metadata on transaction %d", transactionID) { - return nil, fctl.ErrMissingApproval - } - - request := operations.AddMetadataOnTransactionRequest{ - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - Txid: transactionID, - RequestBody: collectionutils.ConvertMap(metadata, collectionutils.ToAny[string]), - } - response, err := stackClient.Ledger.V1.AddMetadataOnTransaction(cmd.Context(), request) - if err != nil { - return nil, err - } - - c.store.Success = response.StatusCode == 204 - return c, nil -} - -// TODO: This need to use the ui.NewListModel -func (c *SetMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata added!") - return nil -} diff --git a/cmd/ledger/transactions/show.go b/cmd/ledger/transactions/show.go deleted file mode 100644 index 83d4b7be..00000000 --- a/cmd/ledger/transactions/show.go +++ /dev/null @@ -1,79 +0,0 @@ -package transactions - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Transaction ledger.Transaction `json:"transaction"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a transaction"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("sh"), - fctl.WithValidArgs("last"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - ledger := fctl.GetString(cmd, internal.LedgerFlag) - txId, err := internal.TransactionIDOrLastN(cmd.Context(), stackClient, ledger, args[0]) - if err != nil { - return nil, err - } - - response, err := stackClient.Ledger.V1.GetTransaction(cmd.Context(), operations.GetTransactionRequest{ - Ledger: ledger, - Txid: txId, - }) - if err != nil { - return nil, err - } - - c.store.Transaction = response.TransactionResponse.Transaction - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintExpandedTransaction(cmd.OutOrStdout(), internal.WrapV1Transaction(c.store.Transaction)) -} diff --git a/cmd/ledger/volumes/list.go b/cmd/ledger/volumes/list.go deleted file mode 100644 index 3d50890b..00000000 --- a/cmd/ledger/volumes/list.go +++ /dev/null @@ -1,178 +0,0 @@ -package volumes - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/ledger/internal" - fctl "github.com/formancehq/fctl/v3/pkg" - "github.com/formancehq/fctl/v3/pkg/printer" -) - -type ListStore struct { - Cursor ledger.V2VolumesWithBalanceCursorResponseCursor -} - -type ListController struct { - store *ListStore - cursorFlag string - pageSizeFlag string - metadataFlag string - addressFlag string - pitFlag string - ootFlag string - useInsertionDateFlag string - groupByFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - address := fctl.GetString(cmd, c.addressFlag) - - body := make([]map[string]map[string]any, 0) - - for key, value := range metadata { - body = append(body, map[string]map[string]any{ - "$match": { - "metadata[" + key + "]": value, - }, - }) - } - - if address != "" { - body = append(body, map[string]map[string]any{ - "$match": { - "account": address, - }, - }) - } - - cursor := fctl.GetString(cmd, c.cursorFlag) - pageSize := int64(fctl.GetInt(cmd, c.pageSizeFlag)) - useInsertionDate := fctl.GetBool(cmd, c.useInsertionDateFlag) - groupBy := int64(fctl.GetInt(cmd, c.groupByFlag)) - - pit, err := fctl.GetDateTime(cmd, c.pitFlag) - if err != nil { - return nil, err - } - oot, err := fctl.GetDateTime(cmd, c.ootFlag) - if err != nil { - return nil, err - } - - request := operations.V2GetVolumesWithBalancesRequest{ - RequestBody: map[string]any{ - "$and": body, - }, - Ledger: fctl.GetString(cmd, internal.LedgerFlag), - StartTime: oot, - EndTime: pit, - Cursor: &cursor, - PageSize: &pageSize, - InsertionDate: &useInsertionDate, - GroupBy: &groupBy, - } - - response, err := stackClient.Ledger.V2.GetVolumesWithBalances(cmd.Context(), request) - - if err != nil { - return nil, fmt.Errorf("Get Volumes With Balances: %w", err) - } - - c.store.Cursor = response.V2VolumesWithBalanceCursorResponse.Cursor - - return c, nil - -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - - tableData := fctl.Map(c.store.Cursor.Data, func(volume ledger.V2VolumesWithBalance) []string { - return []string{ - volume.Account, - volume.Asset, - fmt.Sprintf("%d", volume.Input), - fmt.Sprintf("%d", volume.Output), - fmt.Sprintf("%d", volume.Balance), - } - }) - - tableData = fctl.Prepend(tableData, []string{"Account", "Asset", "Input", "Output", "Balance"}) - - tableData = printer.AddCursorRowsToTable(tableData, printer.CursorArgs{ - HasMore: c.store.Cursor.HasMore, - Next: c.store.Cursor.Next, - PageSize: c.store.Cursor.PageSize, - Previous: c.store.Cursor.Previous, - }) - - writer := cmd.OutOrStdout() - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(writer). - WithData(tableData). - Render() - -} - -func NewListController() *ListController { - return &ListController{ - store: &ListStore{}, - metadataFlag: "metadata", - cursorFlag: "cursor", - pageSizeFlag: "page-size", - addressFlag: "address", - pitFlag: "end-time", - ootFlag: "start-time", - useInsertionDateFlag: "insertion-date", - groupByFlag: "group-by", - } -} - -func NewListCommand() *cobra.Command { - - c := NewListController() - - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List volumes and balances for a time period (OOT–PIT)"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithStringFlag(c.pitFlag, "", "PIT (Point in Time)"), - fctl.WithStringFlag(c.ootFlag, "", "OOT (Origin of Time)"), - fctl.WithBoolFlag(c.useInsertionDateFlag, false, "Use insertion date"), - fctl.WithIntFlag(c.groupByFlag, 0, "Group by address segment level"), - fctl.WithStringFlag(c.addressFlag, "", "Filter accounts by address"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{}, "Filter accounts with metadata"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 10, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/ledger/volumes/root.go b/cmd/ledger/volumes/root.go deleted file mode 100644 index 16b2afc5..00000000 --- a/cmd/ledger/volumes/root.go +++ /dev/null @@ -1,16 +0,0 @@ -package volumes - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewLedgerVolumesCommand() *cobra.Command { - return fctl.NewCommand("volumes", - fctl.WithAliases("vol", "volume", "vols", "vlm"), - fctl.WithShortDescription("Get volumes and balances for accounts"), - fctl.WithChildCommands(NewListCommand()), - ) - -} diff --git a/cmd/login/login.go b/cmd/login/login.go index 5e57d23e..42b04be2 100644 --- a/cmd/login/login.go +++ b/cmd/login/login.go @@ -1,109 +1,70 @@ +// Package login creates or renews Cloud identities using the Membership device flow. package login import ( - "github.com/pterm/pterm" + "fmt" + "io" + "net/http" + "github.com/spf13/cobra" - "github.com/zitadel/oidc/v2/pkg/oidc" - fctl "github.com/formancehq/fctl/v3/pkg" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/connection" ) -type Store struct { - profile *fctl.Profile -} -type Controller struct { - store *Store +func NewCommand(s *connection.Settings) *cobra.Command { + return &cobra.Command{Use: "login", Short: "Log in to the Cloud (creates a profile automatically)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + return runLogin(s, cmd) + }} } -func NewDefaultStore() *Store { - return &Store{ - profile: nil, - } -} -func (c *Controller) GetStore() *Store { - return c.store -} - -func NewLoginController() *Controller { - return &Controller{ - store: NewDefaultStore(), - } -} - -func (c *Controller) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - profile, profileName, err := fctl.LoadCurrentProfile(cmd, *cfg) +func runLogin(s *connection.Settings, cmd *cobra.Command) error { + client, err := loginHTTPClient(s, cmd.ErrOrStderr()) if err != nil { - return nil, err + return err } - - membershipUri, err := cmd.Flags().GetString(fctl.MembershipURIFlag) + opts, entry, name, dir, err := s.LoginProfile(cmd.Context(), cmd) if err != nil { - return nil, err - } - if membershipUri == "" { - membershipUri = profile.GetMembershipURI() - } else { - profile.MembershipURI = membershipUri + return err } - - relyingParty, err := fctl.GetAuthRelyingParty(cmd.Context(), fctl.GetHttpClient(cmd), membershipUri) + session, err := cloud.LoginIdentity(cmd.Context(), client, cloud.Options{Issuer: opts.Issuer, ClientID: opts.ClientID}, cmd.ErrOrStderr(), s.BrowserOpener()) if err != nil { - return nil, err + return err } - - c.store.profile = profile - - ret, err := fctl.Authenticate( - cmd.Context(), - relyingParty, - fctl.NewPTermDialog(), - []fctl.AuthenticationOption{ - fctl.AuthenticateWithScopes( - oidc.ScopeOpenID, - oidc.ScopeOfflineAccess, - "accesses", - "on_behalf", - ), - fctl.AuthenticateWithPrompt("no-org"), - }, - []fctl.TokenOption{}, - ) - if err != nil { - return nil, err + expected := entry.Revision + if err := connection.SaveLoginSession(cmd.Context(), dir, name, &expected, opts, session); err != nil { + return err } - profile.UpdateRootToken(ret) - - currentProfileName := profileName - - cfg.CurrentProfile = currentProfileName - if err := fctl.WriteConfig(cmd, *cfg); err != nil { - return nil, err - } - - if err := fctl.WriteProfile(cmd, currentProfileName, *profile); err != nil { - return nil, err - } - - return c, nil + return command.WriteJSON(cmd.OutOrStdout(), []byte(`{"loggedIn":true}`)) } -func (c *Controller) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Logged in.") - return nil +func loginHTTPClient(s *connection.Settings, out io.Writer) (*http.Client, error) { + if s.Timeout <= 0 { + return nil, fmt.Errorf("timeout must be positive") + } + return s.HTTPClient(out), nil } -func NewCommand() *cobra.Command { - return fctl.NewCommand("login", - fctl.WithStringFlag(fctl.MembershipURIFlag, "", "service url"), - fctl.WithShortDescription("Login"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*Store](NewLoginController()), - ) +func NewLogoutCommand(s *connection.Settings) *cobra.Command { + return &cobra.Command{Use: "logout", Short: "Remove the selected profile's local Cloud tokens", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + _, _, name, dir, err := s.Resolve(cmd) + if err != nil { + return err + } + if name == "" { + return fmt.Errorf("select a saved profile") + } + if err := connection.Update(cmd.Context(), dir, func(store *connection.Store) error { + entry, exists := store.Connections[name] + if !exists { + return fmt.Errorf("profile no longer exists") + } + store.Connections[name] = connection.NewEntry(entry.Options) + return nil + }); err != nil { + return err + } + return command.WriteJSON(cmd.OutOrStdout(), []byte(`{"loggedIn":false}`)) + }} } diff --git a/cmd/orchestration/instances/describe.go b/cmd/orchestration/instances/describe.go deleted file mode 100644 index 4ad3b239..00000000 --- a/cmd/orchestration/instances/describe.go +++ /dev/null @@ -1,325 +0,0 @@ -package instances - -import ( - "fmt" - "io" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InstancesDescribeStore struct { - WorkflowInstancesHistory []orchestration.WorkflowInstanceHistory `json:"workflowInstanceHistory"` -} -type InstancesDescribeController struct { - store *InstancesDescribeStore -} - -var _ fctl.Controller[*InstancesDescribeStore] = (*InstancesDescribeController)(nil) - -func NewDefaultInstancesDescribeStore() *InstancesDescribeStore { - return &InstancesDescribeStore{} -} - -func NewInstancesDescribeController() *InstancesDescribeController { - return &InstancesDescribeController{ - store: NewDefaultInstancesDescribeStore(), - } -} - -func NewDescribeCommand() *cobra.Command { - c := NewInstancesDescribeController() - return fctl.NewCommand("describe ", - fctl.WithShortDescription("Describe a specific workflow instance"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*InstancesDescribeStore](c), - ) -} - -func (c *InstancesDescribeController) GetStore() *InstancesDescribeStore { - return c.store -} - -func (c *InstancesDescribeController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Orchestration.V1.GetInstanceHistory(cmd.Context(), operations.GetInstanceHistoryRequest{ - InstanceID: args[0], - }) - if err != nil { - return nil, err - } - - c.store.WorkflowInstancesHistory = response.GetWorkflowInstanceHistoryResponse.WorkflowInstanceHistoryList - - return c, nil -} - -func (c *InstancesDescribeController) Render(cmd *cobra.Command, args []string) error { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return err - } - - for i, history := range c.store.WorkflowInstancesHistory { - if err := printStage(cmd, i, stackClient, args[0], history); err != nil { - return err - } - } - - return nil -} - -func printHistoryBaseInfo(out io.Writer, name string, ind int, history orchestration.WorkflowInstanceHistory) { - fctl.Section.WithWriter(out).Printf("Stage %d : %s\n", ind, name) - fctl.BasicText.WithWriter(out).Printfln("Started at: %s", history.StartedAt.Format(time.RFC3339)) - if history.Terminated { - fctl.BasicText.WithWriter(out).Printfln("Terminated at: %s", history.StartedAt.Format(time.RFC3339)) - } -} - -func stageSourceName(src *orchestration.StageSendSource) string { - switch { - case src.StageSendSourceWallet != nil: - return fmt.Sprintf("wallet '%s' (balance: %s)", src.StageSendSourceWallet.ID, *src.StageSendSourceWallet.Balance) - case src.StageSendSourceAccount != nil: - return fmt.Sprintf("account '%s' (ledger: %s)", src.StageSendSourceAccount.ID, *src.StageSendSourceAccount.Ledger) - case src.StageSendSourcePayment != nil: - return fmt.Sprintf("payment '%s'", src.StageSendSourcePayment.ID) - default: - return "unknown_source_type" - } -} - -func stageDestinationName(dst *orchestration.StageSendDestination) string { - switch { - case dst.StageSendSourceWallet != nil: - return fmt.Sprintf("wallet '%s' (balance: %s)", dst.StageSendSourceWallet.ID, *dst.StageSendSourceWallet.Balance) - case dst.StageSendSourceAccount != nil: - return fmt.Sprintf("account '%s' (ledger: %s)", dst.StageSendSourceAccount.ID, *dst.StageSendSourceAccount.Ledger) - case dst.StageSendDestinationPayment != nil: - return dst.StageSendDestinationPayment.Psp - default: - return "unknown_source_type" - } -} - -func subjectName(src orchestration.Subject) string { - switch { - case src.WalletSubject != nil: - return fmt.Sprintf("wallet %s (balance: %s)", src.WalletSubject.Identifier, *src.WalletSubject.Balance) - case src.LedgerAccountSubject != nil: - return fmt.Sprintf("account %s", src.LedgerAccountSubject.Identifier) - default: - return "unknown_subject_type" - } -} - -func printMetadata(metadata map[string]string) []pterm.BulletListItem { - ret := make([]pterm.BulletListItem, 0) - ret = append(ret, historyItemDetails("Added metadata:")) - for k, v := range metadata { - ret = append(ret, pterm.BulletListItem{ - Level: 2, - Text: fmt.Sprintf("%s: %s", k, v), - }) - } - return ret -} - -func printStage(cmd *cobra.Command, i int, client *formance.Formance, id string, history orchestration.WorkflowInstanceHistory) error { - cyanWriter := fctl.BasicTextCyan - defaultWriter := fctl.BasicText - - listItems := make([]pterm.BulletListItem, 0) - - switch history.Stage.Type { - case orchestration.StageTypeStageSend: - printHistoryBaseInfo(cmd.OutOrStdout(), "send", i, history) - cyanWriter.Printfln("Send %v %s from %s to %s", history.Stage.StageSend.Monetary.Amount, - history.Stage.StageSend.Monetary.Asset, stageSourceName(history.Stage.StageSend.StageSendSource), - stageDestinationName(history.Stage.StageSend.StageSendDestination)) - fctl.Println() - - stageResponse, err := client.Orchestration.V1.GetInstanceStageHistory(cmd.Context(), operations.GetInstanceStageHistoryRequest{ - InstanceID: id, - Number: int64(i), - }) - if err != nil { - return err - } - - for _, historyStage := range stageResponse.GetWorkflowInstanceHistoryStageResponse.WorkflowInstanceHistoryStageList { - switch { - case historyStage.WorkflowInstanceHistoryStageInput.StripeTransferRequest != nil: - listItems = append(listItems, historyItemTitle("Send %v %s to Stripe connected account: %s", - *historyStage.WorkflowInstanceHistoryStageInput.StripeTransferRequest.Amount, - *historyStage.WorkflowInstanceHistoryStageInput.StripeTransferRequest.Asset, - *historyStage.WorkflowInstanceHistoryStageInput.StripeTransferRequest.Destination, - )) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction != nil: - listItems = append(listItems, historyItemTitle("Send %v %s from account %s to account %s (ledger %s)", - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Postings[0].Amount, - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Postings[0].Asset, - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Postings[0].Source, - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Postings[0].Destination, - *historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.Ledger, - )) - if historyStage.Error == nil && historyStage.LastFailure == nil && historyStage.Terminated { - listItems = append(listItems, historyItemDetails("Created transaction: %d", historyStage.WorkflowInstanceHistoryStageOutput.CreateTransactionResponse.Transaction.ID)) - if historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Reference != nil { - listItems = append(listItems, historyItemDetails("Reference: %s", *historyStage.WorkflowInstanceHistoryStageOutput.CreateTransactionResponse.Transaction.Reference)) - } - if len(historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Metadata) > 0 { - listItems = append(listItems, printMetadata(historyStage.WorkflowInstanceHistoryStageInput.ActivityCreateTransaction.PostTransaction.Metadata)...) - } - } - case historyStage.WorkflowInstanceHistoryStageInput.ActivityConfirmHold != nil: - listItems = append(listItems, historyItemTitle("Confirm debit hold %s", historyStage.WorkflowInstanceHistoryStageInput.ActivityConfirmHold.ID)) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet != nil: - listItems = append(listItems, historyItemTitle("Credit wallet %s (balance: %s) of %v %s from %s", - *historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.ID, - *historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Balance, - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Monetary.Amount, - historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Monetary.Asset, - subjectName(historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Sources[0]), - )) - if historyStage.Error == nil && historyStage.LastFailure == nil && historyStage.Terminated { - if len(historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Metadata) > 0 { - listItems = append(listItems, printMetadata(historyStage.WorkflowInstanceHistoryStageInput.ActivityCreditWallet.CreditWalletRequest.Metadata)...) - } - } - case historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet != nil: - destination := "@world" - if historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Subject != nil { - destination = subjectName(*historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Subject) - } - - listItems = append(listItems, historyItemTitle("Debit wallet %s (balance: %s) of %v %s to %s", - *historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.ID, - historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Balances[0], - historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Monetary.Amount, - historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Monetary.Asset, - destination, - )) - if historyStage.Error == nil && historyStage.LastFailure == nil && historyStage.Terminated { - if len(historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Metadata) > 0 { - listItems = append(listItems, printMetadata(historyStage.WorkflowInstanceHistoryStageInput.ActivityDebitWallet.DebitWalletRequest.Metadata)...) - } - } - case historyStage.WorkflowInstanceHistoryStageInput.ActivityGetAccount != nil: - listItems = append(listItems, historyItemTitle("Read account %s of ledger %s", - historyStage.WorkflowInstanceHistoryStageInput.ActivityGetAccount.ID, - historyStage.WorkflowInstanceHistoryStageInput.ActivityGetAccount.Ledger, - )) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityGetPayment != nil: - listItems = append(listItems, historyItemTitle("Read payment %s", - historyStage.WorkflowInstanceHistoryStageInput.ActivityGetPayment.ID)) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityGetWallet != nil: - listItems = append(listItems, historyItemTitle("Read wallet '%s'", historyStage.WorkflowInstanceHistoryStageInput.ActivityGetWallet.ID)) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityRevertTransaction != nil: - listItems = append(listItems, historyItemTitle("Revert transaction %s", historyStage.WorkflowInstanceHistoryStageInput.ActivityRevertTransaction.ID)) - if historyStage.Error == nil { - listItems = append(listItems, historyItemTitle("Created transaction: %d", historyStage.WorkflowInstanceHistoryStageOutput.CreateTransactionResponse1.Transaction.ID)) - } - case historyStage.WorkflowInstanceHistoryStageInput.ActivityVoidHold != nil: - listItems = append(listItems, historyItemTitle("Cancel debit hold %s", historyStage.WorkflowInstanceHistoryStageInput.ActivityVoidHold.ID)) - case historyStage.WorkflowInstanceHistoryStageInput.ActivityListWallets != nil: - listItems = append(listItems, historyItemTitle("List wallets")) - } - if historyStage.LastFailure != nil { - listItems = append(listItems, historyItemError("%s", *historyStage.LastFailure)) - if historyStage.NextExecution != nil { - listItems = append(listItems, historyItemError("Next try: %s", historyStage.NextExecution.Format(time.RFC3339))) - listItems = append(listItems, historyItemError("Attempt: %d", historyStage.Attempt)) - } - } - if historyStage.Error != nil { - listItems = append(listItems, historyItemError("%s", *historyStage.Error)) - } - } - case orchestration.StageTypeStageDelay: - printHistoryBaseInfo(cmd.OutOrStdout(), "delay", i, history) - switch { - case history.Stage.StageDelay.Duration != nil: - listItems = append(listItems, historyItemTitle("Pause workflow for a delay of %s", *history.Stage.StageDelay.Duration)) - case history.Stage.StageDelay.Until != nil: - listItems = append(listItems, historyItemTitle("Pause workflow until %s", *history.Stage.StageDelay.Until)) - } - case orchestration.StageTypeStageWaitEvent: - printHistoryBaseInfo(cmd.OutOrStdout(), "wait_event", i, history) - listItems = append(listItems, historyItemTitle("Waiting event '%s'", history.Stage.StageWaitEvent.Event)) - if history.Error == nil { - if history.Terminated { - listItems = append(listItems, historyItemDetails("Event received!")) - } else { - listItems = append(listItems, historyItemDetails("Still waiting event...")) - } - } - case orchestration.StageTypeUpdate: - printHistoryBaseInfo(cmd.OutOrStdout(), "update", i, history) - switch { - case history.Stage.Update.UpdateAccount != nil: - account := history.Stage.Update.UpdateAccount - listItems = append(listItems, historyItemTitle("Update account '%s' of ledger '%s'", account.ID, account.Ledger)) - listItems = append(listItems, printMetadata(account.Metadata)...) - } - default: - // Display error? - } - if history.Error != nil { - fctl.BasicTextRed.WithWriter(cmd.OutOrStdout()).Printfln("Stage terminated with error: %s", *history.Error) - } - - if len(listItems) > 0 { - defaultWriter.Print("History :\n") - return pterm.DefaultBulletList.WithWriter(cmd.OutOrStdout()).WithItems(listItems).Render() - } - return nil -} - -func historyItemTitle(format string, args ...any) pterm.BulletListItem { - return pterm.BulletListItem{ - Level: 0, - TextStyle: fctl.StyleGreen, - Text: fmt.Sprintf(format, args...), - } -} - -func historyItemDetails(format string, args ...any) pterm.BulletListItem { - return pterm.BulletListItem{ - Level: 1, - Text: fmt.Sprintf(format, args...), - } -} - -func historyItemError(format string, args ...any) pterm.BulletListItem { - return pterm.BulletListItem{ - Level: 1, - TextStyle: fctl.StyleRed, - Text: fmt.Sprintf(format, args...), - } -} diff --git a/cmd/orchestration/instances/list.go b/cmd/orchestration/instances/list.go deleted file mode 100644 index bc772eaa..00000000 --- a/cmd/orchestration/instances/list.go +++ /dev/null @@ -1,130 +0,0 @@ -package instances - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type WorkflowInstance struct { - InstanceID string `json:"instanceId"` - WorkflowID string `json:"workflowId"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - TerminatedAt string `json:"terminatedAt"` -} - -type InstancesListStore struct { - WorkflowInstance []WorkflowInstance `json:"workflowInstances"` -} -type InstancesListController struct { - store *InstancesListStore - workflowFlag string - runningFlag string -} - -var _ fctl.Controller[*InstancesListStore] = (*InstancesListController)(nil) - -func NewDefaultInstancesListStore() *InstancesListStore { - return &InstancesListStore{} -} - -func NewInstancesListController() *InstancesListController { - return &InstancesListController{ - store: NewDefaultInstancesListStore(), - workflowFlag: "workflow", - runningFlag: "running", - } -} - -func NewListCommand() *cobra.Command { - c := NewInstancesListController() - return fctl.NewCommand("list", - fctl.WithShortDescription("List all workflow instances"), - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(c.workflowFlag, "", "Filter by workflow ID"), - fctl.WithBoolFlag(c.runningFlag, false, "Show only running instances"), - fctl.WithController[*InstancesListStore](c), - ) -} - -func (c *InstancesListController) GetStore() *InstancesListStore { - return c.store -} - -func (c *InstancesListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Orchestration.V1.ListInstances(cmd.Context(), operations.ListInstancesRequest{ - Running: fctl.Ptr(fctl.GetBool(cmd, c.runningFlag)), - WorkflowID: fctl.Ptr(fctl.GetString(cmd, c.workflowFlag)), - }) - if err != nil { - return nil, err - } - - c.store.WorkflowInstance = fctl.Map(response.ListRunsResponse.Data, func(src orchestration.WorkflowInstance) WorkflowInstance { - return WorkflowInstance{ - InstanceID: src.ID, - WorkflowID: src.WorkflowID, - CreatedAt: src.CreatedAt.Format(time.RFC3339), - UpdatedAt: src.UpdatedAt.Format(time.RFC3339), - TerminatedAt: func() string { - if src.TerminatedAt == nil { - return "" - } - return src.TerminatedAt.Format(time.RFC3339) - }(), - } - }) - - return c, nil -} - -func (c *InstancesListController) Render(cmd *cobra.Command, args []string) error { - - if len(c.store.WorkflowInstance) == 0 { - fctl.Println("No workflows found.") - return nil - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.WorkflowInstance, - func(src WorkflowInstance) []string { - return []string{ - src.InstanceID, - src.WorkflowID, - src.CreatedAt, - src.UpdatedAt, - src.TerminatedAt, - } - }), - []string{"ID", "Workflow ID", "Created at", "Updated at", "Terminated at"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - - return nil -} diff --git a/cmd/orchestration/instances/root.go b/cmd/orchestration/instances/root.go deleted file mode 100644 index e3c9aff5..00000000 --- a/cmd/orchestration/instances/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package instances - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("instances", - fctl.WithAliases("ins", "i"), - fctl.WithShortDescription("Manage instances"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewDescribeCommand(), - NewSendEventCommand(), - NewStopCommand(), - ), - ) -} diff --git a/cmd/orchestration/instances/send_event.go b/cmd/orchestration/instances/send_event.go deleted file mode 100644 index 1e5e7d53..00000000 --- a/cmd/orchestration/instances/send_event.go +++ /dev/null @@ -1,82 +0,0 @@ -package instances - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InstancesSendEventStore struct { - Success bool `json:"success"` - InstanceID string `json:"instanceId"` - Event string `json:"event"` -} -type InstancesSendEventController struct { - store *InstancesSendEventStore -} - -var _ fctl.Controller[*InstancesSendEventStore] = (*InstancesSendEventController)(nil) - -func NewDefaultInstancesSendEventStore() *InstancesSendEventStore { - return &InstancesSendEventStore{ - Success: false, - InstanceID: "", - Event: "", - } -} - -func NewInstancesSendEventController() *InstancesSendEventController { - return &InstancesSendEventController{ - store: NewDefaultInstancesSendEventStore(), - } -} - -func NewSendEventCommand() *cobra.Command { - return fctl.NewCommand("send-event ", - fctl.WithShortDescription("Send an event to an instance"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*InstancesSendEventStore](NewInstancesSendEventController()), - ) -} - -func (c *InstancesSendEventController) GetStore() *InstancesSendEventStore { - return c.store -} - -func (c *InstancesSendEventController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - _, err = stackClient.Orchestration.V1.SendEvent(cmd.Context(), operations.SendEventRequest{ - RequestBody: &operations.SendEventRequestBody{ - Name: args[1], - }, - InstanceID: args[0], - }) - - if err != nil { - return nil, err - } - - c.store.Success = true - c.store.InstanceID = args[0] - c.store.Event = args[1] - - return c, nil -} - -func (c *InstancesSendEventController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Event '%s' sent", args[1]) - return nil -} diff --git a/cmd/orchestration/instances/show.go b/cmd/orchestration/instances/show.go deleted file mode 100644 index 6a9c7763..00000000 --- a/cmd/orchestration/instances/show.go +++ /dev/null @@ -1,108 +0,0 @@ -package instances - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - "github.com/formancehq/fctl/v3/cmd/orchestration/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InstancesShowStore struct { - WorkflowInstance orchestration.WorkflowInstance `json:"workflowInstance"` - Workflow orchestration.Workflow `json:"workflow"` -} -type InstancesShowController struct { - store *InstancesShowStore -} - -var _ fctl.Controller[*InstancesShowStore] = (*InstancesShowController)(nil) - -func NewDefaultInstancesShowStore() *InstancesShowStore { - return &InstancesShowStore{} -} - -func NewInstancesShowController() *InstancesShowController { - return &InstancesShowController{ - store: NewDefaultInstancesShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a specific workflow instance"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*InstancesShowStore](NewInstancesShowController()), - ) -} - -func (c *InstancesShowController) GetStore() *InstancesShowStore { - return c.store -} - -func (c *InstancesShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - res, err := stackClient.Orchestration.V1.GetInstance(cmd.Context(), operations.GetInstanceRequest{ - InstanceID: args[0], - }) - if err != nil { - return nil, fmt.Errorf("reading instance: %w", err) - } - - c.store.WorkflowInstance = res.GetWorkflowInstanceResponse.WorkflowInstance - response, err := stackClient.Orchestration.V1.GetWorkflow(cmd.Context(), operations.GetWorkflowRequest{ - FlowID: res.GetWorkflowInstanceResponse.WorkflowInstance.WorkflowID, - }) - if err != nil { - return nil, err - } - - c.store.Workflow = response.GetWorkflowResponse.Workflow - - return c, nil -} - -func (c *InstancesShowController) Render(cmd *cobra.Command, args []string) error { - // Print the instance information - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.WorkflowInstance.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Created at"), c.store.WorkflowInstance.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Updated at"), c.store.WorkflowInstance.UpdatedAt.Format(time.RFC3339)}) - if c.store.WorkflowInstance.Terminated { - tableData = append(tableData, []string{pterm.LightCyan("Terminated at"), c.store.WorkflowInstance.TerminatedAt.Format(time.RFC3339)}) - } - if c.store.WorkflowInstance.Error != nil && *c.store.WorkflowInstance.Error != "" { - tableData = append(tableData, []string{pterm.LightCyan("Error"), *c.store.WorkflowInstance.Error}) - } - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - if err := internal.PrintWorkflowInstance(cmd.OutOrStdout(), c.store.Workflow, c.store.WorkflowInstance); err != nil { - return err - } - - return nil -} diff --git a/cmd/orchestration/instances/stop.go b/cmd/orchestration/instances/stop.go deleted file mode 100644 index dec37ea7..00000000 --- a/cmd/orchestration/instances/stop.go +++ /dev/null @@ -1,75 +0,0 @@ -package instances - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type InstancesStopStore struct { - Success bool `json:"success"` - InstanceID string `json:"instanceId"` -} -type InstancesStopController struct { - store *InstancesStopStore -} - -var _ fctl.Controller[*InstancesStopStore] = (*InstancesStopController)(nil) - -func NewDefaultInstancesStopStore() *InstancesStopStore { - return &InstancesStopStore{} -} - -func NewInstancesStopController() *InstancesStopController { - return &InstancesStopController{ - store: NewDefaultInstancesStopStore(), - } -} - -func NewStopCommand() *cobra.Command { - return fctl.NewCommand("stop ", - fctl.WithShortDescription("Stop a specific workflow instance"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*InstancesStopStore](NewInstancesStopController()), - ) -} - -func (c *InstancesStopController) GetStore() *InstancesStopStore { - return c.store -} - -func (c *InstancesStopController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - _, err = stackClient.Orchestration.V1.CancelEvent(cmd.Context(), operations.CancelEventRequest{ - InstanceID: args[0], - }) - if err != nil { - return nil, err - } - - c.store.Success = true - c.store.InstanceID = args[0] - - return c, nil -} - -func (c *InstancesStopController) Render(cmd *cobra.Command, args []string) error { - // Print the instance information - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Workflow instance %s canceled.", c.store.InstanceID) - - return nil -} diff --git a/cmd/orchestration/internal/print.go b/cmd/orchestration/internal/print.go deleted file mode 100644 index d3e0308c..00000000 --- a/cmd/orchestration/internal/print.go +++ /dev/null @@ -1,54 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "time" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintWorkflowInstance(out io.Writer, w orchestration.Workflow, instance orchestration.WorkflowInstance) error { - fctl.Section.WithWriter(out).Println("Stages") - - ind := 0 - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(out). - WithData( - fctl.Prepend( - fctl.Map(instance.Status, - func(src orchestration.StageStatus) []string { - stage := w.WorkflowConfig.Stages[ind] - var name string - for name = range stage { - } - ind = ind + 1 - return []string{ - name, - src.StartedAt.Format(time.RFC3339), - func() string { - if src.TerminatedAt != nil { - return src.TerminatedAt.Format(time.RFC3339) - } - return "" - }(), - func() string { - if src.Error != nil { - return *src.Error - } - return "" - }(), - } - }), - []string{"Name", "Started at", "Terminated at", "Error"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - return nil -} diff --git a/cmd/orchestration/root.go b/cmd/orchestration/root.go deleted file mode 100644 index f12234c6..00000000 --- a/cmd/orchestration/root.go +++ /dev/null @@ -1,22 +0,0 @@ -package orchestration - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/orchestration/instances" - "github.com/formancehq/fctl/v3/cmd/orchestration/triggers" - "github.com/formancehq/fctl/v3/cmd/orchestration/workflows" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("orchestration", - fctl.WithAliases("orch", "or"), - fctl.WithShortDescription("Orchestration"), - fctl.WithChildCommands( - instances.NewCommand(), - workflows.NewCommand(), - triggers.NewCommand(), - ), - ) -} diff --git a/cmd/orchestration/triggers/create.go b/cmd/orchestration/triggers/create.go deleted file mode 100644 index 311a2c9c..00000000 --- a/cmd/orchestration/triggers/create.go +++ /dev/null @@ -1,112 +0,0 @@ -package triggers - -import ( - "errors" - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TriggersCreateStore struct { - Trigger orchestration.TriggerData `json:"trigger"` -} -type TriggersCreateController struct { - store *TriggersCreateStore - nameFlag string - filterFlag string - varsFlag string -} - -var _ fctl.Controller[*TriggersCreateStore] = (*TriggersCreateController)(nil) - -func NewDefaultTriggersCreateStore() *TriggersCreateStore { - return &TriggersCreateStore{} -} - -func NewTriggersCreateController() *TriggersCreateController { - return &TriggersCreateController{ - store: NewDefaultTriggersCreateStore(), - nameFlag: "name", - filterFlag: "filter", - varsFlag: "vars", - } -} - -func NewCreateCommand() *cobra.Command { - ctrl := NewTriggersCreateController() - return fctl.NewCommand("create ", - fctl.WithShortDescription("Create a trigger"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TriggersCreateStore](ctrl), - fctl.WithStringFlag(ctrl.nameFlag, "", "Trigger name"), - fctl.WithStringFlag(ctrl.filterFlag, "", "Filter events"), - fctl.WithStringSliceFlag(ctrl.varsFlag, []string{}, "Variables to pass to the workflow"), - ) -} - -func (c *TriggersCreateController) GetStore() *TriggersCreateStore { - return c.store -} - -func (c *TriggersCreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var ( - event = args[0] - name = fctl.GetString(cmd, c.nameFlag) - filter = fctl.GetString(cmd, c.filterFlag) - vars = fctl.GetStringSlice(cmd, c.varsFlag) - workflow = args[1] - ) - - data := &orchestration.TriggerData1{ - Event: event, - Name: &name, - WorkflowID: workflow, - Vars: map[string]interface{}{}, - } - if filter != "" { - data.Filter = pointer.For(filter) - } - if len(vars) > 0 { - for _, v := range vars { - parts := strings.SplitN(v, "=", 2) - if len(parts) != 2 { - return nil, errors.New("invalid 'vars' flag") - } - data.Vars[parts[0]] = parts[1] - } - } - - res, err := stackClient.Orchestration.V1.CreateTrigger(cmd.Context(), data) - if err != nil { - return nil, fmt.Errorf("reading trigger: %w", err) - } - - c.store.Trigger = res.CreateTriggerResponse.TriggerData - - return c, nil -} - -func (c *TriggersCreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Trigger created with ID: %s", c.store.Trigger.ID) - - return nil -} diff --git a/cmd/orchestration/triggers/delete.go b/cmd/orchestration/triggers/delete.go deleted file mode 100644 index deaf2f70..00000000 --- a/cmd/orchestration/triggers/delete.go +++ /dev/null @@ -1,74 +0,0 @@ -package triggers - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TriggersDeleteStore struct { - Success bool `json:"success"` - TriggerID string `json:"triggerID"` -} -type TriggersDeleteController struct { - store *TriggersDeleteStore -} - -var _ fctl.Controller[*TriggersDeleteStore] = (*TriggersDeleteController)(nil) - -func NewDefaultTriggersDeleteStore() *TriggersDeleteStore { - return &TriggersDeleteStore{} -} - -func NewTriggersDeleteController() *TriggersDeleteController { - return &TriggersDeleteController{ - store: NewDefaultTriggersDeleteStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithShortDescription("Delete a specific workflow trigger"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TriggersDeleteStore](NewTriggersDeleteController()), - ) -} - -func (c *TriggersDeleteController) GetStore() *TriggersDeleteStore { - return c.store -} - -func (c *TriggersDeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - _, err = stackClient.Orchestration.V1.DeleteTrigger(cmd.Context(), operations.DeleteTriggerRequest{ - TriggerID: args[0], - }) - if err != nil { - return nil, fmt.Errorf("deleting trigger: %w", err) - } - - c.store.Success = true - c.store.TriggerID = args[0] - - return c, nil -} - -func (c *TriggersDeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.Printfln("Trigger %s deleted.", c.store.TriggerID) - return nil -} diff --git a/cmd/orchestration/triggers/list.go b/cmd/orchestration/triggers/list.go deleted file mode 100644 index 36c46e10..00000000 --- a/cmd/orchestration/triggers/list.go +++ /dev/null @@ -1,111 +0,0 @@ -package triggers - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TriggersListStore struct { - WorkflowTrigger []orchestration.TriggerData `json:"workflowTriggers"` -} -type TriggersListController struct { - store *TriggersListStore - nameFlag string -} - -var _ fctl.Controller[*TriggersListStore] = (*TriggersListController)(nil) - -func NewDefaultTriggersListStore() *TriggersListStore { - return &TriggersListStore{} -} - -func NewTriggersListController() *TriggersListController { - return &TriggersListController{ - store: NewDefaultTriggersListStore(), - } -} - -func NewListCommand() *cobra.Command { - c := NewTriggersListController() - return fctl.NewCommand("list", - fctl.WithShortDescription("List all workflow triggers"), - fctl.WithAliases("ls", "l"), - fctl.WithStringFlag(c.nameFlag, "", "Search by name"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TriggersListStore](c), - ) -} - -func (c *TriggersListController) GetStore() *TriggersListStore { - return c.store -} - -func (c *TriggersListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - var name = fctl.GetString(cmd, c.nameFlag) - response, err := stackClient.Orchestration.V1.ListTriggers(cmd.Context(), operations.ListTriggersRequest{ - Name: &name, - }) - - if err != nil { - return nil, err - } - - c.store.WorkflowTrigger = response.ListTriggersResponse.Data - - return c, nil -} - -func (c *TriggersListController) Render(cmd *cobra.Command, args []string) error { - - if len(c.store.WorkflowTrigger) == 0 { - fctl.Println("No triggers found.") - return nil - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.WorkflowTrigger, - func(src orchestration.TriggerData) []string { - return []string{ - src.ID, - *src.Name, - src.WorkflowID, - src.CreatedAt.Format(time.RFC3339), - src.Event, - func() string { - if src.Filter == nil { - return "" - } - return *src.Filter - }(), - } - }), - []string{"ID", "Name", "Workflow ID", "Created at", "Event", "Filter"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - - return nil -} diff --git a/cmd/orchestration/triggers/occurrences/list.go b/cmd/orchestration/triggers/occurrences/list.go deleted file mode 100644 index 5f6c3145..00000000 --- a/cmd/orchestration/triggers/occurrences/list.go +++ /dev/null @@ -1,114 +0,0 @@ -package occurrences - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type OccurrencesListStore struct { - WorkflowOccurrence []orchestration.TriggerOccurrence `json:"occurrences"` -} -type OccurrencesListController struct { - store *OccurrencesListStore -} - -var _ fctl.Controller[*OccurrencesListStore] = (*OccurrencesListController)(nil) - -func NewDefaultOccurrencesListStore() *OccurrencesListStore { - return &OccurrencesListStore{} -} - -func NewOccurrencesListController() *OccurrencesListController { - return &OccurrencesListController{ - store: NewDefaultOccurrencesListStore(), - } -} - -func NewListCommand() *cobra.Command { - c := NewOccurrencesListController() - return fctl.NewCommand("list", - fctl.WithShortDescription("List all workflow occurrences"), - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*OccurrencesListStore](c), - ) -} - -func (c *OccurrencesListController) GetStore() *OccurrencesListStore { - return c.store -} - -func (c *OccurrencesListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Orchestration.V1.ListTriggersOccurrences(cmd.Context(), operations.ListTriggersOccurrencesRequest{ - TriggerID: args[0], - }) - if err != nil { - return nil, err - } - - c.store.WorkflowOccurrence = response.ListTriggersOccurrencesResponse.Data - - return c, nil -} - -func (c *OccurrencesListController) Render(cmd *cobra.Command, args []string) error { - - if len(c.store.WorkflowOccurrence) == 0 { - fctl.Println("No occurrences found.") - return nil - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.WorkflowOccurrence, - func(src orchestration.TriggerOccurrence) []string { - return []string{ - func() string { - if src.WorkflowInstanceID != nil { - return "" - } - return *src.WorkflowInstanceID - }(), - src.Date.Format(time.RFC3339), - fctl.BoolToString(src.WorkflowInstance.Terminated), - src.WorkflowInstance.TerminatedAt.Format(time.RFC3339), - func() string { - if src.Error != nil && *src.Error != "" { - return *src.Error - } - if src.WorkflowInstance != nil && src.WorkflowInstance.Error != nil && *src.WorkflowInstance.Error != "" { - return *src.WorkflowInstance.Error - } - return "" - }(), - } - }), - []string{"Workflow instance ID", "Date", "Terminated", "Terminated at", "Error"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - - return nil -} diff --git a/cmd/orchestration/triggers/occurrences/root.go b/cmd/orchestration/triggers/occurrences/root.go deleted file mode 100644 index 5c7e9ed9..00000000 --- a/cmd/orchestration/triggers/occurrences/root.go +++ /dev/null @@ -1,17 +0,0 @@ -package occurrences - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("occurrences", - fctl.WithAliases("oc", "o"), - fctl.WithShortDescription("Manage trigger occurrences"), - fctl.WithChildCommands( - NewListCommand(), - ), - ) -} diff --git a/cmd/orchestration/triggers/root.go b/cmd/orchestration/triggers/root.go deleted file mode 100644 index ec551d30..00000000 --- a/cmd/orchestration/triggers/root.go +++ /dev/null @@ -1,23 +0,0 @@ -package triggers - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/orchestration/triggers/occurrences" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("triggers", - fctl.WithAliases("trig", "t"), - fctl.WithShortDescription("Manage triggers"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewDeleteCommand(), - NewCreateCommand(), - NewTestCommand(), - occurrences.NewCommand(), - ), - ) -} diff --git a/cmd/orchestration/triggers/show.go b/cmd/orchestration/triggers/show.go deleted file mode 100644 index d548f5ff..00000000 --- a/cmd/orchestration/triggers/show.go +++ /dev/null @@ -1,96 +0,0 @@ -package triggers - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TriggersShowStore struct { - Trigger orchestration.TriggerData `json:"trigger"` -} -type TriggersShowController struct { - store *TriggersShowStore -} - -var _ fctl.Controller[*TriggersShowStore] = (*TriggersShowController)(nil) - -func NewDefaultTriggersShowStore() *TriggersShowStore { - return &TriggersShowStore{} -} - -func NewTriggersShowController() *TriggersShowController { - return &TriggersShowController{ - store: NewDefaultTriggersShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a specific workflow trigger"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TriggersShowStore](NewTriggersShowController()), - ) -} - -func (c *TriggersShowController) GetStore() *TriggersShowStore { - return c.store -} - -func (c *TriggersShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - res, err := stackClient.Orchestration.V1.ReadTrigger(cmd.Context(), operations.ReadTriggerRequest{ - TriggerID: args[0], - }) - if err != nil { - return nil, fmt.Errorf("reading trigger: %w", err) - } - - c.store.Trigger = res.ReadTriggerResponse.TriggerData - - return c, nil -} - -func (c *TriggersShowController) Render(cmd *cobra.Command, args []string) error { - // Print the trigger information - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Trigger.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), *c.store.Trigger.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Created at"), c.store.Trigger.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Workflow ID"), c.store.Trigger.WorkflowID}) - tableData = append(tableData, []string{pterm.LightCyan("Event"), c.store.Trigger.Event}) - tableData = append(tableData, []string{pterm.LightCyan("Filter"), func() string { - if c.store.Trigger.Filter == nil { - return "" - } - return *c.store.Trigger.Filter - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/orchestration/triggers/test.go b/cmd/orchestration/triggers/test.go deleted file mode 100644 index e1819620..00000000 --- a/cmd/orchestration/triggers/test.go +++ /dev/null @@ -1,130 +0,0 @@ -package triggers - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TriggersTestStore struct { - Trigger orchestration.V2TriggerTest `json:"trigger"` -} -type TriggersTestController struct { - store *TriggersTestStore -} - -var _ fctl.Controller[*TriggersTestStore] = (*TriggersTestController)(nil) - -func NewDefaultTriggersTestStore() *TriggersTestStore { - return &TriggersTestStore{} -} - -func NewTriggersTestController() *TriggersTestController { - return &TriggersTestController{ - store: NewDefaultTriggersTestStore(), - } -} - -func NewTestCommand() *cobra.Command { - return fctl.NewCommand("test ", - fctl.WithShortDescription("Test a specific workflow trigger"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TriggersTestStore](NewTriggersTestController()), - ) -} - -func (c *TriggersTestController) GetStore() *TriggersTestStore { - return c.store -} - -func (c *TriggersTestController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - data := make(map[string]any) - if err := json.Unmarshal([]byte(args[1]), &data); err != nil { - return nil, err - } - - res, err := stackClient.Orchestration.V2.TestTrigger(cmd.Context(), operations.TestTriggerRequest{ - TriggerID: args[0], - RequestBody: data, - }) - if err != nil { - return nil, fmt.Errorf("testing trigger: %w", err) - } - - c.store.Trigger = res.V2TestTriggerResponse.V2TriggerTest - - return c, nil -} - -func (c *TriggersTestController) Render(cmd *cobra.Command, args []string) error { - // Print the trigger information - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - if c.store.Trigger.Filter != nil { - if c.store.Trigger.Filter.Match != nil { - tableData = append(tableData, []string{pterm.LightCyan("Filter match"), fctl.BoolToString(*c.store.Trigger.Filter.Match)}) - } - if c.store.Trigger.Filter.Error != nil { - tableData = append(tableData, []string{pterm.LightCyan("Filter error"), *c.store.Trigger.Filter.Error}) - } - } - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - if c.store.Trigger.Variables != nil && len(c.store.Trigger.Variables) > 0 { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Variables") - tableData := make([][]string, 0) - for key, variable := range c.store.Trigger.Variables { - tableData = append(tableData, []string{ - key, - func() string { - if variable.Value != nil { - return *variable.Value - } - return "" - }(), - func() string { - if variable.Error != nil { - return *variable.Error - } - return "" - }(), - }) - } - tableData = fctl.Prepend(tableData, []string{"Key", "Value", "Error"}) - - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - } - - return nil -} diff --git a/cmd/orchestration/workflows/create.go b/cmd/orchestration/workflows/create.go deleted file mode 100644 index 14e86180..00000000 --- a/cmd/orchestration/workflows/create.go +++ /dev/null @@ -1,86 +0,0 @@ -package workflows - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type WorkflowsCreateStore struct { - WorkflowId string `json:"workflowId"` -} -type WorkflowsCreateController struct { - store *WorkflowsCreateStore -} - -var _ fctl.Controller[*WorkflowsCreateStore] = (*WorkflowsCreateController)(nil) - -func NewDefaultWorkflowsCreateStore() *WorkflowsCreateStore { - return &WorkflowsCreateStore{} -} - -func NewWorkflowsCreateController() *WorkflowsCreateController { - return &WorkflowsCreateController{ - store: NewDefaultWorkflowsCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand("create |-", - fctl.WithShortDescription("Create a workflow"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*WorkflowsCreateStore](NewWorkflowsCreateController()), - ) -} - -func (c *WorkflowsCreateController) GetStore() *WorkflowsCreateStore { - return c.store -} - -func (c *WorkflowsCreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - config := orchestration.WorkflowConfig{} - if err := yaml.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Orchestration.V1. - CreateWorkflow(cmd.Context(), &orchestration.WorkflowConfig{ - Name: config.Name, - Stages: config.Stages, - }) - if err != nil { - return nil, err - } - - c.store.WorkflowId = response.CreateWorkflowResponse.Workflow.ID - - return c, nil -} - -func (c *WorkflowsCreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Workflow created with ID: %s", c.store.WorkflowId) - - return nil -} diff --git a/cmd/orchestration/workflows/delete.go b/cmd/orchestration/workflows/delete.go deleted file mode 100644 index 7f1a5de9..00000000 --- a/cmd/orchestration/workflows/delete.go +++ /dev/null @@ -1,76 +0,0 @@ -package workflows - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type WorkflowsDeleteStore struct { - WorkflowId string `json:"workflowId"` - Success bool `json:"success"` -} -type WorkflowsDeleteController struct { - store *WorkflowsDeleteStore -} - -var _ fctl.Controller[*WorkflowsDeleteStore] = (*WorkflowsDeleteController)(nil) - -func NewDefaultWorkflowsDeleteStore() *WorkflowsDeleteStore { - return &WorkflowsDeleteStore{} -} - -func NewWorkflowsDeleteController() *WorkflowsDeleteController { - return &WorkflowsDeleteController{ - store: NewDefaultWorkflowsDeleteStore(), - } -} -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithAliases("del", "d"), - fctl.WithShortDescription("Soft delete a workflow"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*WorkflowsDeleteStore](NewWorkflowsDeleteController()), - ) -} - -func (c *WorkflowsDeleteController) GetStore() *WorkflowsDeleteStore { - return c.store -} - -func (c *WorkflowsDeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - _, err = stackClient.Orchestration.V1.DeleteWorkflow( - cmd.Context(), - operations.DeleteWorkflowRequest{ - FlowID: args[0], - }, - ) - - if err != nil { - return nil, err - } - - c.store.WorkflowId = args[0] - c.store.Success = true - - return c, nil -} - -func (c *WorkflowsDeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithShowLineNumber().Printfln("Workflow %s deleted.", c.store.WorkflowId) - return nil -} diff --git a/cmd/orchestration/workflows/list.go b/cmd/orchestration/workflows/list.go deleted file mode 100644 index 22f64692..00000000 --- a/cmd/orchestration/workflows/list.go +++ /dev/null @@ -1,115 +0,0 @@ -package workflows - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Workflow struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type WorkflowsListStore struct { - Workflows []Workflow `json:"workflows"` -} -type WorkflowsListController struct { - store *WorkflowsListStore -} - -var _ fctl.Controller[*WorkflowsListStore] = (*WorkflowsListController)(nil) - -func NewDefaultWorkflowsListStore() *WorkflowsListStore { - return &WorkflowsListStore{} -} - -func NewWorkflowsListController() *WorkflowsListController { - return &WorkflowsListController{ - store: NewDefaultWorkflowsListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithShortDescription("List all workflows"), - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithController[*WorkflowsListStore](NewWorkflowsListController()), - ) -} - -func (c *WorkflowsListController) GetStore() *WorkflowsListStore { - return c.store -} - -func (c *WorkflowsListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Orchestration.V1.ListWorkflows(cmd.Context()) - if err != nil { - return nil, err - } - - c.store.Workflows = fctl.Map(response.ListWorkflowsResponse.Data, func(src orchestration.Workflow) Workflow { - return Workflow{ - ID: src.ID, - Name: func() string { - if src.WorkflowConfig.Name != nil { - return *src.WorkflowConfig.Name - } - return "" - }(), - CreatedAt: src.CreatedAt.Format(time.RFC3339), - UpdatedAt: src.UpdatedAt.Format(time.RFC3339), - } - }) - - return c, nil -} - -func (c *WorkflowsListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Workflows) == 0 { - fctl.Println("No workflows found.") - return nil - } - - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.Workflows, - func(src Workflow) []string { - return []string{ - src.ID, - src.Name, - src.CreatedAt, - src.UpdatedAt, - } - }), - []string{"ID", "Name", "Created at", "Updated at"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - - return nil -} diff --git a/cmd/orchestration/workflows/root.go b/cmd/orchestration/workflows/root.go deleted file mode 100644 index 3097f21e..00000000 --- a/cmd/orchestration/workflows/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package workflows - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("workflows", - fctl.WithAliases("w", "work"), - fctl.WithShortDescription("Manage workflows"), - fctl.WithChildCommands( - NewListCommand(), - NewCreateCommand(), - NewRunCommand(), - NewShowCommand(), - NewDeleteCommand(), - ), - ) -} diff --git a/cmd/orchestration/workflows/run.go b/cmd/orchestration/workflows/run.go deleted file mode 100644 index 9f6bb180..00000000 --- a/cmd/orchestration/workflows/run.go +++ /dev/null @@ -1,118 +0,0 @@ -package workflows - -import ( - "errors" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - "github.com/formancehq/fctl/v3/cmd/orchestration/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type WorkflowsRunStore struct { - WorkflowInstance orchestration.WorkflowInstance `json:"workflowInstance"` -} -type WorkflowsRunController struct { - store *WorkflowsRunStore - variableFlag string - waitFlag string - wait bool -} - -var _ fctl.Controller[*WorkflowsRunStore] = (*WorkflowsRunController)(nil) - -func NewDefaultWorkflowsRunStore() *WorkflowsRunStore { - return &WorkflowsRunStore{} -} - -func NewWorkflowsRunController() *WorkflowsRunController { - return &WorkflowsRunController{ - store: NewDefaultWorkflowsRunStore(), - variableFlag: "variable", - waitFlag: "wait", - wait: false, - } -} - -func NewRunCommand() *cobra.Command { - c := NewWorkflowsRunController() - return fctl.NewCommand("run ", - fctl.WithShortDescription("Run a workflow"), - fctl.WithAliases("r"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithBoolFlag(c.waitFlag, false, "Wait for the run to complete"), - fctl.WithStringSliceFlag(c.variableFlag, []string{}, "Variable to pass to the workflow"), - fctl.WithController[*WorkflowsRunStore](c), - ) -} - -func (c *WorkflowsRunController) GetStore() *WorkflowsRunStore { - return c.store -} - -func (c *WorkflowsRunController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - wait := fctl.GetBool(cmd, c.waitFlag) - variables := make(map[string]string) - for _, variable := range fctl.GetStringSlice(cmd, c.variableFlag) { - parts := strings.SplitN(variable, "=", 2) - if len(parts) != 2 { - return nil, errors.New("malformed flag: " + variable) - } - variables[parts[0]] = parts[1] - } - - response, err := stackClient.Orchestration.V1. - RunWorkflow(cmd.Context(), operations.RunWorkflowRequest{ - RequestBody: variables, - Wait: &wait, - WorkflowID: args[0], - }) - if err != nil { - return nil, err - } - - c.wait = wait - c.store.WorkflowInstance = response.RunWorkflowResponse.WorkflowInstance - return c, nil -} - -func (c *WorkflowsRunController) Render(cmd *cobra.Command, args []string) error { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return err - } - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Workflow instance created with ID: %s", c.store.WorkflowInstance.ID) - if c.wait { - w, err := stackClient.Orchestration.V1.GetWorkflow(cmd.Context(), operations.GetWorkflowRequest{ - FlowID: args[0], - }) - if err != nil { - panic(err) - } - - return internal.PrintWorkflowInstance(cmd.OutOrStdout(), w.GetWorkflowResponse.Workflow, c.store.WorkflowInstance) - } - return nil -} diff --git a/cmd/orchestration/workflows/show.go b/cmd/orchestration/workflows/show.go deleted file mode 100644 index 29276774..00000000 --- a/cmd/orchestration/workflows/show.go +++ /dev/null @@ -1,104 +0,0 @@ -package workflows - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/orchestration" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type WorkflowsShowStore struct { - Workflow orchestration.Workflow `json:"workflow"` -} -type WorkflowsShowController struct { - store *WorkflowsShowStore -} - -var _ fctl.Controller[*WorkflowsShowStore] = (*WorkflowsShowController)(nil) - -func NewDefaultWorkflowsShowStore() *WorkflowsShowStore { - return &WorkflowsShowStore{} -} - -func NewWorkflowsShowController() *WorkflowsShowController { - return &WorkflowsShowController{ - store: NewDefaultWorkflowsShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a workflow"), - fctl.WithAliases("s"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*WorkflowsShowStore](NewWorkflowsShowController()), - ) -} - -func (c *WorkflowsShowController) GetStore() *WorkflowsShowStore { - return c.store -} - -func (c *WorkflowsShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Orchestration.V1. - GetWorkflow(cmd.Context(), operations.GetWorkflowRequest{ - FlowID: args[0], - }) - if err != nil { - return nil, err - } - - c.store.Workflow = response.GetWorkflowResponse.Workflow - - return c, nil -} - -func (c *WorkflowsShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Workflow.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), func() string { - if c.store.Workflow.WorkflowConfig.Name != nil { - return *c.store.Workflow.WorkflowConfig.Name - } - return "" - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Created at"), c.store.Workflow.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Updated at"), c.store.Workflow.UpdatedAt.Format(time.RFC3339)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fmt.Fprintln(cmd.OutOrStdout()) - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Configuration") - configAsBytes, err := yaml.Marshal(c.store.Workflow.WorkflowConfig) - if err != nil { - panic(err) - } - fmt.Fprintln(cmd.OutOrStdout(), string(configAsBytes)) - - return nil -} diff --git a/cmd/payments/accounts/balances.go b/cmd/payments/accounts/balances.go deleted file mode 100644 index e68650b6..00000000 --- a/cmd/payments/accounts/balances.go +++ /dev/null @@ -1,147 +0,0 @@ -package accounts - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListBalancesStore struct { - Cursor *payments.BalancesCursorCursorBase `json:"cursor"` -} - -type ListBalancesController struct { - store *ListBalancesStore - - cursorFlag string - pageSizeFlag string -} - -var _ fctl.Controller[*ListBalancesStore] = (*ListBalancesController)(nil) - -func NewListBalanceStore() *ListBalancesStore { - return &ListBalancesStore{ - Cursor: &payments.BalancesCursorCursorBase{}, - } -} - -func NewListBalancesController() *ListBalancesController { - return &ListBalancesController{ - store: NewListBalanceStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListBalancesController) GetStore() *ListBalancesStore { - return c.store -} - -func (c *ListBalancesController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Payments.V1.GetAccountBalances( - cmd.Context(), - operations.GetAccountBalancesRequest{ - Cursor: cursor, - PageSize: pageSize, - AccountID: args[0], - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.BalancesCursor.CursorBase - - return c, nil -} - -func (c *ListBalancesController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(balance payments.AccountBalance) []string { - return []string{ - balance.AccountID, - balance.Asset, - balance.Balance.String(), - balance.CreatedAt.Format(time.RFC3339), - balance.LastUpdatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Asset", "Balance", - "CreatedAt", "LastUpdatedAt"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListBalanceCommand() *cobra.Command { - c := NewListBalancesController() - return fctl.NewCommand("balances ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List account balances"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListBalancesStore](c), - ) -} diff --git a/cmd/payments/accounts/create.go b/cmd/payments/accounts/create.go deleted file mode 100644 index 03346a8d..00000000 --- a/cmd/payments/accounts/create.go +++ /dev/null @@ -1,108 +0,0 @@ -package accounts - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - AccountID string `json:"accountID"` -} -type CreateController struct { - PaymentsVersion versions.Version - - store *CreateStore -} - -func (c *CreateController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create an account on the Formance platform"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("accounts creation are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create an account") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - request := payments.AccountRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.CreateAccount(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.AccountID = response.AccountResponse.Account.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Account created with ID: %s", c.store.AccountID) - - return nil -} diff --git a/cmd/payments/accounts/list.go b/cmd/payments/accounts/list.go deleted file mode 100644 index c30b0a52..00000000 --- a/cmd/payments/accounts/list.go +++ /dev/null @@ -1,149 +0,0 @@ -package accounts - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.CursorBase `json:"cursor"` -} - -type ListController struct { - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.CursorBase{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Payments.V1.ListAccountsPayments( - cmd.Context(), - operations.ListAccountsPaymentsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.AccountsCursor.CursorBase - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(acc payments.Account) []string { - return []string{ - acc.ID, - acc.Reference, - acc.CreatedAt.Format(time.RFC3339), - acc.AccountName, - acc.DefaultAsset, - acc.DefaultCurrency, - string(acc.AccountType), - acc.ConnectorID, - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Reference", "CreatedAt", "AccountName", - "DefaultAsset", "DefaultCurrency", "Type", "ConnectorID"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List connector accounts"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/payments/accounts/root.go b/cmd/payments/accounts/root.go deleted file mode 100644 index fa00c58a..00000000 --- a/cmd/payments/accounts/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package accounts - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewAccountsCommand() *cobra.Command { - return fctl.NewCommand("accounts", - fctl.WithAliases("acc", "a", "ac", "account"), - fctl.WithShortDescription("Manage accounts"), - fctl.WithChildCommands( - NewCreateCommand(), - NewListCommand(), - NewShowCommand(), - NewListBalanceCommand(), - ), - ) -} diff --git a/cmd/payments/accounts/show.go b/cmd/payments/accounts/show.go deleted file mode 100644 index 0b474f42..00000000 --- a/cmd/payments/accounts/show.go +++ /dev/null @@ -1,97 +0,0 @@ -package accounts - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Account *payments.Account `json:"account"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get an account"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Payments.V1.GetAccountPayments(cmd.Context(), operations.GetAccountPaymentsRequest{ - AccountID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Account = &response.AccountResponse.Account - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Account.ID}) - tableData = append(tableData, []string{pterm.LightCyan("AccountName"), c.store.Account.AccountName}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Account.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ConnectorID"), string(c.store.Account.ConnectorID)}) - tableData = append(tableData, []string{pterm.LightCyan("DefaultAsset"), c.store.Account.DefaultAsset}) - tableData = append(tableData, []string{pterm.LightCyan("DefaultCurrency"), c.store.Account.DefaultCurrency}) - tableData = append(tableData, []string{pterm.LightCyan("Reference"), c.store.Account.Reference}) - tableData = append(tableData, []string{pterm.LightCyan("Type"), string(c.store.Account.AccountType)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.PrintMetadata(cmd.OutOrStdout(), c.store.Account.AccountMetadata) -} diff --git a/cmd/payments/bankaccounts/create.go b/cmd/payments/bankaccounts/create.go deleted file mode 100644 index 266225e5..00000000 --- a/cmd/payments/bankaccounts/create.go +++ /dev/null @@ -1,127 +0,0 @@ -package bankaccounts - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - BankAccountID string `json:"bankAccountId"` -} -type CreateController struct { - PaymentsVersion versions.Version - - store *CreateStore -} - -func (c *CreateController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create a bank account"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("bank accounts are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a bank account") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - if c.PaymentsVersion.Major >= versions.V3 { - request := payments.V3CreateBankAccountRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - response, err := stackClient.Payments.V3.CreateBankAccount(cmd.Context(), &request) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.BankAccountID = response.V3CreateBankAccountResponse.Data - return c, nil - } - - request := payments.BankAccountRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.CreateBankAccount(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.BankAccountID = response.BankAccountResponse.BankAccount.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Bank account created with ID: %s", c.store.BankAccountID) - - return nil -} diff --git a/cmd/payments/bankaccounts/forward.go b/cmd/payments/bankaccounts/forward.go deleted file mode 100644 index 75ca01b5..00000000 --- a/cmd/payments/bankaccounts/forward.go +++ /dev/null @@ -1,142 +0,0 @@ -package bankaccounts - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ForwardStore struct { - BankAccountID string `json:"bankAccountId"` - ConnectorID string `json:"connectorId"` - - // V3 - TaskID string `json:"taskId"` -} -type ForwardController struct { - PaymentsVersion versions.Version - - store *ForwardStore -} - -func (c *ForwardController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ForwardStore] = (*ForwardController)(nil) - -func NewForwardStore() *ForwardStore { - return &ForwardStore{} -} - -func NewForwardController() *ForwardController { - return &ForwardController{ - store: NewForwardStore(), - } -} - -func NewForwardCommand() *cobra.Command { - c := NewForwardController() - return fctl.NewCommand("forward ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Forward a bank account to a connector"), - fctl.WithAliases("fo", "f"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithController[*ForwardStore](c), - ) -} - -func (c *ForwardController) GetStore() *ForwardStore { - return c.store -} - -func (c *ForwardController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("bank accounts are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to forward a bank account to a connector") { - return nil, fctl.ErrMissingApproval - } - - bankAccountID := args[0] - if bankAccountID == "" { - return nil, errors.New("bank account ID is required") - } - - connectorID := args[1] - if connectorID == "" { - return nil, errors.New("connector ID is required") - } - - if c.PaymentsVersion.Major < versions.V3 { - //nolint:gosimple - response, err := stackClient.Payments.V1.ForwardBankAccount(cmd.Context(), operations.ForwardBankAccountRequest{ - ForwardBankAccountRequest: payments.ForwardBankAccountRequest{ - ConnectorID: connectorID, - }, - BankAccountID: bankAccountID, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.BankAccountID = response.BankAccountResponse.BankAccount.ID - c.store.ConnectorID = *response.BankAccountResponse.BankAccount.ConnectorID - - return c, nil - } - - response, err := stackClient.Payments.V3.ForwardBankAccount(cmd.Context(), operations.V3ForwardBankAccountRequest{ - V3ForwardBankAccountRequest: &payments.V3ForwardBankAccountRequest{ - ConnectorID: connectorID, - }, - BankAccountID: bankAccountID, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TaskID = response.V3ForwardBankAccountResponse.Data.GetTaskID() - return c, nil -} - -func (c *ForwardController) Render(cmd *cobra.Command, args []string) error { - if c.PaymentsVersion.Major < versions.V3 { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Bank account %s forwarded to connector %s.", c.store.BankAccountID, c.store.ConnectorID) - return nil - } - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Bank account forwarding scheduled with task ID: %s", c.store.TaskID) - return nil -} diff --git a/cmd/payments/bankaccounts/list.go b/cmd/payments/bankaccounts/list.go deleted file mode 100644 index d03b4d5a..00000000 --- a/cmd/payments/bankaccounts/list.go +++ /dev/null @@ -1,198 +0,0 @@ -package bankaccounts - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.V3BankAccountsCursorResponseCursor `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.V3BankAccountsCursorResponseCursor{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("bank accounts are only supported in >= v1.0.0") - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - if c.PaymentsVersion.Major >= versions.V3 { - return c.v3list(cmd, stackClient, cursor, pageSize) - } - - response, err := stackClient.Payments.V1.ListBankAccounts( - cmd.Context(), - operations.ListBankAccountsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = ToV3BankAccountCursor(&response.BankAccountsCursor.CursorBase) - - return c, nil -} - -func (c *ListController) v3list(cmd *cobra.Command, stackClient *formance.Formance, cursor *string, pageSize *int64) (fctl.Renderable, error) { - response, err := stackClient.Payments.V3.ListBankAccounts( - cmd.Context(), - operations.V3ListBankAccountsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.V3BankAccountsCursorResponse.Cursor - - return c, nil -} - -func ToV3BankAccountCursor(c *payments.BankAccountsCursorCursorBase) *payments.V3BankAccountsCursorResponseCursor { - cursor := &payments.V3BankAccountsCursorResponseCursor{ - Data: make([]payments.V3BankAccount, 0, len(c.Data)), - HasMore: c.HasMore, - Next: c.Next, - Previous: c.Previous, - PageSize: c.PageSize, - } - for _, acc := range c.Data { - cursor.Data = append(cursor.Data, ToV3BankAccount(&acc)) - } - return cursor -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(bc payments.V3BankAccount) []string { - row := []string{ - bc.ID, - bc.Name, - bc.CreatedAt.Format(time.RFC3339), - "", - } - if bc.Country != nil { - row[3] = *bc.Country - } - return row - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Name", "CreatedAt", "Country"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Paging") - cursorTable := pterm.TableData{} - cursorTable = append(cursorTable, []string{pterm.LightCyan("Page Size"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - cursorTable = append(cursorTable, []string{pterm.LightCyan("Has More"), fmt.Sprintf("%t", c.store.Cursor.HasMore)}) - if c.store.Cursor.Next != nil { - cursorTable = append(cursorTable, []string{pterm.LightCyan("Next"), *c.store.Cursor.Next}) - } - if c.store.Cursor.Previous != nil { - cursorTable = append(cursorTable, []string{pterm.LightCyan("Previous"), *c.store.Cursor.Previous}) - } - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(cursorTable). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithShortDescription("List bank accounts"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/payments/bankaccounts/root.go b/cmd/payments/bankaccounts/root.go deleted file mode 100644 index bc4d8271..00000000 --- a/cmd/payments/bankaccounts/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package bankaccounts - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewBankAccountsCommand() *cobra.Command { - return fctl.NewCommand("bank_accounts", - fctl.WithAliases("bacc", "ba", "bac", "baccount"), - fctl.WithShortDescription("Manage bank accounts"), - fctl.WithChildCommands( - NewCreateCommand(), - NewForwardCommand(), - NewUpdateMetadataCommand(), - NewShowCommand(), - NewListCommand(), - ), - ) -} diff --git a/cmd/payments/bankaccounts/show.go b/cmd/payments/bankaccounts/show.go deleted file mode 100644 index 6812a9d5..00000000 --- a/cmd/payments/bankaccounts/show.go +++ /dev/null @@ -1,173 +0,0 @@ -package bankaccounts - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - BankAccount *payments.V3BankAccount `json:"bankAccount"` -} -type ShowController struct { - PaymentsVersion versions.Version - - store *ShowStore -} - -func (c *ShowController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a bank account"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("bank accounts are only supported in >= v1.0.0") - } - - if c.PaymentsVersion.Major >= versions.V3 { - response, err := stackClient.Payments.V3.GetBankAccount(cmd.Context(), operations.V3GetBankAccountRequest{ - BankAccountID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - c.store.BankAccount = &response.V3GetBankAccountResponse.V3BankAccount - - return c, nil - } - - response, err := stackClient.Payments.V1.GetBankAccount(cmd.Context(), operations.GetBankAccountRequest{ - BankAccountID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - bankAccount := ToV3BankAccount(&response.BankAccountResponse.BankAccount) - c.store.BankAccount = &bankAccount - - return c, nil -} - -func ToV3BankAccount(account *payments.BankAccount) payments.V3BankAccount { - v3Account := payments.V3BankAccount{ - AccountNumber: account.AccountNumber, - Country: &account.Country, - CreatedAt: account.CreatedAt, - Iban: account.Iban, - ID: account.ID, - V3Metadata: account.BankAccountMetadata, - Name: account.Name, - RelatedAccounts: make([]payments.V3BankAccountRelatedAccount, 0, len(account.RelatedAccounts)), - SwiftBicCode: account.SwiftBicCode, - } - for _, acc := range account.RelatedAccounts { - v3Account.RelatedAccounts = append(v3Account.RelatedAccounts, payments.V3BankAccountRelatedAccount{ - AccountID: acc.AccountID, - CreatedAt: acc.CreatedAt, - }) - } - return v3Account -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.BankAccount.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.BankAccount.Name}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.BankAccount.CreatedAt.Format(time.RFC3339)}) - if c.store.BankAccount.Country != nil { - tableData = append(tableData, []string{pterm.LightCyan("Country"), *c.store.BankAccount.Country}) - } - if c.store.BankAccount.AccountNumber != nil { - tableData = append(tableData, []string{pterm.LightCyan("AccountNumber"), *c.store.BankAccount.AccountNumber}) - } - if c.store.BankAccount.Iban != nil { - tableData = append(tableData, []string{pterm.LightCyan("Iban"), *c.store.BankAccount.Iban}) - } - if c.store.BankAccount.SwiftBicCode != nil { - tableData = append(tableData, []string{pterm.LightCyan("SwiftBicCode"), *c.store.BankAccount.SwiftBicCode}) - } - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("RelatedAccounts") - tableData = fctl.Map(c.store.BankAccount.RelatedAccounts, func(ba payments.V3BankAccountRelatedAccount) []string { - return []string{ - ba.AccountID, - ba.CreatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{"AccountID", "CreatedAt"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.PrintMetadata(cmd.OutOrStdout(), c.store.BankAccount.V3Metadata) -} diff --git a/cmd/payments/bankaccounts/update_metadata.go b/cmd/payments/bankaccounts/update_metadata.go deleted file mode 100644 index 170c64c1..00000000 --- a/cmd/payments/bankaccounts/update_metadata.go +++ /dev/null @@ -1,131 +0,0 @@ -package bankaccounts - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateMetadataStore struct { - Success bool `json:"success"` -} -type UpdateMetadataController struct { - PaymentsVersion versions.Version - - store *UpdateMetadataStore -} - -func (c *UpdateMetadataController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*UpdateMetadataStore] = (*UpdateMetadataController)(nil) - -func NewUpdateMetadataStore() *UpdateMetadataStore { - return &UpdateMetadataStore{} -} - -func NewUpdateMetadataController() *UpdateMetadataController { - return &UpdateMetadataController{ - store: NewUpdateMetadataStore(), - } -} - -func NewUpdateMetadataCommand() *cobra.Command { - c := NewUpdateMetadataController() - return fctl.NewCommand("update-metadata [=...]", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Set metadata on a bank account"), - fctl.WithAliases("um", "update-meta"), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithController[*UpdateMetadataStore](c), - ) -} - -func (c *UpdateMetadataController) GetStore() *UpdateMetadataStore { - return c.store -} - -func (c *UpdateMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("bank accounts are only supported in >= v1.0.0") - } - - metadata, err := fctl.ParseMetadata(args[1:]) - if err != nil { - return nil, err - } - - bankAccountID := args[0] - - if !fctl.CheckStackApprobation(cmd, "You are about to set a metadata on bank account '%s'", bankAccountID) { - return nil, fctl.ErrMissingApproval - } - if c.PaymentsVersion.Major >= versions.V3 { - request := operations.V3UpdateBankAccountMetadataRequest{ - V3UpdateBankAccountMetadataRequest: &payments.V3UpdateBankAccountMetadataRequest{ - V3Metadata: metadata, - }, - BankAccountID: bankAccountID, - } - - response, err := stackClient.Payments.V3.UpdateBankAccountMetadata(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Success = response.StatusCode == 204 - return c, nil - } - - request := operations.UpdateBankAccountMetadataRequest{ - UpdateBankAccountMetadataRequest: payments.UpdateBankAccountMetadataRequest{ - BankAccountMetadata: metadata, - }, - BankAccountID: bankAccountID, - } - - response, err := stackClient.Payments.V1.UpdateBankAccountMetadata(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Success = response.StatusCode == 204 - - return c, nil -} - -func (c *UpdateMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata added!") - return nil -} diff --git a/cmd/payments/connectors/configs.go b/cmd/payments/connectors/configs.go deleted file mode 100644 index a119cf46..00000000 --- a/cmd/payments/connectors/configs.go +++ /dev/null @@ -1,194 +0,0 @@ -package connectors - -import ( - "fmt" - "sort" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ConnectorConfigField struct { - Connector string `json:"connector"` - Field string `json:"field"` - DataType string `json:"dataType"` - Required bool `json:"required"` - DefaultValue string `json:"defaultValue,omitempty"` -} - -type ConnectorConfigsStore struct { - Fields []ConnectorConfigField `json:"fields"` -} - -type ConnectorConfigsController struct { - PaymentsVersion versions.Version - store *ConnectorConfigsStore -} - -func (c *ConnectorConfigsController) SetVersion(v versions.Version) { - c.PaymentsVersion = v -} - -var _ fctl.Controller[*ConnectorConfigsStore] = (*ConnectorConfigsController)(nil) - -func NewConnectorConfigsController() *ConnectorConfigsController { - return &ConnectorConfigsController{ - store: &ConnectorConfigsStore{}, - } -} - -func NewConnectorListAvailableCommand() *cobra.Command { - c := NewConnectorConfigsController() - return fctl.NewCommand("list-available", - fctl.WithAliases("la"), - fctl.WithShortDescription("List connectors available for install (dependent on Connectivity module version) and their configuration schemas"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithController[*ConnectorConfigsStore](c), - ) -} - -func (c *ConnectorConfigsController) GetStore() *ConnectorConfigsStore { - return c.store -} - -func (c *ConnectorConfigsController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - if err := c.legacyV1Configs(cmd, stackClient); err != nil { - return nil, err - } - return c, nil - } - - response, err := stackClient.Payments.V3.ListConnectorConfigs(cmd.Context()) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - if response.V3ConnectorConfigsResponse == nil { - return nil, fmt.Errorf("unexpected empty response") - } - data := response.V3ConnectorConfigsResponse.Data - connectorNames := make([]string, 0, len(data)) - for name := range data { - connectorNames = append(connectorNames, name) - } - sort.Strings(connectorNames) - for _, connectorName := range connectorNames { - fieldMap := data[connectorName] - fieldNames := make([]string, 0, len(fieldMap)) - for f := range fieldMap { - fieldNames = append(fieldNames, f) - } - sort.Strings(fieldNames) - for _, fieldName := range fieldNames { - meta := fieldMap[fieldName] - def := "" - if meta.DefaultValue != nil { - def = *meta.DefaultValue - } - c.store.Fields = append(c.store.Fields, ConnectorConfigField{ - Connector: connectorName, - Field: fieldName, - DataType: meta.DataType, - Required: meta.Required, - DefaultValue: def, - }) - } - } - - return c, nil -} - -func (c *ConnectorConfigsController) legacyV1Configs(cmd *cobra.Command, stackClient *formance.Formance) error { - response, err := stackClient.Payments.V1.ListConfigsAvailableConnectors(cmd.Context()) - if err != nil { - return err - } - if response.StatusCode >= 300 { - return fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - if response.ConnectorsConfigsResponse == nil { - return fmt.Errorf("unexpected empty response") - } - data := response.ConnectorsConfigsResponse.Data - connectorNames := make([]string, 0, len(data)) - for name := range data { - connectorNames = append(connectorNames, name) - } - sort.Strings(connectorNames) - for _, connectorName := range connectorNames { - fieldMap := data[connectorName] - fieldNames := make([]string, 0, len(fieldMap)) - for f := range fieldMap { - fieldNames = append(fieldNames, f) - } - sort.Strings(fieldNames) - for _, fieldName := range fieldNames { - meta := fieldMap[fieldName] - def := "" - if meta.DefaultValue != nil { - def = *meta.DefaultValue - } - c.store.Fields = append(c.store.Fields, ConnectorConfigField{ - Connector: connectorName, - Field: fieldName, - DataType: meta.DataType, - Required: meta.Required, - DefaultValue: def, - }) - } - } - return nil -} - -func (c *ConnectorConfigsController) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{{"Connector", "Field", "Type", "Required", "Default"}} - - lastConnector := "" - for _, f := range c.store.Fields { - connectorLabel := f.Connector - if connectorLabel == lastConnector { - connectorLabel = "" - } else { - lastConnector = f.Connector - } - req := "no" - if f.Required { - req = "yes" - } - tableData = append(tableData, []string{ - pterm.LightCyan(connectorLabel), - f.Field, - f.DataType, - req, - f.DefaultValue, - }) - } - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/payments/connectors/configs/getconfig.go b/cmd/payments/connectors/configs/getconfig.go deleted file mode 100644 index a6902b59..00000000 --- a/cmd/payments/connectors/configs/getconfig.go +++ /dev/null @@ -1,290 +0,0 @@ -package configs - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - "github.com/formancehq/go-libs/v4/collectionutils" - - "github.com/formancehq/fctl/v3/cmd/payments/connectors/internal" - "github.com/formancehq/fctl/v3/cmd/payments/connectors/views" - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type PaymentsLoadConfigStore struct { - ConnectorConfig *payments.ConnectorConfigResponse `json:"connectorConfig"` - V3ConfigData map[string]interface{} `json:"v3ConnectorConfig,omitempty"` - Provider string `json:"provider"` - ConnectorID string `json:"connectorId"` -} - -type PaymentsLoadConfigController struct { - PaymentsVersion versions.Version - - store *PaymentsLoadConfigStore - - providerNameFlag string - connectorIDFlag string -} - -func (c *PaymentsLoadConfigController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*PaymentsLoadConfigStore] = (*PaymentsLoadConfigController)(nil) - -func NewDefaultPaymentsLoadConfigStore() *PaymentsLoadConfigStore { - return &PaymentsLoadConfigStore{} -} - -func NewPaymentsLoadConfigController() *PaymentsLoadConfigController { - return &PaymentsLoadConfigController{ - store: NewDefaultPaymentsLoadConfigStore(), - providerNameFlag: "provider", - connectorIDFlag: "connector-id", - } -} - -func NewGetConfigCommand() *cobra.Command { - c := NewPaymentsLoadConfigController() - return fctl.NewCommand("get-config", - fctl.WithAliases("getconfig", "getconf", "gc", "get", "g"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag("provider", "", "Provider name (only used for v0, v1 or v2)"), - fctl.WithStringFlag("connector-id", "", "Connector ID"), - fctl.WithShortDescription("Read a connector config"), - fctl.WithController[*PaymentsLoadConfigStore](c), - ) -} - -func (c *PaymentsLoadConfigController) GetStore() *PaymentsLoadConfigStore { - return c.store -} - -func (c *PaymentsLoadConfigController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - provider := fctl.GetString(cmd, c.providerNameFlag) - connectorID := fctl.GetString(cmd, c.connectorIDFlag) - - switch c.PaymentsVersion.Major { - case versions.V3: - if connectorID == "" { - return nil, fmt.Errorf("connector-id is required for v3") - } - - // Use raw HTTP to avoid the SDK's discriminated union failing on unknown connectors. - clients, err := fctl.NewStackClientsFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - url := strings.TrimRight(clients.URI, "/") + "/api/payments/v3/connectors/" + connectorID + "/config" - req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/json") - - httpResp, err := clients.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - respBody, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } - if httpResp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code %d: %s", httpResp.StatusCode, string(respBody)) - } - - var envelope map[string]interface{} - if err := json.Unmarshal(respBody, &envelope); err != nil { - return nil, fmt.Errorf("parsing response: %w", err) - } - - data, ok := envelope["data"] - if !ok { - return nil, fmt.Errorf("unexpected response shape: missing 'data' key") - } - configData, ok := data.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected response shape: 'data' is not an object") - } - - c.store.V3ConfigData = configData - c.store.ConnectorID = connectorID - if p, ok := configData["provider"].(string); ok { - c.store.Provider = strings.ToLower(p) - } - - case versions.V0: - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if provider == "" { - return nil, fmt.Errorf("provider is required") - } - - response, err := stackClient.Payments.V1.ReadConnectorConfig(cmd.Context(), operations.ReadConnectorConfigRequest{ - Connector: payments.Connector(provider), - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Provider = provider - c.store.ConnectorConfig = response.ConnectorConfigResponse - - default: - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - connectorList, err := stackClient.Payments.V1.ListAllConnectors(cmd.Context()) - if err != nil { - return nil, err - } - if connectorList.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", connectorList.StatusCode) - } - - connectorsFiltered := collectionutils.Filter(connectorList.ConnectorsResponse.Data, func(connector payments.ConnectorsResponseData) bool { - if connectorID != "" { - return connector.ConnectorID == connectorID - } - - if provider != "" { - return connector.Connector == payments.Connector(strings.ToUpper(provider)) - } - - return true - }) - - switch len(connectorsFiltered) { - case 0: - return nil, fmt.Errorf("no connectors found") - case 1: - provider = string(connectorsFiltered[0].Connector) - connectorID = connectorsFiltered[0].ConnectorID - default: - options := make([]string, 0, len(connectorsFiltered)) - for _, connector := range connectorsFiltered { - options = append(options, strings.Join([]string{"id:" + connector.ConnectorID, "provider:" + string(connector.Connector), "name:" + connector.Name, "enabled:" + fctl.BoolPointerToString(connector.Enabled)}, " ")) - } - printer := pterm.DefaultInteractiveSelect.WithOptions(options) - selectedOption, err := printer.Show("Please select a connector") - if err != nil { - return nil, err - } - connectorID = strings.Split(strings.Split(selectedOption, " ")[0], ":")[1] - provider = strings.Split(strings.Split(selectedOption, " ")[1], ":")[1] - } - - response, err := stackClient.Payments.V1.ReadConnectorConfigV1(cmd.Context(), operations.ReadConnectorConfigV1Request{ - Connector: payments.Connector(provider), - ConnectorID: connectorID, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Provider = strings.ToLower(provider) - c.store.ConnectorID = connectorID - c.store.ConnectorConfig = response.ConnectorConfigResponse - } - - return c, nil -} - -func (c *PaymentsLoadConfigController) Render(cmd *cobra.Command, args []string) error { - if c.PaymentsVersion.Major == versions.V3 { - return c.renderV3(cmd, args) - } - return c.renderV1V2(cmd, args) -} - -func (c *PaymentsLoadConfigController) renderV3(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{{"Field", "Value"}} - - keys := make([]string, 0, len(c.store.V3ConfigData)) - for k := range c.store.V3ConfigData { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - val := fmt.Sprintf("%v", c.store.V3ConfigData[k]) - tableData = append(tableData, []string{pterm.LightCyan(k + ":"), val}) - } - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -// TODO: This need to use the ui.NewListModel -func (c *PaymentsLoadConfigController) renderV1V2(cmd *cobra.Command, args []string) error { - if c.store.ConnectorConfig == nil { - return fmt.Errorf("no connector config available") - } - var err error - provider := c.store.Provider - switch provider { - case internal.StripeConnector: - err = views.DisplayStripeConfig(cmd, c.store.ConnectorConfig) - case internal.ModulrConnector: - err = views.DisplayModulrConfig(cmd, c.store.ConnectorConfig) - case internal.BankingCircleConnector: - err = views.DisplayBankingCircleConfig(cmd, c.store.ConnectorConfig) - case internal.CurrencyCloudConnector: - err = views.DisplayCurrencyCloudConfig(cmd, c.store.ConnectorConfig) - case internal.WiseConnector: - err = views.DisplayWiseConfig(cmd, c.store.ConnectorConfig) - case internal.MangoPayConnector: - err = views.DisplayMangopayConfig(cmd, c.store.ConnectorConfig) - case internal.MoneycorpConnector: - err = views.DisplayMoneycorpConfig(cmd, c.store.ConnectorConfig) - case internal.AtlarConnector: - err = views.DisplayAtlarConfig(cmd, c.store.ConnectorConfig) - case internal.AdyenConnector: - err = views.DisplayAdyenConfig(cmd, c.store.ConnectorConfig) - default: - err = fmt.Errorf("unknown provider: %s", provider) - pterm.Error.WithWriter(cmd.OutOrStderr()).Printfln("%s", err.Error()) - } - - return err -} diff --git a/cmd/payments/connectors/configs/updateconfig.go b/cmd/payments/connectors/configs/updateconfig.go deleted file mode 100644 index fef4c1cc..00000000 --- a/cmd/payments/connectors/configs/updateconfig.go +++ /dev/null @@ -1,334 +0,0 @@ -package configs - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/connectors/internal" - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ConnectorUpdateConfigStore struct { - Success bool `json:"success"` - ConnectorID string `json:"connectorId"` -} - -type ConnectorUpdateConfigController struct { - PaymentsVersion versions.Version - store *ConnectorUpdateConfigStore - connectorIDFlag string -} - -func (c *ConnectorUpdateConfigController) SetVersion(v versions.Version) { - c.PaymentsVersion = v -} - -var _ fctl.Controller[*ConnectorUpdateConfigStore] = (*ConnectorUpdateConfigController)(nil) - -func NewConnectorUpdateConfigController() *ConnectorUpdateConfigController { - return &ConnectorUpdateConfigController{ - store: &ConnectorUpdateConfigStore{}, - connectorIDFlag: "connector-id", - } -} - -func NewUpdateConfigCommand() *cobra.Command { - c := NewConnectorUpdateConfigController() - return fctl.NewCommand("update-config |-", - fctl.WithAliases("uc"), - fctl.WithShortDescription("Update the config of a connector"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithConfirmFlag(), - fctl.WithStringFlag("connector-id", "", "Connector ID (required for v3)"), - fctl.WithController[*ConnectorUpdateConfigStore](c), - ) -} - -func (c *ConnectorUpdateConfigController) GetStore() *ConnectorUpdateConfigStore { - return c.store -} - -func (c *ConnectorUpdateConfigController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - connectorName := args[0] - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - connectorID := fctl.GetString(cmd, c.connectorIDFlag) - - script, err := fctl.ReadFile(cmd, args[1]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to update the config of connector '%s'", connectorName) { - return nil, fctl.ErrMissingApproval - } - - switch c.PaymentsVersion.Major { - case versions.V3: - if connectorID == "" { - return nil, fmt.Errorf("--connector-id is required for payments API v3") - } - clients, err := fctl.NewStackClientsFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - return c.runV3(cmd, clients, connectorName, connectorID, script) - default: - if connectorID == "" { - return nil, fmt.Errorf("--connector-id is required") - } - return c.runV1Typed(cmd, connectorName, connectorID, script) - } -} - -func (c *ConnectorUpdateConfigController) runV3( - cmd *cobra.Command, - clients *fctl.StackClients, - connectorName, connectorID, script string, -) (fctl.Renderable, error) { - // Resolve canonical provider name from the live configs endpoint. - canonicalName := connectorName - resp, err := clients.SDK.Payments.V3.ListConnectorConfigs(cmd.Context()) - if err == nil && resp.StatusCode < 300 && resp.V3ConnectorConfigsResponse != nil { - for name := range resp.V3ConnectorConfigsResponse.Data { - if strings.EqualFold(name, connectorName) { - canonicalName = name - break - } - } - } - - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(script), &configMap); err != nil { - return nil, fmt.Errorf("parsing config JSON: %w", err) - } - configMap["provider"] = canonicalName - body, err := json.Marshal(configMap) - if err != nil { - return nil, fmt.Errorf("marshaling config: %w", err) - } - - url := strings.TrimRight(clients.URI, "/") + "/api/payments/v3/connectors/" + connectorID + "/config" - req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPatch, url, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - httpResp, err := clients.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - defer httpResp.Body.Close() - - if httpResp.StatusCode >= 300 { - respBody, _ := io.ReadAll(httpResp.Body) - return nil, fmt.Errorf("unexpected status code %d: %s", httpResp.StatusCode, string(respBody)) - } - - c.store.Success = true - c.store.ConnectorID = connectorID - return c, nil -} - -func (c *ConnectorUpdateConfigController) runV1Typed( - cmd *cobra.Command, - connectorName, connectorID, script string, -) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - sc, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - name := strings.ToLower(connectorName) - switch name { - case internal.AdyenConnector: - config := &payments.AdyenConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{AdyenConfig: config}, - Connector: payments.ConnectorAdyen, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.AtlarConnector: - config := &payments.AtlarConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{AtlarConfig: config}, - Connector: payments.ConnectorAtlar, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.BankingCircleConnector: - config := &payments.BankingCircleConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{BankingCircleConfig: config}, - Connector: payments.ConnectorBankingCircle, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.CurrencyCloudConnector: - config := &payments.CurrencyCloudConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{CurrencyCloudConfig: config}, - Connector: payments.ConnectorCurrencyCloud, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.MangoPayConnector: - config := &payments.MangoPayConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{MangoPayConfig: config}, - Connector: payments.ConnectorMangopay, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.ModulrConnector: - config := &payments.ModulrConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{ModulrConfig: config}, - Connector: payments.ConnectorModulr, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.MoneycorpConnector: - config := &payments.MoneycorpConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{MoneycorpConfig: config}, - Connector: payments.ConnectorMoneycorp, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.StripeConnector: - config := &payments.StripeConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{StripeConfig: config}, - Connector: payments.ConnectorStripe, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - case internal.WiseConnector: - config := &payments.WiseConfig{} - if err := json.Unmarshal([]byte(script), config); err != nil { - return nil, err - } - resp, err := sc.Payments.V1.UpdateConnectorConfigV1(cmd.Context(), operations.UpdateConnectorConfigV1Request{ - ConnectorConfig: payments.ConnectorConfig{WiseConfig: config}, - Connector: payments.ConnectorWise, - ConnectorID: connectorID, - }) - if err != nil { - return nil, fmt.Errorf("updating connector config: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - default: - return nil, fmt.Errorf("connector %q is not supported on payments API v0/v1/v2; upgrade to v3 for dynamic connector support", connectorName) - } - - c.store.Success = true - c.store.ConnectorID = connectorID - return c, nil -} - -func (c *ConnectorUpdateConfigController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Connector '%s' config updated!", c.store.ConnectorID) - return nil -} diff --git a/cmd/payments/connectors/install/install.go b/cmd/payments/connectors/install/install.go deleted file mode 100644 index c089dd3b..00000000 --- a/cmd/payments/connectors/install/install.go +++ /dev/null @@ -1,386 +0,0 @@ -package install - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/connectors/internal" - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ConnectorInstallStore struct { - Success bool `json:"success"` - ConnectorName string `json:"connectorName"` - ConnectorID string `json:"connectorId"` -} - -type ConnectorInstallController struct { - PaymentsVersion versions.Version - store *ConnectorInstallStore -} - -func (c *ConnectorInstallController) SetVersion(v versions.Version) { - c.PaymentsVersion = v -} - -var _ fctl.Controller[*ConnectorInstallStore] = (*ConnectorInstallController)(nil) - -func NewConnectorInstallController() *ConnectorInstallController { - return &ConnectorInstallController{ - store: &ConnectorInstallStore{}, - } -} - -func (c *ConnectorInstallController) GetStore() *ConnectorInstallStore { - return c.store -} - -func (c *ConnectorInstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - connectorName := args[0] - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - script, err := fctl.ReadFile(cmd, args[1]) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to install connector '%s'", connectorName) { - return nil, fctl.ErrMissingApproval - } - - switch c.PaymentsVersion.Major { - case versions.V3: - clients, err := fctl.NewStackClientsFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - return c.runV3(cmd, clients, connectorName, script) - default: - return c.runV1Typed(cmd, connectorName, script) - } -} - -func (c *ConnectorInstallController) runV3( - cmd *cobra.Command, - clients *fctl.StackClients, - connectorName, script string, -) (fctl.Renderable, error) { - // Resolve canonical provider name from the live configs endpoint. - canonicalName := connectorName - resp, err := clients.SDK.Payments.V3.ListConnectorConfigs(cmd.Context()) - if err == nil && resp.StatusCode < 300 && resp.V3ConnectorConfigsResponse != nil { - for name := range resp.V3ConnectorConfigsResponse.Data { - if strings.EqualFold(name, connectorName) { - canonicalName = name - break - } - } - } - - // Inject/override the "provider" field in the config JSON. - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(script), &configMap); err != nil { - return nil, fmt.Errorf("parsing config JSON: %w", err) - } - configMap["provider"] = canonicalName - body, err := json.Marshal(configMap) - if err != nil { - return nil, fmt.Errorf("marshaling config: %w", err) - } - - url := strings.TrimRight(clients.URI, "/") + "/api/payments/v3/connectors/install/" + strings.ToLower(canonicalName) - req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - httpResp, err := clients.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - defer httpResp.Body.Close() - - respBody, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } - - if httpResp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code %d: %s", httpResp.StatusCode, string(respBody)) - } - - var result struct { - Data string `json:"data"` - } - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("decoding response: %w", err) - } - - c.store.Success = true - c.store.ConnectorName = canonicalName - c.store.ConnectorID = result.Data - return c, nil -} - -func (c *ConnectorInstallController) runV1Typed( - cmd *cobra.Command, - connectorName, script string, -) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - name := strings.ToLower(connectorName) - switch name { - case internal.AdyenConnector: - var config payments.AdyenConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{AdyenConfig: &config}, - Connector: payments.ConnectorAdyen, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.AtlarConnector: - var config payments.AtlarConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{AtlarConfig: &config}, - Connector: payments.ConnectorAtlar, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.BankingCircleConnector: - var config payments.BankingCircleConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{BankingCircleConfig: &config}, - Connector: payments.ConnectorBankingCircle, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.CurrencyCloudConnector: - var config payments.CurrencyCloudConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{CurrencyCloudConfig: &config}, - Connector: payments.ConnectorCurrencyCloud, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.MangoPayConnector: - var config payments.MangoPayConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{MangoPayConfig: &config}, - Connector: payments.ConnectorMangopay, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.ModulrConnector: - var config payments.ModulrConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{ModulrConfig: &config}, - Connector: payments.ConnectorModulr, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.MoneycorpConnector: - var config payments.MoneycorpConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{MoneycorpConfig: &config}, - Connector: payments.ConnectorMoneycorp, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.StripeConnector: - var config payments.StripeConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{StripeConfig: &config}, - Connector: payments.ConnectorStripe, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.WiseConnector: - var config payments.WiseConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{WiseConfig: &config}, - Connector: payments.ConnectorWise, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - case internal.GenericConnector: - var config payments.GenericConfig - if err := json.Unmarshal([]byte(script), &config); err != nil { - return nil, err - } - resp, err := stackClient.Payments.V1.InstallConnector(cmd.Context(), operations.InstallConnectorRequest{ - ConnectorConfig: payments.ConnectorConfig{GenericConfig: &config}, - Connector: payments.ConnectorGeneric, - }) - if err != nil { - return nil, fmt.Errorf("installing connector: %w", err) - } - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - c.store.Success = true - c.store.ConnectorName = name - if resp.ConnectorResponse != nil { - c.store.ConnectorID = resp.ConnectorResponse.Data.ConnectorID - } - - default: - return nil, fmt.Errorf("connector %q is not supported on payments API v0/v1/v2; upgrade to v3 for dynamic connector support", connectorName) - } - - return c, nil -} - -func (c *ConnectorInstallController) Render(cmd *cobra.Command, args []string) error { - if c.store.ConnectorID == "" { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("%s: connector installed!", c.store.ConnectorName) - } else { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("%s: connector '%s' installed!", c.store.ConnectorName, c.store.ConnectorID) - } - return nil -} diff --git a/cmd/payments/connectors/install/root.go b/cmd/payments/connectors/install/root.go deleted file mode 100644 index 29240275..00000000 --- a/cmd/payments/connectors/install/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package install - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewInstallCommand() *cobra.Command { - c := NewConnectorInstallController() - return fctl.NewCommand("install |-", - fctl.WithAliases("i"), - fctl.WithShortDescription("Install a connector"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithConfirmFlag(), - fctl.WithController[*ConnectorInstallStore](c), - ) -} diff --git a/cmd/payments/connectors/internal/connectors.go b/cmd/payments/connectors/internal/connectors.go deleted file mode 100644 index d46f7298..00000000 --- a/cmd/payments/connectors/internal/connectors.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -const ( - AdyenConnector = "adyen" - AtlarConnector = "atlar" - BankingCircleConnector = "bankingcircle" - CurrencyCloudConnector = "currencycloud" - ModulrConnector = "modulr" - StripeConnector = "stripe" - WiseConnector = "wise" - MangoPayConnector = "mangopay" - MoneycorpConnector = "moneycorp" - GenericConnector = "generic" - QontoConnector = "qonto" - ColumnConnector = "column" - FireblocksConnector = "fireblocks" - CoinbaseprimeConnector = "coinbaseprime" - IncreaseConnector = "increase" - PowensConnector = "powens" - TinkConnector = "tink" - PlaidConnector = "plaid" -) - -var AllConnectors = []string{ - AdyenConnector, - AtlarConnector, - BankingCircleConnector, - CurrencyCloudConnector, - ModulrConnector, - StripeConnector, - WiseConnector, - MangoPayConnector, - MoneycorpConnector, - GenericConnector, - QontoConnector, - ColumnConnector, - FireblocksConnector, - CoinbaseprimeConnector, - IncreaseConnector, - PowensConnector, - TinkConnector, - PlaidConnector, -} diff --git a/cmd/payments/connectors/list.go b/cmd/payments/connectors/list.go deleted file mode 100644 index 5de24c3a..00000000 --- a/cmd/payments/connectors/list.go +++ /dev/null @@ -1,173 +0,0 @@ -package connectors - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ConnectorData struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` -} - -type PaymentsConnectorsListStore struct { - Connectors []ConnectorData `json:"connectors"` -} -type PaymentsConnectorsListController struct { - PaymentsVersion versions.Version - - store *PaymentsConnectorsListStore - - pageSizeFlag string -} - -func (c *PaymentsConnectorsListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*PaymentsConnectorsListStore] = (*PaymentsConnectorsListController)(nil) - -func NewDefaultPaymentsConnectorsListStore() *PaymentsConnectorsListStore { - return &PaymentsConnectorsListStore{ - Connectors: []ConnectorData{}, - } -} - -func NewPaymentsConnectorsListController() *PaymentsConnectorsListController { - return &PaymentsConnectorsListController{ - store: NewDefaultPaymentsConnectorsListStore(), - pageSizeFlag: "page-size", - } -} - -func NewListCommand() *cobra.Command { - c := NewPaymentsConnectorsListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List all installed connectors"), - fctl.WithIntFlag(c.pageSizeFlag, 10, "Page size"), - fctl.WithController[*PaymentsConnectorsListStore](c), - ) -} - -func (c *PaymentsConnectorsListController) GetStore() *PaymentsConnectorsListStore { - return c.store -} - -func (c *PaymentsConnectorsListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - pageSizeAsInt := int64(fctl.GetInt(cmd, c.pageSizeFlag)) - - switch c.PaymentsVersion.Major { - case versions.V3: - response, err := stackClient.Payments.V3.ListConnectors(cmd.Context(), operations.V3ListConnectorsRequest{ - PageSize: &pageSizeAsInt, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.V3ConnectorsCursorResponse == nil { - return nil, fmt.Errorf("unexpected response: %v", response) - } - c.store.Connectors = fctl.Map(response.V3ConnectorsCursorResponse.Cursor.Data, V3toConnectorData) - - case versions.V0, versions.V1, versions.V2: - response, err := stackClient.Payments.V1.ListAllConnectors(cmd.Context()) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.ConnectorsResponse == nil { - return nil, fmt.Errorf("unexpected response: %v", response) - } - - connectorsLength := len(response.ConnectorsResponse.Data) - endIndex := int(pageSizeAsInt) - if connectorsLength < endIndex { - endIndex = connectorsLength - } - - connectorsSlice := response.ConnectorsResponse.Data[:endIndex] - c.store.Connectors = fctl.Map(connectorsSlice, V1toConnectorData) - - } - - return c, nil -} - -func (c *PaymentsConnectorsListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Connectors, func(connector ConnectorData) []string { - if c.PaymentsVersion.Major >= versions.V1 { - return []string{ - connector.Provider, - connector.Name, - connector.ID, - } - } else { - // V0 - return []string{ - connector.Provider, - } - } - - }) - if c.PaymentsVersion.Major >= versions.V1 { - tableData = fctl.Prepend(tableData, []string{"Provider", "Name", "ConnectorID"}) - } else { - tableData = fctl.Prepend(tableData, []string{"Provider"}) - } - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -func V3toConnectorData(connector payments.V3Connector) ConnectorData { - return ConnectorData{ - ID: connector.ID, - Name: connector.Name, - Provider: connector.Provider, - } -} - -func V1toConnectorData(connector payments.ConnectorsResponseData) ConnectorData { - return ConnectorData{ - ID: connector.ConnectorID, - Name: connector.Name, - Provider: string(connector.Connector), - } -} diff --git a/cmd/payments/connectors/root.go b/cmd/payments/connectors/root.go deleted file mode 100644 index 948ca218..00000000 --- a/cmd/payments/connectors/root.go +++ /dev/null @@ -1,26 +0,0 @@ -package connectors - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/payments/connectors/configs" - "github.com/formancehq/fctl/v3/cmd/payments/connectors/install" - "github.com/formancehq/fctl/v3/cmd/payments/connectors/schedules" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewConnectorsCommand() *cobra.Command { - return fctl.NewCommand("connectors", - fctl.WithAliases("c", "co", "con"), - fctl.WithShortDescription("Manage connectors"), - fctl.WithChildCommands( - NewUninstallCommand(), - NewListCommand(), - install.NewInstallCommand(), - configs.NewUpdateConfigCommand(), - configs.NewGetConfigCommand(), - NewConnectorListAvailableCommand(), - schedules.NewSchedulesCommand(), - ), - ) -} diff --git a/cmd/payments/connectors/schedules/instances/list.go b/cmd/payments/connectors/schedules/instances/list.go deleted file mode 100644 index f56ad69d..00000000 --- a/cmd/payments/connectors/schedules/instances/list.go +++ /dev/null @@ -1,156 +0,0 @@ -package instances - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.V3ConnectorScheduleInstancesCursorResponseCursor `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - - store *ListStore -} - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.V3ConnectorScheduleInstancesCursorResponseCursor{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list ", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List connector schedule instances"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("connector schedules are only supported in >= v3.0.0") - } - - connectorID, scheduleID := args[0], args[1] - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - - request := operations.V3ListConnectorScheduleInstancesRequest{ - ConnectorID: connectorID, - ScheduleID: scheduleID, - PageSize: fctl.Ptr(int64(pageSize)), - } - if cursor != "" { - request.Cursor = &cursor - } - - response, err := stackClient.Payments.V3.ListConnectorScheduleInstances(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.V3ConnectorScheduleInstancesCursorResponse == nil { - return nil, fmt.Errorf("unexpected response: %v", response) - } - - c.store.Cursor = &response.V3ConnectorScheduleInstancesCursorResponse.Cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(i payments.V3Instance) []string { - errStr := "" - if i.Error != nil { - errStr = *i.Error - } - terminatedAt := "" - if i.TerminatedAt != nil { - terminatedAt = i.TerminatedAt.Format(time.RFC3339) - } - return []string{ - i.ID, - fmt.Sprintf("%t", i.Terminated), - i.CreatedAt.Format(time.RFC3339), - i.UpdatedAt.Format(time.RFC3339), - terminatedAt, - errStr, - } - }) - tableData = fctl.Prepend(tableData, []string{ - "ID", "Terminated", - "CreatedAt", "UpdatedAt", "TerminatedAt", "Error", - }) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{ - HasMore: c.store.Cursor.HasMore, - PageSize: c.store.Cursor.PageSize, - Next: c.store.Cursor.Next, - Previous: c.store.Cursor.Previous, - }) -} diff --git a/cmd/payments/connectors/schedules/instances/root.go b/cmd/payments/connectors/schedules/instances/root.go deleted file mode 100644 index 76e90938..00000000 --- a/cmd/payments/connectors/schedules/instances/root.go +++ /dev/null @@ -1,17 +0,0 @@ -package instances - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewInstancesCommand() *cobra.Command { - return fctl.NewCommand("instances", - fctl.WithAliases("inst"), - fctl.WithShortDescription("Manage connector schedule instances"), - fctl.WithChildCommands( - NewListCommand(), - ), - ) -} diff --git a/cmd/payments/connectors/schedules/list.go b/cmd/payments/connectors/schedules/list.go deleted file mode 100644 index ec429dd3..00000000 --- a/cmd/payments/connectors/schedules/list.go +++ /dev/null @@ -1,140 +0,0 @@ -package schedules - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.V3ConnectorSchedulesCursorResponseCursor `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - - store *ListStore -} - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.V3ConnectorSchedulesCursorResponseCursor{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list ", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List connector schedules"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("connector schedules are only supported in >= v3.0.0") - } - - connectorID := args[0] - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - - request := operations.V3ListConnectorSchedulesRequest{ - ConnectorID: connectorID, - PageSize: fctl.Ptr(int64(pageSize)), - } - if cursor != "" { - request.Cursor = &cursor - } - - response, err := stackClient.Payments.V3.ListConnectorSchedules(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.V3ConnectorSchedulesCursorResponse == nil { - return nil, fmt.Errorf("unexpected response: %v", response) - } - - c.store.Cursor = &response.V3ConnectorSchedulesCursorResponse.Cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(s payments.V3Schedule) []string { - return []string{ - s.ID, - s.CreatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "CreatedAt"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{ - HasMore: c.store.Cursor.HasMore, - PageSize: c.store.Cursor.PageSize, - Next: c.store.Cursor.Next, - Previous: c.store.Cursor.Previous, - }) -} diff --git a/cmd/payments/connectors/schedules/root.go b/cmd/payments/connectors/schedules/root.go deleted file mode 100644 index 1ed7cc0d..00000000 --- a/cmd/payments/connectors/schedules/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package schedules - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/payments/connectors/schedules/instances" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewSchedulesCommand() *cobra.Command { - return fctl.NewCommand("schedules", - fctl.WithAliases("sch"), - fctl.WithShortDescription("Manage connector schedules"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - instances.NewInstancesCommand(), - ), - ) -} diff --git a/cmd/payments/connectors/schedules/show.go b/cmd/payments/connectors/schedules/show.go deleted file mode 100644 index a1e6f4ce..00000000 --- a/cmd/payments/connectors/schedules/show.go +++ /dev/null @@ -1,121 +0,0 @@ -package schedules - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Schedule *payments.V3Schedule `json:"schedule"` -} - -type ShowController struct { - PaymentsVersion versions.Version - - store *ShowStore -} - -func (c *ShowController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithAliases("sh", "s"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("Get a connector schedule"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("connector schedules are only supported in >= v3.0.0") - } - - connectorID, scheduleID := args[0], args[1] - - response, err := stackClient.Payments.V3.GetConnectorSchedule(cmd.Context(), operations.V3GetConnectorScheduleRequest{ - ConnectorID: connectorID, - ScheduleID: scheduleID, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.V3ConnectorScheduleResponse == nil { - return nil, fmt.Errorf("unexpected response: %v", response) - } - - c.store.Schedule = &response.V3ConnectorScheduleResponse.V3Schedule - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - s := c.store.Schedule - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{ - {pterm.LightCyan("ID"), s.ID}, - {pterm.LightCyan("ConnectorID"), s.ConnectorID}, - {pterm.LightCyan("CreatedAt"), s.CreatedAt.Format(time.RFC3339)}, - } - if s.PausedAt != nil { - tableData = append(tableData, []string{pterm.LightCyan("PausedAt"), s.PausedAt.Format(time.RFC3339)}) - } - if s.PausedReason != nil { - tableData = append(tableData, []string{pterm.LightCyan("PausedReason"), *s.PausedReason}) - } - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/payments/connectors/uninstall.go b/cmd/payments/connectors/uninstall.go deleted file mode 100644 index 0a057855..00000000 --- a/cmd/payments/connectors/uninstall.go +++ /dev/null @@ -1,178 +0,0 @@ -package connectors - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -var ( - PaymentsConnectorsUninstall = "develop" -) - -type PaymentsConnectorsUninstallStore struct { - Success bool `json:"success"` - Connector string `json:"connector"` - - // V3 - TaskID string `json:"task_id"` -} - -type PaymentsConnectorsUninstallController struct { - PaymentsVersion versions.Version - - store *PaymentsConnectorsUninstallStore - providerFlag string - connectorIDFlag string -} - -func (c *PaymentsConnectorsUninstallController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*PaymentsConnectorsUninstallStore] = (*PaymentsConnectorsUninstallController)(nil) - -func NewDefaultPaymentsConnectorsUninstallStore() *PaymentsConnectorsUninstallStore { - return &PaymentsConnectorsUninstallStore{ - Success: false, - Connector: "", - TaskID: "", - } -} - -func NewPaymentsConnectorsUninstallController() *PaymentsConnectorsUninstallController { - return &PaymentsConnectorsUninstallController{ - store: NewDefaultPaymentsConnectorsUninstallStore(), - providerFlag: "provider", - connectorIDFlag: "connector-id", - } -} - -func NewUninstallCommand() *cobra.Command { - c := NewPaymentsConnectorsUninstallController() - return fctl.NewCommand("uninstall", - fctl.WithAliases("uninstall", "u", "un"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithStringFlag(c.providerFlag, "", "Provider name"), - fctl.WithStringFlag(c.connectorIDFlag, "", "Connector ID"), - fctl.WithShortDescription("Uninstall a connector"), - fctl.WithController[*PaymentsConnectorsUninstallStore](c), - ) -} - -func (c *PaymentsConnectorsUninstallController) GetStore() *PaymentsConnectorsUninstallStore { - return c.store -} - -func (c *PaymentsConnectorsUninstallController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - provider := fctl.GetString(cmd, c.providerFlag) - connectorID := fctl.GetString(cmd, c.connectorIDFlag) - switch c.PaymentsVersion.Major { - case versions.V3: - if connectorID == "" { - return nil, fmt.Errorf("missing connector ID") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to uninstall connector '%s'", connectorID) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V3.UninstallConnector(cmd.Context(), operations.V3UninstallConnectorRequest{ - ConnectorID: connectorID, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TaskID = response.V3UninstallConnectorResponse.Data.GetTaskID() - - case versions.V1: - if provider == "" { - return nil, fmt.Errorf("missing provider") - } - - if connectorID == "" { - return nil, fmt.Errorf("missing connector ID") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to uninstall connector '%s' from provider '%s'", connectorID, provider) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V1.UninstallConnectorV1(cmd.Context(), operations.UninstallConnectorV1Request{ - ConnectorID: connectorID, - Connector: payments.Connector(provider), - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Connector = connectorID - case versions.V0: - if provider == "" { - return nil, fmt.Errorf("missing provider") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to uninstall connector '%s'", provider) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V1.UninstallConnector(cmd.Context(), operations.UninstallConnectorRequest{ - Connector: payments.Connector(provider), - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Connector = provider - } - - c.store.Success = true - - return c, nil -} - -// TODO: This need to use the ui.NewListModel -func (c *PaymentsConnectorsUninstallController) Render(cmd *cobra.Command, args []string) error { - if c.PaymentsVersion.Major < versions.V3 { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Connector '%s' uninstalled!", c.store.Connector) - return nil - } - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Connector uninstall scheduled with task ID: %s", c.store.TaskID) - return nil -} diff --git a/cmd/payments/connectors/views/adyen.go b/cmd/payments/connectors/views/adyen.go deleted file mode 100644 index 86a34930..00000000 --- a/cmd/payments/connectors/views/adyen.go +++ /dev/null @@ -1,29 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayAdyenConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.AdyenConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("ApiKey:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("HMACKey:"), config.HmacKey}) - tableData = append(tableData, []string{pterm.LightCyan("LiveEndpointPrefix:"), fctl.StringPointerToString(config.LiveEndpointPrefix)}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/atlar.go b/cmd/payments/connectors/views/atlar.go deleted file mode 100644 index e7bcad79..00000000 --- a/cmd/payments/connectors/views/atlar.go +++ /dev/null @@ -1,30 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayAtlarConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.AtlarConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("AccessKey:"), config.AccessKey}) - tableData = append(tableData, []string{pterm.LightCyan("Secret:"), config.Secret}) - tableData = append(tableData, []string{pterm.LightCyan("BaseUrl:"), fctl.StringPointerToString(config.BaseURL)}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - tableData = append(tableData, []string{pterm.LightCyan("Transfer Initiation Status Polling Period:"), fctl.StringPointerToString(config.TransferInitiationStatusPollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/banking_circle.go b/cmd/payments/connectors/views/banking_circle.go deleted file mode 100644 index 488041d7..00000000 --- a/cmd/payments/connectors/views/banking_circle.go +++ /dev/null @@ -1,53 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayBankingCircleConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.BankingCircleConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Username:"), config.Username}) - tableData = append(tableData, []string{pterm.LightCyan("Password:"), config.Password}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Authorization endpoint:"), config.AuthorizationEndpoint}) - tableData = append(tableData, []string{pterm.LightCyan("UserCertificate:"), config.UserCertificate}) - tableData = append(tableData, []string{pterm.LightCyan("UserCertificateKey:"), config.UserCertificateKey}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} - -func DisplayBankingCircleConfigV3(cmd *cobra.Command, v3Config *payments.V3GetConnectorConfigResponse) error { - config := v3Config.V3ConnectorConfig.V3BankingcircleConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Username:"), config.Username}) - tableData = append(tableData, []string{pterm.LightCyan("Password:"), config.Password}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Authorization endpoint:"), config.AuthorizationEndpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - tableData = append(tableData, []string{pterm.LightCyan("UserCertificate:"), config.UserCertificate}) - tableData = append(tableData, []string{pterm.LightCyan("UserCertificateKey:"), config.UserCertificateKey}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/currency_cloud.go b/cmd/payments/connectors/views/currency_cloud.go deleted file mode 100644 index 55abfcfd..00000000 --- a/cmd/payments/connectors/views/currency_cloud.go +++ /dev/null @@ -1,53 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayCurrencyCloudConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.CurrencyCloudConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Login ID:"), config.LoginID}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), fctl.StringPointerToString(config.Endpoint)}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} - -func DisplayCurrencyCloudConfigV3(cmd *cobra.Command, v3Config *payments.V3GetConnectorConfigResponse) error { - config := v3Config.V3ConnectorConfig.V3CurrencycloudConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Login ID:"), config.LoginID}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/mangopay.go b/cmd/payments/connectors/views/mangopay.go deleted file mode 100644 index 48758fbc..00000000 --- a/cmd/payments/connectors/views/mangopay.go +++ /dev/null @@ -1,32 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" -) - -func DisplayMangopayConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.MangoPayConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("ClientID:"), config.ClientID}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/modulr.go b/cmd/payments/connectors/views/modulr.go deleted file mode 100644 index c7007597..00000000 --- a/cmd/payments/connectors/views/modulr.go +++ /dev/null @@ -1,58 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayModulrConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.ModulrConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("API secret:"), config.APISecret}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), func() string { - if config.Endpoint == nil { - return "" - } - return *config.Endpoint - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} - -func DisplayModulrConfigV3(cmd *cobra.Command, v3Config *payments.V3GetConnectorConfigResponse) error { - config := v3Config.V3ConnectorConfig.V3ModulrConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("API secret:"), config.APISecret}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/moneycorp.go b/cmd/payments/connectors/views/moneycorp.go deleted file mode 100644 index 17b9b04a..00000000 --- a/cmd/payments/connectors/views/moneycorp.go +++ /dev/null @@ -1,53 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayMoneycorpConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.MoneycorpConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("ClientID:"), config.ClientID}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} - -func DisplayMoneycorpConfigV3(cmd *cobra.Command, v3Config *payments.V3GetConnectorConfigResponse) error { - config := v3Config.V3ConnectorConfig.V3MoneycorpConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("ClientID:"), config.ClientID}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Endpoint:"), config.Endpoint}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/stripe.go b/cmd/payments/connectors/views/stripe.go deleted file mode 100644 index 31bfb653..00000000 --- a/cmd/payments/connectors/views/stripe.go +++ /dev/null @@ -1,30 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" -) - -func DisplayStripeConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.StripeConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/connectors/views/wise.go b/cmd/payments/connectors/views/wise.go deleted file mode 100644 index 15742602..00000000 --- a/cmd/payments/connectors/views/wise.go +++ /dev/null @@ -1,49 +0,0 @@ -package views - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func DisplayWiseConfig(cmd *cobra.Command, connectorConfig *payments.ConnectorConfigResponse) error { - config := connectorConfig.ConnectorConfig.WiseConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), func() string { - if config.PollingPeriod == nil { - return "" - } - return *config.PollingPeriod - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} - -func DisplayWiseConfigV3(cmd *cobra.Command, v3Config *payments.V3GetConnectorConfigResponse) error { - config := v3Config.V3ConnectorConfig.V3WiseConfig - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name:"), config.Name}) - tableData = append(tableData, []string{pterm.LightCyan("API key:"), config.APIKey}) - tableData = append(tableData, []string{pterm.LightCyan("Polling Period:"), fctl.StringPointerToString(config.PollingPeriod)}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return nil -} diff --git a/cmd/payments/conversions/list.go b/cmd/payments/conversions/list.go deleted file mode 100644 index 39af96b9..00000000 --- a/cmd/payments/conversions/list.go +++ /dev/null @@ -1,232 +0,0 @@ -package conversions - -import ( - "fmt" - "math/big" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const conversionsMinMinor = 3 - -type Conversion struct { - ID string `json:"id"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - SourceAsset string `json:"sourceAsset"` - DestinationAsset string `json:"destinationAsset"` - SourceAmount *big.Int `json:"sourceAmount"` - DestinationAmount *big.Int `json:"destinationAmount,omitempty"` - Fee *big.Int `json:"fee,omitempty"` - FeeAsset *string `json:"feeAsset,omitempty"` - Status string `json:"status"` - SourceAccountID *string `json:"sourceAccountID,omitempty"` - DestinationAccountID *string `json:"destinationAccountID,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Error *string `json:"error,omitempty"` -} - -type ListStore struct { - Conversions []Conversion `json:"conversions"` - Cursor fctl.Cursor `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - store *ListStore - - connectorIDFlag string - referenceFlag string - statusFlag string - sourceAssetFlag string - destinationAssetFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func (c *ListController) SetVersion(version versions.Version) { c.PaymentsVersion = version } - -func NewListController() *ListController { - return &ListController{ - store: &ListStore{Conversions: []Conversion{}}, - connectorIDFlag: "connector-id", - referenceFlag: "reference", - statusFlag: "status", - sourceAssetFlag: "source-asset", - destinationAssetFlag: "destination-asset", - } -} - -func (c *ListController) GetStore() *ListStore { return c.store } - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List currency conversions ingested from exchange-style connectors"), - fctl.WithStringFlag(c.connectorIDFlag, "", "Filter by connector ID"), - fctl.WithStringFlag(c.referenceFlag, "", "Filter by PSP-assigned reference"), - fctl.WithStringFlag(c.statusFlag, "", "Filter by status (PENDING|COMPLETED|FAILED|UNKNOWN)"), - fctl.WithStringFlag(c.sourceAssetFlag, "", "Filter by source asset (e.g. USD/2)"), - fctl.WithStringFlag(c.destinationAssetFlag, "", "Filter by destination asset (e.g. USDC/6)"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListStore](c), - ) -} - -// buildQuery turns the filter flags into a $match/$and query body. Keys are the -// payments storage column names (snake_case): the endpoint whitelists them and -// rejects anything else with a VALIDATION error. -func (c *ListController) buildQuery(cmd *cobra.Command) map[string]any { - matches := make([]map[string]any, 0) - addMatch := func(field, val string) { - if val == "" { - return - } - matches = append(matches, map[string]any{"$match": map[string]any{field: val}}) - } - addMatch("connector_id", fctl.GetString(cmd, c.connectorIDFlag)) - addMatch("reference", fctl.GetString(cmd, c.referenceFlag)) - addMatch("status", fctl.GetString(cmd, c.statusFlag)) - addMatch("source_asset", fctl.GetString(cmd, c.sourceAssetFlag)) - addMatch("destination_asset", fctl.GetString(cmd, c.destinationAssetFlag)) - - if len(matches) == 0 { - return nil - } - return map[string]any{"$and": matches} -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, nil, c); err != nil { - return nil, err - } - if !c.PaymentsVersion.IsAtLeast(versions.V3, conversionsMinMinor) { - return nil, fmt.Errorf("conversions require Payments >= v3.%d (stack reports %s)", conversionsMinMinor, c.PaymentsVersion.Raw) - } - - query := c.buildQuery(cmd) - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) - - // The V3 query endpoints accept either a query body (first page) or an - // opaque cursor (subsequent pages), never both. - req := operations.V3ListConversionsRequest{} - if cursor != "" { - req.Cursor = fctl.Ptr(cursor) - } else { - req.RequestBody = query - req.PageSize = fctl.Ptr(int64(pageSize)) - } - - res, err := stackClient.Payments.V3.ListConversions(cmd.Context(), req) - if err != nil { - return nil, err - } - if res.V3ConversionsCursorResponse == nil { - return nil, fmt.Errorf("conversions.list: empty response (status %d)", res.StatusCode) - } - - cur := res.V3ConversionsCursorResponse.Cursor - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.list received=%d hasMore=%v", len(cur.Data), cur.HasMore) - c.store.Conversions = fctl.Map(cur.Data, toConversion) - c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous} - return c, nil -} - -// toConversion maps an SDK V3Conversion onto the local nil-safe store type. -// The status enum is a typed string and metadata lives under V3Metadata. -func toConversion(cv paymentsmodels.V3Conversion) Conversion { - return Conversion{ - ID: cv.ID, - ConnectorID: cv.ConnectorID, - Provider: cv.Provider, - Reference: cv.Reference, - CreatedAt: cv.CreatedAt, - UpdatedAt: cv.UpdatedAt, - SourceAsset: cv.SourceAsset, - DestinationAsset: cv.DestinationAsset, - SourceAmount: cv.SourceAmount, - DestinationAmount: cv.DestinationAmount, - Fee: cv.Fee, - FeeAsset: cv.FeeAsset, - Status: string(cv.V3ConversionStatusEnum), - SourceAccountID: cv.SourceAccountID, - DestinationAccountID: cv.DestinationAccountID, - Metadata: cv.V3Metadata, - Error: cv.Error, - } -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - tableData := fctl.Map(c.store.Conversions, func(cv Conversion) []string { - return []string{ - cv.ID, - cv.Reference, - cv.Provider, - cv.Status, - cv.SourceAsset, - cv.DestinationAsset, - bigIntString(cv.SourceAmount), - bigIntString(cv.DestinationAmount), - cv.CreatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{ - "ID", "Reference", "Provider", "Status", - "SourceAsset", "DestinationAsset", "SourceAmount", "DestinationAmount", "CreatedAt", - }) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) -} - -// bigIntString returns the decimal string of b, or "" when b is nil. -func bigIntString(b *big.Int) string { - if b == nil { - return "" - } - return b.String() -} - -// strDeref returns the value of s, or "" when s is nil. -func strDeref(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/cmd/payments/conversions/list_test.go b/cmd/payments/conversions/list_test.go deleted file mode 100644 index 25c3a289..00000000 --- a/cmd/payments/conversions/list_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package conversions - -import ( - "math/big" - "reflect" - "testing" - "time" - - "github.com/spf13/cobra" - - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" -) - -func TestBuildQuery(t *testing.T) { - c := NewListController() - cmd := &cobra.Command{} - for _, f := range []string{ - c.connectorIDFlag, c.referenceFlag, c.statusFlag, - c.sourceAssetFlag, c.destinationAssetFlag, - } { - cmd.Flags().String(f, "", "") - } - - if got := c.buildQuery(cmd); got != nil { - t.Fatalf("no flags set: want nil query body, got %v", got) - } - - for flag, val := range map[string]string{c.statusFlag: "COMPLETED", c.sourceAssetFlag: "USD/2"} { - if err := cmd.Flags().Set(flag, val); err != nil { - t.Fatalf("set --%s: %v", flag, err) - } - } - - // Keys must be the snake_case storage columns; the endpoint rejects anything else. - want := map[string]any{"$and": []map[string]any{ - {"$match": map[string]any{"status": "COMPLETED"}}, - {"$match": map[string]any{"source_asset": "USD/2"}}, - }} - if got := c.buildQuery(cmd); !reflect.DeepEqual(got, want) { - t.Fatalf("buildQuery mismatch:\n got=%v\nwant=%v", got, want) - } -} - -func TestToConversion(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - got := toConversion(paymentsmodels.V3Conversion{ - ID: "cv_1", - ConnectorID: "conn_1", - Provider: "coinbaseprime", - Reference: "ref-1", - CreatedAt: now, - UpdatedAt: now, - V3ConversionStatusEnum: paymentsmodels.V3ConversionStatusEnum("COMPLETED"), - SourceAsset: "USD/2", - DestinationAsset: "USDC/6", - SourceAmount: big.NewInt(1000), - DestinationAmount: big.NewInt(999), - V3Metadata: map[string]string{"k": "v"}, - }) - - if got.ID != "cv_1" { - t.Errorf("ID = %q", got.ID) - } - // Status is a typed-string enum on the SDK and must surface as a plain string. - if got.Status != "COMPLETED" { - t.Errorf("Status = %q, want COMPLETED", got.Status) - } - if got.SourceAmount.Cmp(big.NewInt(1000)) != 0 || got.DestinationAmount.Cmp(big.NewInt(999)) != 0 { - t.Errorf("amounts mismatch: src=%v dst=%v", got.SourceAmount, got.DestinationAmount) - } - if !reflect.DeepEqual(got.Metadata, map[string]string{"k": "v"}) { - t.Errorf("Metadata = %v", got.Metadata) - } -} diff --git a/cmd/payments/conversions/root.go b/cmd/payments/conversions/root.go deleted file mode 100644 index 20a464fa..00000000 --- a/cmd/payments/conversions/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package conversions - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewConversionsCommand() *cobra.Command { - return fctl.NewCommand("conversions", - fctl.WithAliases("cv"), - fctl.WithShortDescription("Manage currency conversions (read-only) ingested from exchange-style connectors"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/payments/conversions/show.go b/cmd/payments/conversions/show.go deleted file mode 100644 index 3b85517c..00000000 --- a/cmd/payments/conversions/show.go +++ /dev/null @@ -1,110 +0,0 @@ -package conversions - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/metadata" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Conversion *Conversion `json:"conversion"` -} - -type ShowController struct { - PaymentsVersion versions.Version - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func (c *ShowController) SetVersion(version versions.Version) { c.PaymentsVersion = version } - -func NewShowController() *ShowController { - return &ShowController{store: &ShowStore{}} -} - -func (c *ShowController) GetStore() *ShowStore { return c.store } - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithAliases("sh", "s"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("Get a single conversion by its Formance ID"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - if !c.PaymentsVersion.IsAtLeast(versions.V3, conversionsMinMinor) { - return nil, fmt.Errorf("conversions require Payments >= v3.%d (stack reports %s)", conversionsMinMinor, c.PaymentsVersion.Raw) - } - - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("conversions.show conversionID=%q", args[0]) - - res, err := stackClient.Payments.V3.GetConversion(cmd.Context(), operations.V3GetConversionRequest{ConversionID: args[0]}) - if err != nil { - return nil, err - } - if res.V3GetConversionResponse == nil { - return nil, fmt.Errorf("conversions.get: empty response (status %d)", res.StatusCode) - } - - conversion := toConversion(res.V3GetConversionResponse.V3Conversion) - c.store.Conversion = &conversion - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - if c.store.Conversion == nil { - fctl.Println("No conversion data.") - return nil - } - cv := c.store.Conversion - out := cmd.OutOrStdout() - - fctl.Section.WithWriter(out).Println("Information") - info := pterm.TableData{ - {pterm.LightCyan("ID"), cv.ID}, - {pterm.LightCyan("Reference"), cv.Reference}, - {pterm.LightCyan("ConnectorID"), cv.ConnectorID}, - {pterm.LightCyan("Provider"), cv.Provider}, - {pterm.LightCyan("Status"), cv.Status}, - {pterm.LightCyan("SourceAsset"), cv.SourceAsset}, - {pterm.LightCyan("DestinationAsset"), cv.DestinationAsset}, - {pterm.LightCyan("SourceAmount"), bigIntString(cv.SourceAmount)}, - {pterm.LightCyan("DestinationAmount"), bigIntString(cv.DestinationAmount)}, - {pterm.LightCyan("Fee"), bigIntString(cv.Fee)}, - {pterm.LightCyan("FeeAsset"), strDeref(cv.FeeAsset)}, - {pterm.LightCyan("SourceAccountID"), strDeref(cv.SourceAccountID)}, - {pterm.LightCyan("DestinationAccountID"), strDeref(cv.DestinationAccountID)}, - {pterm.LightCyan("CreatedAt"), cv.CreatedAt.Format(time.RFC3339)}, - {pterm.LightCyan("UpdatedAt"), cv.UpdatedAt.Format(time.RFC3339)}, - {pterm.LightCyan("Error"), strDeref(cv.Error)}, - } - if err := pterm.DefaultTable.WithWriter(out).WithData(info).Render(); err != nil { - return err - } - - return fctl.PrintMetadata(out, metadata.Metadata(cv.Metadata)) -} diff --git a/cmd/payments/orders/list.go b/cmd/payments/orders/list.go deleted file mode 100644 index 662dbdff..00000000 --- a/cmd/payments/orders/list.go +++ /dev/null @@ -1,301 +0,0 @@ -package orders - -import ( - "fmt" - "math/big" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ordersMinMinor = 3 - -type Order struct { - ID string `json:"id"` - ConnectorID string `json:"connectorID"` - Provider string `json:"provider"` - Reference string `json:"reference"` - ClientOrderID *string `json:"clientOrderID,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Direction string `json:"direction"` - SourceAsset string `json:"sourceAsset"` - DestinationAsset string `json:"destinationAsset"` - Type string `json:"type"` - Status string `json:"status"` - BaseQuantityOrdered *big.Int `json:"baseQuantityOrdered"` - BaseQuantityFilled *big.Int `json:"baseQuantityFilled,omitempty"` - LimitPrice *big.Int `json:"limitPrice,omitempty"` - StopPrice *big.Int `json:"stopPrice,omitempty"` - TimeInForce string `json:"timeInForce"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - Fee *big.Int `json:"fee,omitempty"` - FeeAsset *string `json:"feeAsset,omitempty"` - AverageFillPrice *big.Int `json:"averageFillPrice,omitempty"` - QuoteAmount *big.Int `json:"quoteAmount,omitempty"` - QuoteAsset *string `json:"quoteAsset,omitempty"` - PriceAsset *string `json:"priceAsset,omitempty"` - SourceAccountID *string `json:"sourceAccountID,omitempty"` - DestinationAccountID *string `json:"destinationAccountID,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Adjustments []OrderAdjustment `json:"adjustments,omitempty"` - Error *string `json:"error,omitempty"` -} - -type OrderAdjustment struct { - ID string `json:"id"` - Reference string `json:"reference"` - CreatedAt time.Time `json:"createdAt"` - Status string `json:"status"` - BaseQuantityFilled *big.Int `json:"baseQuantityFilled,omitempty"` - Fee *big.Int `json:"fee,omitempty"` - FeeAsset *string `json:"feeAsset,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Raw map[string]any `json:"raw,omitempty"` -} - -type ListStore struct { - Orders []Order `json:"orders"` - Cursor fctl.Cursor `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - store *ListStore - - connectorIDFlag string - referenceFlag string - directionFlag string - statusFlag string - typeFlag string - sourceAssetFlag string - destinationAssetFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -func NewListController() *ListController { - return &ListController{ - store: &ListStore{Orders: []Order{}}, - connectorIDFlag: "connector-id", - referenceFlag: "reference", - directionFlag: "direction", - statusFlag: "status", - typeFlag: "type", - sourceAssetFlag: "source-asset", - destinationAssetFlag: "destination-asset", - } -} - -func (c *ListController) GetStore() *ListStore { return c.store } - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List orders ingested from exchange-style connectors"), - fctl.WithStringFlag(c.connectorIDFlag, "", "Filter by connector ID"), - fctl.WithStringFlag(c.referenceFlag, "", "Filter by PSP-assigned reference"), - fctl.WithStringFlag(c.directionFlag, "", "Filter by order direction (BUY|SELL|UNKNOWN)"), - fctl.WithStringFlag(c.statusFlag, "", "Filter by status (PENDING|OPEN|PARTIALLY_FILLED|FILLED|CANCELLED|FAILED|EXPIRED|UNKNOWN)"), - fctl.WithStringFlag(c.typeFlag, "", "Filter by order type (MARKET|LIMIT|STOP|STOP_LIMIT|TWAP|VWAP|PEG|BLOCK|RFQ|TRAILING_STOP|TRAILING_STOP_LIMIT|TAKE_PROFIT|TAKE_PROFIT_LIMIT|LIMIT_MAKER|UNKNOWN)"), - fctl.WithStringFlag(c.sourceAssetFlag, "", "Filter by source asset (e.g. USD/2)"), - fctl.WithStringFlag(c.destinationAssetFlag, "", "Filter by destination asset (e.g. BTC/8)"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListStore](c), - ) -} - -// buildQuery turns the filter flags into a $match/$and query body. Keys are the -// payments storage column names (snake_case): the endpoint whitelists them and -// rejects anything else with a VALIDATION error. -func (c *ListController) buildQuery(cmd *cobra.Command) map[string]any { - matches := make([]map[string]any, 0) - addMatch := func(field, val string) { - if val == "" { - return - } - matches = append(matches, map[string]any{"$match": map[string]any{field: val}}) - } - addMatch("connector_id", fctl.GetString(cmd, c.connectorIDFlag)) - addMatch("reference", fctl.GetString(cmd, c.referenceFlag)) - addMatch("direction", fctl.GetString(cmd, c.directionFlag)) - addMatch("status", fctl.GetString(cmd, c.statusFlag)) - addMatch("type", fctl.GetString(cmd, c.typeFlag)) - addMatch("source_asset", fctl.GetString(cmd, c.sourceAssetFlag)) - addMatch("destination_asset", fctl.GetString(cmd, c.destinationAssetFlag)) - - if len(matches) == 0 { - return nil - } - return map[string]any{"$and": matches} -} - -func (c *ListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, nil, c); err != nil { - return nil, err - } - if !c.PaymentsVersion.IsAtLeast(versions.V3, ordersMinMinor) { - return nil, fmt.Errorf("orders require Payments >= v3.%d (stack reports %s)", ordersMinMinor, c.PaymentsVersion.Raw) - } - - query := c.buildQuery(cmd) - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.list query=%v cursor=%q pageSize=%d", query, cursor, pageSize) - - // The V3 query endpoints accept either a query body (first page) or an - // opaque cursor (subsequent pages), never both. - req := operations.V3ListOrdersRequest{} - if cursor != "" { - req.Cursor = fctl.Ptr(cursor) - } else { - req.RequestBody = query - req.PageSize = fctl.Ptr(int64(pageSize)) - } - - res, err := stackClient.Payments.V3.ListOrders(cmd.Context(), req) - if err != nil { - return nil, err - } - if res.V3OrdersCursorResponse == nil { - return nil, fmt.Errorf("orders.list: empty response (status %d)", res.StatusCode) - } - - cur := res.V3OrdersCursorResponse.Cursor - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.list received=%d hasMore=%v", len(cur.Data), cur.HasMore) - c.store.Orders = fctl.Map(cur.Data, toOrder) - c.store.Cursor = fctl.Cursor{HasMore: cur.HasMore, PageSize: cur.PageSize, Next: cur.Next, Previous: cur.Previous} - return c, nil -} - -// toOrder maps an SDK V3Order onto the local store type, casting the typed -// string enums and reading metadata from the SDK's V3Metadata field. -func toOrder(o paymentsmodels.V3Order) Order { - return Order{ - ID: o.ID, - ConnectorID: o.ConnectorID, - Provider: o.Provider, - Reference: o.Reference, - ClientOrderID: o.ClientOrderID, - CreatedAt: o.CreatedAt, - UpdatedAt: o.UpdatedAt, - Direction: string(o.V3OrderDirectionEnum), - SourceAsset: o.SourceAsset, - DestinationAsset: o.DestinationAsset, - Type: string(o.V3OrderTypeEnum), - Status: string(o.V3OrderStatusEnum), - BaseQuantityOrdered: o.BaseQuantityOrdered, - BaseQuantityFilled: o.BaseQuantityFilled, - LimitPrice: o.LimitPrice, - StopPrice: o.StopPrice, - TimeInForce: string(o.V3TimeInForceEnum), - ExpiresAt: o.ExpiresAt, - Fee: o.Fee, - FeeAsset: o.FeeAsset, - AverageFillPrice: o.AverageFillPrice, - QuoteAmount: o.QuoteAmount, - QuoteAsset: o.QuoteAsset, - PriceAsset: o.PriceAsset, - SourceAccountID: o.SourceAccountID, - DestinationAccountID: o.DestinationAccountID, - Metadata: o.V3Metadata, - Adjustments: fctl.Map(o.Adjustments, toOrderAdjustment), - Error: o.Error, - } -} - -func toOrderAdjustment(a paymentsmodels.V3OrderAdjustment) OrderAdjustment { - return OrderAdjustment{ - ID: a.ID, - Reference: a.Reference, - CreatedAt: a.CreatedAt, - Status: string(a.V3OrderStatusEnum), - BaseQuantityFilled: a.BaseQuantityFilled, - Fee: a.Fee, - FeeAsset: a.FeeAsset, - Metadata: a.V3Metadata, - } -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - tableData := fctl.Map(c.store.Orders, func(o Order) []string { - return []string{ - o.ID, - o.Reference, - o.Provider, - o.Direction, - o.Type, - o.Status, - o.SourceAsset, - o.DestinationAsset, - bigIntString(o.BaseQuantityOrdered), - bigIntString(o.BaseQuantityFilled), - o.CreatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{ - "ID", "Reference", "Provider", "Direction", "Type", "Status", - "SourceAsset", "DestinationAsset", "BaseQuantityOrdered", "BaseQuantityFilled", "CreatedAt", - }) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) -} - -// bigIntString returns the decimal string of b, or "" when b is nil. -func bigIntString(b *big.Int) string { - if b == nil { - return "" - } - return b.String() -} - -// strDeref returns the value of s, or "" when s is nil. -func strDeref(s *string) string { - if s == nil { - return "" - } - return *s -} - -// timeRFC3339 formats t as RFC3339, or "" when t is nil. -func timeRFC3339(t *time.Time) string { - if t == nil { - return "" - } - return t.Format(time.RFC3339) -} diff --git a/cmd/payments/orders/list_test.go b/cmd/payments/orders/list_test.go deleted file mode 100644 index 5c4a6b5e..00000000 --- a/cmd/payments/orders/list_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package orders - -import ( - "math/big" - "reflect" - "testing" - "time" - - "github.com/spf13/cobra" - - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" -) - -func TestBuildQuery(t *testing.T) { - c := NewListController() - cmd := &cobra.Command{} - for _, f := range []string{ - c.connectorIDFlag, c.referenceFlag, c.directionFlag, c.statusFlag, - c.typeFlag, c.sourceAssetFlag, c.destinationAssetFlag, - } { - cmd.Flags().String(f, "", "") - } - - if got := c.buildQuery(cmd); got != nil { - t.Fatalf("no flags set: want nil query body, got %v", got) - } - - for flag, val := range map[string]string{c.statusFlag: "FILLED", c.sourceAssetFlag: "USD/2"} { - if err := cmd.Flags().Set(flag, val); err != nil { - t.Fatalf("set --%s: %v", flag, err) - } - } - - // Keys must be the snake_case storage columns; the endpoint rejects anything else. - want := map[string]any{"$and": []map[string]any{ - {"$match": map[string]any{"status": "FILLED"}}, - {"$match": map[string]any{"source_asset": "USD/2"}}, - }} - if got := c.buildQuery(cmd); !reflect.DeepEqual(got, want) { - t.Fatalf("buildQuery mismatch:\n got=%v\nwant=%v", got, want) - } -} - -func TestToOrder(t *testing.T) { - now := time.Now().UTC().Truncate(time.Second) - got := toOrder(paymentsmodels.V3Order{ - ID: "ord_1", - ConnectorID: "conn_1", - Provider: "coinbaseprime", - Reference: "ref-1", - CreatedAt: now, - UpdatedAt: now, - V3OrderDirectionEnum: paymentsmodels.V3OrderDirectionEnum("BUY"), - V3OrderStatusEnum: paymentsmodels.V3OrderStatusEnum("FILLED"), - V3OrderTypeEnum: paymentsmodels.V3OrderTypeEnum("LIMIT"), - V3TimeInForceEnum: paymentsmodels.V3TimeInForceEnum("GOOD_UNTIL_CANCELLED"), - SourceAsset: "USD/2", - DestinationAsset: "BTC/8", - BaseQuantityOrdered: big.NewInt(100), - V3Metadata: map[string]string{"k": "v"}, - Adjustments: []paymentsmodels.V3OrderAdjustment{{ - ID: "adj_1", - V3OrderStatusEnum: paymentsmodels.V3OrderStatusEnum("PARTIALLY_FILLED"), - V3Metadata: map[string]string{"a": "b"}, - }}, - }) - - // The enums are typed strings on the SDK side and must surface as plain strings. - for _, tc := range []struct{ name, got, want string }{ - {"ID", got.ID, "ord_1"}, - {"Direction", got.Direction, "BUY"}, - {"Status", got.Status, "FILLED"}, - {"Type", got.Type, "LIMIT"}, - {"TimeInForce", got.TimeInForce, "GOOD_UNTIL_CANCELLED"}, - } { - if tc.got != tc.want { - t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) - } - } - if got.BaseQuantityOrdered.Cmp(big.NewInt(100)) != 0 { - t.Errorf("BaseQuantityOrdered = %v, want 100", got.BaseQuantityOrdered) - } - if !reflect.DeepEqual(got.Metadata, map[string]string{"k": "v"}) { - t.Errorf("Metadata = %v", got.Metadata) - } - if len(got.Adjustments) != 1 || got.Adjustments[0].Status != "PARTIALLY_FILLED" { - t.Fatalf("adjustments not mapped: %+v", got.Adjustments) - } - if !reflect.DeepEqual(got.Adjustments[0].Metadata, map[string]string{"a": "b"}) { - t.Errorf("adjustment metadata = %v", got.Adjustments[0].Metadata) - } -} diff --git a/cmd/payments/orders/root.go b/cmd/payments/orders/root.go deleted file mode 100644 index 9c79a627..00000000 --- a/cmd/payments/orders/root.go +++ /dev/null @@ -1,18 +0,0 @@ -package orders - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewOrdersCommand() *cobra.Command { - return fctl.NewCommand("orders", - fctl.WithAliases("o"), - fctl.WithShortDescription("Manage orders (read-only) ingested from exchange-style connectors"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/payments/orders/show.go b/cmd/payments/orders/show.go deleted file mode 100644 index 6b226ffb..00000000 --- a/cmd/payments/orders/show.go +++ /dev/null @@ -1,140 +0,0 @@ -package orders - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/go-libs/v4/metadata" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Order *Order `json:"order"` -} - -type ShowController struct { - PaymentsVersion versions.Version - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func (c *ShowController) SetVersion(version versions.Version) { c.PaymentsVersion = version } - -func NewShowController() *ShowController { - return &ShowController{store: &ShowStore{}} -} - -func (c *ShowController) GetStore() *ShowStore { return c.store } - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithAliases("sh", "s"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("Get a single order by its Formance ID, including its full adjustments history"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - if !c.PaymentsVersion.IsAtLeast(versions.V3, ordersMinMinor) { - return nil, fmt.Errorf("orders require Payments >= v3.%d (stack reports %s)", ordersMinMinor, c.PaymentsVersion.Raw) - } - - pterm.Debug.WithWriter(cmd.ErrOrStderr()).Printfln("orders.show orderID=%q", args[0]) - - res, err := stackClient.Payments.V3.GetOrder(cmd.Context(), operations.V3GetOrderRequest{OrderID: args[0]}) - if err != nil { - return nil, err - } - if res.V3GetOrderResponse == nil { - return nil, fmt.Errorf("orders.get: empty response (status %d)", res.StatusCode) - } - - order := toOrder(res.V3GetOrderResponse.V3Order) - c.store.Order = &order - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, _ []string) error { - if c.store.Order == nil { - fctl.Println("No order data.") - return nil - } - o := c.store.Order - out := cmd.OutOrStdout() - - fctl.Section.WithWriter(out).Println("Information") - info := pterm.TableData{ - {pterm.LightCyan("ID"), o.ID}, - {pterm.LightCyan("Reference"), o.Reference}, - {pterm.LightCyan("ConnectorID"), o.ConnectorID}, - {pterm.LightCyan("Provider"), o.Provider}, - {pterm.LightCyan("ClientOrderID"), strDeref(o.ClientOrderID)}, - {pterm.LightCyan("Direction"), o.Direction}, - {pterm.LightCyan("Type"), o.Type}, - {pterm.LightCyan("Status"), o.Status}, - {pterm.LightCyan("TimeInForce"), o.TimeInForce}, - {pterm.LightCyan("SourceAsset"), o.SourceAsset}, - {pterm.LightCyan("DestinationAsset"), o.DestinationAsset}, - {pterm.LightCyan("BaseQuantityOrdered"), bigIntString(o.BaseQuantityOrdered)}, - {pterm.LightCyan("BaseQuantityFilled"), bigIntString(o.BaseQuantityFilled)}, - {pterm.LightCyan("LimitPrice"), bigIntString(o.LimitPrice)}, - {pterm.LightCyan("StopPrice"), bigIntString(o.StopPrice)}, - {pterm.LightCyan("AverageFillPrice"), bigIntString(o.AverageFillPrice)}, - {pterm.LightCyan("QuoteAmount"), bigIntString(o.QuoteAmount)}, - {pterm.LightCyan("QuoteAsset"), strDeref(o.QuoteAsset)}, - {pterm.LightCyan("PriceAsset"), strDeref(o.PriceAsset)}, - {pterm.LightCyan("Fee"), bigIntString(o.Fee)}, - {pterm.LightCyan("FeeAsset"), strDeref(o.FeeAsset)}, - {pterm.LightCyan("SourceAccountID"), strDeref(o.SourceAccountID)}, - {pterm.LightCyan("DestinationAccountID"), strDeref(o.DestinationAccountID)}, - {pterm.LightCyan("ExpiresAt"), timeRFC3339(o.ExpiresAt)}, - {pterm.LightCyan("CreatedAt"), o.CreatedAt.Format(time.RFC3339)}, - {pterm.LightCyan("UpdatedAt"), o.UpdatedAt.Format(time.RFC3339)}, - {pterm.LightCyan("Error"), strDeref(o.Error)}, - } - if err := pterm.DefaultTable.WithWriter(out).WithData(info).Render(); err != nil { - return err - } - - if err := fctl.PrintMetadata(out, metadata.Metadata(o.Metadata)); err != nil { - return err - } - - if len(o.Adjustments) == 0 { - return nil - } - fctl.Section.WithWriter(out).Println("Adjustments") - adj := fctl.Map(o.Adjustments, func(a OrderAdjustment) []string { - return []string{ - a.CreatedAt.Format(time.RFC3339), - a.Status, - bigIntString(a.BaseQuantityFilled), - bigIntString(a.Fee), - strDeref(a.FeeAsset), - a.ID, - } - }) - adj = fctl.Prepend(adj, []string{"CreatedAt", "Status", "BaseQuantityFilled", "Fee", "FeeAsset", "ID"}) - return pterm.DefaultTable.WithHasHeader().WithWriter(out).WithData(adj).Render() -} diff --git a/cmd/payments/payments/create.go b/cmd/payments/payments/create.go deleted file mode 100644 index ac4fc992..00000000 --- a/cmd/payments/payments/create.go +++ /dev/null @@ -1,108 +0,0 @@ -package payments - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - PaymentID string `json:"paymentID"` -} -type CreateController struct { - PaymentsVersion versions.Version - - store *CreateStore -} - -func (c *CreateController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create a payment on the Formance platform"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("payment creation are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a payment") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - request := paymentsmodels.PaymentRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.CreatePayment(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PaymentID = response.PaymentResponse.Payment.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Payment created with ID: %s", c.store.PaymentID) - - return nil -} diff --git a/cmd/payments/payments/list.go b/cmd/payments/payments/list.go deleted file mode 100644 index 01d2c4c3..00000000 --- a/cmd/payments/payments/list.go +++ /dev/null @@ -1,153 +0,0 @@ -package payments - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *paymentsmodels.PaymentsCursorCursorBase `json:"cursor"` -} -type ListController struct { - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &paymentsmodels.PaymentsCursorCursorBase{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Payments.V1.ListPayments( - cmd.Context(), - operations.ListPaymentsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.PaymentsCursor.CursorBase - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(payment paymentsmodels.Payment) []string { - return []string{ - payment.ID, - string(payment.PaymentType), - fmt.Sprint(payment.Amount), - fmt.Sprint(payment.InitialAmount), - payment.Asset, - string(payment.PaymentStatus), - string(payment.PaymentScheme), - payment.Reference, - payment.SourceAccountID, - payment.DestinationAccountID, - payment.ConnectorID, - payment.CreatedAt.Format(time.RFC3339), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Type", "Amount", "InitialAmount", "Asset", "Status", - "Scheme", "Reference", "Source Account ID", "Destination Account ID", "ConnectorID", "CreatedAt"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List payments"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/payments/payments/root.go b/cmd/payments/payments/root.go deleted file mode 100644 index 5a20f74c..00000000 --- a/cmd/payments/payments/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package payments - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewPaymentsCommand() *cobra.Command { - return fctl.NewCommand("payments", - fctl.WithAliases("p"), - fctl.WithShortDescription("Manage payments"), - fctl.WithChildCommands( - NewCreateCommand(), - NewListCommand(), - NewShowCommand(), - NewSetMetadataCommand(), - ), - ) -} diff --git a/cmd/payments/payments/set_metadata.go b/cmd/payments/payments/set_metadata.go deleted file mode 100644 index fea8c3e9..00000000 --- a/cmd/payments/payments/set_metadata.go +++ /dev/null @@ -1,94 +0,0 @@ -package payments - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetMetadataStore struct { - Success bool `json:"success"` -} -type SetMetadataController struct { - store *SetMetadataStore -} - -var _ fctl.Controller[*SetMetadataStore] = (*SetMetadataController)(nil) - -func NewSetMetadataStore() *SetMetadataStore { - return &SetMetadataStore{} -} - -func NewSetMetadataController() *SetMetadataController { - return &SetMetadataController{ - store: NewSetMetadataStore(), - } -} - -func NewSetMetadataCommand() *cobra.Command { - c := NewSetMetadataController() - return fctl.NewCommand("set-metadata [=...]", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Set metadata on a payment"), - fctl.WithAliases("sm", "set-meta"), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*SetMetadataStore](c), - ) -} - -func (c *SetMetadataController) GetStore() *SetMetadataStore { - return c.store -} - -func (c *SetMetadataController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(args[1:]) - if err != nil { - return nil, err - } - - paymentID := args[0] - - if !fctl.CheckStackApprobation(cmd, "You are about to set metadata on paymentID '%s'", paymentID) { - return nil, fctl.ErrMissingApproval - } - - request := operations.UpdateMetadataRequest{ - RequestBody: metadata, - PaymentID: paymentID, - } - - response, err := stackClient.Payments.V1.UpdateMetadata(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Success = response.StatusCode == 204 - - return c, nil -} - -func (c *SetMetadataController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Metadata added!") - return nil -} diff --git a/cmd/payments/payments/show.go b/cmd/payments/payments/show.go deleted file mode 100644 index 20ff0ba8..00000000 --- a/cmd/payments/payments/show.go +++ /dev/null @@ -1,125 +0,0 @@ -package payments - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - paymentsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Payment *paymentsmodels.Payment `json:"payment"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a payment"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - response, err := stackClient.Payments.V1.GetPayment(cmd.Context(), operations.GetPaymentRequest{ - PaymentID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Payment = &response.PaymentResponse.Payment - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Payment.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Reference"), c.store.Payment.Reference}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Payment.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ConnectorID"), c.store.Payment.ConnectorID}) - tableData = append(tableData, []string{pterm.LightCyan("Asset"), c.store.Payment.Asset}) - tableData = append(tableData, []string{pterm.LightCyan("Amount"), c.store.Payment.Amount.String()}) - tableData = append(tableData, []string{pterm.LightCyan("InitialAmount"), c.store.Payment.InitialAmount.String()}) - tableData = append(tableData, []string{pterm.LightCyan("Type"), string(c.store.Payment.PaymentType)}) - tableData = append(tableData, []string{pterm.LightCyan("Scheme"), string(c.store.Payment.PaymentScheme)}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), string(c.store.Payment.PaymentStatus)}) - tableData = append(tableData, []string{pterm.LightCyan("DestinationAccountID"), c.store.Payment.DestinationAccountID}) - tableData = append(tableData, []string{pterm.LightCyan("SourceAccountID"), c.store.Payment.SourceAccountID}) - tableData = append(tableData, []string{pterm.LightCyan("Provider"), func() string { - if c.store.Payment.Connector == nil { - return "" - } - return string(*c.store.Payment.Connector) - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = fctl.Map(c.store.Payment.Adjustments, func(pa paymentsmodels.PaymentAdjustment) []string { - return []string{ - pa.Reference, - string(pa.PaymentStatus), - pa.CreatedAt.Format(time.RFC3339), - pa.Amount.String(), - } - }) - - tableData = fctl.Prepend(tableData, []string{"Reference", "Status", "CreatedAt", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.PrintMetadata(cmd.OutOrStdout(), c.store.Payment.PaymentMetadata) -} diff --git a/cmd/payments/pools/add_accounts.go b/cmd/payments/pools/add_accounts.go deleted file mode 100644 index 4bb47413..00000000 --- a/cmd/payments/pools/add_accounts.go +++ /dev/null @@ -1,102 +0,0 @@ -package pools - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type AddAccountStore struct { - PoolID string `json:"poolID"` - AccountID string `json:"accountID"` - Success bool `json:"success"` -} -type AddAccountController struct { - PaymentsVersion versions.Version - - store *AddAccountStore -} - -func (c *AddAccountController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*AddAccountStore] = (*AddAccountController)(nil) - -func NewAddAccountStore() *AddAccountStore { - return &AddAccountStore{} -} - -func NewAddAccountController() *AddAccountController { - return &AddAccountController{ - store: NewAddAccountStore(), - } -} - -func NewAddAccountCommand() *cobra.Command { - c := NewAddAccountController() - return fctl.NewCommand("add-account ", - fctl.WithShortDescription("Add an account to a pool"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("add", "a"), - fctl.WithController[*AddAccountStore](c), - ) -} - -func (c *AddAccountController) GetStore() *AddAccountStore { - return c.store -} - -func (c *AddAccountController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - response, err := stackClient.Payments.V1.AddAccountToPool(cmd.Context(), operations.AddAccountToPoolRequest{ - PoolID: args[0], - AddAccountToPoolRequest: payments.AddAccountToPoolRequest{ - AccountID: args[1], - }, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = args[0] - c.store.AccountID = args[1] - - return c, nil -} - -func (c *AddAccountController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Successfully added '%s' to '%s'", c.store.AccountID, c.store.PoolID) - - return nil -} diff --git a/cmd/payments/pools/balances.go b/cmd/payments/pools/balances.go deleted file mode 100644 index 14915ab9..00000000 --- a/cmd/payments/pools/balances.go +++ /dev/null @@ -1,102 +0,0 @@ -package pools - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type BalancesStore struct { - Balances *payments.PoolBalances `json:"balances"` -} - -type BalancesController struct { - store *BalancesStore -} - -var _ fctl.Controller[*BalancesStore] = (*BalancesController)(nil) - -func NewBalancesStore() *BalancesStore { - return &BalancesStore{ - Balances: &payments.PoolBalances{}, - } -} - -func NewBalancesController() *BalancesController { - return &BalancesController{ - store: NewBalancesStore(), - } -} - -func (c *BalancesController) GetStore() *BalancesStore { - return c.store -} - -func (c *BalancesController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - at, err := time.Parse(time.RFC3339, args[1]) - if err != nil { - return nil, err - } - - response, err := stackClient.Payments.V1.GetPoolBalances( - cmd.Context(), - operations.GetPoolBalancesRequest{ - At: at, - PoolID: args[0], - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Balances = &response.PoolBalancesResponse.PoolBalances - - return c, nil -} - -func (c *BalancesController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Balances.Balances, func(balance payments.PoolBalance) []string { - return []string{ - balance.Asset, - balance.Amount.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -func NewBalancesCommand() *cobra.Command { - c := NewBalancesController() - return fctl.NewCommand("balances ", - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List pool balances"), - fctl.WithController[*BalancesStore](c), - ) -} diff --git a/cmd/payments/pools/create.go b/cmd/payments/pools/create.go deleted file mode 100644 index bbe37de4..00000000 --- a/cmd/payments/pools/create.go +++ /dev/null @@ -1,132 +0,0 @@ -package pools - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const dynamicPoolMinMinor = 1 - -type CreateStore struct { - PoolID string `json:"poolID"` -} -type CreateController struct { - PaymentsVersion versions.Version - - store *CreateStore -} - -func (c *CreateController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create a pool"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new pool") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - switch c.PaymentsVersion.Major { - case versions.V3: - request := payments.V3CreatePoolRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - if request.Query != nil && !c.PaymentsVersion.IsAtLeast(versions.V3, dynamicPoolMinMinor) { - return nil, fmt.Errorf("dynamic pools require Connectivity >= v3.1") - } - - response, err := stackClient.Payments.V3.CreatePool(cmd.Context(), &request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = response.V3CreatePoolResponse.Data - default: - request := payments.PoolRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - response, err := stackClient.Payments.V1.CreatePool(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = response.PoolResponse.Pool.ID - } - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Pool created with ID: %s", c.store.PoolID) - - return nil -} diff --git a/cmd/payments/pools/delete.go b/cmd/payments/pools/delete.go deleted file mode 100644 index a06045ad..00000000 --- a/cmd/payments/pools/delete.go +++ /dev/null @@ -1,105 +0,0 @@ -package pools - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - PoolID string `json:"poolID"` - Success bool `json:"success"` -} - -type DeleteController struct { - PaymentsVersion versions.Version - - store *DeleteStore -} - -func (c *DeleteController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDeleteStore(), - } -} -func NewDeleteCommand() *cobra.Command { - c := NewDeleteController() - return fctl.NewCommand("delete ", - fctl.WithConfirmFlag(), - fctl.WithAliases("d"), - fctl.WithShortDescription("Delete a pool"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*DeleteStore](c), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V1.DeletePool( - cmd.Context(), - operations.DeletePoolRequest{ - PoolID: args[0], - }, - ) - - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = args[0] - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Pool %s deleted.", c.store.PoolID) - return nil -} diff --git a/cmd/payments/pools/latest_balances.go b/cmd/payments/pools/latest_balances.go deleted file mode 100644 index 7abe0fb6..00000000 --- a/cmd/payments/pools/latest_balances.go +++ /dev/null @@ -1,143 +0,0 @@ -package pools - -import ( - "context" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type LatestBalancesStore struct { - Balances *payments.PoolBalances `json:"balances"` -} - -type LatestBalancesController struct { - PaymentsVersion versions.Version - - store *LatestBalancesStore -} - -func (c *LatestBalancesController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*LatestBalancesStore] = (*LatestBalancesController)(nil) - -func NewLatestBalancesStore() *LatestBalancesStore { - return &LatestBalancesStore{ - Balances: &payments.PoolBalances{}, - } -} - -func NewLatestBalancesController() *LatestBalancesController { - return &LatestBalancesController{ - store: NewLatestBalancesStore(), - } -} - -func (c *LatestBalancesController) GetStore() *LatestBalancesStore { - return c.store -} - -func (c *LatestBalancesController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - poolID := args[0] - - switch c.PaymentsVersion.Major { - case versions.V1, versions.V2: - return c.CallV1(cmd.Context(), stackClient, poolID) - case versions.V3: - return c.CallV3(cmd.Context(), stackClient, poolID) - default: - return nil, fmt.Errorf("unsupported payments version: %d", c.PaymentsVersion.Major) - } -} - -func (c *LatestBalancesController) CallV1(context context.Context, client *formance.Formance, poolID string) (fctl.Renderable, error) { - response, err := client.Payments.V1.GetPoolBalancesLatest( - context, - operations.GetPoolBalancesLatestRequest{ - PoolID: poolID, - }) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - c.store.Balances = &payments.PoolBalances{Balances: response.PoolBalancesLatestResponse.PoolBalancesLatest} - return c, nil -} - -func (c *LatestBalancesController) CallV3(context context.Context, client *formance.Formance, poolID string) (fctl.Renderable, error) { - response, err := client.Payments.V3.GetPoolBalancesLatest( - context, - operations.V3GetPoolBalancesLatestRequest{ - PoolID: poolID, - }) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - v3Balances := &response.V3PoolBalancesResponse.V3PoolBalances - - poolBalances := make([]payments.PoolBalance, 0, len(*v3Balances)) - for _, v3Balance := range *v3Balances { - poolBalance := payments.PoolBalance{ - Asset: v3Balance.Asset, - Amount: v3Balance.Amount, - } - poolBalances = append(poolBalances, poolBalance) - } - c.store.Balances = &payments.PoolBalances{Balances: poolBalances} - return c, nil -} - -func (c *LatestBalancesController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Balances.Balances, func(balance payments.PoolBalance) []string { - return []string{ - balance.Asset, - balance.Amount.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -func NewLatestBalancesCommand() *cobra.Command { - c := NewLatestBalancesController() - return fctl.NewCommand("latest-balances ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List pool latest balances"), - fctl.WithController[*LatestBalancesStore](c), - ) -} diff --git a/cmd/payments/pools/list.go b/cmd/payments/pools/list.go deleted file mode 100644 index 8a150242..00000000 --- a/cmd/payments/pools/list.go +++ /dev/null @@ -1,161 +0,0 @@ -package pools - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.PoolsCursorCursorBase `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.PoolsCursorCursorBase{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Payments.V1.ListPools( - cmd.Context(), - operations.ListPoolsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.PoolsCursor.CursorBase - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(bc payments.Pool) []string { - return []string{ - bc.ID, - bc.Name, - func() string { - return strings.Join(bc.Accounts, ", ") - }(), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Name", "Accounts"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List pools"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/payments/pools/remove_account.go b/cmd/payments/pools/remove_account.go deleted file mode 100644 index d233ac51..00000000 --- a/cmd/payments/pools/remove_account.go +++ /dev/null @@ -1,99 +0,0 @@ -package pools - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RemoveAccountStore struct { - PoolID string `json:"poolID"` - AccountID string `json:"accountID"` - Success bool `json:"success"` -} -type RemoveAccountController struct { - PaymentsVersion versions.Version - - store *RemoveAccountStore -} - -func (c *RemoveAccountController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*RemoveAccountStore] = (*RemoveAccountController)(nil) - -func NewRemoveAccountStore() *RemoveAccountStore { - return &RemoveAccountStore{} -} - -func NewRemoveAccountController() *RemoveAccountController { - return &RemoveAccountController{ - store: NewRemoveAccountStore(), - } -} - -func NewRemoveAccountCommand() *cobra.Command { - c := NewRemoveAccountController() - return fctl.NewCommand("remove-account ", - fctl.WithShortDescription("Remove an account from a pool"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("remove", "rm"), - fctl.WithController[*RemoveAccountStore](c), - ) -} - -func (c *RemoveAccountController) GetStore() *RemoveAccountStore { - return c.store -} - -func (c *RemoveAccountController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - response, err := stackClient.Payments.V1.RemoveAccountFromPool(cmd.Context(), operations.RemoveAccountFromPoolRequest{ - PoolID: args[0], - AccountID: args[1], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = args[0] - c.store.AccountID = args[1] - - return c, nil -} - -func (c *RemoveAccountController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Successfully removed '%s' to '%s'", c.store.AccountID, c.store.PoolID) - - return nil -} diff --git a/cmd/payments/pools/root.go b/cmd/payments/pools/root.go deleted file mode 100644 index 0e2e52c3..00000000 --- a/cmd/payments/pools/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package pools - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewPoolsCommand() *cobra.Command { - return fctl.NewCommand("pools", - fctl.WithAliases("p"), - fctl.WithShortDescription("Manage pools"), - fctl.WithChildCommands( - NewListCommand(), - NewCreateCommand(), - NewShowCommand(), - NewDeleteCommand(), - NewBalancesCommand(), - NewLatestBalancesCommand(), - NewAddAccountCommand(), - NewRemoveAccountCommand(), - NewUpdateQueryCommand(), - ), - ) -} diff --git a/cmd/payments/pools/show.go b/cmd/payments/pools/show.go deleted file mode 100644 index 3d596731..00000000 --- a/cmd/payments/pools/show.go +++ /dev/null @@ -1,110 +0,0 @@ -package pools - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Pool *payments.Pool `json:"pool"` -} -type ShowController struct { - PaymentsVersion versions.Version - - store *ShowStore -} - -func (c *ShowController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a pool"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("pools are only supported in >= v1.0.0") - } - - response, err := stackClient.Payments.V1.GetPool(cmd.Context(), operations.GetPoolRequest{ - PoolID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Pool = &response.PoolResponse.Pool - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Pool.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Pool.Name}) - tableData = append(tableData, []string{pterm.LightCyan("Accounts"), func() string { - return strings.Join(c.store.Pool.Accounts, ", ") - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/payments/pools/update_query.go b/cmd/payments/pools/update_query.go deleted file mode 100644 index 93b5d5a7..00000000 --- a/cmd/payments/pools/update_query.go +++ /dev/null @@ -1,112 +0,0 @@ -package pools - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateQueryStore struct { - PoolID string `json:"poolID"` -} - -type UpdateQueryController struct { - PaymentsVersion versions.Version - - store *UpdateQueryStore -} - -func (c *UpdateQueryController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*UpdateQueryStore] = (*UpdateQueryController)(nil) - -func NewUpdateQueryStore() *UpdateQueryStore { - return &UpdateQueryStore{} -} - -func NewUpdateQueryController() *UpdateQueryController { - return &UpdateQueryController{ - store: NewUpdateQueryStore(), - } -} - -func NewUpdateQueryCommand() *cobra.Command { - c := NewUpdateQueryController() - return fctl.NewCommand("update-query |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Update the query of a dynamic pool"), - fctl.WithAliases("uq"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*UpdateQueryStore](c), - ) -} - -func (c *UpdateQueryController) GetStore() *UpdateQueryStore { - return c.store -} - -func (c *UpdateQueryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if !c.PaymentsVersion.IsAtLeast(versions.V3, 1) { - return nil, fmt.Errorf("update-query is only supported in payments >= v3.1.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to update the query of pool '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[1]) - if err != nil { - return nil, err - } - - var request payments.V3UpdatePoolQueryRequest - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - response, err := stackClient.Payments.V3.UpdatePoolQuery(cmd.Context(), operations.V3UpdatePoolQueryRequest{ - PoolID: args[0], - V3UpdatePoolQueryRequest: &request, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PoolID = args[0] - - return c, nil -} - -func (c *UpdateQueryController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Query updated for pool: %s", c.store.PoolID) - return nil -} diff --git a/cmd/payments/root.go b/cmd/payments/root.go deleted file mode 100644 index 6f834506..00000000 --- a/cmd/payments/root.go +++ /dev/null @@ -1,33 +0,0 @@ -package payments - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/payments/accounts" - "github.com/formancehq/fctl/v3/cmd/payments/bankaccounts" - "github.com/formancehq/fctl/v3/cmd/payments/connectors" - "github.com/formancehq/fctl/v3/cmd/payments/conversions" - "github.com/formancehq/fctl/v3/cmd/payments/orders" - paymentsmodels "github.com/formancehq/fctl/v3/cmd/payments/payments" - "github.com/formancehq/fctl/v3/cmd/payments/pools" - "github.com/formancehq/fctl/v3/cmd/payments/tasks" - "github.com/formancehq/fctl/v3/cmd/payments/transferinitiation" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("payments", - fctl.WithShortDescription("Manage payments"), - fctl.WithChildCommands( - connectors.NewConnectorsCommand(), - paymentsmodels.NewPaymentsCommand(), - transferinitiation.NewTransferInitiationCommand(), - bankaccounts.NewBankAccountsCommand(), - accounts.NewAccountsCommand(), - pools.NewPoolsCommand(), - orders.NewOrdersCommand(), - conversions.NewConversionsCommand(), - tasks.NewTasksCommand(), - ), - ) -} diff --git a/cmd/payments/tasks/root.go b/cmd/payments/tasks/root.go deleted file mode 100644 index 4c54441a..00000000 --- a/cmd/payments/tasks/root.go +++ /dev/null @@ -1,17 +0,0 @@ -package tasks - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewTasksCommand() *cobra.Command { - return fctl.NewCommand("tasks", - fctl.WithAliases("t"), - fctl.WithShortDescription("Manage tasks"), - fctl.WithChildCommands( - NewShowCommand(), - ), - ) -} diff --git a/cmd/payments/tasks/show.go b/cmd/payments/tasks/show.go deleted file mode 100644 index 30b5f55e..00000000 --- a/cmd/payments/tasks/show.go +++ /dev/null @@ -1,127 +0,0 @@ -package tasks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Task *payments.V3Task `json:"task"` -} -type ShowController struct { - PaymentsVersion versions.Version - - store *ShowStore -} - -func (c *ShowController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a task"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("tasks are only supported in >= v3.0.0") - } - - response, err := stackClient.Payments.V3.GetTask(cmd.Context(), operations.V3GetTaskRequest{ - TaskID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Task = &response.V3GetTaskResponse.V3Task - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - var ( - errStr string - connectorID string - createdObjectID string - ) - - if c.store.Task.ConnectorID != nil { - connectorID = *c.store.Task.ConnectorID - } - if c.store.Task.CreatedObjectID != nil { - createdObjectID = *c.store.Task.CreatedObjectID - } - if c.store.Task.Error != nil { - errStr = *c.store.Task.Error - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Task.ID}) - tableData = append(tableData, []string{pterm.LightCyan("ConnectorID"), connectorID}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedObjectID"), createdObjectID}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Task.CreatedAt.String()}) - tableData = append(tableData, []string{pterm.LightCyan("Error"), errStr}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), string(c.store.Task.V3TaskStatusEnum)}) - tableData = append(tableData, []string{pterm.LightCyan("UpdatedAt"), c.store.Task.UpdatedAt.String()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/payments/transferinitiation/approve.go b/cmd/payments/transferinitiation/approve.go deleted file mode 100644 index 7d5dafc0..00000000 --- a/cmd/payments/transferinitiation/approve.go +++ /dev/null @@ -1,100 +0,0 @@ -package transferinitiation - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ApproveStore struct { - TransferID string `json:"transferId"` - TaskID string `json:"taskId"` -} -type ApproveController struct { - PaymentsVersion versions.Version - - store *ApproveStore -} - -func (c *ApproveController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ApproveStore] = (*ApproveController)(nil) - -func NewApproveStore() *ApproveStore { - return &ApproveStore{} -} - -func NewApproveController() *ApproveController { - return &ApproveController{ - store: NewApproveStore(), - } -} - -func NewApproveCommand() *cobra.Command { - c := NewApproveController() - return fctl.NewCommand("approve ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Approve a transfer initiation"), - fctl.WithAliases("a"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ApproveStore](c), - ) -} - -func (c *ApproveController) GetStore() *ApproveStore { - return c.store -} - -func (c *ApproveController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("transfer initiation approval is only supported in >= v3.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to approve the transfer initiation %q", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V3.ApprovePaymentInitiation(cmd.Context(), operations.V3ApprovePaymentInitiationRequest{ - PaymentInitiationID: args[0], - }) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - c.store.TransferID = args[0] - c.store.TaskID = response.V3ApprovePaymentInitiationResponse.Data.TaskID - - return c, nil -} - -func (c *ApproveController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation approval scheduled with task ID: %s", c.store.TaskID) - - return nil -} diff --git a/cmd/payments/transferinitiation/create.go b/cmd/payments/transferinitiation/create.go deleted file mode 100644 index 34b9c313..00000000 --- a/cmd/payments/transferinitiation/create.go +++ /dev/null @@ -1,108 +0,0 @@ -package transferinitiation - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - TransferInitiationId string `json:"transferInitiationId"` -} -type CreateController struct { - PaymentsVersion versions.Version - - store *CreateStore -} - -func (c *CreateController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create a transfer initiation"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a transfer initiation") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - request := payments.TransferInitiationRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.CreateTransferInitiation(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferInitiationId = response.TransferInitiationResponse.TransferInitiation.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation created with ID: %s", c.store.TransferInitiationId) - - return nil -} diff --git a/cmd/payments/transferinitiation/delete.go b/cmd/payments/transferinitiation/delete.go deleted file mode 100644 index 4e2d9850..00000000 --- a/cmd/payments/transferinitiation/delete.go +++ /dev/null @@ -1,104 +0,0 @@ -package transferinitiation - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - TransferID string `json:"transferId"` - Success bool `json:"success"` -} - -type DeleteController struct { - PaymentsVersion versions.Version - - store *DeleteStore -} - -func (c *DeleteController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDeleteStore(), - } -} -func NewDeleteCommand() *cobra.Command { - c := NewDeleteController() - return fctl.NewCommand("delete ", - fctl.WithConfirmFlag(), - fctl.WithAliases("d"), - fctl.WithShortDescription("Delete a transfer initiation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*DeleteStore](c), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V1.DeleteTransferInitiation( - cmd.Context(), - operations.DeleteTransferInitiationRequest{ - TransferID: args[0], - }, - ) - - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferID = args[0] - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation %s deleted.", c.store.TransferID) - return nil -} diff --git a/cmd/payments/transferinitiation/list.go b/cmd/payments/transferinitiation/list.go deleted file mode 100644 index 499055c0..00000000 --- a/cmd/payments/transferinitiation/list.go +++ /dev/null @@ -1,170 +0,0 @@ -package transferinitiation - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *payments.TransferInitiationsCursorCursorBase `json:"cursor"` -} - -type ListController struct { - PaymentsVersion versions.Version - - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -func (c *ListController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &payments.TransferInitiationsCursorCursorBase{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Payments.V1.ListTransferInitiations( - cmd.Context(), - operations.ListTransferInitiationsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.TransferInitiationsCursor.CursorBase - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(tf payments.TransferInitiation) []string { - return []string{ - tf.ID, - tf.Reference, - tf.CreatedAt.Format(time.RFC3339), - tf.ScheduledAt.Format(time.RFC3339), - tf.Description, - tf.SourceAccountID, - tf.DestinationAccountID, - tf.ConnectorID, - string(tf.Type), - fmt.Sprint(tf.Amount), - fmt.Sprint(tf.InitialAmount), - tf.Asset, - string(tf.TransferInitiationStatus), - *tf.Error, - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Reference", "CreatedAt", "ScheduledAt", "Description", "Source Account ID", - "Destination Account ID", "ConnectorID", "Type", "Amount", "InitialAmount", "Asset", "Status", "Error"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List transfer initiations"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/payments/transferinitiation/reject.go b/cmd/payments/transferinitiation/reject.go deleted file mode 100644 index 047254ce..00000000 --- a/cmd/payments/transferinitiation/reject.go +++ /dev/null @@ -1,98 +0,0 @@ -package transferinitiation - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RejectStore struct { - TransferID string `json:"transferId"` -} - -type RejectController struct { - PaymentsVersion versions.Version - - store *RejectStore -} - -func (c *RejectController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*RejectStore] = (*RejectController)(nil) - -func NewRejectStore() *RejectStore { - return &RejectStore{} -} - -func NewRejectController() *RejectController { - return &RejectController{ - store: NewRejectStore(), - } -} - -func NewRejectCommand() *cobra.Command { - c := NewRejectController() - return fctl.NewCommand("reject ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Reject a transfer initiation"), - fctl.WithAliases("rj"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*RejectStore](c), - ) -} - -func (c *RejectController) GetStore() *RejectStore { - return c.store -} - -func (c *RejectController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V3 { - return nil, fmt.Errorf("transfer initiation rejection is only supported in >= v3.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to reject the transfer initiation %q", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V3.RejectPaymentInitiation(cmd.Context(), operations.V3RejectPaymentInitiationRequest{ - PaymentInitiationID: args[0], - }) - if err != nil { - return nil, err - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - c.store.TransferID = args[0] - - return c, nil -} - -func (c *RejectController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation %s rejected.", c.store.TransferID) - return nil -} diff --git a/cmd/payments/transferinitiation/retry.go b/cmd/payments/transferinitiation/retry.go deleted file mode 100644 index 546e05a0..00000000 --- a/cmd/payments/transferinitiation/retry.go +++ /dev/null @@ -1,102 +0,0 @@ -package transferinitiation - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RetryStore struct { - TransferID string `json:"transferId"` - Success bool `json:"success"` -} -type RetryController struct { - PaymentsVersion versions.Version - - store *RetryStore -} - -func (c *RetryController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*RetryStore] = (*RetryController)(nil) - -func NewRetryStore() *RetryStore { - return &RetryStore{} -} - -func NewRetryController() *RetryController { - return &RetryController{ - store: NewRetryStore(), - } -} - -func NewRetryCommand() *cobra.Command { - c := NewRetryController() - return fctl.NewCommand("retry ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Retry a failed transfer initiation"), - fctl.WithAliases("r"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*RetryStore](c), - ) -} - -func (c *RetryController) GetStore() *RetryStore { - return c.store -} - -func (c *RetryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to retry the transfer initiation '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.RetryTransferInitiation(cmd.Context(), operations.RetryTransferInitiationRequest{ - TransferID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferID = args[0] - c.store.Success = true - - return c, nil -} - -func (c *RetryController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation %s queued for retry.", c.store.TransferID) - - return nil -} diff --git a/cmd/payments/transferinitiation/reverse.go b/cmd/payments/transferinitiation/reverse.go deleted file mode 100644 index 1a5163e9..00000000 --- a/cmd/payments/transferinitiation/reverse.go +++ /dev/null @@ -1,117 +0,0 @@ -package transferinitiation - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ReverseStore struct { - TransferID string `json:"transferId"` - Success bool `json:"success"` -} - -type ReverseController struct { - PaymentsVersion versions.Version - - store *ReverseStore -} - -func (c *ReverseController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ReverseStore] = (*ReverseController)(nil) - -func NewReverseStore() *ReverseStore { - return &ReverseStore{} -} - -func NewReverseController() *ReverseController { - return &ReverseController{ - store: NewReverseStore(), - } -} -func NewReverseCommand() *cobra.Command { - c := NewReverseController() - return fctl.NewCommand("reverse |-", - fctl.WithConfirmFlag(), - fctl.WithAliases("re", "r"), - fctl.WithShortDescription("Reverse a transfer initiation"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithController[*ReverseStore](c), - ) -} - -func (c *ReverseController) GetStore() *ReverseStore { - return c.store -} - -func (c *ReverseController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - script, err := fctl.ReadFile(cmd, args[1]) - if err != nil { - return nil, err - } - - request := payments.ReverseTransferInitiationRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to reverse '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Payments.V1.ReverseTransferInitiation( - cmd.Context(), - operations.ReverseTransferInitiationRequest{ - TransferID: args[0], - ReverseTransferInitiationRequest: request, - }, - ) - - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferID = args[0] - c.store.Success = true - - return c, nil -} - -func (c *ReverseController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation %s reversed.", c.store.TransferID) - return nil -} diff --git a/cmd/payments/transferinitiation/root.go b/cmd/payments/transferinitiation/root.go deleted file mode 100644 index 10e94884..00000000 --- a/cmd/payments/transferinitiation/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package transferinitiation - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewTransferInitiationCommand() *cobra.Command { - return fctl.NewCommand("transfer_initiation", - fctl.WithAliases("ti"), - fctl.WithShortDescription("Manage transfer initiations"), - fctl.WithChildCommands( - NewApproveCommand(), - NewCreateCommand(), - NewDeleteCommand(), - NewListCommand(), - NewRejectCommand(), - NewRetryCommand(), - NewShowCommand(), - NewUpdateStatusCommand(), - NewReverseCommand(), - ), - ) -} diff --git a/cmd/payments/transferinitiation/show.go b/cmd/payments/transferinitiation/show.go deleted file mode 100644 index 84b4b428..00000000 --- a/cmd/payments/transferinitiation/show.go +++ /dev/null @@ -1,152 +0,0 @@ -package transferinitiation - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - TransferInitiation *payments.TransferInitiation `json:"transferInitiation"` -} -type ShowController struct { - PaymentsVersion versions.Version - - store *ShowStore -} - -func (c *ShowController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a transfer initiation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation are only supported in >= v1.0.0") - } - - response, err := stackClient.Payments.V1.GetTransferInitiation(cmd.Context(), operations.GetTransferInitiationRequest{ - TransferID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferInitiation = &response.TransferInitiationResponse.TransferInitiation - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.TransferInitiation.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Reference"), c.store.TransferInitiation.Reference}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.TransferInitiation.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ScheduledAt"), c.store.TransferInitiation.ScheduledAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Description"), c.store.TransferInitiation.Description}) - tableData = append(tableData, []string{pterm.LightCyan("SourceAccountID"), c.store.TransferInitiation.SourceAccountID}) - tableData = append(tableData, []string{pterm.LightCyan("DestinationAccountID"), c.store.TransferInitiation.DestinationAccountID}) - tableData = append(tableData, []string{pterm.LightCyan("ConnectorID"), string(c.store.TransferInitiation.ConnectorID)}) - tableData = append(tableData, []string{pterm.LightCyan("Type"), string(c.store.TransferInitiation.Type)}) - tableData = append(tableData, []string{pterm.LightCyan("Amount"), fmt.Sprint(c.store.TransferInitiation.Amount)}) - tableData = append(tableData, []string{pterm.LightCyan("InitialAmount"), fmt.Sprint(c.store.TransferInitiation.InitialAmount)}) - tableData = append(tableData, []string{pterm.LightCyan("Asset"), c.store.TransferInitiation.Asset}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), string(c.store.TransferInitiation.TransferInitiationStatus)}) - tableData = append(tableData, []string{pterm.LightCyan("Error"), *c.store.TransferInitiation.Error}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = fctl.Map(c.store.TransferInitiation.RelatedPayments, func(tf payments.TransferInitiationPayments) []string { - return []string{ - tf.PaymentID, - tf.CreatedAt.Format(time.RFC3339), - string(tf.LegacyPaymentStatus), - *tf.Error, - } - }) - tableData = fctl.Prepend(tableData, []string{"PaymentID", "CreatedAt", "Status", "Error"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = fctl.Map(c.store.TransferInitiation.RelatedAdjustments, func(tf payments.TransferInitiationAdjustments) []string { - return []string{ - tf.AdjustmentID, - tf.CreatedAt.Format(time.RFC3339), - string(tf.TransferInitiationStatus), - *tf.Error, - } - }) - tableData = fctl.Prepend(tableData, []string{"AdjustmentID", "CreatedAt", "Status", "Error"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.PrintMetadata(cmd.OutOrStdout(), c.store.TransferInitiation.Metadata) -} diff --git a/cmd/payments/transferinitiation/update_status.go b/cmd/payments/transferinitiation/update_status.go deleted file mode 100644 index 9d3c3d85..00000000 --- a/cmd/payments/transferinitiation/update_status.go +++ /dev/null @@ -1,109 +0,0 @@ -package transferinitiation - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/payments" - - "github.com/formancehq/fctl/v3/cmd/payments/versions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateStatusStore struct { - TransferID string `json:"transferId"` - Status string `json:"status"` - Success bool `json:"success"` -} -type UpdateStatusController struct { - PaymentsVersion versions.Version - - store *UpdateStatusStore -} - -func (c *UpdateStatusController) SetVersion(version versions.Version) { - c.PaymentsVersion = version -} - -var _ fctl.Controller[*UpdateStatusStore] = (*UpdateStatusController)(nil) - -func NewUpdateStatusStore() *UpdateStatusStore { - return &UpdateStatusStore{} -} - -func NewUpdateStatusController() *UpdateStatusController { - return &UpdateStatusController{ - store: NewUpdateStatusStore(), - } -} - -func NewUpdateStatusCommand() *cobra.Command { - c := NewUpdateStatusController() - return fctl.NewCommand("update_status ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Update the status of a transfer initiation (deprecated in >= v3.0.0)"), - fctl.WithAliases("u"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*UpdateStatusStore](c), - ) -} - -func (c *UpdateStatusController) GetStore() *UpdateStatusStore { - return c.store -} - -func (c *UpdateStatusController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if err := versions.GetPaymentsVersion(cmd, args, c); err != nil { - return nil, err - } - - if c.PaymentsVersion.Major < versions.V1 { - return nil, fmt.Errorf("transfer initiation updates are only supported in >= v2.0.0") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to update the status of the transfer initiation '%s' to '%s'", args[0], args[1]) { - return nil, fctl.ErrMissingApproval - } - - //nolint:gosimple - response, err := stackClient.Payments.V1.UpdateTransferInitiationStatus(cmd.Context(), operations.UpdateTransferInitiationStatusRequest{ - UpdateTransferInitiationStatusRequest: payments.UpdateTransferInitiationStatusRequest{ - Status: payments.Status(args[1]), - }, - TransferID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.TransferID = args[0] - c.store.Status = args[1] - c.store.Success = true - - return c, nil -} - -func (c *UpdateStatusController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Transfer initiation %s status updated to %s.", c.store.TransferID, c.store.Status) - - return nil -} diff --git a/cmd/payments/versions/versions.go b/cmd/payments/versions/versions.go deleted file mode 100644 index d50f0524..00000000 --- a/cmd/payments/versions/versions.go +++ /dev/null @@ -1,99 +0,0 @@ -package versions - -import ( - "fmt" - "math" - "strconv" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/mod/semver" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type PaymentMajorVersion int - -const ( - V0 PaymentMajorVersion = iota - V1 - V2 - V3 -) - -type Version struct { - Major PaymentMajorVersion - Minor int - Raw string -} - -type VersionController interface { - SetVersion(Version) -} - -func GetPaymentsVersion(cmd *cobra.Command, _ []string, controller VersionController) error { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return err - } - response, err := stackClient.Payments.V1.GetServerInfoPayments(cmd.Context()) - if err != nil { - return err - } - - if response.StatusCode >= 300 { - return fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - version := "v" + strings.TrimPrefix(*response.ServerInfo.Version, "v") - - paymentVersion, err := computePaymentVersion(version) - if err != nil { - return err - } - - controller.SetVersion(*paymentVersion) - return nil -} - -func computePaymentVersion(rawVersion string) (*Version, error) { - semverVersion := semver.MajorMinor(rawVersion) - if semverVersion == "" { - // we assume the version is a commit id - // thus corresponds to the latest possible version - return &Version{Major: V3, Minor: math.MaxInt, Raw: rawVersion}, nil - } - - parts := strings.Split(semver.Canonical(semverVersion), ".") - if len(parts) < 2 { - return nil, fmt.Errorf("expected both major and minor for version string: %s", rawVersion) - } - - var major PaymentMajorVersion - minor, _ := strconv.Atoi(parts[1]) - - switch parts[0] { - case "v0", "v1", "v2": - major = V1 - case "v3": - major = V3 - default: - return nil, fmt.Errorf("invalid major version string: %s", rawVersion) - } - - return &Version{ - Major: major, - Minor: minor, - Raw: rawVersion, - }, nil -} - -func (v *Version) IsAtLeast(major PaymentMajorVersion, minor int) bool { - return v.Major > major || (v.Major == major && v.Minor >= minor) -} diff --git a/cmd/plugins/auth_distribution_commands_test.go b/cmd/plugins/auth_distribution_commands_test.go new file mode 100644 index 00000000..1951d67d --- /dev/null +++ b/cmd/plugins/auth_distribution_commands_test.go @@ -0,0 +1,45 @@ +package plugins + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" +) + +func TestAuthDistributionCommandsPreserveLedgerDefaults(t *testing.T) { + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + if err := root.PersistentFlags().Set("auth-mode", "none"); err != nil { + t.Fatal(err) + } + if err := root.PersistentFlags().Set("ledger-url", "http://127.0.0.1:1"); err != nil { + t.Fatal(err) + } + for _, command := range []*cobra.Command{newInstallCommand(settings), newSyncCommand(settings), newShowCommand(settings)} { + flag := command.Flags().Lookup("service") + if flag == nil || flag.DefValue != "ledger" { + t.Fatalf("%s changed Ledger default: %+v", command.Name(), flag) + } + } + root.AddCommand(NewCommand(settings)) + root.SilenceUsage = true + root.SilenceErrors = true + root.SetArgs([]string{"plugins", "install"}) + if err := root.ExecuteContext(t.Context()); err == nil || !strings.Contains(err.Error(), "ledger plugin executable") { + t.Fatalf("Ledger install: %v", err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + root.SetArgs([]string{"plugins", "sync", "--catalogue", filepath.Join(t.TempDir(), "catalogue.json"), "--service-version", "3.0.0"}) + if err := root.ExecuteContext(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Ledger sync: %v", err) + } +} diff --git a/cmd/plugins/install.go b/cmd/plugins/install.go new file mode 100644 index 00000000..c6dd6c37 --- /dev/null +++ b/cmd/plugins/install.go @@ -0,0 +1,29 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginhost" +) + +func newInstallCommand(settings *connection.Settings) *cobra.Command { + var binary, localVersion, service string + install := &cobra.Command{ + Use: "install", Short: "Install a service plugin executable built locally", Args: cobra.NoArgs, + Annotations: map[string]string{"fctl.target": "stack"}, + Example: " fctl plugins install --binary ./build/fctl-plugin-ledger --profile local", + } + install.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") + install.Flags().StringVar(&binary, "binary", "", "Explicit path to the trusted executable") + install.Flags().StringVar(&localVersion, "service-version", "", "Exact service version; defaults to selected service /_info discovery") + install.RunE = func(cmd *cobra.Command, _ []string) error { + lock, err := pluginhost.Install(cmd, settings, service, binary, localVersion) + if err != nil { + return err + } + return writePluginResult(cmd, summarizePlugin(lock)) + } + + return install +} diff --git a/cmd/plugins/list.go b/cmd/plugins/list.go new file mode 100644 index 00000000..d094ac33 --- /dev/null +++ b/cmd/plugins/list.go @@ -0,0 +1,24 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginhost" +) + +func newListCommand(settings *connection.Settings) *cobra.Command { + list := &cobra.Command{Use: "list", Short: "List plugins prepared for your profiles and targets", Args: cobra.NoArgs} + list.RunE = func(cmd *cobra.Command, _ []string) error { + locks, err := pluginhost.List(settings, cmd) + if err != nil { + return err + } + rows := make([]pluginSummary, 0, len(locks)) + for _, lock := range locks { + rows = append(rows, summarizePlugin(lock)) + } + return writePluginResult(cmd, rows) + } + return list +} diff --git a/cmd/plugins/output.go b/cmd/plugins/output.go new file mode 100644 index 00000000..76027944 --- /dev/null +++ b/cmd/plugins/output.go @@ -0,0 +1,41 @@ +package plugins + +import ( + "encoding/json" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func writePluginResult(cmd *cobra.Command, result any) error { + data, err := json.Marshal(result) + if err != nil { + return err + } + return command.WriteJSON(cmd.OutOrStdout(), data) +} + +type pluginSummary struct { + Profile string `json:"profile"` + Organization string `json:"organization,omitzero"` + Stack string `json:"stack,omitzero"` + Endpoint string `json:"endpoint"` + Service string `json:"service"` + Version string `json:"version"` + Revision int `json:"revision"` + Platform string `json:"platform"` + Source string `json:"source"` + SHA256 string `json:"sha256"` +} + +func summarizePlugin(lock pluginmanager.Lock) pluginSummary { + source := lock.ArtifactDigest + if lock.Local { + source = "local" + } + return pluginSummary{Profile: lock.Target.Profile, Organization: lock.Target.Organization, Stack: lock.Target.Stack, + Endpoint: lock.Target.Endpoint, Service: lock.Service, Version: lock.ServiceVersion, Revision: lock.Revision, + Platform: lock.Platform.String(), Source: source, SHA256: lock.SHA256} +} diff --git a/cmd/plugins/plugins.go b/cmd/plugins/plugins.go new file mode 100644 index 00000000..e78e1fa5 --- /dev/null +++ b/cmd/plugins/plugins.go @@ -0,0 +1,14 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" +) + +// NewCommand builds the external plugin management commands. +func NewCommand(settings *connection.Settings) *cobra.Command { + root := &cobra.Command{Use: "plugins", Short: "Prepare and inspect external service plugins"} + root.AddCommand(newListCommand(settings), newSelectionsCommand(settings), newShowCommand(settings), newSyncCommand(settings), newInstallCommand(settings)) + return root +} diff --git a/cmd/plugins/selections.go b/cmd/plugins/selections.go new file mode 100644 index 00000000..96d08957 --- /dev/null +++ b/cmd/plugins/selections.go @@ -0,0 +1,26 @@ +package plugins + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +func newSelectionsCommand(settings *connection.Settings) *cobra.Command { + cmd := &cobra.Command{Use: "selections", Short: "List the legacy or modern provider selected for each service target", Args: cobra.NoArgs} + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + directory, err := settings.DirectoryPath() + if err != nil { + return err + } + selections, err := pluginselection.List(filepath.Join(directory, "plugins")) + if err != nil { + return err + } + return writePluginResult(cmd, selections) + } + return cmd +} diff --git a/cmd/plugins/show.go b/cmd/plugins/show.go new file mode 100644 index 00000000..122aa803 --- /dev/null +++ b/cmd/plugins/show.go @@ -0,0 +1,22 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginhost" +) + +func newShowCommand(settings *connection.Settings) *cobra.Command { + var service string + show := &cobra.Command{Use: "show", Short: "Show the service plugin locked for the selected target", Args: cobra.NoArgs} + show.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") + show.RunE = func(cmd *cobra.Command, _ []string) error { + lock, err := pluginhost.Show(settings, cmd, service) + if err != nil { + return err + } + return writePluginResult(cmd, summarizePlugin(lock)) + } + return show +} diff --git a/cmd/plugins/sync.go b/cmd/plugins/sync.go new file mode 100644 index 00000000..2746a3de --- /dev/null +++ b/cmd/plugins/sync.go @@ -0,0 +1,31 @@ +package plugins + +import ( + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginhost" +) + +func newSyncCommand(settings *connection.Settings) *cobra.Command { + var catalogue, serviceVersion, service string + var revision int + sync := &cobra.Command{ + Use: "sync", Short: "Download the service plugin for the exact deployed service version", Args: cobra.NoArgs, + Annotations: map[string]string{"fctl.target": "stack"}, + Example: " fctl plugins sync --organization ORG --stack STACK\n fctl plugins sync --catalogue registry.yaml --service-version 3.0.0 --profile local", + } + sync.Flags().StringVar(&service, "service", "ledger", "Service plugin: ledger or auth") + sync.Flags().StringVar(&catalogue, "catalogue", "", "YAML or JSON catalogue file or HTTPS URL; defaults to the official registry (FCTL_PLUGIN_CATALOGUE)") + sync.Flags().StringVar(&serviceVersion, "service-version", "", "Exact service version; defaults to selected service /_info discovery") + sync.Flags().IntVar(&revision, "revision", 0, "Plugin revision; defaults to the highest for this exact service version") + sync.RunE = func(cmd *cobra.Command, _ []string) error { + lock, err := pluginhost.Sync(cmd, settings, service, catalogue, serviceVersion, revision) + if err != nil { + return err + } + return writePluginResult(cmd, summarizePlugin(lock)) + } + + return sync +} diff --git a/cmd/profiles/delete.go b/cmd/profiles/delete.go deleted file mode 100644 index 79bff007..00000000 --- a/cmd/profiles/delete.go +++ /dev/null @@ -1,57 +0,0 @@ -package profiles - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ProfilesDeleteStore struct { - Success bool `json:"success"` -} -type ProfileDeleteController struct { - store *ProfilesDeleteStore -} - -var _ fctl.Controller[*ProfilesDeleteStore] = (*ProfileDeleteController)(nil) - -func NewDefaultDeleteProfileStore() *ProfilesDeleteStore { - return &ProfilesDeleteStore{ - Success: false, - } -} - -func NewDeleteProfileController() *ProfileDeleteController { - return &ProfileDeleteController{ - store: NewDefaultDeleteProfileStore(), - } -} - -func (c *ProfileDeleteController) GetStore() *ProfilesDeleteStore { - return c.store -} - -func (c *ProfileDeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - if err := fctl.DeleteProfile(cmd, args[0]); err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *ProfileDeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Profile deleted!") - return nil -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithShortDescription("Delete a profile"), - fctl.WithValidArgsFunction(ProfileNamesAutoCompletion), - fctl.WithController(NewDeleteProfileController()), - ) -} diff --git a/cmd/profiles/list.go b/cmd/profiles/list.go deleted file mode 100644 index 962ebbaf..00000000 --- a/cmd/profiles/list.go +++ /dev/null @@ -1,90 +0,0 @@ -package profiles - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Profile struct { - Name string `json:"name"` - Active string `json:"active"` -} -type ProfilesListStore struct { - Profiles []*Profile `json:"profiles"` -} -type ProfilesListController struct { - store *ProfilesListStore -} - -var _ fctl.Controller[*ProfilesListStore] = (*ProfilesListController)(nil) - -func NewDefaultProfilesListStore() *ProfilesListStore { - return &ProfilesListStore{ - Profiles: []*Profile{}, - } -} - -func NewProfilesListController() *ProfilesListController { - return &ProfilesListController{ - store: NewDefaultProfilesListStore(), - } -} - -func (c *ProfilesListController) GetStore() *ProfilesListStore { - return c.store -} - -func (c *ProfilesListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - profiles, err := fctl.ListProfiles(cmd) - if err != nil { - return nil, err - } - - currentProfileName := fctl.GetCurrentProfileName(cmd, *cfg) - - for _, k := range profiles { - c.store.Profiles = append(c.store.Profiles, &Profile{ - Name: k, - Active: func(k string) string { - if currentProfileName == k { - return "Yes" - } - return "No" - }(k), - }) - } - - return c, nil - -} - -func (c *ProfilesListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Profiles, func(p *Profile) []string { - return []string{ - p.Name, - p.Active, - } - }) - tableData = fctl.Prepend(tableData, []string{"Name", "Active"}) - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("l"), - fctl.WithShortDescription("List profiles"), - fctl.WithController(NewProfilesListController()), - ) -} diff --git a/cmd/profiles/profiles.go b/cmd/profiles/profiles.go new file mode 100644 index 00000000..06687329 --- /dev/null +++ b/cmd/profiles/profiles.go @@ -0,0 +1,142 @@ +// Package profiles manages named v4 service profiles. +package profiles + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/connection" +) + +func NewCommand(settings *connection.Settings) *cobra.Command { + root := &cobra.Command{Use: "profiles", Short: "Manage named service profiles"} + root.AddCommand(add(settings), use(settings), show(settings), list(settings), deleteCommand(settings)) + return root +} + +func add(s *connection.Settings) *cobra.Command { + var replace bool + cmd := &cobra.Command{Use: "add NAME", Short: "Save profile settings (client secrets stay in FCTL_CLIENT_SECRET)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := connection.ValidateName(args[0]); err != nil { + return err + } + opts := s.Input(cmd) + if err := connection.Validate(opts); err != nil { + return err + } + dir, err := s.DirectoryPath() + if err != nil { + return err + } + if err := connection.Update(cmd.Context(), dir, func(store *connection.Store) error { + if _, exists := store.Connections[args[0]]; exists && !replace { + return fmt.Errorf("profile exists; use --replace to replace settings and clear its login") + } + store.Connections[args[0]] = connection.NewEntry(opts) + if store.Active == "" { + store.Active = args[0] + } + return nil + }); err != nil { + return err + } + return print(cmd, opts) + }} + cmd.Flags().BoolVar(&replace, "replace", false, "Replace existing settings and clear its Cloud session") + return cmd +} + +func use(s *connection.Settings) *cobra.Command { + return &cobra.Command{Use: "use NAME", Short: "Select the default profile", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + dir, err := s.DirectoryPath() + if err != nil { + return err + } + if err := connection.Update(cmd.Context(), dir, func(store *connection.Store) error { + if _, exists := store.Connections[args[0]]; !exists { + return fmt.Errorf("profile %q does not exist", args[0]) + } + store.Active = args[0] + return nil + }); err != nil { + return err + } + return print(cmd, map[string]string{"active": args[0]}) + }} +} + +func show(s *connection.Settings) *cobra.Command { + return &cobra.Command{Use: "show", Short: "Show resolved settings without credentials", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + opts, _, _, _, err := s.Resolve(cmd) + if err != nil { + return err + } + return print(cmd, opts) + }} +} + +func list(s *connection.Settings) *cobra.Command { + return &cobra.Command{Use: "list", Short: "List saved profiles without credentials", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + dir, err := s.DirectoryPath() + if err != nil { + return err + } + store, err := connection.Load(dir) + if err != nil { + return err + } + type row struct { + Name string `json:"name"` + Active bool `json:"active"` + Options connection.Options `json:"options"` + LoggedIn bool `json:"loggedIn"` + } + rows := make([]row, 0, len(store.Connections)) + for _, name := range slices.Sorted(maps.Keys(store.Connections)) { + entry := store.Connections[name] + rows = append(rows, row{name, name == store.Active, entry.Options, entry.Session != nil}) + } + return print(cmd, rows) + }} +} + +func deleteCommand(s *connection.Settings) *cobra.Command { + var confirm bool + cmd := &cobra.Command{Use: "delete NAME", Short: "Delete a profile and its local login", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !confirm { + return fmt.Errorf("profile deletion requires --confirm") + } + dir, err := s.DirectoryPath() + if err != nil { + return err + } + if err := connection.Update(cmd.Context(), dir, func(store *connection.Store) error { + if _, exists := store.Connections[args[0]]; !exists { + return fmt.Errorf("profile %q does not exist", args[0]) + } + delete(store.Connections, args[0]) + if store.Active == args[0] { + store.Active = "" + } + return nil + }); err != nil { + return err + } + return print(cmd, map[string]string{"deleted": args[0]}) + }} + cmd.Flags().BoolVar(&confirm, "confirm", false, "Confirm deletion") + return cmd +} + +func print(cmd *cobra.Command, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + return command.WriteJSON(cmd.OutOrStdout(), data) +} diff --git a/cmd/profiles/rename.go b/cmd/profiles/rename.go deleted file mode 100644 index 117d0205..00000000 --- a/cmd/profiles/rename.go +++ /dev/null @@ -1,71 +0,0 @@ -package profiles - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type RenameStore struct { - Success bool `json:"success"` -} -type RenameController struct { - store *RenameStore -} - -var _ fctl.Controller[*RenameStore] = (*RenameController)(nil) - -func NewDefaultProfilesRenameStore() *RenameStore { - return &RenameStore{ - Success: false, - } -} - -func NewProfilesRenameController() *RenameController { - return &RenameController{ - store: NewDefaultProfilesRenameStore(), - } -} - -func (c *RenameController) GetStore() *RenameStore { - return c.store -} - -func (c *RenameController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - oldName := args[0] - newName := args[1] - - config, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - if err := fctl.RenameProfile(cmd, oldName, newName); err != nil { - return nil, err - } - - if config.CurrentProfile == oldName { - config.CurrentProfile = newName - if err := fctl.WriteConfig(cmd, *config); err != nil { - return nil, err - } - } - - c.store.Success = true - return c, nil -} - -func (c *RenameController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Profile renamed!") - return nil -} - -func NewRenameCommand() *cobra.Command { - return fctl.NewCommand("rename ", - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithShortDescription("Rename a profile"), - fctl.WithValidArgsFunction(ProfileNamesAutoCompletion), - fctl.WithController(NewProfilesRenameController()), - ) -} diff --git a/cmd/profiles/reset.go b/cmd/profiles/reset.go deleted file mode 100644 index 3fb5e850..00000000 --- a/cmd/profiles/reset.go +++ /dev/null @@ -1,57 +0,0 @@ -package profiles - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ResetStore struct { - Success bool `json:"success"` -} -type ResetController struct { - store *ResetStore -} - -var _ fctl.Controller[*ResetStore] = (*ResetController)(nil) - -func NewResetStore() *ResetStore { - return &ResetStore{ - Success: false, - } -} - -func NewResetController() *ResetController { - return &ResetController{ - store: NewResetStore(), - } -} - -func (c *ResetController) GetStore() *ResetStore { - return c.store -} - -func (c *ResetController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - if err := fctl.ResetProfile(cmd, args[0]); err != nil { - return nil, err - } - - c.store.Success = true - - return c, nil -} - -func (c *ResetController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Profile reset on default !") - return nil -} - -func NewResetCommand() *cobra.Command { - return fctl.NewCommand("reset ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithShortDescription("Reset a profile keeping the environment"), - fctl.WithValidArgsFunction(ProfileNamesAutoCompletion), - fctl.WithController(NewResetController()), - ) -} diff --git a/cmd/profiles/root.go b/cmd/profiles/root.go deleted file mode 100644 index a06ddf86..00000000 --- a/cmd/profiles/root.go +++ /dev/null @@ -1,24 +0,0 @@ -package profiles - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("profiles", - fctl.WithAliases("p", "prof", "profile"), - fctl.WithShortDescription("Manage profiles"), - fctl.WithChildCommands( - NewDeleteCommand(), - NewListCommand(), - NewRenameCommand(), - NewShowCommand(), - NewUseCommand(), - NewSetDefaultOrganizationCommand(), - NewSetDefaultStackCommand(), - NewResetCommand(), - ), - ) -} diff --git a/cmd/profiles/setdefaultorganization.go b/cmd/profiles/setdefaultorganization.go deleted file mode 100644 index 86650d3d..00000000 --- a/cmd/profiles/setdefaultorganization.go +++ /dev/null @@ -1,81 +0,0 @@ -package profiles - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetDefaultOrganizationStore struct { - Success bool `json:"success"` -} -type SetDefaultOrganizationController struct { - store *SetDefaultOrganizationStore -} - -var _ fctl.Controller[*SetDefaultOrganizationStore] = (*SetDefaultOrganizationController)(nil) - -func NewDefaultProfilesSetDefaultOrganizationStore() *SetDefaultOrganizationStore { - return &SetDefaultOrganizationStore{ - Success: false, - } -} - -func NewProfilesSetDefaultOrganizationController() *SetDefaultOrganizationController { - return &SetDefaultOrganizationController{ - store: NewDefaultProfilesSetDefaultOrganizationStore(), - } -} - -func (c *SetDefaultOrganizationController) GetStore() *SetDefaultOrganizationStore { - return c.store -} - -func (c *SetDefaultOrganizationController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - currentProfile, profileName, err := fctl.LoadCurrentProfile(cmd, *cfg) - if err != nil { - return nil, err - } - - if !currentProfile.IsConnected() { - return nil, errors.New("You are not connected, please run 'fctl login'") - } - - if !currentProfile.RootTokens.ID.Claims.HasOrganizationAccess(args[0]) { - return nil, fmt.Errorf("organization %s not found in your access list", args[0]) - } - - currentProfile.DefaultOrganization = args[0] - currentProfile.DefaultStack = "" - - if err := fctl.WriteProfile(cmd, profileName, *currentProfile); err != nil { - return nil, fmt.Errorf("Updating config: %w", err) - } - - c.store.Success = true - return c, nil -} - -func (c *SetDefaultOrganizationController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Default organization updated!") - return nil -} - -func NewSetDefaultOrganizationCommand() *cobra.Command { - return fctl.NewCommand("set-default-organization ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("sdo"), - fctl.WithShortDescription("Set default organization"), - fctl.WithValidArgsFunction(fctl.OrganizationCompletion), - fctl.WithController(NewProfilesSetDefaultOrganizationController()), - ) -} diff --git a/cmd/profiles/setdefaultstack.go b/cmd/profiles/setdefaultstack.go deleted file mode 100644 index 685fe527..00000000 --- a/cmd/profiles/setdefaultstack.go +++ /dev/null @@ -1,84 +0,0 @@ -package profiles - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type SetDefaultStackStore struct { - Success bool `json:"success"` -} -type SetDefaultStackController struct { - store *SetDefaultStackStore -} - -var _ fctl.Controller[*SetDefaultStackStore] = (*SetDefaultStackController)(nil) - -func NewDefaultSetDefaultStackStore() *SetDefaultStackStore { - return &SetDefaultStackStore{ - Success: false, - } -} - -func NewSetDefaultStackController() *SetDefaultStackController { - return &SetDefaultStackController{ - store: NewDefaultSetDefaultStackStore(), - } -} - -func (c *SetDefaultStackController) GetStore() *SetDefaultStackStore { - return c.store -} - -func (c *SetDefaultStackController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - currentProfile, profileName, err := fctl.LoadCurrentProfile(cmd, *cfg) - if err != nil { - return nil, err - } - - if !currentProfile.IsConnected() { - return nil, errors.New("You are not connected, please run 'fctl login'") - } - - organizationID, err := fctl.ResolveOrganizationID(cmd, *currentProfile) - if err != nil { - return nil, err - } - - if !currentProfile.RootTokens.ID.Claims.HasStackAccess(organizationID, args[0]) { - return nil, fmt.Errorf("stack %s not found in your access list", args[0]) - } - - currentProfile.DefaultStack = args[0] - if err := fctl.WriteProfile(cmd, profileName, *currentProfile); err != nil { - return nil, fmt.Errorf("Updating config: %w", err) - } - - c.store.Success = true - return c, nil -} - -func (c *SetDefaultStackController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Default stack updated!") - return nil -} - -func NewSetDefaultStackCommand() *cobra.Command { - return fctl.NewCommand("set-default-stack ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("sds"), - fctl.WithShortDescription("Set default stack"), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithController(NewSetDefaultStackController()), - ) -} diff --git a/cmd/profiles/show.go b/cmd/profiles/show.go deleted file mode 100644 index 15c35ce8..00000000 --- a/cmd/profiles/show.go +++ /dev/null @@ -1,78 +0,0 @@ -package profiles - -import ( - "errors" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ProfilesShowStore struct { - MembershipURI string `json:"membershipUri"` - DefaultOrganization string `json:"defaultOrganization"` - DefaultStack string `json:"defaultStack"` -} -type ProfilesShowController struct { - store *ProfilesShowStore -} - -var _ fctl.Controller[*ProfilesShowStore] = (*ProfilesShowController)(nil) - -func NewDefaultProfilesShowStore() *ProfilesShowStore { - return &ProfilesShowStore{ - MembershipURI: "", - DefaultOrganization: "", - DefaultStack: "", - } -} - -func NewProfilesShowController() *ProfilesShowController { - return &ProfilesShowController{ - store: NewDefaultProfilesShowStore(), - } -} - -func (c *ProfilesShowController) GetStore() *ProfilesShowStore { - return c.store -} - -func (c *ProfilesShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - p, err := fctl.LoadProfile(cmd, args[0]) - if err != nil { - return nil, err - } - if p == nil { - return nil, errors.New("not found") - } - - c.store.DefaultOrganization = p.GetDefaultOrganization() - c.store.MembershipURI = p.GetMembershipURI() - c.store.DefaultStack = p.GetDefaultStack() - - return c, nil -} - -func (c *ProfilesShowController) Render(cmd *cobra.Command, args []string) error { - - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Membership URI"), c.store.MembershipURI}) - tableData = append(tableData, []string{pterm.LightCyan("Default organization"), c.store.DefaultOrganization}) - tableData = append(tableData, []string{pterm.LightCyan("Default stack"), c.store.DefaultStack}) - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithAliases("s"), - fctl.WithShortDescription("Show a profile"), - fctl.WithValidArgsFunction(ProfileNamesAutoCompletion), - fctl.WithController(NewProfilesShowController()), - ) -} diff --git a/cmd/profiles/use.go b/cmd/profiles/use.go deleted file mode 100644 index 5f1f32fe..00000000 --- a/cmd/profiles/use.go +++ /dev/null @@ -1,82 +0,0 @@ -package profiles - -import ( - "fmt" - "os" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UseStore struct { -} -type UseController struct { - store *UseStore -} - -var _ fctl.Controller[*UseStore] = (*UseController)(nil) - -func NewDefaultProfilesUseStore() *UseStore { - return &UseStore{} -} - -func NewProfilesUseController() *UseController { - return &UseController{ - store: NewDefaultProfilesUseStore(), - } -} - -func ProfileNamesAutoCompletion(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - ret, err := fctl.ListProfiles(cmd, func(s string) bool { - return strings.HasPrefix(s, toComplete) - }) - if err != nil { - return []string{}, cobra.ShellCompDirectiveError - } - - return ret, cobra.ShellCompDirectiveNoFileComp -} - -func (c *UseController) GetStore() *UseStore { - return c.store -} - -func (c *UseController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - config, err := fctl.LoadConfig(cmd) - if err != nil { - return nil, err - } - - _, err = fctl.LoadProfile(cmd, args[0]) - if os.IsNotExist(err) { - return nil, fmt.Errorf("profile %s not found", args[0]) - } - if err != nil { - return nil, err - } - - config.CurrentProfile = args[0] - if err := fctl.WriteConfig(cmd, *config); err != nil { - return nil, fmt.Errorf("Updating config: %w", err) - } - - return c, nil -} - -func (c *UseController) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Selected profile updated!") - return nil -} - -func NewUseCommand() *cobra.Command { - return fctl.NewCommand("use ", - fctl.WithAliases("u"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithShortDescription("Use profile"), - fctl.WithValidArgsFunction(ProfileNamesAutoCompletion), - fctl.WithController(NewProfilesUseController()), - ) -} diff --git a/cmd/profiles_context_test.go b/cmd/profiles_context_test.go deleted file mode 100644 index 121d13d7..00000000 --- a/cmd/profiles_context_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package cmd_test - -import ( - "bytes" - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/formancehq/go-libs/v4/oidc" - - "github.com/formancehq/fctl/v3/cmd" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func newProfileContext(t *testing.T) (*cobra.Command, string) { - t.Helper() - for _, env := range []string{"PROFILE", "ORGANIZATION", "STACK"} { - t.Setenv(env, "") - } - dir := t.TempDir() - storage := &cobra.Command{} - storage.Flags().String(fctl.ConfigDir, dir, "") - return storage, dir -} - -func executeProfileContext(t *testing.T, dir string, args ...string) (string, error) { - t.Helper() - root := cmd.NewRootCommand() - var stdout, stderr bytes.Buffer - root.SetOut(&stdout) - root.SetErr(&stderr) - root.SetIn(strings.NewReader("")) - root.SetArgs(append([]string{"--config-dir", dir, "--telemetry=false"}, args...)) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err := root.ExecuteContext(ctx) - t.Logf("stdout: %s\nstderr: %s", stdout.String(), stderr.String()) - return pterm.RemoveColorFromString(stdout.String()), err -} - -func TestProfilesOfflineLifecycle(t *testing.T) { - storage, dir := newProfileContext(t) - profile := fctl.Profile{MembershipURI: "https://membership.invalid", DefaultOrganization: "org", DefaultStack: "stack"} - require.NoError(t, fctl.WriteProfile(storage, "work", profile)) - require.NoError(t, fctl.WriteProfile(storage, "other", fctl.Profile{})) - - out, err := executeProfileContext(t, dir, "profiles", "use", "work") - require.NoError(t, err) - require.Contains(t, out, "Selected profile updated!") - cfg, err := fctl.LoadConfig(storage) - require.NoError(t, err) - require.Equal(t, "work", cfg.CurrentProfile) - - out, err = executeProfileContext(t, dir, "profiles", "list") - require.NoError(t, err) - require.Regexp(t, `work\s+\|\s+Yes`, out) - require.Regexp(t, `other\s+\|\s+No`, out) - out, err = executeProfileContext(t, dir, "--profile=other", "profiles", "list") - require.NoError(t, err) - require.Regexp(t, `other\s+\|\s+Yes`, out) - require.Regexp(t, `work\s+\|\s+No`, out) - cfg, err = fctl.LoadConfig(storage) - require.NoError(t, err) - require.Equal(t, "work", cfg.CurrentProfile, "--profile must not change the saved selection") - - out, err = executeProfileContext(t, dir, "profiles", "show", "work") - require.NoError(t, err) - require.Contains(t, out, profile.MembershipURI) - require.Contains(t, out, profile.DefaultOrganization) - require.Contains(t, out, profile.DefaultStack) - - out, err = executeProfileContext(t, dir, "profiles", "rename", "work", "renamed") - require.NoError(t, err) - require.Contains(t, out, "Profile renamed!") - cfg, err = fctl.LoadConfig(storage) - require.NoError(t, err) - require.Equal(t, "renamed", cfg.CurrentProfile) - _, err = fctl.LoadProfile(storage, "work") - require.ErrorIs(t, err, os.ErrNotExist) - loaded, err := fctl.LoadProfile(storage, "renamed") - require.NoError(t, err) - require.Equal(t, profile, *loaded) - - out, err = executeProfileContext(t, dir, "profiles", "reset", "renamed") - require.NoError(t, err) - require.Contains(t, out, "Profile reset") - loaded, err = fctl.LoadProfile(storage, "renamed") - require.NoError(t, err) - profile.MembershipURI = fctl.DefaultMembershipURI - require.Equal(t, profile, *loaded) - - out, err = executeProfileContext(t, dir, "profiles", "delete", "other") - require.NoError(t, err) - require.Contains(t, out, "Profile deleted!") - names, err := fctl.ListProfiles(storage) - require.NoError(t, err) - require.Equal(t, []string{"renamed"}, names) -} - -func TestProfilesUseRejectsMissingOrInvalidProfile(t *testing.T) { - for _, name := range []string{"missing", "invalid"} { - t.Run(name, func(t *testing.T) { - storage, dir := newProfileContext(t) - require.NoError(t, fctl.WriteConfig(storage, fctl.Config{CurrentProfile: "saved"})) - if name == "invalid" { - require.NoError(t, fctl.WriteProfile(storage, name, fctl.Profile{})) - require.NoError(t, os.WriteFile(filepath.Join(dir, "profiles", name, "profile.json"), []byte("{broken"), 0600)) - } - _, err := executeProfileContext(t, dir, "profiles", "use", name) - require.Error(t, err) - cfg, err := fctl.LoadConfig(storage) - require.NoError(t, err) - require.Equal(t, "saved", cfg.CurrentProfile) - }) - } -} - -func TestProfilesSetDefaultsTargetsSelectedProfile(t *testing.T) { - storage, dir := newProfileContext(t) - profile := fctl.Profile{DefaultOrganization: "old", DefaultStack: "old-stack", RootTokens: &fctl.Tokens{ - ID: fctl.IDToken{Claims: fctl.IDTokenClaims{Organizations: []fctl.OrganizationAccess{ - {ID: "new", Stacks: []fctl.StackAccess{{ID: "new-stack"}}}, - }}}, - }} - require.NoError(t, fctl.WriteProfile(storage, "saved", profile)) - require.NoError(t, fctl.WriteProfile(storage, "selected", profile)) - require.NoError(t, fctl.WriteConfig(storage, fctl.Config{CurrentProfile: "saved"})) - t.Setenv("PROFILE", "saved") - - out, err := executeProfileContext(t, dir, "--profile=selected", "profiles", "set-default-organization", "new") - require.NoError(t, err) - require.Contains(t, out, "Default organization updated!") - selected, err := fctl.LoadProfile(storage, "selected") - require.NoError(t, err) - require.Equal(t, "new", selected.DefaultOrganization) - require.Empty(t, selected.DefaultStack, "switching organization clears the previous stack") - - out, err = executeProfileContext(t, dir, "--profile=selected", "profiles", "set-default-stack", "new-stack") - require.NoError(t, err) - require.Contains(t, out, "Default stack updated!") - selected, err = fctl.LoadProfile(storage, "selected") - require.NoError(t, err) - require.Equal(t, "new-stack", selected.DefaultStack) - _, err = executeProfileContext(t, dir, "--profile=selected", "profiles", "set-default-stack", "unknown") - require.ErrorContains(t, err, "not found in your access list") - unchanged, err := fctl.LoadProfile(storage, "selected") - require.NoError(t, err) - require.Equal(t, selected, unchanged) - saved, err := fctl.LoadProfile(storage, "saved") - require.NoError(t, err) - require.Equal(t, profile, *saved) -} - -func TestProfilesSetDefaultStackDisconnected(t *testing.T) { - for _, organization := range []string{"", "saved-org"} { - t.Run("organization="+organization, func(t *testing.T) { - storage, dir := newProfileContext(t) - profile := fctl.Profile{DefaultOrganization: organization} - require.NoError(t, fctl.WriteProfile(storage, "default", profile)) - _, err := executeProfileContext(t, dir, "profiles", "set-default-stack", "stack") - require.ErrorContains(t, err, "please run 'fctl login'") - unchanged, err := fctl.LoadProfile(storage, "default") - require.NoError(t, err) - require.Equal(t, profile, *unchanged) - }) - } -} - -func TestRootContextSelection(t *testing.T) { - for _, tc := range []struct { - name, envProfile, envOrg, envStack string - args []string - wantErr string - }{ - {name: "explicit flags override environment and saved defaults", envProfile: "missing", envOrg: "wrong", envStack: "wrong", args: []string{"--profile=selected", "--organization=org-b", "--stack=stack-b"}}, - {name: "environment overrides saved defaults", envProfile: "selected", envOrg: "org-b", envStack: "stack-b"}, - {name: "saved profile defaults"}, - {name: "ambiguous organizations", args: []string{"--profile=ambiguous"}, wantErr: fctl.ErrMultipleOrganizationsFound.Error()}, - {name: "ambiguous stacks", args: []string{"--profile=ambiguous", "--organization=org-b"}, wantErr: "found more than one stack and no stack specified"}, - } { - t.Run(tc.name, func(t *testing.T) { - storage, dir := newProfileContext(t) - requests := make(chan string, 4) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case requests <- r.Method + " " + r.URL.Path + " " + r.Header.Get("Authorization"): - default: - t.Error("unexpected extra API request") - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":[]}`)) - })) - defer server.Close() - profile := fctl.Profile{MembershipURI: server.URL, DefaultOrganization: "org-b", DefaultStack: "stack-b", RootTokens: &fctl.Tokens{ - ID: fctl.IDToken{Claims: fctl.IDTokenClaims{Organizations: []fctl.OrganizationAccess{ - {ID: "org-a"}, {ID: "org-b", Stacks: []fctl.StackAccess{{ID: "stack-a"}, {ID: "stack-b"}}}, - }}}, - }} - require.NoError(t, fctl.WriteProfile(storage, "saved", profile)) - require.NoError(t, fctl.WriteConfig(storage, fctl.Config{CurrentProfile: "saved"})) - profile.DefaultOrganization, profile.DefaultStack = "org-a", "stack-a" - require.NoError(t, fctl.WriteProfile(storage, "selected", profile)) - profile.DefaultOrganization, profile.DefaultStack = "", "" - require.NoError(t, fctl.WriteProfile(storage, "ambiguous", profile)) - for _, name := range []string{"saved", "selected"} { - token := fctl.AccessToken{} - token.Token = name + "-token" - token.Claims.OrganizationID = "org-b" - token.Claims.Expiration = oidc.Time(time.Now().Add(time.Hour).Unix()) - require.NoError(t, fctl.WriteOrganizationToken(storage, name, token)) - } - t.Setenv("PROFILE", tc.envProfile) - t.Setenv("ORGANIZATION", tc.envOrg) - t.Setenv("STACK", tc.envStack) - out, err := executeProfileContext(t, dir, append([]string{"stack", "modules", "list"}, tc.args...)...) - if tc.wantErr != "" { - require.EqualError(t, err, tc.wantErr) - require.Empty(t, requests, "ambiguous contexts must not reach the API") - return - } - require.NoError(t, err) - require.Contains(t, out, "Name") - tokenProfile := "selected" - if tc.envProfile == "" { - tokenProfile = "saved" - } - require.Len(t, requests, 1) - require.Equal(t, "GET /organizations/org-b/stacks/stack-b/modules Bearer "+tokenProfile+"-token", <-requests) - }) - } -} diff --git a/cmd/prompt.go b/cmd/prompt.go deleted file mode 100644 index d951856c..00000000 --- a/cmd/prompt.go +++ /dev/null @@ -1,286 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "os" - "runtime/debug" - "sort" - "strings" - - goprompt "github.com/c-bata/go-prompt" - "github.com/iancoleman/strcase" - "github.com/mattn/go-shellwords" - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -var ( - ErrMalformedCommand = errors.New("malformed command") -) - -type prompt struct { - promptColor goprompt.Color - history []string - userEmail string - actualProfile string -} - -func (p *prompt) completionsFromCommand(subCommand *cobra.Command, completionsArgs []string, d goprompt.Document) []goprompt.Suggest { - - defer func() { - // The autocompletion library sometimes panic - // As it is not critical, we just catch the error and display it only when debug enabled - if err := recover(); err != nil { - isDebug, _ := subCommand.Flags().GetBool(fctl.DebugFlag) - if isDebug { - fmt.Println(err) - debug.PrintStack() - } - } - }() - - _, completions, _, err := subCommand.GetCompletions(completionsArgs) - if err != nil { - return []goprompt.Suggest{} - } - return goprompt.FilterHasPrefix(fctl.Map(completions, func(src string) goprompt.Suggest { - parts := strings.SplitN(src, "\t", 2) - description := "" - if len(parts) > 1 { - description = parts[1] - } - return goprompt.Suggest{ - Text: parts[0], - Description: description, - } - }), d.GetWordBeforeCursor(), true) -} - -func (p *prompt) completions(cmd *cobra.Command, d goprompt.Document) ([]goprompt.Suggest, error) { - suggestions := make([]goprompt.Suggest, 0) - switch { - case strings.HasPrefix(d.Text, ":set "+fctl.ProfileFlag): - profiles, err := fctl.ListProfiles(cmd) - if err != nil { - return nil, err - } - - sort.Strings(profiles) - for _, p := range profiles { - suggestions = append(suggestions, goprompt.Suggest{ - Text: p, - Description: "Select profile", - }) - } - case strings.HasPrefix(d.Text, ":set "+fctl.DebugFlag) || strings.HasPrefix(d.Text, ":set "+fctl.InsecureTlsFlag): - suggestions = append(suggestions, goprompt.Suggest{ - Text: "true", - }, goprompt.Suggest{ - Text: "false", - }) - case strings.HasPrefix(d.Text, ":set"): - suggestions = append(suggestions, goprompt.Suggest{ - Text: fctl.ProfileFlag, - Description: "Select profile", - }, goprompt.Suggest{ - Text: fctl.DebugFlag, - Description: "Set debug", - }, goprompt.Suggest{ - Text: fctl.InsecureTlsFlag, - Description: "Set insecure TLS", - }) - default: - suggestions = append(suggestions, goprompt.Suggest{ - Text: ":set", - Description: "Set config", - }, goprompt.Suggest{ - Text: ":exit", - Description: "Exit terminal", - }) - } - - return goprompt.FilterHasPrefix(suggestions, d.GetWordBeforeCursor(), true), nil -} - -func (p *prompt) startPrompt(cmd *cobra.Command, prompt string, opts ...goprompt.Option) (string, error) { - return goprompt.Input(prompt, func(d goprompt.Document) []goprompt.Suggest { - subCommand := NewRootCommand() - subCommand.SetContext(cmd.Context()) - - switch { - case d.Text == "": - return p.completionsFromCommand(subCommand, []string{""}, d) - case strings.HasPrefix(d.Text, ":"): - completions, err := p.completions(cmd, d) - if err != nil { - return []goprompt.Suggest{} - } - - return completions - default: - parse, err := shellwords.Parse(d.Text) - if err != nil { - return []goprompt.Suggest{} - } - - if strings.HasSuffix(d.Text, " ") || len(parse) == 0 { - parse = append(parse, "") - } - return p.completionsFromCommand(subCommand, parse, d) - } - }, opts...), nil -} - -func (p *prompt) executeCommand(cmd *cobra.Command, t string) error { - parse, err := shellwords.Parse(t) - if err != nil { - return fmt.Errorf("%w: %w", errors.ErrUnsupported, err) - } - - subCommand := NewRootCommand() - subCommand.SetArgs(parse) - subCommand.SetOut(cmd.OutOrStdout()) - subCommand.SetErr(cmd.ErrOrStderr()) - subCommand.SilenceErrors = true - subCommand.SilenceUsage = true - return subCommand.ExecuteContext(cmd.Context()) -} - -func (p *prompt) executePromptCommand(cmd *cobra.Command, t string) error { - switch { - case strings.TrimRight(t, " ") == ":exit": - os.Exit(0) - case strings.HasPrefix(t, ":set "): - v := strings.TrimPrefix(t, ":set ") - v = strings.TrimLeft(v, " ") - v = strings.TrimRight(v, " ") - parts := strings.SplitN(v, " ", 2) - if len(parts) != 2 { - return ErrMalformedCommand - } else { - if v := parts[0]; v != fctl.ProfileFlag && v != fctl.DebugFlag && v != fctl.InsecureTlsFlag { - return fmt.Errorf("unknown configuration: %s", v) - } - _ = cmd.Flags().Set(parts[0], parts[1]) - if err := os.Setenv(strcase.ToScreamingSnake(parts[0]), parts[1]); err != nil { - return err - } - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Set %s=%s", parts[0], parts[1]) - } - default: - return ErrMalformedCommand - } - return nil -} - -func (p *prompt) refreshUserEmail(cmd *cobra.Command, cfg fctl.Config) error { - profile, _, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfileWithConfig(cmd, cfg) - if err != nil { - return err - } - if !profile.IsConnected() { - p.userEmail = "" - return nil - } - - userInfo, err := fctl.UserInfo(cmd, relyingParty, profile.RootTokens.Access) - if err != nil { - p.userEmail = "" - return nil - } - p.userEmail = userInfo.Email - return nil -} - -func (p *prompt) displayHeader(cmd *cobra.Command, cfg *fctl.Config) error { - header := "" - if p.userEmail != "" { - currentProfile, _, err := fctl.LoadCurrentProfile(cmd, *cfg) - if err != nil { - return err - } - header += " / " + p.userEmail - if organizationID := currentProfile.GetDefaultOrganization(); organizationID != "" { - header += " / " + organizationID - } - - if stackID := currentProfile.GetDefaultStack(); stackID != "" { - header += " / " + stackID - } - } - header += " #" - fctl.BasicTextCyan.WithWriter(cmd.OutOrStdout()).Printf("%s\r\n", header) - return nil -} - -func (p *prompt) nextCommand(cmd *cobra.Command) error { - - cfg, err := fctl.LoadConfig(cmd) - if err != nil { - return err - } - - currentProfileName := fctl.GetCurrentProfileName(cmd, *cfg) - if currentProfileName != p.actualProfile || p.userEmail == "" { - if err := p.refreshUserEmail(cmd, *cfg); err != nil { - return err - } - p.actualProfile = currentProfileName - } - - if err := p.displayHeader(cmd, cfg); err != nil { - return err - } - - prompt, err := p.startPrompt(cmd, " > ", - goprompt.OptionPrefixTextColor(p.promptColor), - goprompt.OptionHistory(p.history), - goprompt.OptionCompletionOnDown()) - if err != nil { - return err - } - switch prompt { - case "": - p.promptColor = goprompt.Blue - default: - var err error - if strings.HasPrefix(prompt, ":") { - err = p.executePromptCommand(cmd, prompt) - } else { - err = p.executeCommand(cmd, prompt) - } - if err != nil { - pterm.Error.WithWriter(cmd.OutOrStderr()).Printfln("%s", err) - p.promptColor = goprompt.Red - } else { - p.promptColor = goprompt.Blue - } - p.history = append(p.history, prompt) - } - - return nil -} - -func (p *prompt) run(cmd *cobra.Command) error { - for { - if err := p.nextCommand(cmd); err != nil { - return err - } - } -} - -func NewPromptCommand() *cobra.Command { - return fctl.NewCommand("prompt", - fctl.WithShortDescription("Start a prompt"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithRunE(func(cmd *cobra.Command, args []string) error { - return (&prompt{ - promptColor: goprompt.Blue, - history: make([]string, 0), - }).run(cmd) - }), - ) -} diff --git a/cmd/reconciliation/alerts/root.go b/cmd/reconciliation/alerts/root.go deleted file mode 100644 index d2c0d445..00000000 --- a/cmd/reconciliation/alerts/root.go +++ /dev/null @@ -1,216 +0,0 @@ -package alerts - -import ( - "fmt" - "net/http" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/reconciliation/internal/clarity" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const queryFlag = "query" - -func NewCommand() *cobra.Command { - return fctl.NewCommand("alerts", - fctl.WithAliases("alert"), - fctl.WithShortDescription("Manage Ledger Clarity alerts"), - fctl.WithChildCommands( - newListCommand(), - newGetCommand(), - newEventsCommand(), - newAckCommand(), - newResolveCommand(), - newAcceptCommand(), - newSnoozeCommand(), - newUnsnoozeCommand(), - ), - ) -} - -func newListCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, _ []string, client *clarity.Client) (clarity.Store, error) { - query, err := clarity.PaginationQuery(cmd) - if err != nil { - return nil, err - } - var body any - if query.Get("cursor") == "" { - body, err = clarity.QueryBody(cmd, queryFlag) - if err != nil { - return nil, err - } - } - var response clarity.CursorResponse[clarity.Alert] - if err := client.Do(cmd, http.MethodGet, "/alerts", query, body, &response); err != nil { - return nil, err - } - return clarity.Store{"alerts": response.Cursor.Data, "cursor": clarity.Page(response.Cursor)}, nil - }, renderAlerts) - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List alerts"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithStringFlag(queryFlag, "", "Query-builder expression as JSON"), - fctl.WithController[clarity.Store](controller), - ) -} - -func newGetCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - var response clarity.DataResponse[clarity.Alert] - if err := client.Do(cmd, http.MethodGet, alertPath(args[0]), nil, nil, &response); err != nil { - return nil, err - } - return clarity.Store{"alert": response.Data}, nil - }, nil) - return fctl.NewCommand("get ", - fctl.WithAliases("show", "sh", "s"), - fctl.WithShortDescription("Get an alert"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func newEventsCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - query, err := clarity.PaginationQuery(cmd) - if err != nil { - return nil, err - } - var response clarity.CursorResponse[clarity.AlertEvent] - if err := client.Do(cmd, http.MethodGet, alertPath(args[0])+"/events", query, nil, &response); err != nil { - return nil, err - } - return clarity.Store{"events": response.Cursor.Data, "cursor": clarity.Page(response.Cursor)}, nil - }, renderEvents) - return fctl.NewCommand("events ", - fctl.WithShortDescription("List an alert's append-only event timeline"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func newAckCommand() *cobra.Command { - return newTransitionCommand("ack ", "Acknowledge an alert", "ack", []string{"by"}, func(cmd *cobra.Command) map[string]any { - return optionalNote(map[string]any{"by": fctl.GetString(cmd, "by")}, cmd) - }, - fctl.WithStringFlag("by", "", "Actor acknowledging the alert"), - fctl.WithStringFlag("note", "", "Optional acknowledgement note"), - ) -} - -func newResolveCommand() *cobra.Command { - return newTransitionCommand("resolve ", "Resolve an alert as fixed by booking", "resolve", []string{"by"}, func(cmd *cobra.Command) map[string]any { - body := optionalNote(map[string]any{"by": fctl.GetString(cmd, "by")}, cmd) - refs, _ := cmd.Flags().GetStringArray("transaction-ref") - if len(refs) > 0 { - body["transactionRefs"] = refs - } - return body - }, - fctl.WithStringFlag("by", "", "Actor resolving the alert"), - fctl.WithStringFlag("note", "", "Optional resolution note"), - fctl.WithStringArrayFlag("transaction-ref", nil, "Related transaction reference (repeatable)"), - ) -} - -func newAcceptCommand() *cobra.Command { - return newTransitionCommand("accept ", "Accept an alert as a business exception", "accept", []string{"by", "note"}, func(cmd *cobra.Command) map[string]any { - return map[string]any{"by": fctl.GetString(cmd, "by"), "note": fctl.GetString(cmd, "note")} - }, - fctl.WithStringFlag("by", "", "Actor accepting the alert"), - fctl.WithStringFlag("note", "", "Required business justification"), - ) -} - -func newSnoozeCommand() *cobra.Command { - return newTransitionCommand("snooze ", "Snooze an alert's notifications", "snooze", []string{"by", "until"}, func(cmd *cobra.Command) map[string]any { - return optionalNote(map[string]any{"by": fctl.GetString(cmd, "by"), "until": fctl.GetString(cmd, "until")}, cmd) - }, - fctl.WithStringFlag("by", "", "Actor snoozing the alert"), - fctl.WithStringFlag("until", "", "Future snooze deadline (RFC3339)"), - fctl.WithStringFlag("note", "", "Optional snooze note"), - ) -} - -func newUnsnoozeCommand() *cobra.Command { - return newTransitionCommand("unsnooze ", "Lift an alert snooze early", "unsnooze", []string{"by"}, func(cmd *cobra.Command) map[string]any { - return map[string]any{"by": fctl.GetString(cmd, "by")} - }, fctl.WithStringFlag("by", "", "Actor lifting the snooze")) -} - -func newTransitionCommand(use, description, endpoint string, required []string, body func(*cobra.Command) map[string]any, options ...fctl.CommandOption) *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - if !fctl.CheckStackApprobation(cmd, "You are about to %s alert '%s'", endpoint, args[0]) { - return nil, fctl.ErrMissingApproval - } - var response clarity.DataResponse[clarity.Alert] - if err := client.Do(cmd, http.MethodPost, alertPath(args[0])+"/"+endpoint, nil, body(cmd), &response); err != nil { - return nil, err - } - return clarity.Store{"alert": response.Data}, nil - }, nil) - baseOptions := []fctl.CommandOption{ - fctl.WithShortDescription(description), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - } - cmd := fctl.NewCommand(use, append(baseOptions, options...)...) - for _, flag := range required { - _ = cmd.MarkFlagRequired(flag) - } - return cmd -} - -func optionalNote(body map[string]any, cmd *cobra.Command) map[string]any { - if note := fctl.GetString(cmd, "note"); note != "" { - body["note"] = note - } - return body -} - -func alertPath(id string) string { return "/alerts/" + clarity.ResourcePath(id) } - -func renderAlerts(cmd *cobra.Command, store clarity.Store) error { - alerts := store["alerts"].([]clarity.Alert) - rows := pterm.TableData{{"ID", "RuleID", "Fingerprint", "Period", "Status", "Severity", "Occurrences", "LastSeenAt"}} - for _, alert := range alerts { - rows = append(rows, []string{alert.ID, alert.RuleID, alert.Fingerprint, alert.PeriodID, alert.Status, alert.Severity, fmt.Sprint(alert.OccurrenceCount), alert.LastSeenAt}) - } - if err := pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(rows).Render(); err != nil { - return err - } - cursor := store["cursor"].(clarity.PageCursor) - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{HasMore: cursor.HasMore, PageSize: cursor.PageSize, Next: cursor.Next, Previous: cursor.Previous}) -} - -func renderEvents(cmd *cobra.Command, store clarity.Store) error { - events := store["events"].([]clarity.AlertEvent) - rows := pterm.TableData{{"ID", "Type", "Previous", "NewStatus", "At", "Reopen", "Notify", "EvaluationID"}} - for _, event := range events { - previous, evaluationID := "", "" - if event.Previous != nil { - previous = *event.Previous - } - if event.EvaluationID != nil { - evaluationID = *event.EvaluationID - } - rows = append(rows, []string{event.ID, event.Type, previous, event.NewStatus, event.At, fmt.Sprint(event.IsReopen), fmt.Sprint(event.Notify), evaluationID}) - } - if err := pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(rows).Render(); err != nil { - return err - } - cursor := store["cursor"].(clarity.PageCursor) - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{HasMore: cursor.HasMore, PageSize: cursor.PageSize, Next: cursor.Next, Previous: cursor.Previous}) -} diff --git a/cmd/reconciliation/evaluations/root.go b/cmd/reconciliation/evaluations/root.go deleted file mode 100644 index 7f3bece7..00000000 --- a/cmd/reconciliation/evaluations/root.go +++ /dev/null @@ -1,82 +0,0 @@ -package evaluations - -import ( - "net/http" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/reconciliation/internal/clarity" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const queryFlag = "query" - -func NewCommand() *cobra.Command { - return fctl.NewCommand("evaluations", - fctl.WithAliases("evaluation", "evals"), - fctl.WithShortDescription("Inspect Ledger Clarity evaluations"), - fctl.WithChildCommands(newListCommand(), newGetCommand()), - ) -} - -func newListCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, _ []string, client *clarity.Client) (clarity.Store, error) { - query, err := clarity.PaginationQuery(cmd) - if err != nil { - return nil, err - } - var body any - if query.Get("cursor") == "" { - body, err = clarity.QueryBody(cmd, queryFlag) - if err != nil { - return nil, err - } - } - var response clarity.CursorResponse[clarity.Evaluation] - if err := client.Do(cmd, http.MethodGet, "/evaluations", query, body, &response); err != nil { - return nil, err - } - return clarity.Store{"evaluations": response.Cursor.Data, "cursor": clarity.Page(response.Cursor)}, nil - }, renderEvaluations) - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List evaluations"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithStringFlag(queryFlag, "", "Query-builder expression as JSON"), - fctl.WithController[clarity.Store](controller), - ) -} - -func newGetCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - var response clarity.DataResponse[clarity.Evaluation] - path := "/evaluations/" + clarity.ResourcePath(args[0]) - if err := client.Do(cmd, http.MethodGet, path, nil, nil, &response); err != nil { - return nil, err - } - return clarity.Store{"evaluation": response.Data}, nil - }, nil) - return fctl.NewCommand("get ", - fctl.WithAliases("show", "sh", "s"), - fctl.WithShortDescription("Get an evaluation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func renderEvaluations(cmd *cobra.Command, store clarity.Store) error { - evaluations := store["evaluations"].([]clarity.Evaluation) - rows := pterm.TableData{{"ID", "RuleID", "Result", "StartedAt", "EndedAt", "CostUnits"}} - for _, evaluation := range evaluations { - rows = append(rows, []string{evaluation.ID, evaluation.RuleID, evaluation.Result, evaluation.StartedAt, evaluation.EndedAt, pterm.Sprintf("%d", evaluation.CostUnits)}) - } - if err := pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(rows).Render(); err != nil { - return err - } - cursor := store["cursor"].(clarity.PageCursor) - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{HasMore: cursor.HasMore, PageSize: cursor.PageSize, Next: cursor.Next, Previous: cursor.Previous}) -} diff --git a/cmd/reconciliation/internal/clarity/client.go b/cmd/reconciliation/internal/clarity/client.go deleted file mode 100644 index dc7f4031..00000000 --- a/cmd/reconciliation/internal/clarity/client.go +++ /dev/null @@ -1,95 +0,0 @@ -package clarity - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const apiPrefix = "/api/reconciliation" - -type Client struct { - HTTPClient *http.Client - BaseURL string -} - -func NewClient(cmd *cobra.Command) (*Client, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - clients, err := fctl.NewStackClientsFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - return &Client{HTTPClient: clients.HTTPClient, BaseURL: clients.URI}, nil -} - -func (c *Client) Do(cmd *cobra.Command, method, requestPath string, query url.Values, body any, response any) error { - var reader io.Reader - if body != nil { - var encoded []byte - if raw, ok := body.(json.RawMessage); ok { - encoded = raw - } else { - var err error - encoded, err = json.Marshal(body) - if err != nil { - return fmt.Errorf("encoding request: %w", err) - } - } - reader = bytes.NewReader(encoded) - } - - endpoint := strings.TrimRight(c.BaseURL, "/") + apiPrefix + requestPath - if len(query) > 0 { - endpoint += "?" + query.Encode() - } - req, err := http.NewRequestWithContext(cmd.Context(), method, endpoint, reader) - if err != nil { - return err - } - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := c.HTTPClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - responseBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("reading response: %w", err) - } - if resp.StatusCode >= http.StatusMultipleChoices { - var apiError ErrorResponse - if json.Unmarshal(responseBody, &apiError) == nil && apiError.ErrorMessage != "" { - if apiError.Details != "" { - return fmt.Errorf("reconciliation API returned %d (%s): %s: %s", resp.StatusCode, apiError.ErrorCode, apiError.ErrorMessage, apiError.Details) - } - return fmt.Errorf("reconciliation API returned %d (%s): %s", resp.StatusCode, apiError.ErrorCode, apiError.ErrorMessage) - } - return fmt.Errorf("reconciliation API returned %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) - } - if response == nil || len(responseBody) == 0 { - return nil - } - if err := json.Unmarshal(responseBody, response); err != nil { - return fmt.Errorf("decoding response: %w", err) - } - return nil -} - -func ResourcePath(resource string) string { - return url.PathEscape(resource) -} diff --git a/cmd/reconciliation/internal/clarity/client_test.go b/cmd/reconciliation/internal/clarity/client_test.go deleted file mode 100644 index 59964148..00000000 --- a/cmd/reconciliation/internal/clarity/client_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package clarity - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestClientDo(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - t.Errorf("method = %s, want GET", r.Method) - } - if r.URL.Path != "/api/reconciliation/rules" { - t.Errorf("path = %s", r.URL.Path) - } - if got := r.URL.Query().Get("pageSize"); got != "42" { - t.Errorf("pageSize = %q", got) - } - var query map[string]any - if err := json.NewDecoder(r.Body).Decode(&query); err != nil { - t.Fatalf("decode body: %v", err) - } - if _, ok := query["$match"]; !ok { - t.Errorf("body = %#v", query) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"cursor":{"pageSize":42,"hasMore":false,"data":[]}}`)) - })) - defer server.Close() - - client := &Client{HTTPClient: server.Client(), BaseURL: server.URL + "/"} - var response CursorResponse[Rule] - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - err := client.Do(cmd, http.MethodGet, "/rules", url.Values{"pageSize": {"42"}}, map[string]any{ - "$match": map[string]any{"enabled": true}, - }, &response) - if err != nil { - t.Fatal(err) - } - if response.Cursor.PageSize != 42 { - t.Fatalf("page size = %d", response.Cursor.PageSize) - } -} - -func TestClientDoReturnsStructuredAPIError(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"errorCode":"VALIDATION","errorMessage":"bad rule","details":"unknown template"}`)) - })) - defer server.Close() - - client := &Client{HTTPClient: server.Client(), BaseURL: server.URL} - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - err := client.Do(cmd, http.MethodPost, "/rules", nil, map[string]any{}, nil) - if err == nil || !strings.Contains(err.Error(), "VALIDATION") || !strings.Contains(err.Error(), "unknown template") { - t.Fatalf("error = %v", err) - } -} - -func TestResourcePathEscapesPathSeparators(t *testing.T) { - t.Parallel() - if got := ResourcePath("rule/one"); got != "rule%2Fone" { - t.Fatalf("ResourcePath() = %q", got) - } -} - -func TestClientDoForwardsRawJSONWithoutRounding(t *testing.T) { - t.Parallel() - - const body = `{"threshold":9007199254740993}` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - data, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read body: %v", err) - } - if string(data) != body { - t.Errorf("body = %s, want %s", data, body) - } - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - client := &Client{HTTPClient: server.Client(), BaseURL: server.URL} - if err := client.Do(cmd, http.MethodPost, "/rules", nil, json.RawMessage(body), nil); err != nil { - t.Fatal(err) - } -} diff --git a/cmd/reconciliation/internal/clarity/controller.go b/cmd/reconciliation/internal/clarity/controller.go deleted file mode 100644 index 180f729d..00000000 --- a/cmd/reconciliation/internal/clarity/controller.go +++ /dev/null @@ -1,105 +0,0 @@ -package clarity - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/url" - - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type Store map[string]any - -type RunFunc func(cmd *cobra.Command, args []string, client *Client) (Store, error) -type RenderFunc func(cmd *cobra.Command, store Store) error - -type Controller struct { - store Store - run RunFunc - render RenderFunc -} - -var _ fctl.Controller[Store] = (*Controller)(nil) - -func NewController(run RunFunc, render RenderFunc) *Controller { - return &Controller{store: Store{}, run: run, render: render} -} - -func (c *Controller) GetStore() Store { return c.store } - -func (c *Controller) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - client, err := NewClient(cmd) - if err != nil { - return nil, err - } - c.store, err = c.run(cmd, args, client) - if err != nil { - return nil, err - } - return c, nil -} - -func (c *Controller) Render(cmd *cobra.Command, _ []string) error { - if c.render != nil { - return c.render(cmd, c.store) - } - return PrintJSON(cmd.OutOrStdout(), c.store) -} - -func PrintJSON(writer io.Writer, value any) error { - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - _, err = fmt.Fprintln(writer, string(data)) - return err -} - -func PaginationQuery(cmd *cobra.Command) (url.Values, error) { - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return nil, err - } - values := url.Values{} - if cursor != "" { - values.Set("cursor", cursor) - return values, nil - } - values.Set("pageSize", fmt.Sprintf("%d", pageSize)) - return values, nil -} - -func QueryBody(cmd *cobra.Command, flag string) (any, error) { - raw, err := cmd.Flags().GetString(flag) - if err != nil || raw == "" { - return nil, err - } - return parseJSONObject([]byte(raw), fmt.Sprintf("--%s JSON", flag)) -} - -func ReadJSONObject(cmd *cobra.Command, path string) (json.RawMessage, error) { - raw, err := fctl.ReadFile(cmd, path) - if err != nil { - return nil, err - } - return parseJSONObject([]byte(raw), "JSON in "+path) -} - -func parseJSONObject(raw []byte, source string) (json.RawMessage, error) { - trimmed := bytes.TrimSpace(raw) - if !json.Valid(trimmed) { - return nil, fmt.Errorf("invalid %s", source) - } - if len(trimmed) == 0 || trimmed[0] != '{' { - return nil, fmt.Errorf("invalid %s: expected an object", source) - } - return json.RawMessage(trimmed), nil -} diff --git a/cmd/reconciliation/internal/clarity/controller_test.go b/cmd/reconciliation/internal/clarity/controller_test.go deleted file mode 100644 index 891f5dcb..00000000 --- a/cmd/reconciliation/internal/clarity/controller_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package clarity - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestQueryBodyRejectsNull(t *testing.T) { - t.Parallel() - - cmd := &cobra.Command{} - cmd.Flags().String("query", "", "") - if err := cmd.Flags().Set("query", "null"); err != nil { - t.Fatal(err) - } - - _, err := QueryBody(cmd, "query") - if err == nil || !strings.Contains(err.Error(), "expected an object") { - t.Fatalf("error = %v", err) - } -} - -func TestQueryBodyWithoutValueReturnsNilInterface(t *testing.T) { - t.Parallel() - - cmd := &cobra.Command{} - cmd.Flags().String("query", "", "") - body, err := QueryBody(cmd, "query") - if err != nil { - t.Fatal(err) - } - if body != nil { - t.Fatalf("body = %#v, want nil", body) - } -} - -func TestQueryBodyPreservesLargeNumbers(t *testing.T) { - t.Parallel() - - const query = `{"$match":{"threshold":9007199254740993}}` - cmd := &cobra.Command{} - cmd.Flags().String("query", "", "") - if err := cmd.Flags().Set("query", query); err != nil { - t.Fatal(err) - } - - body, err := QueryBody(cmd, "query") - if err != nil { - t.Fatal(err) - } - raw, ok := body.(json.RawMessage) - if !ok { - t.Fatalf("body type = %T, want json.RawMessage", body) - } - if string(raw) != query { - t.Fatalf("body = %s, want %s", raw, query) - } -} - -func TestReadJSONObjectRejectsNull(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "request.json") - if err := os.WriteFile(path, []byte("null"), 0o600); err != nil { - t.Fatal(err) - } - - _, err := ReadJSONObject(&cobra.Command{}, path) - if err == nil || !strings.Contains(err.Error(), "expected an object") { - t.Fatalf("error = %v", err) - } -} - -func TestReadJSONObjectPreservesLargeNumbers(t *testing.T) { - t.Parallel() - - const request = `{"threshold":9007199254740993}` - path := filepath.Join(t.TempDir(), "request.json") - if err := os.WriteFile(path, []byte(request), 0o600); err != nil { - t.Fatal(err) - } - - body, err := ReadJSONObject(&cobra.Command{}, path) - if err != nil { - t.Fatal(err) - } - if string(body) != request { - t.Fatalf("body = %s, want %s", body, request) - } -} diff --git a/cmd/reconciliation/internal/clarity/models.go b/cmd/reconciliation/internal/clarity/models.go deleted file mode 100644 index 318c3cd7..00000000 --- a/cmd/reconciliation/internal/clarity/models.go +++ /dev/null @@ -1,103 +0,0 @@ -package clarity - -import "encoding/json" - -type ErrorResponse struct { - ErrorCode string `json:"errorCode"` - ErrorMessage string `json:"errorMessage"` - Details string `json:"details"` -} - -type Cursor[T any] struct { - PageSize int64 `json:"pageSize"` - HasMore bool `json:"hasMore"` - Previous *string `json:"previous,omitempty"` - Next *string `json:"next,omitempty"` - Data []T `json:"data"` -} - -type CursorResponse[T any] struct { - Cursor Cursor[T] `json:"cursor"` -} - -type PageCursor struct { - PageSize int64 `json:"pageSize"` - HasMore bool `json:"hasMore"` - Previous *string `json:"previous,omitempty"` - Next *string `json:"next,omitempty"` -} - -func Page[T any](cursor Cursor[T]) PageCursor { - return PageCursor{ - PageSize: cursor.PageSize, - HasMore: cursor.HasMore, - Previous: cursor.Previous, - Next: cursor.Next, - } -} - -type DataResponse[T any] struct { - Data T `json:"data"` -} - -type Rule struct { - ID string `json:"id"` - Name string `json:"name"` - TemplateKind string `json:"templateKind"` - TemplateSpec json.RawMessage `json:"templateSpec"` - ExplanationCEL string `json:"explanationCEL,omitempty"` - Enabled bool `json:"enabled"` - Severity string `json:"severity"` - Cadence string `json:"cadence"` - Schedule json.RawMessage `json:"schedule,omitempty"` - Notifications []string `json:"notifications,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type Evaluation struct { - ID string `json:"id"` - RuleID string `json:"ruleID"` - StartedAt string `json:"startedAt"` - EndedAt string `json:"endedAt"` - PITPerSource map[string]string `json:"pitPerSource,omitempty"` - Result string `json:"result"` - Evidence json.RawMessage `json:"evidence,omitempty"` - Error string `json:"error,omitempty"` - CostUnits int64 `json:"costUnits,omitempty"` - CreatedAt string `json:"createdAt"` -} - -type Alert struct { - ID string `json:"id"` - RuleID string `json:"ruleID"` - Fingerprint string `json:"fingerprint"` - PeriodID string `json:"periodID"` - Status string `json:"status"` - Severity string `json:"severity"` - FirstSeenAt string `json:"firstSeenAt"` - LastSeenAt string `json:"lastSeenAt"` - OccurrenceCount int64 `json:"occurrenceCount"` - LastEvaluationID string `json:"lastEvaluationID"` - Evidence json.RawMessage `json:"evidence,omitempty"` - Ack json.RawMessage `json:"ack,omitempty"` - Resolution json.RawMessage `json:"resolution,omitempty"` - Snooze json.RawMessage `json:"snooze,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` -} - -type AlertEvent struct { - ID string `json:"id"` - AlertID string `json:"alertID"` - EvaluationID *string `json:"evaluationID,omitempty"` - Type string `json:"type"` - Previous *string `json:"prevStatus,omitempty"` - NewStatus string `json:"newStatus"` - Payload json.RawMessage `json:"payload,omitempty"` - At string `json:"at"` - IsReopen bool `json:"isReopen"` - Notify bool `json:"notify"` -} diff --git a/cmd/reconciliation/list.go b/cmd/reconciliation/list.go deleted file mode 100644 index b69ac42b..00000000 --- a/cmd/reconciliation/list.go +++ /dev/null @@ -1,148 +0,0 @@ -package reconciliation - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - reconciliationmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *reconciliationmodels.ReconciliationsCursorResponseCursor `json:"cursor"` -} - -type ListController struct { - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &reconciliationmodels.ReconciliationsCursorResponseCursor{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Reconciliation.V1.ListReconciliations( - cmd.Context(), - operations.ListReconciliationsRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.ReconciliationsCursorResponse.Cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(p reconciliationmodels.Reconciliation) []string { - return []string{ - p.ID, - p.PolicyID, - p.CreatedAt.Format(time.RFC3339), - p.ReconciledAtLedger.Format(time.RFC3339), - p.ReconciledAtPayments.Format(time.RFC3339), - p.Status, - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "PolicyID", "CreatedAt", "ReconciledAtLedger", - "ReconciledAtPayments", "Status"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List reconciliations"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/reconciliation/policies/create.go b/cmd/reconciliation/policies/create.go deleted file mode 100644 index 74ec9820..00000000 --- a/cmd/reconciliation/policies/create.go +++ /dev/null @@ -1,94 +0,0 @@ -package policies - -import ( - "encoding/json" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - PolicyID string `json:"policyID"` -} -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create |-", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Create a policy"), - fctl.WithAliases("cr", "c"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a new policy") { - return nil, fctl.ErrMissingApproval - } - - script, err := fctl.ReadFile(cmd, args[0]) - if err != nil { - return nil, err - } - - request := reconciliation.PolicyRequest{} - if err := json.Unmarshal([]byte(script), &request); err != nil { - return nil, err - } - - //nolint:gosimple - response, err := stackClient.Reconciliation.V1.CreatePolicy(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PolicyID = response.PolicyResponse.Policy.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Policy created with ID: %s", c.store.PolicyID) - - return nil -} diff --git a/cmd/reconciliation/policies/delete.go b/cmd/reconciliation/policies/delete.go deleted file mode 100644 index f636db72..00000000 --- a/cmd/reconciliation/policies/delete.go +++ /dev/null @@ -1,90 +0,0 @@ -package policies - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteStore struct { - PolicyID string `json:"policyID"` - Success bool `json:"success"` -} - -type DeleteController struct { - store *DeleteStore -} - -var _ fctl.Controller[*DeleteStore] = (*DeleteController)(nil) - -func NewDeleteStore() *DeleteStore { - return &DeleteStore{} -} - -func NewDeleteController() *DeleteController { - return &DeleteController{ - store: NewDeleteStore(), - } -} -func NewDeleteCommand() *cobra.Command { - c := NewDeleteController() - return fctl.NewCommand("delete ", - fctl.WithConfirmFlag(), - fctl.WithAliases("d"), - fctl.WithShortDescription("Delete a policy"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*DeleteStore](c), - ) -} - -func (c *DeleteController) GetStore() *DeleteStore { - return c.store -} - -func (c *DeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - - response, err := stackClient.Reconciliation.V1.DeletePolicy( - cmd.Context(), - operations.DeletePolicyRequest{ - PolicyID: args[0], - }, - ) - - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.PolicyID = args[0] - c.store.Success = true - - return c, nil -} - -func (c *DeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Policy %s deleted.", c.store.PolicyID) - return nil -} diff --git a/cmd/reconciliation/policies/list.go b/cmd/reconciliation/policies/list.go deleted file mode 100644 index 1f9536d6..00000000 --- a/cmd/reconciliation/policies/list.go +++ /dev/null @@ -1,156 +0,0 @@ -package policies - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Cursor *reconciliation.Cursor `json:"cursor"` -} - -type ListController struct { - store *ListStore - - cursorFlag string - pageSizeFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewListStore() *ListStore { - return &ListStore{ - Cursor: &reconciliation.Cursor{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewListStore(), - - cursorFlag: "cursor", - pageSizeFlag: "page-size", - } -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var cursor *string - if c := fctl.GetString(cmd, c.cursorFlag); c != "" { - cursor = &c - } - - var pageSize *int64 - if ps := fctl.GetInt(cmd, c.pageSizeFlag); ps > 0 { - pageSize = fctl.Ptr(int64(ps)) - } - - response, err := stackClient.Reconciliation.V1.ListPolicies( - cmd.Context(), - operations.ListPoliciesRequest{ - Cursor: cursor, - PageSize: pageSize, - }, - ) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Cursor = &response.PoliciesCursorResponse.Cursor - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - tableData := fctl.Map(c.store.Cursor.Data, func(p reconciliation.Policy) []string { - return []string{ - p.ID, - p.Name, - p.CreatedAt.Format(time.RFC3339), - p.LedgerName, - func() string { - if p.LedgerQuery == nil { - return "" - } - - raw, _ := json.Marshal(p.LedgerQuery) - return string(raw) - }(), - p.PaymentsPoolID, - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Name", "CreatedAt", "LedgerName", - "LedgerQuery", "PaymentsPoolID"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - tableData = pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", c.store.Cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", c.store.Cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if c.store.Cursor.Next == nil { - return "" - } - return *c.store.Cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if c.store.Cursor.Previous == nil { - return "" - } - return *c.store.Cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithShortDescription("List policies"), - fctl.WithStringFlag(c.cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(c.pageSizeFlag, 0, "Page size"), - fctl.WithController[*ListStore](c), - ) -} diff --git a/cmd/reconciliation/policies/reconciliation.go b/cmd/reconciliation/policies/reconciliation.go deleted file mode 100644 index d0c69ce6..00000000 --- a/cmd/reconciliation/policies/reconciliation.go +++ /dev/null @@ -1,162 +0,0 @@ -package policies - -import ( - "fmt" - "math/big" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ReconciliationStore struct { - Reconciliation reconciliation.Reconciliation `json:"reconciliation"` -} -type ReconciliationController struct { - store *ReconciliationStore -} - -var _ fctl.Controller[*ReconciliationStore] = (*ReconciliationController)(nil) - -func NewReconciliationStore() *ReconciliationStore { - return &ReconciliationStore{} -} - -func NewReconciliationController() *ReconciliationController { - return &ReconciliationController{ - store: NewReconciliationStore(), - } -} - -func NewReconciliationCommand() *cobra.Command { - return fctl.NewCommand("reconcile ", - fctl.WithShortDescription("Launch a reconciliation from a policy"), - fctl.WithArgs(cobra.ExactArgs(3)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("r"), - fctl.WithController[*ReconciliationStore](NewReconciliationController()), - ) -} - -func (c *ReconciliationController) GetStore() *ReconciliationStore { - return c.store -} - -func (c *ReconciliationController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - atLedger, err := time.Parse(time.RFC3339, args[1]) - if err != nil { - return nil, err - } - - atPayments, err := time.Parse(time.RFC3339, args[2]) - if err != nil { - return nil, err - } - - response, err := stackClient.Reconciliation.V1.Reconcile(cmd.Context(), operations.ReconcileRequest{ - PolicyID: args[0], - ReconciliationRequest: reconciliation.ReconciliationRequest{ - ReconciledAtLedger: atLedger, - ReconciledAtPayments: atPayments, - }, - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.ReconciliationResponse == nil { - return nil, fmt.Errorf("policy not found") - } - - c.store.Reconciliation = response.ReconciliationResponse.Reconciliation - - return c, nil -} - -func (c *ReconciliationController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Reconciliation.ID}) - tableData = append(tableData, []string{pterm.LightCyan("PolicyID"), c.store.Reconciliation.PolicyID}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Reconciliation.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ReconciledAtLedger"), c.store.Reconciliation.ReconciledAtLedger.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ReconciledAtPayments"), c.store.Reconciliation.ReconciledAtPayments.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), c.store.Reconciliation.Status}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Ledger Balances") - tableData = fctl.MapMap(c.store.Reconciliation.LedgerBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Payments Balances") - tableData = fctl.MapMap(c.store.Reconciliation.PaymentsBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Drift Balances") - tableData = fctl.MapMap(c.store.Reconciliation.DriftBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/reconciliation/policies/root.go b/cmd/reconciliation/policies/root.go deleted file mode 100644 index e88719b6..00000000 --- a/cmd/reconciliation/policies/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package policies - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewPoliciesCommand() *cobra.Command { - return fctl.NewCommand("policies", - fctl.WithAliases("p"), - fctl.WithShortDescription("Manage policies"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewCreateCommand(), - NewDeleteCommand(), - NewReconciliationCommand(), - ), - ) -} diff --git a/cmd/reconciliation/policies/show.go b/cmd/reconciliation/policies/show.go deleted file mode 100644 index cc3b88d7..00000000 --- a/cmd/reconciliation/policies/show.go +++ /dev/null @@ -1,106 +0,0 @@ -package policies - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Policy reconciliation.Policy `json:"policy"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a policy"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Reconciliation.V1.GetPolicy(cmd.Context(), operations.GetPolicyRequest{ - PolicyID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.PolicyResponse == nil { - return nil, fmt.Errorf("policy not found") - } - - c.store.Policy = response.PolicyResponse.Policy - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Policy.ID}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), c.store.Policy.Name}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Policy.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("LedgerName"), c.store.Policy.LedgerName}) - tableData = append(tableData, []string{pterm.LightCyan("LedgerQuey"), func() string { - if c.store.Policy.LedgerQuery == nil { - return "" - } - raw, _ := json.Marshal(c.store.Policy.LedgerQuery) - return string(raw) - }()}) - tableData = append(tableData, []string{pterm.LightCyan("PaymentsPoolID"), c.store.Policy.PaymentsPoolID}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/reconciliation/root.go b/cmd/reconciliation/root.go deleted file mode 100644 index 2ebe3725..00000000 --- a/cmd/reconciliation/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package reconciliation - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/reconciliation/alerts" - "github.com/formancehq/fctl/v3/cmd/reconciliation/evaluations" - "github.com/formancehq/fctl/v3/cmd/reconciliation/policies" - "github.com/formancehq/fctl/v3/cmd/reconciliation/rules" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("reconciliation", - fctl.WithShortDescription("Manage reconciliation"), - fctl.WithChildCommands( - policies.NewPoliciesCommand(), - rules.NewCommand(), - evaluations.NewCommand(), - alerts.NewCommand(), - NewListCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/reconciliation/root_test.go b/cmd/reconciliation/root_test.go deleted file mode 100644 index c2dbcee4..00000000 --- a/cmd/reconciliation/root_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package reconciliation - -import "testing" - -func TestLedgerClarityCommandSurface(t *testing.T) { - t.Parallel() - - root := NewCommand() - commands := [][]string{ - {"rules", "list"}, - {"rules", "get"}, - {"rules", "create"}, - {"rules", "update"}, - {"rules", "delete"}, - {"rules", "evaluate"}, - {"evaluations", "list"}, - {"evaluations", "get"}, - {"alerts", "list"}, - {"alerts", "get"}, - {"alerts", "events"}, - {"alerts", "ack"}, - {"alerts", "resolve"}, - {"alerts", "accept"}, - {"alerts", "snooze"}, - {"alerts", "unsnooze"}, - } - for _, path := range commands { - command, remaining, err := root.Find(path) - if err != nil { - t.Errorf("find %v: %v", path, err) - continue - } - if len(remaining) != 0 || command.Name() != path[len(path)-1] { - t.Errorf("find %v returned %q with remaining %v", path, command.Name(), remaining) - } - } -} - -func TestEvaluateCommandRequiresConfirmation(t *testing.T) { - t.Parallel() - - command, _, err := NewCommand().Find([]string{"rules", "evaluate"}) - if err != nil { - t.Fatal(err) - } - if command.Flags().Lookup("confirm") == nil { - t.Fatal("evaluate command does not expose --confirm") - } -} diff --git a/cmd/reconciliation/rules/root.go b/cmd/reconciliation/rules/root.go deleted file mode 100644 index 96d7d83d..00000000 --- a/cmd/reconciliation/rules/root.go +++ /dev/null @@ -1,212 +0,0 @@ -package rules - -import ( - "fmt" - "net/http" - "strings" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/reconciliation/internal/clarity" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const queryFlag = "query" - -func NewCommand() *cobra.Command { - return fctl.NewCommand("rules", - fctl.WithAliases("rule"), - fctl.WithShortDescription("Manage Ledger Clarity rules"), - fctl.WithChildCommands( - newListCommand(), - newGetCommand(), - newCreateCommand(), - newUpdateCommand(), - newDeleteCommand(), - newEvaluateCommand(), - ), - ) -} - -func newListCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, _ []string, client *clarity.Client) (clarity.Store, error) { - query, err := clarity.PaginationQuery(cmd) - if err != nil { - return nil, err - } - var body any - if query.Get("cursor") == "" { - body, err = clarity.QueryBody(cmd, queryFlag) - if err != nil { - return nil, err - } - } - var response clarity.CursorResponse[clarity.Rule] - if err := client.Do(cmd, http.MethodGet, "/rules", query, body, &response); err != nil { - return nil, err - } - return clarity.Store{"rules": response.Cursor.Data, "cursor": clarity.Page(response.Cursor)}, nil - }, renderRules) - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List rules"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithStringFlag(queryFlag, "", "Query-builder expression as JSON"), - fctl.WithController[clarity.Store](controller), - ) -} - -func newGetCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - var response clarity.DataResponse[clarity.Rule] - if err := client.Do(cmd, http.MethodGet, "/rules/"+clarity.ResourcePath(args[0]), nil, nil, &response); err != nil { - return nil, err - } - return clarity.Store{"rule": response.Data}, nil - }, nil) - return fctl.NewCommand("get ", - fctl.WithAliases("show", "sh", "s"), - fctl.WithShortDescription("Get a rule"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func newCreateCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - if !fctl.CheckStackApprobation(cmd, "You are about to create a reconciliation rule") { - return nil, fctl.ErrMissingApproval - } - body, err := clarity.ReadJSONObject(cmd, args[0]) - if err != nil { - return nil, err - } - var response clarity.DataResponse[clarity.Rule] - if err := client.Do(cmd, http.MethodPost, "/rules", nil, body, &response); err != nil { - return nil, err - } - return clarity.Store{"rule": response.Data}, nil - }, func(cmd *cobra.Command, store clarity.Store) error { - rule := store["rule"].(clarity.Rule) - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Rule created with ID: %s", rule.ID) - return nil - }) - return fctl.NewCommand("create |-", - fctl.WithAliases("cr", "c"), - fctl.WithShortDescription("Create a rule from a JSON file"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithController[clarity.Store](controller), - ) -} - -func newUpdateCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - if !fctl.CheckStackApprobation(cmd, "You are about to update rule '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - body, err := clarity.ReadJSONObject(cmd, args[1]) - if err != nil { - return nil, err - } - var response clarity.DataResponse[clarity.Rule] - if err := client.Do(cmd, http.MethodPatch, "/rules/"+clarity.ResourcePath(args[0]), nil, body, &response); err != nil { - return nil, err - } - return clarity.Store{"rule": response.Data}, nil - }, nil) - return fctl.NewCommand("update |-", - fctl.WithAliases("patch"), - fctl.WithShortDescription("Partially update a rule from a JSON file"), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithConfirmFlag(), - fctl.WithController[clarity.Store](controller), - ) -} - -func newDeleteCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - if !fctl.CheckStackApprobation(cmd, "You are about to delete rule '%s' and its evaluations and alerts", args[0]) { - return nil, fctl.ErrMissingApproval - } - if err := client.Do(cmd, http.MethodDelete, "/rules/"+clarity.ResourcePath(args[0]), nil, nil, nil); err != nil { - return nil, err - } - return clarity.Store{"ruleID": args[0], "success": true}, nil - }, func(cmd *cobra.Command, store clarity.Store) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Rule %s deleted.", store["ruleID"]) - return nil - }) - return fctl.NewCommand("delete ", - fctl.WithAliases("d"), - fctl.WithShortDescription("Delete a rule"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func newEvaluateCommand() *cobra.Command { - controller := clarity.NewController(func(cmd *cobra.Command, args []string, client *clarity.Client) (clarity.Store, error) { - if !fctl.CheckStackApprobation(cmd, "You are about to evaluate rule '%s'", args[0]) { - return nil, fctl.ErrMissingApproval - } - body := map[string]any{} - if at := fctl.GetString(cmd, "at"); at != "" { - body["at"] = at - } - if margin := fctl.GetString(cmd, "safety-margin"); margin != "" { - body["safetyMargin"] = margin - } - sourcePITValues, err := cmd.Flags().GetStringArray("source-pit") - if err != nil { - return nil, err - } - if len(sourcePITValues) > 0 { - sourcePITs := map[string]string{} - for _, item := range sourcePITValues { - parts := strings.SplitN(item, "=", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, fmt.Errorf("invalid --source-pit %q: expected key=RFC3339", item) - } - sourcePITs[parts[0]] = parts[1] - } - body["sourcePITs"] = sourcePITs - } - var response clarity.DataResponse[clarity.Evaluation] - path := "/rules/" + clarity.ResourcePath(args[0]) + "/evaluate" - if err := client.Do(cmd, http.MethodPost, path, nil, body, &response); err != nil { - return nil, err - } - return clarity.Store{"evaluation": response.Data}, nil - }, nil) - return fctl.NewCommand("evaluate ", - fctl.WithAliases("run"), - fctl.WithShortDescription("Evaluate a rule now"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithConfirmFlag(), - fctl.WithStringFlag("at", "", "Canonical point in time (RFC3339; defaults to now)"), - fctl.WithStringFlag("safety-margin", "", "Safety margin as a Go duration (for example 30s)"), - fctl.WithStringArrayFlag("source-pit", nil, "Per-source PIT as key=RFC3339 (repeatable)"), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[clarity.Store](controller), - ) -} - -func renderRules(cmd *cobra.Command, store clarity.Store) error { - rules := store["rules"].([]clarity.Rule) - rows := pterm.TableData{{"ID", "Name", "Template", "Enabled", "Severity", "Cadence", "UpdatedAt"}} - for _, rule := range rules { - rows = append(rows, []string{rule.ID, rule.Name, rule.TemplateKind, fmt.Sprint(rule.Enabled), rule.Severity, rule.Cadence, rule.UpdatedAt}) - } - if err := pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(rows).Render(); err != nil { - return err - } - cursor := store["cursor"].(clarity.PageCursor) - return fctl.RenderCursor(cmd.OutOrStdout(), fctl.Cursor{HasMore: cursor.HasMore, PageSize: cursor.PageSize, Next: cursor.Next, Previous: cursor.Previous}) -} diff --git a/cmd/reconciliation/show.go b/cmd/reconciliation/show.go deleted file mode 100644 index 1f444df7..00000000 --- a/cmd/reconciliation/show.go +++ /dev/null @@ -1,148 +0,0 @@ -package reconciliation - -import ( - "fmt" - "math/big" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - reconciliationmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/reconciliation" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Reconciliation reconciliationmodels.Reconciliation `json:"policy"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("get ", - fctl.WithShortDescription("Get a reconciliation"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithAliases("sh", "s"), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - response, err := stackClient.Reconciliation.V1.GetReconciliation(cmd.Context(), operations.GetReconciliationRequest{ - ReconciliationID: args[0], - }) - if err != nil { - return nil, err - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - if response.ReconciliationResponse == nil { - return nil, fmt.Errorf("policy not found") - } - - c.store.Reconciliation = response.ReconciliationResponse.Reconciliation - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), c.store.Reconciliation.ID}) - tableData = append(tableData, []string{pterm.LightCyan("PolicyID"), c.store.Reconciliation.PolicyID}) - tableData = append(tableData, []string{pterm.LightCyan("CreatedAt"), c.store.Reconciliation.CreatedAt.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ReconciledAtLedger"), c.store.Reconciliation.ReconciledAtLedger.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("ReconciledAtPayments"), c.store.Reconciliation.ReconciledAtPayments.Format(time.RFC3339)}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), c.store.Reconciliation.Status}) - - if err := pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Ledger Balances") - tableData = fctl.MapMap(c.store.Reconciliation.LedgerBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Payments Balances") - tableData = fctl.MapMap(c.store.Reconciliation.PaymentsBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.WithWriter(cmd.OutOrStdout()).Println("Drift Balances") - tableData = fctl.MapMap(c.store.Reconciliation.DriftBalances, func(key string, value *big.Int) []string { - return []string{ - key, - value.String(), - } - }) - tableData = fctl.Prepend(tableData, []string{"Asset", "Amount"}) - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/root.go b/cmd/root.go index eac426da..57b75240 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,163 +2,81 @@ package cmd import ( "context" - "encoding/json" - "errors" - "fmt" "os" "os/signal" - "runtime/debug" - "github.com/pterm/pterm" "github.com/spf13/cobra" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/apierrors" - ledgermodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/ledger" - "github.com/formancehq/go-libs/v4/api" - "github.com/formancehq/go-libs/v4/logging" - - "github.com/formancehq/fctl/v3/cmd/auth" - "github.com/formancehq/fctl/v3/cmd/cloud" - "github.com/formancehq/fctl/v3/cmd/ledger" - "github.com/formancehq/fctl/v3/cmd/login" - "github.com/formancehq/fctl/v3/cmd/orchestration" - "github.com/formancehq/fctl/v3/cmd/payments" - "github.com/formancehq/fctl/v3/cmd/profiles" - "github.com/formancehq/fctl/v3/cmd/reconciliation" - "github.com/formancehq/fctl/v3/cmd/stack" - "github.com/formancehq/fctl/v3/cmd/ui" - "github.com/formancehq/fctl/v3/cmd/version" - "github.com/formancehq/fctl/v3/cmd/wallets" - "github.com/formancehq/fctl/v3/cmd/webhooks" - fctl "github.com/formancehq/fctl/v3/pkg" + "github.com/formancehq/fctl/v4/cmd/cloudtools" + "github.com/formancehq/fctl/v4/cmd/factory" + "github.com/formancehq/fctl/v4/cmd/login" + "github.com/formancehq/fctl/v4/cmd/plugins" + "github.com/formancehq/fctl/v4/cmd/profiles" + "github.com/formancehq/fctl/v4/cmd/version" + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/connection" ) -func init() { - cobra.EnableTraverseRunHooks = true -} - func NewRootCommand() *cobra.Command { - homedir, err := os.UserHomeDir() - if err != nil { - panic(err) - } + return newRootCommand(context.Background(), nil) +} - cmd := fctl.NewCommand("fctl", - fctl.WithSilenceError(), - fctl.WithShortDescription("Formance Control CLI"), - fctl.WithChildCommands( - ui.NewCommand(), - version.NewCommand(), - login.NewCommand(), - NewPromptCommand(), - ledger.NewCommand(), - payments.NewCommand(), - reconciliation.NewCommand(), - profiles.NewCommand(), - stack.NewCommand(), - auth.NewCommand(), - cloud.NewCommand(), - webhooks.NewCommand(), - wallets.NewCommand(), - orchestration.NewCommand(), - ), - fctl.WithPersistentStringPFlag(fctl.ProfileFlag, "p", "", "Configuration profile to use"), - fctl.WithPersistentStringPFlag(fctl.ConfigDir, "c", fmt.Sprintf("%s/.config/formance/fctl", homedir), "Path to configuration dir"), - fctl.WithPersistentBoolPFlag(fctl.DebugFlag, "d", false, "Enable debug mode"), - fctl.WithPersistentStringPFlag(fctl.OutputFlag, "o", "plain", "Output format (plain, json)"), - fctl.WithPersistentBoolFlag(fctl.InsecureTlsFlag, false, "Allow insecure TLS connections"), - fctl.WithPersistentBoolFlag(fctl.TelemetryFlag, false, "Enable telemetry"), - fctl.WithPersistentPreRunE(func(cmd *cobra.Command, args []string) error { - logger := logging.NewDefaultLogger(cmd.OutOrStdout(), fctl.GetBool(cmd, fctl.DebugFlag), false, false) - ctx := logging.ContextWithLogger(cmd.Context(), logger) - cmd.SetContext(ctx) - return nil - }), - ) +// NewRootCommandWithArgs selects installed plugin metadata before Cobra parses +// service flags. Help and completion read cached metadata without starting a +// plugin process. Historical targets select SDK legacy facades; modern targets +// prepare exact-version external service plugins. +func NewRootCommandWithArgs(ctx context.Context, args []string) *cobra.Command { + return newRootCommand(ctx, args) +} - cmd.Version = version.Version - err = cmd.RegisterFlagCompletionFunc(fctl.ProfileFlag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - profiles, err := fctl.ListProfiles(cmd) - if err != nil { - return []string{}, cobra.ShellCompDirectiveError +//nolint:contextcheck // Command construction attaches callbacks; execution uses Cobra's context. +func newRootCommand(ctx context.Context, args []string) *cobra.Command { + root := &cobra.Command{ + Use: "fctl", + Short: "Formance Control CLI", + Version: version.Version, + SilenceErrors: true, + SilenceUsage: true, + Example: " fctl login\n fctl cloud stack list --organization ORGANIZATION_ID\n fctl auth clients list --organization ORGANIZATION_ID --stack STACK_ID", + } + root.SetContext(ctx) + settings := &connection.Settings{} + settings.Bind(root) + root.PersistentFlags().Bool("no-input", false, "Disable interactive forms and selections (FCTL_NO_INPUT, CI)") + services := factory.Prepare(ctx, root, settings, args) + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + if err := services.PreparationError(cmd); err != nil { + return err } - - return profiles, cobra.ShellCompDirectiveNoFileComp - }) - if err != nil { + return command.ConfigureOutput(cmd, settings.Output, settings.Color) + } + command.InstallHelp(root, &settings.Color) + root.AddGroup(&cobra.Group{ID: "cloud", Title: "Cloud:"}, &cobra.Group{ID: "modules", Title: "Modules:"}, &cobra.Group{ID: "profiles", Title: "Profiles:"}, &cobra.Group{ID: "plugins", Title: "Plugins:"}) + root.AddCommand(version.NewCommand(), profiles.NewCommand(settings), login.NewCommand(settings), login.NewLogoutCommand(settings)) + root.AddCommand(plugins.NewCommand(settings)) + if err := services.AddTo(root, settings); err != nil { panic(err) } - return cmd -} - -func Execute() { - defer func() { - if e := recover(); e != nil { - pterm.Error.WithWriter(os.Stderr).Printfln("%s", e) - debug.PrintStack() - os.Exit(255) - } - }() - ctx, _ := signal.NotifyContext(context.TODO(), os.Interrupt) - cmd := NewRootCommand() - if err := cmd.ExecuteContext(ctx); err != nil { - switch { - case errors.Is(err, fctl.ErrMissingApproval): - pterm.Error.WithWriter(os.Stderr).Printfln("Command aborted as you didn't approve.") - os.Exit(1) - case fctl.IsInvalidAuthentication(err): - pterm.Error.WithWriter(os.Stderr).Printfln("Your authentication is invalid, please login :)") - default: - unwrapped := err - for unwrapped != nil { - //notes(gfyrag): not a clean assertion but following errors does not implements standard Is() helper for errors - switch err := unwrapped.(type) { - case *ledgermodels.ErrorResponseError: - printErrorResponse(err) - return - case *ledgermodels.V2ErrorResponseError: - printV2ErrorResponse(err) - return - case *apierrors.APIError: - body := err.Body - - errResponse := api.ErrorResponse{} - if err := json.Unmarshal([]byte(body), &errResponse); err != nil { - pterm.Error.WithWriter(os.Stderr).Printf("%s\r\n", body) - os.Exit(2) - } - printError(errResponse.ErrorCode, errResponse.ErrorMessage, &errResponse.Details) - return - case *apierrors.Error: - errMsg := "" - if err.ErrorMessage != nil { - errMsg = *err.ErrorMessage - } - printError(err.ErrorCode, errMsg, nil) - return - default: - pterm.Error.WithWriter(os.Stderr).Println(unwrapped) - unwrapped = errors.Unwrap(unwrapped) - } - } - } - - os.Exit(255) + if err := cloudtools.AddTo(root, settings); err != nil { + panic(err) } -} - -func printError(code string, message string, details *string) { - pterm.Error.WithWriter(os.Stderr).Printfln("Got error with code %s: %s", code, message) - if details != nil && *details != "" { - pterm.Error.WithWriter(os.Stderr).Printfln("Details:\r\n%s", *details) + for _, cmd := range root.Commands() { + switch cmd.Name() { + case "cloud": + cmd.GroupID = "cloud" + case "auth", "ledger", "legacy", "payments", "orchestration", "reconciliation", "wallets", "webhooks": + cmd.GroupID = "modules" + case "profiles", "login", "logout": + cmd.GroupID = "profiles" + case "plugins": + cmd.GroupID = "plugins" + } } - os.Exit(2) -} - -func printV2ErrorResponse(target *ledgermodels.V2ErrorResponseError) { - printError(string(target.V2ErrorsEnum), target.ErrorMessage, target.Details) + return root } -func printErrorResponse(target *ledgermodels.ErrorResponseError) { - printError(string(target.ErrorsEnum), target.ErrorMessage, target.Details) +func Execute() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + return NewRootCommandWithArgs(ctx, os.Args[1:]).ExecuteContext(ctx) } diff --git a/cmd/root_execution_test.go b/cmd/root_execution_test.go deleted file mode 100644 index 90c5d053..00000000 --- a/cmd/root_execution_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestCLIExecution(t *testing.T) { - // Build the real entrypoint once: go run would hide the binary's exit status. - binary := filepath.Join(t.TempDir(), "fctl") - if runtime.GOOS == "windows" { - binary += ".exe" - } - build := exec.Command("go", "build", "-o", binary, "..") // #nosec G204 -- Fixed build command and test-owned output path. - output, err := build.CombinedOutput() - require.NoError(t, err, "%s", output) - - for _, tt := range []struct { - name string - args []string - withoutHome bool - apiError bool - wantStatus int - wantOut string - wantErr string - }{ - {name: "help", args: []string{"--help"}, wantOut: "Usage:"}, - {name: "version", args: []string{"--version"}, wantOut: "develop"}, - {name: "json version", args: []string{"version", "-o", "json"}}, - {name: "unknown command", args: []string{"unknown-command"}, wantStatus: 255, wantErr: `unknown command "unknown-command"`}, - {name: "invalid arguments", args: []string{"version", "unexpected", "-o", "json"}, wantStatus: 255, wantErr: "accepts 0 arg(s), received 1"}, - {name: "missing profile", args: []string{"profiles", "show", "missing", "-o", "json"}, wantStatus: 255, wantErr: "profile.json"}, - {name: "not authenticated", args: []string{"stack", "list", "-o", "json"}, wantStatus: 255, wantErr: "Your authentication is invalid"}, - {name: "recovered panic", args: []string{"version"}, withoutHome: true, wantStatus: 255, wantErr: "is not defined"}, - {name: "non-JSON API error", args: []string{"cloud", "organizations", "list", "-o", "json"}, apiError: true, wantStatus: 2, wantErr: "synthetic upstream failure"}, - } { - t.Run(tt.name, func(t *testing.T) { - home := t.TempDir() - configDir := filepath.Join(home, "config") - require.NoError(t, os.Mkdir(configDir, 0700)) - if tt.apiError { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/organizations" || r.Header.Get("Authorization") != "Bearer synthetic-token" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL) - } - http.Error(w, "synthetic upstream failure", http.StatusBadRequest) - })) - t.Cleanup(server.Close) - profileDir := filepath.Join(configDir, "profiles", "default") - require.NoError(t, os.MkdirAll(profileDir, 0700)) - profile := `{"membershipURI":"` + server.URL + `","rootTokens":{"accessToken":{"token":"synthetic-token","claims":{"exp":4102444800}}}}` - require.NoError(t, os.WriteFile(filepath.Join(profileDir, "profile.json"), []byte(profile), 0600)) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, binary, append([]string{"--config-dir", configDir}, tt.args...)...) // #nosec G204 -- Test-built binary, fixed cases and temporary config. - // Do not inherit user profiles, credentials, proxies, or flag overrides. - cmd.Env = []string{"XDG_CONFIG_HOME=" + home, "NO_COLOR=1", "TERM=dumb"} - if !tt.withoutHome { - cmd.Env = append(cmd.Env, "HOME="+home, "USERPROFILE="+home) - } - var stdout, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &stdout, &stderr - err := cmd.Run() - require.NoError(t, ctx.Err(), "CLI timed out: %s", stderr.String()) - if tt.wantStatus == 0 { - require.NoError(t, err, "%s", stderr.String()) - } else { - var exitErr *exec.ExitError - require.ErrorAs(t, err, &exitErr, "stderr: %s", stderr.String()) - require.Equal(t, tt.wantStatus, exitErr.ExitCode()) - } - if tt.wantErr != "" { - require.Contains(t, stderr.String(), tt.wantErr) - require.Empty(t, stdout.String(), "errors must not pollute stdout") - } else { - require.Empty(t, stderr.String()) - if tt.wantOut != "" { - require.Contains(t, stdout.String(), tt.wantOut) - } else { - var document map[string]map[string]string - require.NoError(t, json.Unmarshal(stdout.Bytes(), &document), "stdout: %q", stdout.String()) - require.Equal(t, map[string]map[string]string{ - "data": {"version": "develop", "commit": "-", "buildDate": "-"}, - }, document) - } - } - }) - } -} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..4de77605 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,152 @@ +package cmd_test + +import ( + "bytes" + "errors" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/v4/cmd" +) + +func executeRoot(t *testing.T, args ...string) (string, string, error) { + t.Helper() + root := cmd.NewRootCommand() + // Service metadata comes from the selected target's external plugin cache. + if slices.Contains(args, "auth") || slices.Contains(args, "ledger") { + root = cmd.NewRootCommandWithArgs(t.Context(), args) + } + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"--no-browser"}, args...)) + err := root.ExecuteContext(t.Context()) + return stdout.String(), stderr.String(), err +} + +func TestRootHelpWithoutHomeOrProfile(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for _, args := range [][]string{{}, {"--help"}, {"help"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + stdout, stderr, err := executeRoot(t, args...) + if err != nil { + t.Fatalf("help failed without a home or profile: %v", err) + } + for _, want := range []string{"Formance Control CLI", "Usage:", "completion", "help", "version"} { + if !strings.Contains(stdout, want) { + t.Errorf("help missing %q: %s", want, stdout) + } + } + if stderr != "" { + t.Errorf("unexpected stderr: %q", stderr) + } + }) + } +} + +func TestRootDefaultVersion(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"command", []string{"version"}, "fctl v4.0.0-dev (commit: -, built: -)\n"}, + {"flag", []string{"--version"}, "fctl version v4.0.0-dev\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := executeRoot(t, tc.args...) + if err != nil { + t.Fatalf("version failed: %v", err) + } + if stdout != tc.want { + t.Errorf("stdout = %q, want %q", stdout, tc.want) + } + if stderr != "" { + t.Errorf("unexpected stderr: %q", stderr) + } + }) + } +} + +func TestRootErrorsDoNotPrintUsageOrErrors(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"unknown command", []string{"does-not-exist"}, `unknown command "does-not-exist" for "fctl"`}, + {"version argument", []string{"version", "extra"}, `unknown command "extra" for "fctl version"`}, + } { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := executeRoot(t, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", err, tc.want) + } + if stdout != "" || stderr != "" { + t.Errorf("error printed output: stdout=%q stderr=%q", stdout, stderr) + } + }) + } +} + +func TestRootCompletionOnlyOffersCurrentCommands(t *testing.T) { + stdout, _, err := executeRoot(t, "__complete", "") + if err != nil { + t.Fatalf("completion failed: %v", err) + } + var names []string + for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { + if strings.HasPrefix(line, ":") { + continue + } + name, _, _ := strings.Cut(line, "\t") + names = append(names, name) + } + slices.Sort(names) + if want := []string{"auth", "cloud", "completion", "help", "ledger", "legacy", "login", "logout", "plugins", "profiles", "version"}; !slices.Equal(names, want) { + t.Errorf("completed commands = %v, want %v", names, want) + } +} + +func TestRootGeneratesShellCompletions(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + t.Run(shell, func(t *testing.T) { + stdout, stderr, err := executeRoot(t, "completion", shell) + if err != nil { + t.Fatalf("generate completion: %v", err) + } + if !strings.Contains(stdout, "fctl") { + t.Errorf("completion script does not reference fctl: %q", stdout) + } + if stderr != "" { + t.Errorf("unexpected stderr: %q", stderr) + } + }) + } +} + +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestRootVersionReturnsWriterError(t *testing.T) { + want := errors.New("version output unavailable") + root := cmd.NewRootCommand() + root.SetArgs([]string{"version"}) + root.SetOut(failingWriter{err: want}) + var stderr bytes.Buffer + root.SetErr(&stderr) + if err := root.ExecuteContext(t.Context()); !errors.Is(err, want) { + t.Errorf("error = %v, want %v", err, want) + } + if stderr.Len() != 0 { + t.Errorf("unexpected stderr: %q", stderr.String()) + } +} diff --git a/cmd/service_loader_test.go b/cmd/service_loader_test.go new file mode 100644 index 00000000..52a75976 --- /dev/null +++ b/cmd/service_loader_test.go @@ -0,0 +1,187 @@ +package cmd_test + +import ( + "bytes" + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func loaderManifest(service string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: service, Service: service, Version: "1.0.0", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: service, Short: "Cached external " + service, Subcommands: []pluginsdk.CommandSpec{{Use: "probe", Short: "External probe", Runnable: true}}}} +} + +func loaderSettings(t *testing.T) (*cobra.Command, *connection.Settings) { + t.Helper() + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + loaderFlag(t, root, "auth-mode", "none") + return root, settings +} + +func loaderFlag(t *testing.T, root *cobra.Command, name, value string) { + t.Helper() + if err := root.PersistentFlags().Set(name, value); err != nil { + t.Fatal(err) + } +} + +func installLoaderFixture(t *testing.T, manager *pluginmanager.Manager, target pluginmanager.Target, service string) pluginmanager.Lock { + t.Helper() + path := filepath.Join(t.TempDir(), "not-an-executable") + if err := os.WriteFile(path, []byte("cached help must never start this file"), 0o600); err != nil { + t.Fatal(err) + } + lock, err := manager.InstallLocal(t.Context(), path, target, loaderManifest(service)) + if err != nil { + t.Fatal(err) + } + return lock +} + +type discoveryTransport func(*http.Request) (*http.Response, error) + +func (f discoveryTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +//nolint:gocognit // One offline lifecycle verifies both cached providers. +func TestServiceLoaderOfflineRoots(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + root, settings := loaderSettings(t) + loaderFlag(t, root, "auth-mode", "cloud") + loaderFlag(t, root, "issuer", "https://issuer.example") + manager, err := pluginmanager.New(filepath.Join(settings.Directory, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + target := pluginmanager.Target{Profile: "", Organization: "org", Stack: "stack", Endpoint: settings.Options.Issuer} + for _, service := range []string{"auth", "ledger"} { + installLoaderFixture(t, manager, target, service) + } + for _, service := range []string{"auth", "ledger"} { + for _, suffix := range [][]string{{service, "--help"}, {"__complete", service, ""}} { + args := append([]string{"--config-dir", settings.Directory, "--auth-mode", "cloud", "--issuer", settings.Options.Issuer}, suffix...) + command := cmd.NewRootCommandWithArgs(t.Context(), args) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + if err := command.ExecuteContext(t.Context()); err != nil { + t.Fatalf("offline %v: %v", suffix, err) + } + if !strings.Contains(output.String(), "probe") { + t.Fatalf("cached metadata not registered: %s", output.String()) + } + count := 0 + for _, child := range command.Commands() { + if child.Name() == service { + count++ + } + } + if count != 1 { + t.Fatalf("%s roots: %d", service, count) + } + } + } +} + +func TestAuthLoaderNilArgsUsesCachedMetadataOrPlaceholder(t *testing.T) { + root, settings := loaderSettings(t) + loaderFlag(t, root, "auth-url", "https://auth.example") + t.Setenv("FCTL_CONFIG_DIR", settings.Directory) + t.Setenv("FCTL_AUTH_MODE", "none") + t.Setenv("FCTL_AUTH_URL", "https://auth.example") + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + base := http.DefaultTransport + http.DefaultTransport = discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("nil-args Auth metadata accessed network: %s", request.URL) + return nil, context.Canceled + }) + t.Cleanup(func() { http.DefaultTransport = base }) + + command := cmd.NewRootCommand() + auth, _, err := command.Find([]string{"auth"}) + if err != nil || auth == command || auth.HasSubCommands() { + t.Fatalf("uncached Auth must expose only a placeholder: %v", err) + } + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs([]string{"auth", "--help"}) + if err := command.ExecuteContext(t.Context()); err != nil || !strings.Contains(output.String(), "plugins sync --service auth") { + t.Fatalf("placeholder help: %s %v", output.String(), err) + } + + manager, err := pluginmanager.New(filepath.Join(settings.Directory, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + installLoaderFixture(t, manager, pluginmanager.Target{Endpoint: "https://auth.example"}, "auth") + for _, args := range [][]string{{"auth", "--help"}, {"__complete", "auth", ""}} { + command = cmd.NewRootCommand() + output.Reset() + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + if err := command.ExecuteContext(t.Context()); err != nil || !strings.Contains(output.String(), "probe") { + t.Fatalf("nil-args cached Auth %v: %s %v", args, output.String(), err) + } + } +} + +func TestLedgerLoaderNilArgsUsesCachedMetadataOrPlaceholder(t *testing.T) { + root, settings := loaderSettings(t) + loaderFlag(t, root, "ledger-url", "https://ledger.example") + t.Setenv("FCTL_CONFIG_DIR", settings.Directory) + t.Setenv("FCTL_AUTH_MODE", "none") + t.Setenv("FCTL_LEDGER_URL", "https://ledger.example") + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + base := http.DefaultTransport + http.DefaultTransport = discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("nil-args Ledger metadata accessed network: %s", request.URL) + return nil, context.Canceled + }) + t.Cleanup(func() { http.DefaultTransport = base }) + + command := cmd.NewRootCommand() + ledger, _, err := command.Find([]string{"ledger"}) + if err != nil || ledger == command || ledger.HasSubCommands() { + t.Fatalf("uncached Ledger must expose only a placeholder: %v", err) + } + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs([]string{"ledger", "--help"}) + if err := command.ExecuteContext(t.Context()); err != nil || !strings.Contains(output.String(), "plugins sync --service ledger") { + t.Fatalf("placeholder help: %s %v", output.String(), err) + } + + manager, err := pluginmanager.New(filepath.Join(settings.Directory, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + installLoaderFixture(t, manager, pluginmanager.Target{Endpoint: "https://ledger.example"}, "ledger") + for _, args := range [][]string{{"ledger", "--help"}, {"__complete", "ledger", ""}} { + command = cmd.NewRootCommand() + output.Reset() + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + if err := command.ExecuteContext(t.Context()); err != nil || !strings.Contains(output.String(), "probe") { + t.Fatalf("nil-args cached Ledger %v: %s %v", args, output.String(), err) + } + } +} diff --git a/cmd/stack/controller.go b/cmd/stack/controller.go deleted file mode 100644 index afb5f575..00000000 --- a/cmd/stack/controller.go +++ /dev/null @@ -1,77 +0,0 @@ -package stack - -import ( - "fmt" - "net/http" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - "github.com/formancehq/fctl/v3/cmd/stack/internal" -) - -func waitStackReady(cmd *cobra.Command, client *membershipclient.SDK, organizationId, stackId string) (*components.Stack, error) { - waitTime := 2 * time.Second - sum := 2 * time.Second - - // Hack to ignore first Status - select { - case <-cmd.Context().Done(): - return nil, cmd.Context().Err() - case <-time.After(waitTime): - } - - for { - request := operations.GetStackRequest{ - OrganizationID: organizationId, - StackID: stackId, - } - - stackRsp, err := client.GetStack(cmd.Context(), request) - if err != nil { - return nil, err - } - - if stackRsp.GetHTTPMeta().Response.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("stack %s not found", stackId) - } - - if stackRsp.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - stackData := stackRsp.ReadStackResponse.GetData() - - if stackData == nil { - return nil, fmt.Errorf("unexpected response: stack data is nil") - } - - if stackData.GetStatus() == "READY" { - return stackData, nil - } - - if sum > 10*time.Minute { - pterm.Warning.Printf("Check stack status with: fctl stack show %s --organization %s", stackId, organizationId) - problem := fmt.Errorf("there might a problem with the stack scheduling, if the problem persists, please contact the support") - - err = internal.PrintStackInformation(cmd.OutOrStdout(), stackData, nil) - if err != nil { - return nil, problem - } - - return nil, problem - } - - sum += waitTime - select { - case <-time.After(waitTime): - case <-cmd.Context().Done(): - return nil, cmd.Context().Err() - } - } -} diff --git a/cmd/stack/create.go b/cmd/stack/create.go deleted file mode 100644 index 09cbbb42..00000000 --- a/cmd/stack/create.go +++ /dev/null @@ -1,264 +0,0 @@ -package stack - -import ( - "cmp" - "errors" - "fmt" - "net/http" - "slices" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "golang.org/x/mod/semver" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/gateway" - "github.com/formancehq/go-libs/v4/pointer" - - "github.com/formancehq/fctl/v3/cmd/stack/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - regionFlag = "region" - nowaitFlag = "no-wait" - versionFlag = "version" -) - -type CreateStore struct { - Stack *components.Stack - Versions *gateway.GetVersionsResponse -} - -type CreateController struct { - store *CreateStore -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultStackCreateStore() *CreateStore { - return &CreateStore{ - Stack: &components.Stack{}, - Versions: &gateway.GetVersionsResponse{}, - } -} -func NewStackCreateController() *CreateController { - return &CreateController{ - store: NewDefaultStackCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewMembershipCommand("create [name]", - fctl.WithShortDescription("Create a new stack"), - fctl.WithAliases("c", "cr"), - fctl.WithArgs(cobra.RangeArgs(0, 1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(regionFlag, "", "Region on which deploy the stack"), - fctl.WithStringFlag(versionFlag, "", "Version of the stack"), - fctl.WithBoolFlag(nowaitFlag, false, "Not wait stack availability"), - fctl.WithController(NewStackCreateController()), - ) -} -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - var err error - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - name := "" - if len(args) > 0 { - name = args[0] - } else { - name, err = pterm.DefaultInteractiveTextInput.WithMultiLine(false).Show("Enter a name") - if err != nil { - return nil, err - } - } - - region := fctl.GetString(cmd, regionFlag) - if region == "" { - listRegionsRequest := operations.ListRegionsRequest{ - OrganizationID: organizationID, - } - - regionsResponse, err := apiClient.ListRegions(cmd.Context(), listRegionsRequest) - if err != nil { - return nil, fmt.Errorf("listing regions: %w", err) - } - - if regionsResponse.ListRegionsResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - var options []string - for _, regionItem := range regionsResponse.ListRegionsResponse.GetData() { - privacy := "Private" - if regionItem.GetPublic() { - privacy = "Public " - } - name := "" - if regionName := regionItem.GetName(); regionName != "" { - name = regionName - } - options = append(options, fmt.Sprintf("%s | %s | %s", regionItem.GetID(), privacy, name)) - } - - if len(options) == 0 { - return nil, errors.New("no regions available") - } - - printer := pterm.DefaultInteractiveSelect.WithOptions(options) - selectedOption, err := printer.Show("Please select a region") - if err != nil { - return nil, err - } - for i := 0; i < len(options); i++ { - if selectedOption == options[i] { - region = regionsResponse.ListRegionsResponse.GetData()[i].GetID() - break - } - } - } - - req := components.CreateStackRequest{ - Name: name, - RegionID: region, - } - - getVersionsRequest := operations.GetRegionVersionsRequest{ - OrganizationID: organizationID, - RegionID: region, - } - - availableVersionsResponse, err := apiClient.GetRegionVersions(cmd.Context(), getVersionsRequest) - if err != nil { - return nil, fmt.Errorf("retrieving available versions: %w", err) - } - - if availableVersionsResponse.GetRegionVersionsResponse == nil { - return nil, fmt.Errorf("unexpected response: no versions data") - } - - specifiedVersion := fctl.GetString(cmd, versionFlag) - if specifiedVersion == "" { - var options []string - for _, version := range availableVersionsResponse.GetRegionVersionsResponse.GetData() { - options = append(options, version.GetName()) - } - sortVersionsDescending(options) - - selectedOption := "" - if len(options) > 0 { - printer := pterm.DefaultInteractiveSelect.WithOptions(options) - selectedOption, err = printer.Show("Please select a version") - if err != nil { - return nil, err - } - } - - specifiedVersion = selectedOption - } - req.Version = pointer.For(specifiedVersion) - - createStackRequest := operations.CreateStackRequest{ - OrganizationID: organizationID, - Body: &req, - } - - stackResponse, err := apiClient.CreateStack(cmd.Context(), createStackRequest) - if err != nil { - return nil, fmt.Errorf("creating stack: %w", err) - } - - if stackResponse.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - stackData := stackResponse.ReadStackResponse.GetData() - - if stackData == nil { - return nil, fmt.Errorf("unexpected response: stack data is nil") - } - - if !fctl.GetBool(cmd, nowaitFlag) { - spinner, err := pterm.DefaultSpinner.Start("Waiting services availability") - if err != nil { - return nil, err - } - - stack, err := waitStackReady(cmd, apiClient, stackData.GetOrganizationID(), stackData.GetID()) - if err != nil { - return nil, err - } - c.store.Stack = stack - - if err := spinner.Stop(); err != nil { - return nil, err - } - } else { - c.store.Stack = stackData - } - - portal := fctl.DefaultConsoleURL - serverInfo, err := fctl.MembershipServerInfo(cmd.Context(), apiClient) - if err != nil { - return nil, err - } - if v := serverInfo.GetConsoleURL(); v != nil { - portal = *v - } - - fctl.BasicTextCyan.WithWriter(cmd.OutOrStdout()).Println("Your portal will be reachable on: " + portal) - - // todo: need a long running client with auto refresh - stackClient, err := fctl.NewStackClient( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - organizationID, - stackData.GetID(), - ) - if err != nil { - return nil, err - } - - versions, err := stackClient.GetVersions(cmd.Context()) - if err != nil { - return nil, err - } - if versions.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d when reading versions", versions.StatusCode) - } - - c.store.Versions = versions.GetVersionsResponse - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, _ []string) error { - return internal.PrintStackInformation(cmd.OutOrStdout(), c.store.Stack, c.store.Versions) -} - -func sortVersionsDescending(versions []string) { - slices.SortFunc(versions, func(a, b string) int { - if order := semver.Compare(b, a); order != 0 { - return order - } - return cmp.Compare(b, a) - }) -} diff --git a/cmd/stack/create_test.go b/cmd/stack/create_test.go deleted file mode 100644 index 2f690a6e..00000000 --- a/cmd/stack/create_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package stack - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSortVersionsDescending(t *testing.T) { - versions := []string{ - "v2.9.0", - "v3.0.0-rc.1", - "v2.10.0", - "v3.0.0", - "v1.0.0+build.1", - "v1.0.0+build.2", - "preview", - "development", - } - - sortVersionsDescending(versions) - - assert.Equal(t, []string{ - "v3.0.0", - "v3.0.0-rc.1", - "v2.10.0", - "v2.9.0", - "v1.0.0+build.2", - "v1.0.0+build.1", - "preview", - "development", - }, versions) -} diff --git a/cmd/stack/delete.go b/cmd/stack/delete.go deleted file mode 100644 index 1fb9419a..00000000 --- a/cmd/stack/delete.go +++ /dev/null @@ -1,143 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - stackNameFlag = "name" - forceFlag = "force" -) - -type DeletedStackStore struct { - Stack *components.Stack `json:"stack"` - Status string `json:"status"` -} -type StackDeleteController struct { - store *DeletedStackStore -} - -var _ fctl.Controller[*DeletedStackStore] = (*StackDeleteController)(nil) - -func NewDefaultDeletedStackStore() *DeletedStackStore { - return &DeletedStackStore{ - Stack: &components.Stack{}, - Status: "", - } -} - -func NewStackDeleteController() *StackDeleteController { - return &StackDeleteController{ - store: NewDefaultDeletedStackStore(), - } -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewMembershipCommand("delete ( | --name=)", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Delete a stack"), - fctl.WithAliases("del", "d"), - fctl.WithArgs(cobra.MaximumNArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(stackNameFlag, "", "Stack to delete"), - fctl.WithBoolFlag(forceFlag, false, "Force to delete a stack without retention policy"), - fctl.WithController(NewStackDeleteController()), - ) -} -func (c *StackDeleteController) GetStore() *DeletedStackStore { - return c.store -} - -func (c *StackDeleteController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var stack *components.Stack - if len(args) == 1 { - if fctl.GetString(cmd, stackNameFlag) != "" { - return nil, errors.New("need either an id of a name specified using --name flag") - } - - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - rsp, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, err - } - if rsp.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - stack = rsp.ReadStackResponse.GetData() - } else { - if fctl.GetString(cmd, stackNameFlag) == "" { - return nil, errors.New("need either an id of a name specified using --name flag") - } - listRequest := operations.ListStacksRequest{ - OrganizationID: organizationID, - } - stacksResponse, err := apiClient.ListStacks(cmd.Context(), listRequest) - if err != nil { - return nil, fmt.Errorf("listing stacks: %w", err) - } - if stacksResponse.ListStacksResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - for _, s := range stacksResponse.ListStacksResponse.GetData() { - if s.GetName() == fctl.GetString(cmd, stackNameFlag) { - stackData := s - stack = &stackData - break - } - } - } - if stack == nil { - return nil, errors.New("Stack not found") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete stack '%s'", stack.GetName()) { - return nil, fctl.ErrMissingApproval - } - - deleteRequest := operations.DeleteStackRequest{ - OrganizationID: organizationID, - StackID: stack.GetID(), - } - if fctl.GetBool(cmd, forceFlag) { - deleteRequest.Force = pointer.For(true) - } - - _, err = apiClient.DeleteStack(cmd.Context(), deleteRequest) - if err != nil { - return nil, fmt.Errorf("deleting stack: %w", err) - } - - c.store.Stack = stack - c.store.Status = "OK" - - return c, nil -} - -func (c *StackDeleteController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Stack deleted.") - return nil -} diff --git a/cmd/stack/disable.go b/cmd/stack/disable.go deleted file mode 100644 index 030cc2c9..00000000 --- a/cmd/stack/disable.go +++ /dev/null @@ -1,135 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DisabledStore struct { - Stack *components.Stack `json:"stack"` - Status string `json:"status"` -} -type DisableController struct { - store *DisabledStore -} - -var _ fctl.Controller[*DisabledStore] = (*DisableController)(nil) - -func NewDisableStore() *DisabledStore { - return &DisabledStore{ - Stack: &components.Stack{}, - Status: "", - } -} - -func NewDisableController() *DisableController { - return &DisableController{ - store: NewDisableStore(), - } -} - -func NewDisableCommand() *cobra.Command { - const ( - stackNameFlag = "name" - ) - return fctl.NewMembershipCommand("disable ( | --name=)", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Disable a stack"), - fctl.WithArgs(cobra.MaximumNArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(stackNameFlag, "", "Stack to disable"), - fctl.WithController(NewDisableController()), - ) -} -func (c *DisableController) GetStore() *DisabledStore { - return c.store -} - -func (c *DisableController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - const ( - stackNameFlag = "name" - ) - stackName := fctl.GetString(cmd, stackNameFlag) - if (len(args) == 0 && stackName == "") || (len(args) == 1 && (args[0] == "" || stackName != "")) { - return nil, errors.New("need either a stack id or a name specified using --name flag") - } - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - var stack *components.Stack - if len(args) == 1 { - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - rsp, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, err - } - if rsp.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - stack = rsp.ReadStackResponse.GetData() - } else { - listRequest := operations.ListStacksRequest{ - OrganizationID: organizationID, - } - stacksResponse, err := apiClient.ListStacks(cmd.Context(), listRequest) - if err != nil { - return nil, fmt.Errorf("listing stacks: %w", err) - } - if stacksResponse.ListStacksResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - for _, s := range stacksResponse.ListStacksResponse.GetData() { - if s.GetName() == stackName { - stackData := s - stack = &stackData - break - } - } - } - if stack == nil { - return nil, errors.New("Stack not found") - } - if stack.GetID() == "" { - return nil, errors.New("unexpected response: missing stack id") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to disable stack '%s'", stack.GetName()) { - return nil, fctl.ErrMissingApproval - } - - disableRequest := operations.DisableStackRequest{ - OrganizationID: organizationID, - StackID: stack.GetID(), - } - if _, err := apiClient.DisableStack(cmd.Context(), disableRequest); err != nil { - return nil, fmt.Errorf("stack disable: %w", err) - } - - c.store.Stack = stack - c.store.Status = "OK" - - return c, nil -} - -func (c *DisableController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Stack disabled.") - return nil -} diff --git a/cmd/stack/disable_test.go b/cmd/stack/disable_test.go deleted file mode 100644 index a45fd6a9..00000000 --- a/cmd/stack/disable_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package stack - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/formancehq/go-libs/v4/oidc" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestDisableCommandApproval(t *testing.T) { - const stackPath = "/organizations/organization-test/stacks/stack-target" - const listPath = "/organizations/organization-test/stacks" - for _, tc := range []struct { - name string - args []string - answer string - readResponse string - wantError string - wantRefusal bool - wantPrompts int - wantRequests []string - }{ - { - name: "refusing an ID never mutates", args: []string{"stack-target"}, answer: "n", - wantRefusal: true, wantPrompts: 1, wantRequests: []string{"GET " + stackPath}, - }, - { - name: "refusing a name never mutates", args: []string{"--name=sandbox"}, answer: "n", - wantRefusal: true, wantPrompts: 1, wantRequests: []string{"GET " + listPath}, - }, - { - name: "confirm disables exactly the requested ID", args: []string{"stack-target", "--confirm"}, - wantRequests: []string{"GET " + stackPath, "PUT " + stackPath + "/disable"}, - }, - { - name: "confirm disables exactly the named stack", args: []string{"--name=sandbox", "--confirm"}, - wantRequests: []string{"GET " + listPath, "PUT " + stackPath + "/disable"}, - }, - { - name: "interactive approval disables once", args: []string{"stack-target"}, answer: "Y", - wantPrompts: 1, wantRequests: []string{"GET " + stackPath, "PUT " + stackPath + "/disable"}, - }, - { - name: "explicit false still requires approval", args: []string{"stack-target", "--confirm=false"}, answer: "n", - wantRefusal: true, wantPrompts: 1, wantRequests: []string{"GET " + stackPath}, - }, - { - name: "confirm cannot bypass a missing selector", args: []string{"--confirm"}, - wantError: "need either", - }, - { - name: "confirm cannot bypass conflicting selectors", args: []string{"stack-target", "--name=sandbox", "--confirm"}, - wantError: "need either", - }, - { - name: "confirm cannot bypass extra arguments", args: []string{"stack-target", "another-stack", "--confirm"}, - wantError: "accepts at most 1 arg", - }, - { - name: "confirm cannot bypass an empty ID", args: []string{"", "--confirm"}, - wantError: "need either", - }, - { - name: "unknown ID never mutates", args: []string{"unknown", "--confirm"}, - wantError: "404", wantRequests: []string{"GET /organizations/organization-test/stacks/unknown"}, - }, - { - name: "unknown name never mutates", args: []string{"--name=unknown", "--confirm"}, - wantError: "Stack not found", wantRequests: []string{"GET " + listPath}, - }, - { - name: "missing response ID never mutates", args: []string{"stack-target", "--confirm"}, - readResponse: `{"data":{"name":"sandbox"}}`, wantError: "missing stack id", - wantRequests: []string{"GET " + stackPath}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - var mu sync.Mutex - var requests []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - requests = append(requests, r.Method+" "+r.URL.RequestURI()) - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == http.MethodPut: - // Record every mutation, including one to an unexpected resource. - w.WriteHeader(http.StatusAccepted) - case r.Method == http.MethodGet && r.URL.Path == stackPath: - response := tc.readResponse - if response == "" { - response = `{"data":{"id":"stack-target","name":"sandbox"}}` - } - _, _ = io.WriteString(w, response) - case r.Method == http.MethodGet && r.URL.Path == listPath: - _, _ = io.WriteString(w, `{"data":[{"id":"stack-other","name":"other"},{"id":"stack-target","name":"sandbox"}]}`) - default: - http.Error(w, `{"error":"not found"}`, http.StatusNotFound) - } - })) - t.Cleanup(server.Close) - - cmd := newDisableTestCommand(t, server.URL) - prompts := 0 - cmd.SetContext(fctl.WithApprovalPrompt(context.Background(), func(text string) (string, error) { - prompts++ - require.Contains(t, text, "You are about to disable stack 'sandbox'") - return tc.answer, nil - })) - cmd.SetArgs(tc.args) - err := cmd.Execute() - require.Equal(t, tc.wantPrompts, prompts) - mu.Lock() - defer mu.Unlock() - require.Equal(t, tc.wantRequests, requests) - switch { - case tc.wantRefusal: - require.ErrorIs(t, err, fctl.ErrMissingApproval) - case tc.wantError != "": - require.ErrorContains(t, err, tc.wantError) - default: - require.NoError(t, err) - } - }) - } -} - -func newDisableTestCommand(t *testing.T, serverURL string) *cobra.Command { - t.Helper() - t.Setenv("NAME", "") - cmd := NewDisableCommand() - cmd.Flags().String(fctl.ConfigDir, t.TempDir(), "") - cmd.Flags().String(fctl.ProfileFlag, "approval-test", "") - cmd.Flags().String(fctl.OutputFlag, "json", "") - for _, flag := range []string{fctl.DebugFlag, fctl.InsecureTlsFlag, fctl.HTTPCloseOnErrorFlag, fctl.TelemetryFlag} { - cmd.Flags().Bool(flag, false, "") - } - require.NoError(t, cmd.PersistentFlags().Set(fctl.OrganizationFlag, "organization-test")) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - - require.NoError(t, fctl.WriteProfile(cmd, "approval-test", fctl.Profile{ - MembershipURI: serverURL, - RootTokens: &fctl.Tokens{ID: fctl.IDToken{Claims: fctl.IDTokenClaims{ - Organizations: []fctl.OrganizationAccess{{ID: "organization-test"}}, - }}}, - })) - require.NoError(t, fctl.WriteOrganizationToken(cmd, "approval-test", fctl.AccessToken{ - TokenWithClaims: fctl.TokenWithClaims[fctl.AccessTokenClaims]{ - Token: "synthetic-organization-token", - Claims: fctl.AccessTokenClaims{ - TokenClaims: oidc.TokenClaims{Expiration: oidc.Time(time.Now().Add(time.Hour).Unix())}, - OrganizationID: "organization-test", - }, - }, - })) - return cmd -} diff --git a/cmd/stack/enable.go b/cmd/stack/enable.go deleted file mode 100644 index 9df74ed7..00000000 --- a/cmd/stack/enable.go +++ /dev/null @@ -1,136 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type EnableStore struct { - Stack *components.Stack `json:"stack"` - Status string `json:"status"` -} -type EnableController struct { - store *EnableStore -} - -var _ fctl.Controller[*EnableStore] = (*EnableController)(nil) - -func NewEnableStore() *EnableStore { - return &EnableStore{ - Stack: &components.Stack{}, - Status: "", - } -} - -func NewEnableController() *EnableController { - return &EnableController{ - store: NewEnableStore(), - } -} - -func NewEnableCommand() *cobra.Command { - const ( - stackNameFlag = "name" - ) - return fctl.NewMembershipCommand("enable ( | --name=)", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Enable a stack"), - fctl.WithArgs(cobra.MaximumNArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(stackNameFlag, "", "Stack to enable"), - fctl.WithController(NewEnableController()), - ) -} -func (c *EnableController) GetStore() *EnableStore { - return c.store -} - -func (c *EnableController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - const ( - stackNameFlag = "name" - ) - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - var stack *components.Stack - if len(args) == 1 { - if fctl.GetString(cmd, stackNameFlag) != "" { - return nil, errors.New("need either an id or a name specified using --name flag") - } - - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - rsp, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, err - } - if rsp.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - stack = rsp.ReadStackResponse.GetData() - } else { - if fctl.GetString(cmd, stackNameFlag) == "" { - return nil, errors.New("need either an id of a name specified using --name flag") - } - listRequest := operations.ListStacksRequest{ - OrganizationID: organizationID, - } - stacksResponse, err := apiClient.ListStacks(cmd.Context(), listRequest) - if err != nil { - return nil, fmt.Errorf("listing stacks: %w", err) - } - if stacksResponse.ListStacksResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - for _, s := range stacksResponse.ListStacksResponse.GetData() { - if s.GetName() == fctl.GetString(cmd, stackNameFlag) { - stackData := s - stack = &stackData - break - } - } - } - if stack == nil { - return nil, errors.New("Stack not found") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to enable stack '%s'", stack.GetName()) { - return nil, fctl.ErrMissingApproval - } - - enableRequest := operations.EnableStackRequest{ - OrganizationID: organizationID, - StackID: stack.GetID(), - } - if _, err := apiClient.EnableStack(cmd.Context(), enableRequest); err != nil { - return nil, fmt.Errorf("stack enable: %w", err) - } - - c.store.Stack = stack - c.store.Status = "OK" - - return c, nil -} - -func (c *EnableController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Stack enabled.") - return nil -} diff --git a/cmd/stack/history.go b/cmd/stack/history.go deleted file mode 100644 index b76c5b0f..00000000 --- a/cmd/stack/history.go +++ /dev/null @@ -1,146 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" - "github.com/formancehq/fctl/v3/pkg/printer" -) - -const ( - pageSizeFlag = "page-size" - cursorFlag = "cursor" - - actionFlag = "action" - dataFlag = "data" - userIdFlag = "user-id" - displayDataFlag = "display-data" -) - -type HistoryStore struct { - Cursor *components.LogCursorData `json:"cursor"` -} - -type HistoryController struct { - store *HistoryStore -} - -var _ fctl.Controller[*HistoryStore] = (*HistoryController)(nil) - -func NewDefaultHistoryStore() *HistoryStore { - return &HistoryStore{ - Cursor: &components.LogCursorData{}, - } -} -func NewHistoryController() *HistoryController { - return &HistoryController{ - store: NewDefaultHistoryStore(), - } -} - -func NewHistoryCommand() *cobra.Command { - return fctl.NewMembershipCommand("history [id]", - fctl.WithShortDescription("Query stack history"), - fctl.WithAliases("hist"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(actionFlag, "", "Filter by action"), - fctl.WithStringFlag(userIdFlag, "", "Filter by user ID (use SYSTEM to filter by system logs)"), - fctl.WithStringFlag(dataFlag, "", "Filter by modified data (format: key=value, key is a JSONB text path)"), - - fctl.WithBoolFlag(displayDataFlag, false, "Display data"), - - fctl.WithStringFlag(cursorFlag, "", "Pagination cursor"), - fctl.WithIntFlag(pageSizeFlag, 10, "Page size"), - fctl.WithController(NewHistoryController()), - ) -} -func (c *HistoryController) GetStore() *HistoryStore { - return c.store -} - -func (c *HistoryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - pageSize := fctl.GetInt(cmd, pageSizeFlag) - stackID := args[0] - - cursor := fctl.GetString(cmd, cursorFlag) - userID := fctl.GetString(cmd, userIdFlag) - action := fctl.GetString(cmd, actionFlag) - data := fctl.GetString(cmd, dataFlag) - - if cursor != "" { - if userID != "" || action != "" || data != "" { - return nil, errors.New("cursor can't be used with other flags") - } - } - - if stackID == "" && cursor == "" { - return nil, errors.New("stack-id or cursor is required") - } - - request := operations.ListLogsRequest{ - OrganizationID: organizationID, - StackID: &stackID, - PageSize: pointer.For(int64(pageSize)), - } - - if cursor != "" { - request.Cursor = &cursor - } - - if userID != "" { - request.UserID = &userID - } - - if action != "" { - if !strings.Contains(action, "stacks") { - return nil, errors.New("stacks history are scoped to 'stacks.*' actions") - } - actionEnum := components.Action(action) - request.Action = &actionEnum - } - - if data != "" { - keyVal := strings.Split(data, "=") - if len(keyVal) != 2 { - return nil, errors.New("data filter must be in the form key=value") - } - request.Key = &keyVal[0] - request.Value = &keyVal[1] - } - - response, err := apiClient.ListLogs(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.LogCursor == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - cursorData := response.LogCursor.GetData() - c.store.Cursor = &cursorData - return c, nil -} - -func (c *HistoryController) Render(cmd *cobra.Command, args []string) error { - return printer.LogCursor(cmd.OutOrStdout(), c.store.Cursor, fctl.GetBool(cmd, displayDataFlag)) -} diff --git a/cmd/stack/internal/print.go b/cmd/stack/internal/print.go deleted file mode 100644 index bf23308a..00000000 --- a/cmd/stack/internal/print.go +++ /dev/null @@ -1,92 +0,0 @@ -package internal - -import ( - "fmt" - "io" - - "github.com/iancoleman/strcase" - "github.com/pterm/pterm" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/gateway" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintStackInformation(out io.Writer, stack *components.Stack, versions *gateway.GetVersionsResponse) error { - err := printInformation(out, stack) - - if err != nil { - return err - } - - if versions != nil { - err = printVersion(out, stack.URI, versions) - - if err != nil { - return err - } - } - - err = printMetadata(out, stack) - if err != nil { - return err - } - - return nil -} - -func printInformation(out io.Writer, stack *components.Stack) error { - - fctl.Section.WithWriter(out).Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), stack.ID, ""}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), stack.Name, ""}) - tableData = append(tableData, []string{pterm.LightCyan("Region"), stack.RegionID, ""}) - tableData = append(tableData, []string{pterm.LightCyan("Status"), string(stack.State), ""}) - tableData = append(tableData, []string{pterm.LightCyan("Effective status"), string(stack.Status), ""}) - - if stack.AuditEnabled != nil { - tableData = append(tableData, []string{pterm.LightCyan("Audit enabled"), fctl.BoolPointerToString(stack.AuditEnabled), ""}) - } - - return pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render() -} - -func printVersion(out io.Writer, url string, versions *gateway.GetVersionsResponse) error { - fctl.Println() - fctl.Section.WithWriter(out).Println("Versions") - - tableData := pterm.TableData{} - - for _, service := range versions.Versions { - tableData = append(tableData, []string{pterm.LightCyan(strcase.ToCamel(service.Name)), service.Version, - fmt.Sprintf("%s/api/%s", url, service.Name)}) - } - - return pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render() -} - -func printMetadata(out io.Writer, stack *components.Stack) error { - fctl.Println() - fctl.Section.WithWriter(out).Println("Metadata") - - tableData := pterm.TableData{} - - if stack.Metadata != nil { - for k, v := range stack.Metadata { - tableData = append(tableData, []string{pterm.LightCyan(k), v}) - } - } - - return pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render() -} diff --git a/cmd/stack/list.go b/cmd/stack/list.go deleted file mode 100644 index b17291ed..00000000 --- a/cmd/stack/list.go +++ /dev/null @@ -1,185 +0,0 @@ -package stack - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - allFlag = "all" - deletedFlag = "deleted" -) - -type Stack struct { - Id string `json:"id"` - Name string `json:"name"` - Dashboard string `json:"dashboard"` - RegionID string `json:"region"` - DisabledAt *string `json:"disabledAt"` - DeletedAt *string `json:"deletedAt"` - AuditEnabled string `json:"auditEnabled"` - Status string `json:"status"` -} -type StackListStore struct { - Stacks []Stack `json:"stacks"` -} - -type StackListController struct { - store *StackListStore -} - -var _ fctl.Controller[*StackListStore] = (*StackListController)(nil) - -func NewDefaultStackListStore() *StackListStore { - return &StackListStore{ - Stacks: []Stack{}, - } -} - -func NewStackListController() *StackListController { - return &StackListController{ - store: NewDefaultStackListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewMembershipCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List stacks"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithBoolFlag(deletedFlag, false, "Display deleted stacks"), - fctl.WithBoolFlag(allFlag, false, "Display deleted stacks"), - fctl.WithDeprecatedFlag(deletedFlag, "Use --all instead"), - fctl.WithController(NewStackListController()), - ) -} -func (c *StackListController) GetStore() *StackListStore { - return c.store -} - -func (c *StackListController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.ListStacksRequest{ - OrganizationID: organizationID, - All: pointer.For(fctl.GetBool(cmd, allFlag)), - Deleted: pointer.For(fctl.GetBool(cmd, deletedFlag)), - } - - rsp, err := apiClient.ListStacks(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("listing stacks: %w", err) - } - - if rsp.ListStacksResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - if len(rsp.ListStacksResponse.GetData()) == 0 { - return c, nil - } - - portal := fctl.DefaultConsoleURL - serverInfo, err := fctl.MembershipServerInfo(cmd.Context(), apiClient) - if err != nil { - return nil, err - } - if v := serverInfo.GetConsoleURL(); v != nil { - portal = *v - } - - c.store.Stacks = fctl.Map(rsp.ListStacksResponse.GetData(), func(stack components.Stack) Stack { - return Stack{ - Id: stack.GetID(), - Name: stack.GetName(), - Dashboard: portal, - RegionID: stack.GetRegionID(), - Status: string(stack.GetState()), - AuditEnabled: fctl.BoolPointerToString(stack.GetAuditEnabled()), - DisabledAt: func() *string { - if disabledAt := stack.GetDisabledAt(); disabledAt != nil { - t := disabledAt.Format(time.RFC3339) - return &t - } - return nil - }(), - DeletedAt: func() *string { - if deletedAt := stack.GetDeletedAt(); deletedAt != nil { - t := deletedAt.Format(time.RFC3339) - return &t - } - return nil - }(), - } - }) - - return c, nil -} - -func (c *StackListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Stacks) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No stacks found.") - return nil - } - - tableData := fctl.Map(c.store.Stacks, func(stack Stack) []string { - data := []string{ - stack.Id, - stack.Name, - stack.Dashboard, - stack.RegionID, - stack.Status, - stack.AuditEnabled, - } - if fctl.GetBool(cmd, allFlag) { - if stack.DisabledAt != nil { - data = append(data, *stack.DisabledAt) - } else { - data = append(data, "") - } - - if stack.DeletedAt != nil { - data = append(data, *stack.DeletedAt) - } else { - if stack.Status != "DELETED" { - data = append(data, "") - } else { - data = append(data, "") - } - } - } - - return data - }) - - headers := []string{"ID", "Name", "Dashboard", "Region", "Status", "Audit Enabled"} - if fctl.GetBool(cmd, allFlag) { - headers = append(headers, "Disabled At", "Deleted At") - } - tableData = fctl.Prepend(tableData, headers) - - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() -} diff --git a/cmd/stack/mcp/root.go b/cmd/stack/mcp/root.go deleted file mode 100644 index d4ea5fd2..00000000 --- a/cmd/stack/mcp/root.go +++ /dev/null @@ -1,423 +0,0 @@ -package mcp - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/oauth2" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - transportFlag = "transport" - maxMCPMessageSize = 10 * 1024 * 1024 -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("mcp", - fctl.WithShortDescription("Run stack MCP integrations"), - fctl.WithChildCommands(NewServeCommand()), - ) -} - -func NewServeCommand() *cobra.Command { - return fctl.NewStackCommand("serve", - fctl.WithShortDescription("Start a stack MCP server"), - fctl.WithStringFlag(transportFlag, "stdio", "MCP transport to use (stdio)"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithRunE(runServe), - ) -} - -func runServe(cmd *cobra.Command, _ []string) error { - transport := fctl.GetString(cmd, transportFlag) - if transport != "stdio" { - return fmt.Errorf("unsupported MCP transport %q: only stdio is currently supported", transport) - } - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return err - } - - stackToken, stackAccess, err := fctl.EnsureStackAccess(cmd, relyingParty, stderrDialog{w: cmd.ErrOrStderr()}, profileName, *profile, organizationID, stackID) - if err != nil { - return err - } - - tokenSource := fctl.NewStackTokenSource( - *stackToken, - stackAccess, - relyingParty, - func(newToken fctl.AccessToken) error { - return fctl.WriteStackToken(cmd, profileName, stackID, newToken) - }, - cmd, - profileName, - organizationID, - stackID, - ) - httpClient := oauth2.NewClient(cmd.Context(), tokenSource) - - server := &stdioServer{ - in: os.Stdin, - out: os.Stdout, - err: cmd.ErrOrStderr(), - httpClient: httpClient, - stackURI: stackAccess.URI, - } - return server.Serve(cmd.Context()) -} - -type stderrDialog struct { - w io.Writer -} - -func (d stderrDialog) Info(msg string, args ...any) { - _, _ = fmt.Fprintf(d.w, msg+"\n", args...) -} - -type stdioServer struct { - in io.Reader - out io.Writer - err io.Writer - httpClient *http.Client - stackURI string - remote *remoteMCPClient -} - -type rpcMessage struct { - JSONRPC string `json:"jsonrpc,omitempty"` - ID json.RawMessage `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` -} - -type rpcResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result any `json:"result,omitempty"` - Error *rpcError `json:"error,omitempty"` -} - -type rpcError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (s *stdioServer) Serve(ctx context.Context) error { - s.remote = newRemoteMCPClient(s.httpClient, s.stackURI) - reader := bufio.NewReader(s.in) - - type readResult struct { - data []byte - err error - } - reads := make(chan readResult, 1) - go func() { - for { - data, err := readMCPMessage(reader) - select { - case reads <- readResult{data: data, err: err}: - case <-ctx.Done(): - return - } - if err != nil { - return - } - } - }() - - for { - var read readResult - select { - case <-ctx.Done(): - return ctx.Err() - case read = <-reads: - } - - data, err := read.data, read.err - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - if len(bytes.TrimSpace(data)) == 0 { - continue - } - - var msg rpcMessage - if err := json.Unmarshal(data, &msg); err != nil { - _ = s.writeResponse(rpcResponse{ - JSONRPC: "2.0", - ID: json.RawMessage("null"), - Error: &rpcError{Code: -32700, Message: "parse error"}, - }) - continue - } - - if len(msg.ID) == 0 { - s.handleNotification(msg) - continue - } - - result, rpcErr := s.handleRequest(ctx, msg) - resp := rpcResponse{ - JSONRPC: "2.0", - ID: msg.ID, - Result: result, - Error: rpcErr, - } - if err := s.writeResponse(resp); err != nil { - return err - } - } -} - -func (s *stdioServer) handleNotification(msg rpcMessage) { - switch msg.Method { - case "notifications/cancelled": - _, _ = fmt.Fprintf(s.err, "MCP request cancelled\n") - if err := s.remote.Notify(context.Background(), msg); err != nil { - _, _ = fmt.Fprintf(s.err, "forwarding MCP notification %q failed: %v\n", msg.Method, err) - } - case "notifications/initialized": - if err := s.remote.Notify(context.Background(), msg); err != nil { - _, _ = fmt.Fprintf(s.err, "forwarding MCP notification %q failed: %v\n", msg.Method, err) - } - } -} - -func (s *stdioServer) handleRequest(ctx context.Context, msg rpcMessage) (any, *rpcError) { - if msg.Method == "ping" { - return map[string]any{}, nil - } - result, err := s.remote.Request(ctx, msg) - if err != nil { - return nil, &rpcError{Code: -32000, Message: err.Error()} - } - return result, nil -} - -type remoteMCPClient struct { - httpClient *http.Client - endpoint string - sessionID string - protocolVersion string -} - -func newRemoteMCPClient(httpClient *http.Client, stackURI string) *remoteMCPClient { - if httpClient == nil { - httpClient = http.DefaultClient - } - base, err := url.Parse(stackURI) - if err != nil { - return &remoteMCPClient{httpClient: httpClient, endpoint: stackURI, protocolVersion: "2024-11-05"} - } - endpoint := base.ResolveReference(&url.URL{Path: "/api/mcp"}).String() - return &remoteMCPClient{httpClient: httpClient, endpoint: endpoint, protocolVersion: "2024-11-05"} -} - -func (c *remoteMCPClient) Request(ctx context.Context, msg rpcMessage) (any, error) { - resp, err := c.send(ctx, msg) - if err != nil { - return nil, err - } - if resp.Error != nil { - return nil, fmt.Errorf("remote MCP error %d: %s", resp.Error.Code, resp.Error.Message) - } - return resp.Result, nil -} - -func (c *remoteMCPClient) Notify(ctx context.Context, msg rpcMessage) error { - _, err := c.send(ctx, msg) - return err -} - -func (c *remoteMCPClient) send(ctx context.Context, msg rpcMessage) (*rpcResponse, error) { - if msg.Method == "initialize" { - c.captureProtocolVersion(msg.Params) - } - data, err := json.Marshal(msg) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - req.Header.Set("MCP-Protocol-Version", c.protocolVersion) - if c.sessionID != "" { - req.Header.Set("Mcp-Session-Id", c.sessionID) - } - - httpResp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - if sessionID := httpResp.Header.Get("Mcp-Session-Id"); sessionID != "" { - c.sessionID = sessionID - } - - if httpResp.StatusCode == http.StatusAccepted && len(msg.ID) == 0 { - return &rpcResponse{JSONRPC: "2.0"}, nil - } - - payload, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - if httpResp.StatusCode >= 300 { - return nil, fmt.Errorf("remote MCP HTTP %d: %s", httpResp.StatusCode, strings.TrimSpace(string(payload))) - } - if len(bytes.TrimSpace(payload)) == 0 { - return &rpcResponse{JSONRPC: "2.0"}, nil - } - - return decodeRemoteMCPResponse(httpResp.Header.Get("Content-Type"), payload) -} - -func (c *remoteMCPClient) captureProtocolVersion(params json.RawMessage) { - var initParams struct { - ProtocolVersion string `json:"protocolVersion"` - } - if err := json.Unmarshal(params, &initParams); err == nil && initParams.ProtocolVersion != "" { - c.protocolVersion = initParams.ProtocolVersion - } -} - -func decodeRemoteMCPResponse(contentType string, payload []byte) (*rpcResponse, error) { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - mediaType = contentType - } - - if mediaType == "text/event-stream" { - return decodeSSEResponse(payload) - } - - var resp rpcResponse - if err := json.Unmarshal(payload, &resp); err != nil { - return nil, fmt.Errorf("decoding remote MCP response: %w", err) - } - return &resp, nil -} - -func decodeSSEResponse(payload []byte) (*rpcResponse, error) { - scanner := bufio.NewScanner(bytes.NewReader(payload)) - var events [][]string - var dataLines []string - for scanner.Scan() { - line := strings.TrimRight(scanner.Text(), "\r") - if line == "" { - if len(dataLines) > 0 { - events = append(events, dataLines) - dataLines = nil - } - continue - } - if strings.HasPrefix(line, "data:") { - dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) - } - } - if err := scanner.Err(); err != nil { - return nil, err - } - if len(dataLines) > 0 { - events = append(events, dataLines) - } - if len(events) == 0 { - return nil, fmt.Errorf("remote MCP SSE response did not contain data") - } - if len(events) > 1 { - // The stdio bridge writes one JSON-RPC response per request; streaming SSE is not supported yet. - return nil, fmt.Errorf("remote MCP SSE response contained multiple events") - } - var resp rpcResponse - if err := json.Unmarshal([]byte(strings.Join(events[0], "\n")), &resp); err != nil { - return nil, fmt.Errorf("decoding remote MCP SSE response: %w", err) - } - return &resp, nil -} - -func readMCPMessage(reader *bufio.Reader) ([]byte, error) { - for { - first, err := reader.Peek(1) - if err != nil { - return nil, err - } - if first[0] != '\n' && first[0] != '\r' && first[0] != ' ' && first[0] != '\t' { - break - } - _, _ = reader.ReadByte() - } - - headerOrJSON, err := reader.ReadString('\n') - if err != nil { - if errors.Is(err, io.EOF) && strings.TrimSpace(headerOrJSON) != "" { - return []byte(strings.TrimSpace(headerOrJSON)), nil - } - return nil, err - } - if strings.HasPrefix(strings.ToLower(headerOrJSON), "content-length:") { - _, lengthValue, _ := strings.Cut(headerOrJSON, ":") - lengthValue = strings.TrimSpace(lengthValue) - length, err := strconv.Atoi(lengthValue) - if err != nil { - return nil, fmt.Errorf("invalid Content-Length header: %w", err) - } - if length < 0 { - return nil, fmt.Errorf("invalid Content-Length header: must be non-negative") - } - if length > maxMCPMessageSize { - return nil, fmt.Errorf("invalid Content-Length header: exceeds maximum size %d", maxMCPMessageSize) - } - for { - line, err := reader.ReadString('\n') - if err != nil { - return nil, err - } - if strings.TrimSpace(line) == "" { - break - } - } - payload := make([]byte, length) - if _, err := io.ReadFull(reader, payload); err != nil { - return nil, err - } - return payload, nil - } - return []byte(strings.TrimSpace(headerOrJSON)), nil -} - -func (s *stdioServer) writeResponse(resp rpcResponse) error { - data, err := json.Marshal(resp) - if err != nil { - return err - } - _, err = s.out.Write(append(data, '\n')) - return err -} diff --git a/cmd/stack/mcp/root_test.go b/cmd/stack/mcp/root_test.go deleted file mode 100644 index a4c19f0d..00000000 --- a/cmd/stack/mcp/root_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package mcp - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" -) - -func TestStdioServerForwardsInitialize(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/mcp" { - t.Fatalf("path = %s, want /api/mcp", r.URL.Path) - } - var request rpcMessage - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Fatalf("decoding remote request: %v", err) - } - if request.Method != "initialize" { - t.Fatalf("method = %s, want initialize", request.Method) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"remote"}}}`)) - })) - defer upstream.Close() - - input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}` + "\n" - var output bytes.Buffer - server := &stdioServer{ - in: strings.NewReader(input), - out: &output, - err: &bytes.Buffer{}, - httpClient: upstream.Client(), - stackURI: upstream.URL, - } - - if err := server.Serve(context.Background()); err != nil { - t.Fatalf("Serve() error = %v", err) - } - - var response rpcResponse - if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &response); err != nil { - t.Fatalf("invalid response: %v", err) - } - if string(response.ID) != "1" { - t.Fatalf("response id = %s, want 1", response.ID) - } - if response.Error != nil { - t.Fatalf("response error = %#v", response.Error) - } -} - -func TestReadMCPMessageContentLength(t *testing.T) { - payload := []byte(`{"jsonrpc":"2.0","id":"abc","method":"ping"}`) - input := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(payload), payload) - - got, err := readMCPMessage(bufioReader(input)) - if err != nil { - t.Fatalf("readMCPMessage() error = %v", err) - } - if string(got) != string(payload) { - t.Fatalf("payload = %q, want %q", got, payload) - } -} - -func TestReadMCPMessageRejectsNegativeContentLength(t *testing.T) { - _, err := readMCPMessage(bufioReader("Content-Length: -1\r\n\r\n")) - if err == nil { - t.Fatalf("readMCPMessage() expected error") - } - if !strings.Contains(err.Error(), "must be non-negative") { - t.Fatalf("readMCPMessage() error = %v, want non-negative framing error", err) - } -} - -func TestReadMCPMessageRejectsOversizedContentLength(t *testing.T) { - input := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxMCPMessageSize+1) - - _, err := readMCPMessage(bufioReader(input)) - if err == nil { - t.Fatalf("readMCPMessage() expected error") - } - if !strings.Contains(err.Error(), "exceeds maximum size") { - t.Fatalf("readMCPMessage() error = %v, want maximum size framing error", err) - } -} - -func TestStdioServerStopsWhenContextIsCancelled(t *testing.T) { - reader, writer := io.Pipe() - defer func() { _ = reader.Close() }() - defer func() { _ = writer.Close() }() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - server := &stdioServer{ - in: reader, - out: &bytes.Buffer{}, - err: &bytes.Buffer{}, - httpClient: http.DefaultClient, - stackURI: "http://127.0.0.1", - } - - if err := server.Serve(ctx); !errors.Is(err, context.Canceled) { - t.Fatalf("Serve() error = %v, want context.Canceled", err) - } -} - -func TestStdioServerForwardsCancelledNotification(t *testing.T) { - var gotMethod atomic.Value - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request rpcMessage - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Fatalf("decoding remote request: %v", err) - } - gotMethod.Store(request.Method) - w.WriteHeader(http.StatusAccepted) - })) - defer upstream.Close() - - input := `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}` + "\n" - server := &stdioServer{ - in: strings.NewReader(input), - out: &bytes.Buffer{}, - err: &bytes.Buffer{}, - httpClient: upstream.Client(), - stackURI: upstream.URL, - } - - if err := server.Serve(context.Background()); err != nil { - t.Fatalf("Serve() error = %v", err) - } - if got := gotMethod.Load(); got != "notifications/cancelled" { - t.Fatalf("forwarded method = %v, want notifications/cancelled", got) - } -} - -func TestStdioServerPreservesForwardedMethodErrors(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "invalid_token", http.StatusUnauthorized) - })) - defer upstream.Close() - - input := `{"jsonrpc":"2.0","id":1,"method":"resources/list"}` + "\n" - var output bytes.Buffer - server := &stdioServer{ - in: strings.NewReader(input), - out: &output, - err: &bytes.Buffer{}, - httpClient: upstream.Client(), - stackURI: upstream.URL, - } - - if err := server.Serve(context.Background()); err != nil { - t.Fatalf("Serve() error = %v", err) - } - - var response rpcResponse - if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &response); err != nil { - t.Fatalf("invalid response: %v", err) - } - if response.Error == nil { - t.Fatalf("response error is nil") - } - if response.Error.Code != -32000 { - t.Fatalf("response error code = %d, want -32000", response.Error.Code) - } - if !strings.Contains(response.Error.Message, "remote MCP HTTP 401") { - t.Fatalf("response error message = %q, want remote HTTP error", response.Error.Message) - } -} - -func TestRemoteMCPClientKeepsSessionID(t *testing.T) { - var count atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestNumber := count.Add(1) - if requestNumber == 1 { - w.Header().Set("Mcp-Session-Id", "session-123") - } else if got := r.Header.Get("Mcp-Session-Id"); got != "session-123" { - t.Fatalf("Mcp-Session-Id = %q, want session-123", got) - } - if got := r.Header.Get("MCP-Protocol-Version"); got != "2025-03-26" { - t.Fatalf("MCP-Protocol-Version = %q, want 2025-03-26", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - })) - defer upstream.Close() - - client := newRemoteMCPClient(upstream.Client(), upstream.URL) - _, err := client.Request(context.Background(), rpcMessage{ - JSONRPC: "2.0", - ID: json.RawMessage("1"), - Method: "initialize", - Params: json.RawMessage(`{"protocolVersion":"2025-03-26"}`), - }) - if err != nil { - t.Fatalf("initialize request error = %v", err) - } - - _, err = client.Request(context.Background(), rpcMessage{ - JSONRPC: "2.0", - ID: json.RawMessage("2"), - Method: "tools/list", - }) - if err != nil { - t.Fatalf("tools/list request error = %v", err) - } -} - -func TestDecodeSSEResponse(t *testing.T) { - resp, err := decodeRemoteMCPResponse("text/event-stream", []byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n")) - if err != nil { - t.Fatalf("decodeRemoteMCPResponse() error = %v", err) - } - result, ok := resp.Result.(map[string]any) - if !ok || result["ok"] != true { - t.Fatalf("result = %#v, want ok=true", resp.Result) - } -} - -func TestDecodeSSEResponseRejectsMultipleEvents(t *testing.T) { - payload := []byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"first\":true}}\n\n" + - "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"second\":true}}\n\n") - - _, err := decodeRemoteMCPResponse("text/event-stream", payload) - if err == nil { - t.Fatalf("decodeRemoteMCPResponse() expected error") - } - if !strings.Contains(err.Error(), "multiple events") { - t.Fatalf("decodeRemoteMCPResponse() error = %v, want multiple events error", err) - } -} - -func bufioReader(s string) *bufio.Reader { - return bufio.NewReader(strings.NewReader(s)) -} diff --git a/cmd/stack/modules/disable.go b/cmd/stack/modules/disable.go deleted file mode 100644 index 5dbf47c9..00000000 --- a/cmd/stack/modules/disable.go +++ /dev/null @@ -1,81 +0,0 @@ -package modules - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DisableStore struct{} -type DisableController struct { - store *DisableStore -} - -var _ fctl.Controller[*DisableStore] = (*DisableController)(nil) - -func NewDefaultDisableStore() *DisableStore { - return &DisableStore{} -} - -func NewDisableController() *DisableController { - return &DisableController{ - store: NewDefaultDisableStore(), - } -} - -func NewDisableCommand() *cobra.Command { - return fctl.NewStackCommand("disable ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Disable a module"), - fctl.WithAliases("dis", "d"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewDisableController()), - ) -} -func (c *DisableController) GetStore() *DisableStore { - return c.store -} - -func (c *DisableController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to disable a module") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DisableModuleRequest{ - OrganizationID: organizationID, - StackID: stackID, - Name: args[0], - } - - _, err = apiClient.DisableModule(cmd.Context(), request) - if err != nil { - return nil, err - } - - return c, nil -} - -func (c *DisableController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Module disabled.") - return nil -} diff --git a/cmd/stack/modules/enable.go b/cmd/stack/modules/enable.go deleted file mode 100644 index aa8adc01..00000000 --- a/cmd/stack/modules/enable.go +++ /dev/null @@ -1,80 +0,0 @@ -package modules - -import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type EnableStore struct { -} -type EnableController struct { - store *EnableStore -} - -var _ fctl.Controller[*EnableStore] = (*EnableController)(nil) - -func NewDefaultEnableStore() *EnableStore { - return &EnableStore{} -} - -func NewEnableController() *EnableController { - return &EnableController{ - store: NewDefaultEnableStore(), - } -} - -func NewEnableCommand() *cobra.Command { - return fctl.NewStackCommand("enable ", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("Enable a module"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewEnableController()), - ) -} -func (c *EnableController) GetStore() *EnableStore { - return c.store -} - -func (c *EnableController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - _, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to enable a module") { - return nil, fctl.ErrMissingApproval - } - - request := operations.EnableModuleRequest{ - OrganizationID: organizationID, - StackID: stackID, - Name: args[0], - } - - _, err = apiClient.EnableModule(cmd.Context(), request) - if err != nil { - return nil, err - } - return c, nil -} - -func (c *EnableController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Module enabled") - return nil -} diff --git a/cmd/stack/modules/list.go b/cmd/stack/modules/list.go deleted file mode 100644 index 56b6936a..00000000 --- a/cmd/stack/modules/list.go +++ /dev/null @@ -1,104 +0,0 @@ -package modules - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Modules []components.Module `json:"modules"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewMembershipCommand("list --stack=", - fctl.WithConfirmFlag(), - fctl.WithShortDescription("List modules in a stack"), - fctl.WithAliases("ls"), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewListController()), - ) -} -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return nil, err - } - - request := operations.ListModulesRequest{ - OrganizationID: organizationID, - StackID: stackID, - } - - response, err := apiClient.ListModules(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.ListModulesResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Modules = response.ListModulesResponse.GetData() - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - header := []string{"Name", "State", "Cluster status", "Last state update", "Last cluster state update"} - - tableData := fctl.Map(c.store.Modules, func(module components.Module) []string { - return []string{ - module.GetName(), - string(module.GetState()), - string(module.GetStatus()), - time.Time{Time: module.GetLastStateUpdate()}.String(), - time.Time{Time: module.GetLastStatusUpdate()}.String(), - } - }) - - tableData = fctl.Prepend(tableData, header) - - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithHasHeader(). - WithData(tableData). - Render() -} diff --git a/cmd/stack/modules/root.go b/cmd/stack/modules/root.go deleted file mode 100644 index 87c17869..00000000 --- a/cmd/stack/modules/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package modules - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("modules", - fctl.WithShortDescription("Manage your modules"), - fctl.WithAliases("module", "mod"), - fctl.WithChildCommands( - NewDisableCommand(), - NewEnableCommand(), - NewListCommand(), - ), - ) -} diff --git a/cmd/stack/proxy.go b/cmd/stack/proxy.go deleted file mode 100644 index 9f4941ac..00000000 --- a/cmd/stack/proxy.go +++ /dev/null @@ -1,283 +0,0 @@ -package stack - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/spf13/cobra" - "golang.org/x/oauth2" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - proxyPortFlag = "port" - allowedOriginsFlag = "allowed-origins" -) - -type ProxyStore struct { -} - -type ProxyController struct { - store *ProxyStore -} - -var _ fctl.Controller[*ProxyStore] = (*ProxyController)(nil) - -func NewDefaultStackProxyStore() *ProxyStore { - return &ProxyStore{} -} - -func NewStackProxyController() *ProxyController { - return &ProxyController{ - store: NewDefaultStackProxyStore(), - } -} - -func NewProxyCommand() *cobra.Command { - cmd := fctl.NewStackCommand("proxy", - fctl.WithShortDescription("Start a local proxy server to access the stack with authentication"), - fctl.WithDescription("Start a local proxy server that adds authentication headers to requests to the stack"), - fctl.WithIntFlag(proxyPortFlag, 55001, "Port to use for the local proxy server"), - fctl.WithStringSliceFlag(allowedOriginsFlag, []string{}, "Allowed origins for CORS (comma-separated). If not specified, CORS is disabled."), - fctl.WithController(NewStackProxyController()), - ) - - return cmd -} - -func (c *ProxyController) GetStore() *ProxyStore { - return c.store -} - -// corsMiddleware creates a CORS middleware that handles preflight requests and adds CORS headers -func corsMiddleware(allowedOrigins []string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - - // Check if origin is allowed - originAllowed := false - allowWildcard := false - for _, allowedOrigin := range allowedOrigins { - if allowedOrigin == "*" { - originAllowed = true - allowWildcard = true - break - } else if allowedOrigin == origin { - originAllowed = true - break - } - } - - // Set CORS headers only for allowed origins - if originAllowed { - if allowWildcard { - w.Header().Set("Access-Control-Allow-Origin", "*") - } else if origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) - } - - // TODO: forward services provided headers - also check where headers are cleared (line ~208) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS") - w.Header().Set("Access-Control-Expose-Headers", "Count") - w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-CSRF-Token, X-Requested-With") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Max-Age", "86400") // 24 hours - } - - // Handle preflight requests - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - next.ServeHTTP(w, r) - }) -} - -func (c *ProxyController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - stackToken, stackAccess, err := fctl.EnsureStackAccess(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID, stackID) - if err != nil { - return nil, err - } - - port := fctl.GetInt(cmd, proxyPortFlag) - allowedOrigins := fctl.GetStringSlice(cmd, allowedOriginsFlag) - - stackBaseURL, err := url.Parse(stackAccess.URI) - if err != nil { - return nil, fmt.Errorf("error parsing stack URL: %v", err) - } - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Target Stack URL: %s\r\n", stackBaseURL.String()) - - // Only show CORS info if origins are specified - if len(allowedOrigins) > 0 { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "CORS enabled - Allowed Origins: %s\r\n", strings.Join(allowedOrigins, ", ")) - } else { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "CORS disabled\r\n") - } - - proxy := httputil.NewSingleHostReverseProxy(stackBaseURL) - - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - req.Host = stackBaseURL.Host - - targetURL := &url.URL{ - Scheme: stackBaseURL.Scheme, - Host: stackBaseURL.Host, - Path: req.URL.Path, - RawQuery: req.URL.RawQuery, - } - - sourceURL := &url.URL{ - Path: req.URL.Path, - RawQuery: req.URL.RawQuery, - } - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "[%s] Proxying %s %s to %s\r\n", - time.Now().Format(time.RFC3339), - req.Method, - sourceURL.String(), - targetURL.String()) - } - - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - targetURL := &url.URL{ - Scheme: stackBaseURL.Scheme, - Host: stackBaseURL.Host, - Path: r.URL.Path, - RawQuery: r.URL.RawQuery, - } - - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[%s] ERROR proxying request to %s: %v\r\n", - time.Now().Format(time.RFC3339), - targetURL.String(), - err) - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusBadGateway) - errorMsg := fmt.Sprintf("Proxy Error: %v\nTarget URL: %s", - err, - targetURL.String()) - _, _ = io.WriteString(w, errorMsg) - } - - proxy.Transport = &oauth2.Transport{ - Base: fctl.GetHttpClient(cmd).Transport, - Source: fctl.NewStackTokenSource( - *stackToken, - stackAccess, - relyingParty, - func(newToken fctl.AccessToken) error { - return fctl.WriteStackToken(cmd, profileName, stackID, newToken) - }, - cmd, - profileName, - organizationID, - stackID, - ), - } - - // Clear any CORS headers from upstream to prevent conflicts with our CORS middleware - // TODO: forward services provided headers - also check where headers are added (line ~97) - proxy.ModifyResponse = func(resp *http.Response) error { - resp.Header.Del("Access-Control-Allow-Origin") - resp.Header.Del("Access-Control-Allow-Methods") - resp.Header.Del("Access-Control-Allow-Headers") - resp.Header.Del("Access-Control-Allow-Credentials") - resp.Header.Del("Access-Control-Max-Age") - resp.Header.Del("Access-Control-Expose-Headers") - resp.Header.Del("Access-Control-Request-Method") - resp.Header.Del("Access-Control-Request-Headers") - - return nil - } - - // Only wrap with CORS middleware if origins are specified - var handler http.Handler = proxy - if len(allowedOrigins) > 0 { - handler = corsMiddleware(allowedOrigins, proxy) - } - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: handler, - ReadHeaderTimeout: 10 * time.Second, - } - - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - - // Create error channel to handle server errors from the main goroutine - serverErrors := make(chan error, 1) - - go func() { - fmt.Fprintf(cmd.OutOrStdout(), "Starting proxy server at http://localhost:%d -> %s\r\n", port, stackAccess.URI) - fmt.Fprintf(cmd.OutOrStdout(), "Press Ctrl+C to stop the server\r\n") - - // Check if port is available before starting the server - addr := fmt.Sprintf(":%d", port) - listener, err := net.Listen("tcp", addr) - if err != nil { - serverErrors <- fmt.Errorf("port %d is already in use or unavailable: %w", port, err) - return - } - - // Use the existing listener instead of closing it and calling ListenAndServe - if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { - serverErrors <- err - } - }() - - // Handle either server error or stop signal - select { - case err := <-serverErrors: - fmt.Fprintf(cmd.ErrOrStderr(), "Server error: %v\r\n", err) - return nil, err - case <-stop: - fmt.Fprintf(cmd.OutOrStdout(), "\r\nShutting down server...\r\n") - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := server.Shutdown(ctx); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error during server shutdown: %v\r\n", err) - } else { - fmt.Fprintf(cmd.OutOrStdout(), "Server stopped successfully\r\n") - } - - // Create a dummy renderable to avoid nil pointer dereference - return &EmptyRenderable{}, nil -} - -// EmptyRenderable is a dummy implementation of the Renderable interface -type EmptyRenderable struct{} - -func (r *EmptyRenderable) Render(cmd *cobra.Command, args []string) error { - return nil -} diff --git a/cmd/stack/restore.go b/cmd/stack/restore.go deleted file mode 100644 index b6662281..00000000 --- a/cmd/stack/restore.go +++ /dev/null @@ -1,159 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - "net/http" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/gateway" - - "github.com/formancehq/fctl/v3/cmd/stack/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type StackRestoreStore struct { - Stack *components.Stack `json:"stack"` - Versions *gateway.GetVersionsResponse `json:"versions"` -} -type StackRestoreController struct { - store *StackRestoreStore - config fctl.Config -} - -var _ fctl.Controller[*StackRestoreStore] = (*StackRestoreController)(nil) - -func NewDefaultVersionStore() *StackRestoreStore { - return &StackRestoreStore{ - Stack: &components.Stack{}, - Versions: &gateway.GetVersionsResponse{}, - } -} - -func NewStackRestoreController() *StackRestoreController { - return &StackRestoreController{ - store: NewDefaultVersionStore(), - } -} - -func NewRestoreStackCommand() *cobra.Command { - const stackNameFlag = "name" - - return fctl.NewMembershipCommand("restore ", - fctl.WithShortDescription("Restore a deleted stack"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(stackNameFlag, "", ""), - fctl.WithController(NewStackRestoreController()), - ) -} -func (c *StackRestoreController) GetStore() *StackRestoreStore { - return c.store -} - -func (c *StackRestoreController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - cfg, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - var stack *components.Stack - if len(args) == 1 { - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - rsp, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, err - } - if rsp.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - stack = rsp.ReadStackResponse.GetData() - } - - if stack == nil { - return nil, errors.New("Stack not found") - } - - if !fctl.CheckStackApprobation(cmd, "You are about to restore stack '%s'", stack.GetName()) { - return nil, fctl.ErrMissingApproval - } - - restoreRequest := operations.RestoreStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - - response, err := apiClient.RestoreStack(cmd.Context(), restoreRequest) - if err != nil { - return nil, err - } - - if response.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - restoredStackData := response.ReadStackResponse.GetData() - - if !fctl.GetBool(cmd, nowaitFlag) { - spinner, err := pterm.DefaultSpinner.Start("Waiting services availability") - if err != nil { - return nil, err - } - - stack, err = waitStackReady(cmd, apiClient, restoredStackData.GetOrganizationID(), restoredStackData.GetID()) - if err != nil { - return nil, err - } - - if err := spinner.Stop(); err != nil { - return nil, err - } - - c.store.Stack = stack - } else { - c.store.Stack = restoredStackData - } - - stackClient, err := fctl.NewStackClient( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - restoredStackData.GetOrganizationID(), - restoredStackData.GetID(), - ) - if err != nil { - return nil, err - } - - versions, err := stackClient.GetVersions(cmd.Context()) - if err != nil { - return nil, err - } - - if versions.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d when reading versions", versions.StatusCode) - } - - c.store.Versions = versions.GetVersionsResponse - c.config = *cfg - - return c, nil -} - -func (c *StackRestoreController) Render(cmd *cobra.Command, _ []string) error { - return internal.PrintStackInformation(cmd.OutOrStdout(), c.store.Stack, c.store.Versions) -} diff --git a/cmd/stack/root.go b/cmd/stack/root.go deleted file mode 100644 index c7018e20..00000000 --- a/cmd/stack/root.go +++ /dev/null @@ -1,33 +0,0 @@ -package stack - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/stack/mcp" - "github.com/formancehq/fctl/v3/cmd/stack/modules" - "github.com/formancehq/fctl/v3/cmd/stack/users" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewMembershipCommand("stack", - fctl.WithShortDescription("Manage your stack"), - fctl.WithAliases("stack", "stacks", "st"), - fctl.WithChildCommands( - NewCreateCommand(), - NewListCommand(), - NewDeleteCommand(), - NewShowCommand(), - NewDisableCommand(), - NewEnableCommand(), - NewRestoreStackCommand(), - NewUpdateCommand(), - NewUpgradeCommand(), - NewHistoryCommand(), - NewProxyCommand(), - mcp.NewCommand(), - users.NewCommand(), - modules.NewCommand(), - ), - ) -} diff --git a/cmd/stack/show.go b/cmd/stack/show.go deleted file mode 100644 index d3daa29c..00000000 --- a/cmd/stack/show.go +++ /dev/null @@ -1,160 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - "net/http" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/gateway" - - "github.com/formancehq/fctl/v3/cmd/stack/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -var errStackNotFound = errors.New("stack not found") - -type StackShowStore struct { - Stack *components.Stack `json:"stack"` - Versions *gateway.GetVersionsResponse `json:"versions"` -} - -type StackShowController struct { - store *StackShowStore - config fctl.Config -} - -var _ fctl.Controller[*StackShowStore] = (*StackShowController)(nil) - -func NewDefaultStackShowStore() *StackShowStore { - return &StackShowStore{ - Stack: &components.Stack{}, - } -} - -func NewStackShowController() *StackShowController { - return &StackShowController{ - store: NewDefaultStackShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - var stackNameFlag = "name" - - return fctl.NewMembershipCommand("show ( | --name=)", - fctl.WithAliases("s", "sh"), - fctl.WithShortDescription("Show a stack"), - fctl.WithArgs(cobra.MaximumNArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(stackNameFlag, "", ""), - fctl.WithController(NewStackShowController()), - ) -} - -func (c *StackShowController) GetStore() *StackShowStore { - return c.store -} - -func (c *StackShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - var stackNameFlag = "name" - var stack *components.Stack - - cfg, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if len(args) == 1 { - if fctl.GetString(cmd, stackNameFlag) != "" { - return nil, errors.New("need either an id of a name specified using --name flag") - } - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - stackResponse, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - if stackResponse.GetHTTPMeta().Response.StatusCode == http.StatusNotFound { - return nil, errStackNotFound - } - return nil, fmt.Errorf("listing stacks: %w", err) - } - if stackResponse.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - stack = stackResponse.ReadStackResponse.GetData() - } else { - if fctl.GetString(cmd, stackNameFlag) == "" { - return nil, errors.New("need either an id of a name specified using --name flag") - } - listRequest := operations.ListStacksRequest{ - OrganizationID: organizationID, - } - stacksResponse, err := apiClient.ListStacks(cmd.Context(), listRequest) - if err != nil { - return nil, fmt.Errorf("listing stacks: %w", err) - } - if stacksResponse.ListStacksResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - for _, s := range stacksResponse.ListStacksResponse.GetData() { - if s.GetName() == fctl.GetString(cmd, stackNameFlag) { - stackData := s - stack = &stackData - break - } - } - } - - if stack == nil { - return nil, errStackNotFound - } - - c.store.Stack = stack - c.config = *cfg - - // the stack is not active, we can't get the running versions - // Maybe add something in the process with sync status and store it in membership - if stack.GetStatus() != "ACTIVE" { - return c, nil - } - - stackClient, err := fctl.NewStackClient( - cmd, - relyingParty, - fctl.NewPTermDialog(), - profileName, - *profile, - stack.GetOrganizationID(), - stack.GetID(), - ) - if err != nil { - return nil, err - } - - versions, err := stackClient.GetVersions(cmd.Context()) - if err != nil { - - return nil, err - } - - if versions.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d when reading versions", versions.StatusCode) - } - - c.store.Versions = versions.GetVersionsResponse - - return c, nil - -} - -func (c *StackShowController) Render(cmd *cobra.Command, args []string) error { - return internal.PrintStackInformation(cmd.OutOrStdout(), c.store.Stack, c.store.Versions) -} diff --git a/cmd/stack/update.go b/cmd/stack/update.go deleted file mode 100644 index dd6b15cd..00000000 --- a/cmd/stack/update.go +++ /dev/null @@ -1,118 +0,0 @@ -package stack - -import ( - "errors" - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - "github.com/formancehq/fctl/v3/cmd/stack/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - nameFlag = "name" -) - -type UpdateStore struct { - Stack *components.Stack -} - -type UpdateController struct { - store *UpdateStore - profile fctl.Profile -} - -var _ fctl.Controller[*UpdateStore] = (*UpdateController)(nil) - -func NewDefaultStackUpdateStore() *UpdateStore { - return &UpdateStore{ - Stack: &components.Stack{}, - } -} -func NewStackUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultStackUpdateStore(), - } -} - -func NewUpdateCommand() *cobra.Command { - return fctl.NewMembershipCommand("update ", - fctl.WithShortDescription("Update a created stack, name, or metadata"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithStringFlag(nameFlag, "", "Name of the stack"), - fctl.WithController(NewStackUpdateController()), - ) -} -func (c *UpdateController) GetStore() *UpdateStore { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - c.profile = *profile - - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - stackResponse, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, fmt.Errorf("retrieving stack: %w", err) - } - if stackResponse.GetHTTPMeta().Response.StatusCode > 300 { - return nil, errors.New("stack not found") - } - - if stackResponse.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - stackData := stackResponse.ReadStackResponse.GetData() - - name := fctl.GetString(cmd, nameFlag) - if name == "" { - name = stackData.GetName() - } - - updateData := components.StackData{ - Name: name, - } - - updateRequest := operations.UpdateStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - Body: &updateData, - } - - updatedStackResponse, err := apiClient.UpdateStack(cmd.Context(), updateRequest) - if err != nil { - return nil, fmt.Errorf("updating stack: %w", err) - } - - if updatedStackResponse.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.Stack = updatedStackResponse.ReadStackResponse.GetData() - - return c, nil -} - -func (c *UpdateController) Render(cmd *cobra.Command, _ []string) error { - return internal.PrintStackInformation(cmd.OutOrStdout(), c.store.Stack, nil) -} diff --git a/cmd/stack/upgrade.go b/cmd/stack/upgrade.go deleted file mode 100644 index ca6f5d70..00000000 --- a/cmd/stack/upgrade.go +++ /dev/null @@ -1,189 +0,0 @@ -package stack - -import ( - "context" - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "golang.org/x/mod/semver" - - "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/go-libs/v4/pointer" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpgradeStore struct { - Stack *components.Stack -} - -type UpgradeController struct { - store *UpgradeStore -} - -var _ fctl.Controller[*UpgradeStore] = (*UpgradeController)(nil) - -func NewDefaultUpgradeStore() *UpgradeStore { - return &UpgradeStore{ - Stack: &components.Stack{}, - } -} -func NewUpgradeController() *UpgradeController { - return &UpgradeController{ - store: NewDefaultUpgradeStore(), - } -} - -func NewUpgradeCommand() *cobra.Command { - return fctl.NewMembershipCommand("upgrade ", - fctl.WithShortDescription("Upgrade a stack to specified version"), - fctl.WithBoolFlag(nowaitFlag, false, "Wait stack availability"), - fctl.WithArgs(cobra.RangeArgs(1, 2)), - fctl.WithValidArgsFunction(fctl.StackCompletion), - fctl.WithController(NewUpgradeController()), - ) -} -func (c *UpgradeController) GetStore() *UpgradeStore { - return c.store -} - -func (c *UpgradeController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - getRequest := operations.GetStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - } - - stackResponse, err := apiClient.GetStack(cmd.Context(), getRequest) - if err != nil { - return nil, fmt.Errorf("retrieving stack: %w", err) - } - - if stackResponse.GetHTTPMeta().Response.StatusCode > 300 { - return nil, fmt.Errorf("unexpected status code: %d", stackResponse.GetHTTPMeta().Response.StatusCode) - } - - if stackResponse.ReadStackResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - stackData := stackResponse.ReadStackResponse.GetData() - - req := components.StackVersion{ - Version: nil, - } - specifiedVersion := fctl.GetString(cmd, versionFlag) - if specifiedVersion == "" { - upgradeOpts, err := retrieveUpgradableVersion(cmd.Context(), organizationID, *stackData, apiClient) - if err != nil { - return nil, err - } - printer := pterm.DefaultInteractiveSelect.WithOptions(upgradeOpts) - selectedOption, err := printer.Show("Please select a version") - if err != nil { - return nil, err - } - - specifiedVersion = selectedOption - } - - currentVersion := stackData.GetVersion() - if currentVersion == nil { - return nil, fmt.Errorf("stack has no version") - } - - if specifiedVersion != *currentVersion { - if !fctl.CheckStackApprobation(cmd, "Disclaimer: You are about to migrate the stack '%s' from '%s' to '%s'. It might take some time to fully migrate", stackData.GetName(), *currentVersion, specifiedVersion) { - return nil, fctl.ErrMissingApproval - } - } else { - pterm.Warning.WithWriter(cmd.OutOrStdout()).Printfln("Stack is already at version %s", specifiedVersion) - return nil, nil - } - req.Version = pointer.For(specifiedVersion) - - upgradeRequest := operations.UpgradeStackRequest{ - OrganizationID: organizationID, - StackID: args[0], - Body: &req, - } - - upgradeResponse, err := apiClient.UpgradeStack(cmd.Context(), upgradeRequest) - if err != nil { - return nil, fmt.Errorf("upgrading stack: %w", err) - } - - if upgradeResponse.GetHTTPMeta().Response.StatusCode > 300 { - return nil, fmt.Errorf("unexpected status code: %d", upgradeResponse.GetHTTPMeta().Response.StatusCode) - } - - if !fctl.GetBool(cmd, nowaitFlag) { - spinner, err := pterm.DefaultSpinner.Start("Waiting services availability") - if err != nil { - return nil, err - } - - readyStack, err := waitStackReady(cmd, apiClient, stackData.GetOrganizationID(), stackData.GetID()) - if err != nil { - return nil, err - } - c.store.Stack = readyStack - - if err := spinner.Stop(); err != nil { - return nil, err - } - } - - return c, nil -} - -func (c *UpgradeController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Stack upgrade progressing.") - return nil -} - -func retrieveUpgradableVersion(ctx context.Context, organization string, stack components.Stack, apiClient *membershipclient.SDK) ([]string, error) { - getVersionsRequest := operations.GetRegionVersionsRequest{ - OrganizationID: organization, - RegionID: stack.GetRegionID(), - } - - availableVersionsResponse, err := apiClient.GetRegionVersions(ctx, getVersionsRequest) - if err != nil { - return nil, err - } - - if availableVersionsResponse.GetRegionVersionsResponse == nil { - return nil, fmt.Errorf("unexpected response: no versions data") - } - - currentVersion := stack.GetVersion() - if currentVersion == nil { - return nil, fmt.Errorf("stack has no version") - } - - var upgradeOptions []string - for _, version := range availableVersionsResponse.GetRegionVersionsResponse.GetData() { - versionName := version.GetName() - if versionName == *currentVersion { - continue - } - if !semver.IsValid(versionName) || semver.Compare(versionName, *currentVersion) >= 1 { - upgradeOptions = append(upgradeOptions, versionName) - } - } - return upgradeOptions, nil -} diff --git a/cmd/stack/users/link.go b/cmd/stack/users/link.go deleted file mode 100644 index 2a20eb77..00000000 --- a/cmd/stack/users/link.go +++ /dev/null @@ -1,97 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type LinkStore struct { - OrganizationID string `json:"organizationId"` - UserID string `json:"userId"` - StackID string `json:"stackId"` -} -type LinkController struct { - store *LinkStore -} - -var _ fctl.Controller[*LinkStore] = (*LinkController)(nil) - -func NewDefaultLinkStore() *LinkStore { - return &LinkStore{} -} - -func NewLinkController() *LinkController { - return &LinkController{ - store: NewDefaultLinkStore(), - } -} - -func NewLinkCommand() *cobra.Command { - return fctl.NewCommand("link ", - fctl.WithIntFlag("policy-id", 0, "Policy ID"), - fctl.WithShortDescription("Link a stack user to the current stack"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*LinkStore](NewLinkController()), - ) -} - -func (c *LinkController) GetStore() *LinkStore { - return c.store -} - -func (c *LinkController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return nil, err - } - - policyID := fctl.GetInt(cmd, "policy-id") - if policyID == 0 { - return nil, fmt.Errorf("policy id is required") - } - - _, err = apiClient. - UpsertStackUserAccess(cmd.Context(), operations.UpsertStackUserAccessRequest{ - OrganizationID: organizationID, - StackID: stackID, - UserID: args[0], - Body: &components.UpdateStackUserRequest{ - PolicyID: int64(policyID), - }, - }) - if err != nil { - return nil, err - } - - c.store.OrganizationID = organizationID - c.store.StackID = stackID - c.store.UserID = args[0] - - return c, nil -} - -func (c *LinkController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Organization '%s': stack %s, access roles updated for user %s", c.store.OrganizationID, c.store.StackID, c.store.UserID) - - return nil - -} diff --git a/cmd/stack/users/list.go b/cmd/stack/users/list.go deleted file mode 100644 index 174bb06b..00000000 --- a/cmd/stack/users/list.go +++ /dev/null @@ -1,100 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - list []components.StackUserAccessResponseData -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{ - list: []components.StackUserAccessResponseData{}, - } -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("l"), - fctl.WithShortDescription("List stack access roles within an organization"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return nil, err - } - - request := operations.ListStackUsersAccessesRequest{ - OrganizationID: organizationID, - StackID: stackID, - } - - response, err := apiClient.ListStackUsersAccesses(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.StackUserAccessResponse == nil { - return nil, fmt.Errorf("unexpected response: no data") - } - - c.store.list = response.StackUserAccessResponse.GetData() - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, _ []string) error { - stackUserAccessMap := fctl.Map(c.store.list, func(o components.StackUserAccessResponseData) []string { - return []string{ - o.GetStackID(), - o.GetUserID(), - o.GetEmail(), - fmt.Sprintf("%d", o.GetPolicyID()), - } - }) - - tableData := fctl.Prepend(stackUserAccessMap, []string{"Stack Id", "User Id", "Email", "Role"}) - - return pterm.DefaultTable.WithHasHeader().WithWriter(cmd.OutOrStdout()).WithData(tableData).Render() - -} diff --git a/cmd/stack/users/root.go b/cmd/stack/users/root.go deleted file mode 100644 index 32c73bcb..00000000 --- a/cmd/stack/users/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package users - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("users", - fctl.WithAliases("u", "user"), - fctl.WithShortDescription("Manage stack users within an organization"), - fctl.WithChildCommands( - NewLinkCommand(), - NewListCommand(), - NewUnlinkCommand(), - ), - ) -} diff --git a/cmd/stack/users/unlink.go b/cmd/stack/users/unlink.go deleted file mode 100644 index 62283d4f..00000000 --- a/cmd/stack/users/unlink.go +++ /dev/null @@ -1,88 +0,0 @@ -package users - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UnlinkStore struct { - Stack *components.Stack `json:"stack"` - Status string `json:"status"` -} -type UnlinkController struct { - store *UnlinkStore -} - -var _ fctl.Controller[*UnlinkStore] = (*UnlinkController)(nil) - -func NewDefaultUnlinkStore() *UnlinkStore { - return &UnlinkStore{ - Stack: &components.Stack{}, - Status: "", - } -} - -func NewUnlinkController() *UnlinkController { - return &UnlinkController{ - store: NewDefaultUnlinkStore(), - } -} - -func NewUnlinkCommand() *cobra.Command { - return fctl.NewMembershipCommand("unlink ", - fctl.WithShortDescription("Unlink a stack user from the current stack"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewUnlinkController()), - ) -} -func (c *UnlinkController) GetStore() *UnlinkStore { - return c.store -} - -func (c *UnlinkController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - organizationID, stackID, err := fctl.ResolveStackID(cmd, *profile) - if err != nil { - return nil, err - } - - apiClient, err := fctl.NewMembershipClientForOrganization(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile, organizationID) - if err != nil { - return nil, err - } - - request := operations.DeleteStackUserAccessRequest{ - OrganizationID: organizationID, - StackID: stackID, - UserID: args[0], - } - - response, err := apiClient.DeleteStackUserAccess(cmd.Context(), request) - if err != nil { - return nil, err - } - - if response.Error != nil { - return nil, fmt.Errorf("error deleting stack user access: %s", response.Error.GetErrorCode()) - } - - return c, nil -} - -func (c *UnlinkController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Stack user access deleted.") - return nil -} diff --git a/cmd/ui/ui.go b/cmd/ui/ui.go deleted file mode 100644 index 0f692721..00000000 --- a/cmd/ui/ui.go +++ /dev/null @@ -1,106 +0,0 @@ -package ui - -import ( - "fmt" - "net/url" - "os/exec" - "runtime" - - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UiStruct struct { - UIUrl string `json:"stackUrl"` - FoundBrowser bool `json:"browserFound"` -} - -type UiController struct { - store *UiStruct -} - -var _ fctl.Controller[*UiStruct] = (*UiController)(nil) - -func NewDefaultUiStore() *UiStruct { - return &UiStruct{ - UIUrl: fctl.DefaultConsoleURL, - FoundBrowser: false, - } -} - -func NewUiController() *UiController { - return &UiController{ - store: NewDefaultUiStore(), - } -} - -func openUrl(urlString string) error { - var ( - cmd string - args []string - ) - - if _, err := url.Parse(urlString); err != nil { - return fmt.Errorf("invalid URL: %s", urlString) - } - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - args = append(args, urlString) - return exec.Command(cmd, args...).Start() //nolint:gosec -} - -func (c *UiController) GetStore() *UiStruct { - return c.store -} - -func (c *UiController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - _, apiClient, err := fctl.NewMembershipClientForOrganizationFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - serverInfo, err := fctl.MembershipServerInfo(cmd.Context(), apiClient) - if err != nil { - return nil, err - } - - if v := serverInfo.GetConsoleURL(); v != nil { - c.store.UIUrl = *v - } - - if err := openUrl(c.store.UIUrl); err != nil { - c.store.FoundBrowser = true - } - - return c, nil -} - -func (c *UiController) Render(cmd *cobra.Command, args []string) error { - fmt.Println("Opening url: ", c.store.UIUrl) - - return nil -} - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("ui", - fctl.WithShortDescription("Open UI"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*UiStruct](NewUiController()), - ) -} diff --git a/cmd/version/version.go b/cmd/version/version.go index 49105358..acd27aa3 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -1,68 +1,26 @@ package version import ( - "github.com/pterm/pterm" - "github.com/spf13/cobra" + "fmt" - fctl "github.com/formancehq/fctl/v3/pkg" + "github.com/spf13/cobra" ) +// Release builds inject these values through GoReleaser ldflags. var ( - Version = "develop" + Version = "v4.0.0-dev" Commit = "-" BuildDate = "-" ) -type Store struct { - Version string `json:"version"` - BuildDate string `json:"buildDate"` - Commit string `json:"commit"` -} -type Controller struct { - store *Store -} - -var _ fctl.Controller[*Store] = (*Controller)(nil) - -func NewDefaultVersionStore() *Store { - return &Store{ - Version: Version, - BuildDate: BuildDate, - Commit: Commit, - } -} - -func NewVersionController() *Controller { - return &Controller{ - store: NewDefaultVersionStore(), - } -} - func NewCommand() *cobra.Command { - return fctl.NewCommand("version", - fctl.WithShortDescription("Get version"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*Store](NewVersionController()), - ) -} - -func (c *Controller) GetStore() *Store { - return c.store -} - -func (c *Controller) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - return c, nil -} - -// TODO: This need to use the ui.NewListModel -func (c *Controller) Render(cmd *cobra.Command, args []string) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Version"), c.store.Version}) - tableData = append(tableData, []string{pterm.LightCyan("Date"), c.store.BuildDate}) - tableData = append(tableData, []string{pterm.LightCyan("Commit"), c.store.Commit}) - return pterm.DefaultTable. - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() + return &cobra.Command{ + Use: "version", + Short: "Print fctl version and build information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "fctl %s (commit: %s, built: %s)\n", Version, Commit, BuildDate) + return err + }, + } } diff --git a/cmd/wallets/balances/create.go b/cmd/wallets/balances/create.go deleted file mode 100644 index dbdb92be..00000000 --- a/cmd/wallets/balances/create.go +++ /dev/null @@ -1,109 +0,0 @@ -package balances - -import ( - "fmt" - "math/big" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - BalanceName string `json:"balanceName"` -} -type CreateController struct { - store *CreateStore -} - -const expiresAtFlag = "expires-at" - -const priorityFlag = "priority" - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - } -} - -func NewCreateCommand() *cobra.Command { - return fctl.NewCommand("create ", - fctl.WithShortDescription("Create a new balance"), - fctl.WithAliases("c", "cr"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - internal.WithTargetingWalletByID(), - internal.WithTargetingWalletByName(), - fctl.WithStringFlag(expiresAtFlag, "", "Balance expiration date"), - fctl.WithIntFlag(priorityFlag, 0, "Balance priority"), - fctl.WithController[*CreateStore](NewCreateController()), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RequireWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - expiresAt, err := fctl.GetDateTime(cmd, expiresAtFlag) - if err != nil { - return nil, err - } - - var priority *big.Int = nil - priorityInt := fctl.GetInt(cmd, priorityFlag) - if priorityInt != 0 { - priority = big.NewInt(int64(priorityInt)) - } - - request := operations.CreateBalanceRequest{ - ID: walletID, - Balance: &wallets.Balance{ - Name: args[0], - ExpiresAt: expiresAt, - Priority: priority, - }, - } - response, err := stackClient.Wallets.V1.CreateBalance(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("creating balance: %w", err) - } - - c.store.BalanceName = response.CreateBalanceResponse.Balance.Name - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln( - "Balance created successfully with name: %s", c.store.BalanceName) - return nil - -} diff --git a/cmd/wallets/balances/list.go b/cmd/wallets/balances/list.go deleted file mode 100644 index f6b5fdf4..00000000 --- a/cmd/wallets/balances/list.go +++ /dev/null @@ -1,102 +0,0 @@ -package balances - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - BalancesNames [][]string `json:"balancesNames"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List balances"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - internal.WithTargetingWalletByName(), - internal.WithTargetingWalletByID(), - fctl.WithController[*ListStore](NewListController()), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RequireWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - request := operations.ListBalancesRequest{ - ID: walletID, - } - response, err := stackClient.Wallets.V1.ListBalances(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("listing balance: %w", err) - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.BalancesNames = fctl.Map(response.ListBalancesResponse.Cursor.Data, func(balance wallets.Balance) []string { - return []string{ - balance.Name, - } - }) - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.BalancesNames) == 0 { - fctl.Println("No balances found.") - return nil - } - - tableData := fctl.Prepend(c.store.BalancesNames, []string{"Name"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/wallets/balances/root.go b/cmd/wallets/balances/root.go deleted file mode 100644 index cddeeecb..00000000 --- a/cmd/wallets/balances/root.go +++ /dev/null @@ -1,19 +0,0 @@ -package balances - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("balances", - fctl.WithAliases("balance", "bls", "bal"), - fctl.WithShortDescription("Wallet balances"), - fctl.WithChildCommands( - NewListCommand(), - NewShowCommand(), - NewCreateCommand(), - ), - ) -} diff --git a/cmd/wallets/balances/show.go b/cmd/wallets/balances/show.go deleted file mode 100644 index 5adc3444..00000000 --- a/cmd/wallets/balances/show.go +++ /dev/null @@ -1,84 +0,0 @@ -package balances - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - "github.com/formancehq/fctl/v3/cmd/wallets/internal/views" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Balance wallets.AssetHolder1 `json:"balance"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a balance"), - fctl.WithAliases("sh"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - internal.WithTargetingWalletByID(), - internal.WithTargetingWalletByName(), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RequireWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - request := operations.GetBalanceRequest{ - ID: walletID, - BalanceName: args[0], - } - response, err := stackClient.Wallets.V1.GetBalance(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("getting balance: %w", err) - } - - c.store.Balance = response.GetBalanceResponse.AssetHolder - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - return views.PrintBalance(cmd.OutOrStdout(), c.store.Balance) -} diff --git a/cmd/wallets/create.go b/cmd/wallets/create.go deleted file mode 100644 index 7f920794..00000000 --- a/cmd/wallets/create.go +++ /dev/null @@ -1,98 +0,0 @@ -package wallets - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - walletsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreateStore struct { - WalletID string `json:"walletId"` -} -type CreateController struct { - store *CreateStore - metadataFlag string - ikFlag string -} - -var _ fctl.Controller[*CreateStore] = (*CreateController)(nil) - -func NewDefaultCreateStore() *CreateStore { - return &CreateStore{} -} - -func NewCreateController() *CreateController { - return &CreateController{ - store: NewDefaultCreateStore(), - metadataFlag: "metadata", - ikFlag: "ik", - } -} - -func NewCreateCommand() *cobra.Command { - c := NewCreateController() - return fctl.NewCommand("create ", - fctl.WithShortDescription("Create a new wallet"), - fctl.WithAliases("cr"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency key"), - fctl.WithController[*CreateStore](c), - ) -} - -func (c *CreateController) GetStore() *CreateStore { - return c.store -} - -func (c *CreateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a wallet") { - return nil, fctl.ErrMissingApproval - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - request := operations.CreateWalletRequest{ - CreateWalletRequest: &walletsmodels.CreateWalletRequest{ - Name: args[0], - Metadata: metadata, - }, - IdempotencyKey: fctl.Ptr(fctl.GetString(cmd, c.ikFlag)), - } - response, err := stackClient.Wallets.V1.CreateWallet(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("creating wallet: %w", err) - } - - c.store.WalletID = response.CreateWalletResponse.Wallet.ID - - return c, nil -} - -func (c *CreateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln( - "Wallet created successfully with ID: %s", c.store.WalletID) - return nil -} diff --git a/cmd/wallets/credit.go b/cmd/wallets/credit.go deleted file mode 100644 index 910e9018..00000000 --- a/cmd/wallets/credit.go +++ /dev/null @@ -1,138 +0,0 @@ -package wallets - -import ( - "errors" - "fmt" - "math/big" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - walletsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type CreditWalletStore struct { - Success bool `json:"success"` -} -type CreditWalletController struct { - store *CreditWalletStore - metadataFlag string - balanceFlag string - sourceFlag string - ikFlag string -} - -var _ fctl.Controller[*CreditWalletStore] = (*CreditWalletController)(nil) - -func NewDefaultCreditWalletStore() *CreditWalletStore { - return &CreditWalletStore{} -} - -func NewCreditWalletController() *CreditWalletController { - return &CreditWalletController{ - store: NewDefaultCreditWalletStore(), - metadataFlag: "metadata", - balanceFlag: "balance", - sourceFlag: "source", - ikFlag: "ik", - } -} -func NewCreditWalletCommand() *cobra.Command { - c := NewCreditWalletController() - return fctl.NewCommand("credit ", - fctl.WithShortDescription("Credit a wallet"), - fctl.WithAliases("cr"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithStringFlag(c.balanceFlag, "", "Balance to credit"), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency Key"), - fctl.WithStringSliceFlag(c.sourceFlag, []string{}, `Use --source account= | --source wallet=id:[/] | --source wallet=name:[/]`), - internal.WithTargetingWalletByName(), - internal.WithTargetingWalletByID(), - fctl.WithController[*CreditWalletStore](c), - ) -} - -func (c *CreditWalletController) GetStore() *CreditWalletStore { - return c.store -} - -func (c *CreditWalletController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckStackApprobation(cmd, "You are about to credit a wallet") { - return nil, fctl.ErrMissingApproval - } - - amountStr := args[0] - asset := args[1] - walletID, err := internal.RetrieveWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - if walletID == "" { - return nil, errors.New("You need to specify wallet id using --id or --name flags") - } - - amount, ok := big.NewInt(0).SetString(amountStr, 10) - if !ok { - return nil, fmt.Errorf("unable to parse '%s' as big int", amountStr) - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - sources := make([]walletsmodels.Subject, 0) - for _, sourceStr := range fctl.GetStringSlice(cmd, c.sourceFlag) { - source, err := internal.ParseSubject(sourceStr, cmd, stackClient) - if err != nil { - return nil, err - } - sources = append(sources, *source) - } - - request := operations.CreditWalletRequest{ - IdempotencyKey: fctl.Ptr(fctl.GetString(cmd, c.ikFlag)), - ID: walletID, - CreditWalletRequest: &walletsmodels.CreditWalletRequest{ - Monetary: walletsmodels.Monetary{ - Asset: asset, - Amount: amount, - }, - Metadata: metadata, - Sources: sources, - Balance: formance.String(fctl.GetString(cmd, c.balanceFlag)), - }, - } - _, err = stackClient.Wallets.V1.CreditWallet(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("crediting wallet: %w", err) - } - - c.store.Success = true - - return c, nil -} - -func (c *CreditWalletController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Wallet credited successfully!") - return nil -} diff --git a/cmd/wallets/debit.go b/cmd/wallets/debit.go deleted file mode 100644 index b13634e3..00000000 --- a/cmd/wallets/debit.go +++ /dev/null @@ -1,158 +0,0 @@ -package wallets - -import ( - "fmt" - "math/big" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - walletsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DebitWalletStore struct { - HoldID *string `json:"holdId"` - Success bool `json:"success"` -} -type DebitWalletController struct { - store *DebitWalletStore - pendingFlag string - metadataFlag string - descriptionFlag string - balanceFlag string - destinationFlag string - ikFlag string -} - -var _ fctl.Controller[*DebitWalletStore] = (*DebitWalletController)(nil) - -func NewDefaultDebitWalletStore() *DebitWalletStore { - return &DebitWalletStore{ - HoldID: nil, - Success: false, - } -} - -func NewDebitWalletController() *DebitWalletController { - return &DebitWalletController{ - store: NewDefaultDebitWalletStore(), - pendingFlag: "pending", - metadataFlag: "metadata", - descriptionFlag: "description", - balanceFlag: "balance", - destinationFlag: "destination", - ikFlag: "ik", - } -} - -func NewDebitWalletCommand() *cobra.Command { - c := NewDebitWalletController() - return fctl.NewCommand("debit ", - fctl.WithShortDescription("Debit a wallet"), - fctl.WithAliases("deb"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.RangeArgs(2, 3)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(c.descriptionFlag, "", "Debit description"), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency Key"), - fctl.WithBoolFlag(c.pendingFlag, false, "Create a pending debit"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithStringSliceFlag(c.balanceFlag, []string{""}, "Balance to debit"), - fctl.WithStringFlag(c.destinationFlag, "", - `Use --destination account= | --destination wallet=id:[/] | --destination wallet=name:[/]`), - internal.WithTargetingWalletByName(), - internal.WithTargetingWalletByID(), - fctl.WithController[*DebitWalletStore](c), - ) -} - -func (c *DebitWalletController) GetStore() *DebitWalletStore { - return c.store -} - -func (c *DebitWalletController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to debit a wallet") { - return nil, fctl.ErrMissingApproval - } - - pending := fctl.GetBool(cmd, c.pendingFlag) - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - amountStr := args[0] - asset := args[1] - walletID, err := internal.RequireWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - description := fctl.GetString(cmd, c.descriptionFlag) - - amount, ok := big.NewInt(0).SetString(amountStr, 10) - if !ok { - return nil, fmt.Errorf("unable to parse '%s' as big int", amountStr) - } - - var destination *walletsmodels.Subject - if destinationStr := fctl.GetString(cmd, c.destinationFlag); destinationStr != "" { - destination, err = internal.ParseSubject(destinationStr, cmd, stackClient) - if err != nil { - return nil, err - } - } - - response, err := stackClient.Wallets.V1.DebitWallet(cmd.Context(), operations.DebitWalletRequest{ - DebitWalletRequest: &walletsmodels.DebitWalletRequest{ - Monetary: walletsmodels.Monetary{ - Asset: asset, - Amount: amount, - }, - Pending: &pending, - Metadata: metadata, - Description: &description, - Subject: destination, - Balances: fctl.GetStringSlice(cmd, c.balanceFlag), - }, - ID: walletID, - }) - if err != nil { - return nil, fmt.Errorf("debiting wallet: %w", err) - } - - if response.DebitWalletResponse != nil { - c.store.HoldID = &response.DebitWalletResponse.Hold.ID - } - - c.store.Success = true - - return c, nil -} - -func (c *DebitWalletController) Render(cmd *cobra.Command, args []string) error { - if c.store.HoldID != nil && *c.store.HoldID != "" { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Wallet debited successfully with hold ID: %s.", *c.store.HoldID) - } else { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Wallet debited successfully!") - } - - return nil - -} diff --git a/cmd/wallets/holds/confirm.go b/cmd/wallets/holds/confirm.go deleted file mode 100644 index 0b077f0f..00000000 --- a/cmd/wallets/holds/confirm.go +++ /dev/null @@ -1,98 +0,0 @@ -package holds - -import ( - "fmt" - "math/big" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ConfirmStore struct { - Success bool `json:"success"` - HoldId string `json:"holdId"` -} -type ConfirmController struct { - store *ConfirmStore - finalFlag string - amountFlag string - ikFlag string -} - -var _ fctl.Controller[*ConfirmStore] = (*ConfirmController)(nil) - -func NewDefaultConfirmStore() *ConfirmStore { - return &ConfirmStore{} -} - -func NewConfirmController() *ConfirmController { - return &ConfirmController{ - store: NewDefaultConfirmStore(), - finalFlag: "final", - amountFlag: "amount", - ikFlag: "ik", - } -} - -func NewConfirmCommand() *cobra.Command { - c := NewConfirmController() - return fctl.NewCommand("confirm ", - fctl.WithShortDescription("Confirm a hold"), - fctl.WithAliases("c", "conf"), - fctl.WithArgs(cobra.RangeArgs(1, 2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithBoolFlag(c.finalFlag, false, "Is final debit (close hold)"), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency Key"), - fctl.WithIntFlag(c.amountFlag, 0, "Amount to confirm"), - fctl.WithController[*ConfirmStore](c), - ) -} - -func (c *ConfirmController) GetStore() *ConfirmStore { - return c.store -} - -func (c *ConfirmController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - final := fctl.GetBool(cmd, c.finalFlag) - amount := int64(fctl.GetInt(cmd, c.amountFlag)) - - request := operations.ConfirmHoldRequest{ - HoldID: args[0], - ConfirmHoldRequest: &wallets.ConfirmHoldRequest{ - Amount: big.NewInt(amount), - Final: &final, - }, - } - _, err = stackClient.Wallets.V1.ConfirmHold(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("confirming hold: %w", err) - } - - c.store.Success = true //Todo: check status code - c.store.HoldId = args[0] - - return c, nil -} - -func (c *ConfirmController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Hold '%s' confirmed.", args[0]) - - return nil - -} diff --git a/cmd/wallets/holds/list.go b/cmd/wallets/holds/list.go deleted file mode 100644 index 3bfdbb9c..00000000 --- a/cmd/wallets/holds/list.go +++ /dev/null @@ -1,119 +0,0 @@ -package holds - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Holds []wallets.Hold `json:"holds"` -} -type ListController struct { - store *ListStore - metadataFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - metadataFlag: "metadata", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithShortDescription("List holds of a wallet"), - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.RangeArgs(0, 1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - internal.WithTargetingWalletByName(), - internal.WithTargetingWalletByID(), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RetrieveWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - request := operations.GetHoldsRequest{ - WalletID: &walletID, - Metadata: metadata, - } - response, err := stackClient.Wallets.V1.GetHolds(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("getting holds: %w", err) - } - - c.store.Holds = response.GetHoldsResponse.Cursor.Data - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Holds) == 0 { - fctl.Println("No holds found.") - return nil - } - - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.Holds, - func(src wallets.Hold) []string { - return []string{ - src.ID, - src.WalletID, - src.Description, - fctl.MetadataAsShortString(src.Metadata), - } - }), - []string{"ID", "Wallet ID", "Description", "Metadata"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - - return nil - -} diff --git a/cmd/wallets/holds/root.go b/cmd/wallets/holds/root.go deleted file mode 100644 index 68680a19..00000000 --- a/cmd/wallets/holds/root.go +++ /dev/null @@ -1,20 +0,0 @@ -package holds - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("holds", - fctl.WithAliases("h", "hold"), - fctl.WithShortDescription("Manage wallet holds"), - fctl.WithChildCommands( - NewListCommand(), - NewVoidCommand(), - NewConfirmCommand(), - NewShowCommand(), - ), - ) -} diff --git a/cmd/wallets/holds/show.go b/cmd/wallets/holds/show.go deleted file mode 100644 index 0d13e1c6..00000000 --- a/cmd/wallets/holds/show.go +++ /dev/null @@ -1,77 +0,0 @@ -package holds - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal/views" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Hold wallets.Hold1 `json:"hold"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithShortDescription("Show a hold"), - fctl.WithAliases("sh"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ShowStore](NewShowController()), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - request := operations.GetHoldRequest{ - HoldID: args[0], - } - response, err := stackClient.Wallets.V1.GetHold(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("getting hold: %w", err) - } - - c.store.Hold = response.GetHoldResponse.Hold - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - - return views.PrintHold(cmd.OutOrStdout(), c.store.Hold) - -} diff --git a/cmd/wallets/holds/void.go b/cmd/wallets/holds/void.go deleted file mode 100644 index 99b065d0..00000000 --- a/cmd/wallets/holds/void.go +++ /dev/null @@ -1,83 +0,0 @@ -package holds - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type VoidStore struct { - Success bool `json:"success"` - HoldId string `json:"holdId"` -} -type VoidController struct { - store *VoidStore - ikFlag string -} - -var _ fctl.Controller[*VoidStore] = (*VoidController)(nil) - -func NewDefaultVoidStore() *VoidStore { - return &VoidStore{} -} - -func NewVoidController() *VoidController { - return &VoidController{ - store: NewDefaultVoidStore(), - ikFlag: "ik", - } -} - -func NewVoidCommand() *cobra.Command { - c := NewVoidController() - return fctl.NewCommand("void ", - fctl.WithShortDescription("Void a hold"), - fctl.WithAliases("v"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency Key"), - fctl.WithController[*VoidStore](c), - ) -} - -func (c *VoidController) GetStore() *VoidStore { - return c.store -} - -func (c *VoidController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - request := operations.VoidHoldRequest{ - IdempotencyKey: fctl.Ptr(fctl.GetString(cmd, c.ikFlag)), - HoldID: args[0], - } - _, err = stackClient.Wallets.V1.VoidHold(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("voiding hold: %w", err) - } - - c.store.Success = true - c.store.HoldId = args[0] - - return c, nil -} - -func (c *VoidController) Render(cmd *cobra.Command, args []string) error { - - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Hold '%s' voided.", args[0]) - - return nil -} diff --git a/cmd/wallets/internal/command.go b/cmd/wallets/internal/command.go deleted file mode 100644 index 1fe7df3c..00000000 --- a/cmd/wallets/internal/command.go +++ /dev/null @@ -1,82 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - walletNameFlag = "name" - walletIDFlag = "id" -) - -var ( - ErrUndefinedName = errors.New("missing wallet name") -) - -func WithTargetingWalletByName() fctl.CommandOptionFn { - return fctl.WithStringFlag(walletNameFlag, "", "Wallet name to use") -} - -func WithTargetingWalletByID() fctl.CommandOptionFn { - return fctl.WithStringFlag(walletIDFlag, "", "Wallet ID to use") -} - -func DiscoverWalletIDFromName(cmd *cobra.Command, client *formance.Formance, walletName string) (string, error) { - request := operations.ListWalletsRequest{ - Name: &walletName, - } - wallets, err := client.Wallets.V1.ListWallets(cmd.Context(), request) - if err != nil { - return "", fmt.Errorf("listing wallets to retrieve wallet by name: %w", err) - } - - if wallets.StatusCode >= 300 { - return "", fmt.Errorf("unexpected status code: %d", wallets.StatusCode) - } - - if len(wallets.ListWalletsResponse.Cursor.Data) > 1 { - return "", fmt.Errorf("found multiple wallets with name: %s", walletName) - } - if len(wallets.ListWalletsResponse.Cursor.Data) == 0 { - return "", fmt.Errorf("wallet with name '%s' not found", walletName) - } - return wallets.ListWalletsResponse.Cursor.Data[0].ID, nil -} - -func RetrieveWalletIDFromName(cmd *cobra.Command, client *formance.Formance) (string, error) { - walletName := fctl.GetString(cmd, walletNameFlag) - if walletName == "" { - return "", ErrUndefinedName - } - return DiscoverWalletIDFromName(cmd, client, walletName) -} - -func RetrieveWalletID(cmd *cobra.Command, client *formance.Formance) (string, error) { - walletID, err := RetrieveWalletIDFromName(cmd, client) - if err != nil && err != ErrUndefinedName { - return "", err - } - if err == ErrUndefinedName { - return fctl.GetString(cmd, walletIDFlag), nil - } - return walletID, nil -} - -func RequireWalletID(cmd *cobra.Command, client *formance.Formance) (string, error) { - walletID, err := RetrieveWalletID(cmd, client) - if err != nil { - return "", err - } - if walletID == "" { - return "", errors.New("You need to specify wallet id using --id or --name flags") - } - return walletID, nil -} diff --git a/cmd/wallets/internal/subject.go b/cmd/wallets/internal/subject.go deleted file mode 100644 index 42c0d9fe..00000000 --- a/cmd/wallets/internal/subject.go +++ /dev/null @@ -1,49 +0,0 @@ -package internal - -import ( - "errors" - "strings" - - "github.com/spf13/cobra" - - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" -) - -func ParseSubject(subject string, cmd *cobra.Command, client *formance.Formance) (*wallets.Subject, error) { - var err error - switch { - case strings.HasPrefix(subject, "wallet="): - walletDefinition := strings.TrimPrefix(subject, "wallet=") - parts := strings.SplitN(walletDefinition, "/", 2) - balance := "main" - if len(parts) > 1 { - balance = parts[1] - } - - var walletID string - switch { - case strings.HasPrefix(walletDefinition, "id:"): - walletID = strings.TrimPrefix(parts[0], "id:") - case strings.HasPrefix(walletDefinition, "name:"): - walletID, err = DiscoverWalletIDFromName(cmd, client, strings.TrimPrefix(parts[0], "name:")) - if err != nil { - return nil, err - } - default: - return nil, errors.New("malformed wallet source definition") - } - subject := wallets.CreateSubjectWalletSubject(wallets.WalletSubject{ - Identifier: walletID, - Balance: &balance, - }) - return &subject, nil - case strings.HasPrefix(subject, "account="): - subject := wallets.CreateSubjectLedgerAccountSubject(wallets.LedgerAccountSubject{ - Identifier: strings.TrimPrefix(subject, "account="), - }) - return &subject, nil - default: - return nil, errors.New("malformed source definition") - } -} diff --git a/cmd/wallets/internal/views/balance.go b/cmd/wallets/internal/views/balance.go deleted file mode 100644 index 3c43f32b..00000000 --- a/cmd/wallets/internal/views/balance.go +++ /dev/null @@ -1,45 +0,0 @@ -package views - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintBalance(out io.Writer, balance wallets.AssetHolder1) error { - fctl.Section.Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("Name"), balance.Name}) - - if err := pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.Println("Assets") - if len(balance.Assets) == 0 { - fctl.Println("No assets found.") - return nil - } - tableData = pterm.TableData{} - tableData = append(tableData, []string{"Asset", "Amount"}) - for asset, amount := range balance.Assets { - tableData = append(tableData, []string{asset, fmt.Sprint(amount)}) - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} diff --git a/cmd/wallets/internal/views/hold.go b/cmd/wallets/internal/views/hold.go deleted file mode 100644 index b3a2cb1f..00000000 --- a/cmd/wallets/internal/views/hold.go +++ /dev/null @@ -1,30 +0,0 @@ -package views - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintHold(out io.Writer, hold wallets.Hold1) error { - fctl.Section.Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), fmt.Sprint(hold.ID)}) - tableData = append(tableData, []string{pterm.LightCyan("Wallet ID"), hold.WalletID}) - tableData = append(tableData, []string{pterm.LightCyan("Original amount"), fmt.Sprint(hold.OriginalAmount)}) - tableData = append(tableData, []string{pterm.LightCyan("Remaining"), fmt.Sprint(hold.Remaining)}) - - if err := pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - return fctl.PrintMetadata(out, hold.Metadata) -} diff --git a/cmd/wallets/internal/views/wallet.go b/cmd/wallets/internal/views/wallet.go deleted file mode 100644 index c80bee04..00000000 --- a/cmd/wallets/internal/views/wallet.go +++ /dev/null @@ -1,54 +0,0 @@ -package views - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func PrintWallet(out io.Writer, wallet wallets.WalletWithBalances) error { - fctl.Section.Println("Information") - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("ID"), fmt.Sprint(wallet.ID)}) - tableData = append(tableData, []string{pterm.LightCyan("Name"), wallet.Name}) - - if err := pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - fctl.Section.Println("Balances") - if len(wallet.Balances.AssetHolder.Assets) == 0 { - fctl.Println("No balances found.") - return nil - } - tableData = pterm.TableData{} - tableData = append(tableData, []string{"Asset", "Amount"}) - for asset, amount := range wallet.Balances.AssetHolder.Assets { - tableData = append(tableData, []string{asset, fmt.Sprint(amount)}) - } - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(out). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} -func PrintWalletWithMetadata(out io.Writer, wallet wallets.WalletWithBalances) error { - err := PrintWallet(out, wallet) - if err != nil { - return err - } - - return fctl.PrintMetadata(out, wallet.Metadata) -} diff --git a/cmd/wallets/list.go b/cmd/wallets/list.go deleted file mode 100644 index dedb4e43..00000000 --- a/cmd/wallets/list.go +++ /dev/null @@ -1,109 +0,0 @@ -package wallets - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - walletsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Wallets []walletsmodels.Wallet `json:"wallets"` -} -type ListController struct { - store *ListStore - metadataFlag string -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - metadataFlag: "metadata", - } -} - -func NewListCommand() *cobra.Command { - c := NewListController() - return fctl.NewCommand("list", - fctl.WithShortDescription("List all wallets"), - fctl.WithAliases("ls", "l"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ListStore](c), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - response, err := stackClient.Wallets.V1.ListWallets(cmd.Context(), operations.ListWalletsRequest{ - Metadata: metadata, - }) - if err != nil { - return nil, fmt.Errorf("listing wallet: %w", err) - } - - if response.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) - } - - c.store.Wallets = response.ListWalletsResponse.Cursor.Data - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Wallets) == 0 { - fctl.Println("No wallets found.") - return nil - } - - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.Wallets, - func(src walletsmodels.Wallet) []string { - return []string{ - src.ID, - src.Name, - } - }), - []string{"ID", "Name"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - return nil -} diff --git a/cmd/wallets/root.go b/cmd/wallets/root.go deleted file mode 100644 index ab5d8401..00000000 --- a/cmd/wallets/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package wallets - -import ( - "github.com/spf13/cobra" - - "github.com/formancehq/fctl/v3/cmd/wallets/balances" - "github.com/formancehq/fctl/v3/cmd/wallets/holds" - "github.com/formancehq/fctl/v3/cmd/wallets/transactions" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("wallets", - fctl.WithAliases("wal", "wa", "wallet"), - fctl.WithShortDescription("Manage wallets"), - fctl.WithChildCommands( - NewCreateCommand(), - NewUpdateCommand(), - NewListCommand(), - NewShowCommand(), - NewCreditWalletCommand(), - NewDebitWalletCommand(), - transactions.NewCommand(), - holds.NewCommand(), - balances.NewCommand(), - ), - ) -} diff --git a/cmd/wallets/show.go b/cmd/wallets/show.go deleted file mode 100644 index 52aaccfe..00000000 --- a/cmd/wallets/show.go +++ /dev/null @@ -1,88 +0,0 @@ -package wallets - -import ( - "errors" - "fmt" - - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - walletsmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - "github.com/formancehq/fctl/v3/cmd/wallets/internal/views" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowStore struct { - Wallet walletsmodels.WalletWithBalances `json:"wallet"` -} -type ShowController struct { - store *ShowStore -} - -var _ fctl.Controller[*ShowStore] = (*ShowController)(nil) - -func NewDefaultShowStore() *ShowStore { - return &ShowStore{} -} - -func NewShowController() *ShowController { - return &ShowController{ - store: NewDefaultShowStore(), - } -} - -func NewShowCommand() *cobra.Command { - c := NewShowController() - return fctl.NewCommand("show", - fctl.WithShortDescription("Show a wallet"), - fctl.WithAliases("sh"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - internal.WithTargetingWalletByID(), - internal.WithTargetingWalletByName(), - fctl.WithController[*ShowStore](c), - ) -} - -func (c *ShowController) GetStore() *ShowStore { - return c.store -} - -func (c *ShowController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RetrieveWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - if walletID == "" { - return nil, errors.New("You need to specify wallet id using --id or --name flags") - } - - response, err := stackClient.Wallets.V1.GetWallet(cmd.Context(), operations.GetWalletRequest{ - ID: walletID, - }) - if err != nil { - return nil, fmt.Errorf("getting wallet: %w", err) - } - - c.store.Wallet = response.GetWalletResponse.WalletWithBalances - - return c, nil -} - -func (c *ShowController) Render(cmd *cobra.Command, args []string) error { - return views.PrintWalletWithMetadata(cmd.OutOrStdout(), c.store.Wallet) -} diff --git a/cmd/wallets/transactions/list.go b/cmd/wallets/transactions/list.go deleted file mode 100644 index ae0bb9c4..00000000 --- a/cmd/wallets/transactions/list.go +++ /dev/null @@ -1,102 +0,0 @@ -package transactions - -import ( - "fmt" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - "github.com/formancehq/formance-sdk-go/v4/pkg/models/wallets" - - "github.com/formancehq/fctl/v3/cmd/wallets/internal" - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListStore struct { - Transactions []wallets.Transaction `json:"transactions"` -} -type ListController struct { - store *ListStore -} - -var _ fctl.Controller[*ListStore] = (*ListController)(nil) - -func NewDefaultListStore() *ListStore { - return &ListStore{} -} - -func NewListController() *ListController { - return &ListController{ - store: NewDefaultListStore(), - } -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List transactions"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ListStore](NewListController()), - internal.WithTargetingWalletByName(), - internal.WithTargetingWalletByID(), - ) -} - -func (c *ListController) GetStore() *ListStore { - return c.store -} - -func (c *ListController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - walletID, err := internal.RetrieveWalletID(cmd, stackClient) - if err != nil { - return nil, err - } - - request := operations.GetTransactionsRequest{ - WalletID: &walletID, - } - response, err := stackClient.Wallets.V1.GetTransactions(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("listing transactions: %w", err) - } - - c.store.Transactions = response.GetTransactionsResponse.Cursor.Data - - return c, nil -} - -func (c *ListController) Render(cmd *cobra.Command, args []string) error { - if len(c.store.Transactions) == 0 { - fctl.Println("No transactions found.") - return nil - } - - tableData := fctl.Map(c.store.Transactions, func(tx wallets.Transaction) []string { - return []string{ - fmt.Sprintf("%d", tx.ID), - tx.Timestamp.Format(time.RFC3339), - fctl.MetadataAsShortString(tx.Metadata), - } - }) - tableData = fctl.Prepend(tableData, []string{"ID", "Date", "Metadata"}) - return pterm.DefaultTable. - WithHasHeader(). - WithWriter(cmd.OutOrStdout()). - WithData(tableData). - Render() - -} diff --git a/cmd/wallets/transactions/root.go b/cmd/wallets/transactions/root.go deleted file mode 100644 index ae5a1a72..00000000 --- a/cmd/wallets/transactions/root.go +++ /dev/null @@ -1,17 +0,0 @@ -package transactions - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewCommand("transactions", - fctl.WithAliases("transaction", "tx", "txs"), - fctl.WithShortDescription("Wallet transactions"), - fctl.WithChildCommands( - NewListCommand(), - ), - ) -} diff --git a/cmd/wallets/update.go b/cmd/wallets/update.go deleted file mode 100644 index d4da41d4..00000000 --- a/cmd/wallets/update.go +++ /dev/null @@ -1,93 +0,0 @@ -package wallets - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateStore struct { - Success bool `json:"success"` -} -type UpdateController struct { - store *UpdateStore - metadataFlag string - ikFlag string -} - -var _ fctl.Controller[*UpdateStore] = (*UpdateController)(nil) - -func NewDefaultUpdateStore() *UpdateStore { - return &UpdateStore{} -} - -func NewUpdateController() *UpdateController { - return &UpdateController{ - store: NewDefaultUpdateStore(), - metadataFlag: "metadata", - ikFlag: "ik", - } -} - -func NewUpdateCommand() *cobra.Command { - c := NewUpdateController() - return fctl.NewCommand("update ", - fctl.WithShortDescription("Update a wallet"), - fctl.WithAliases("up"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(c.ikFlag, "", "Idempotency Key"), - fctl.WithStringSliceFlag(c.metadataFlag, []string{""}, "Metadata to use"), - fctl.WithController[*UpdateStore](c), - ) -} - -func (c *UpdateController) GetStore() *UpdateStore { - return c.store -} - -func (c *UpdateController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to update a wallets") { - return nil, fctl.ErrMissingApproval - } - - metadata, err := fctl.ParseMetadata(fctl.GetStringSlice(cmd, c.metadataFlag)) - if err != nil { - return nil, err - } - - _, err = stackClient.Wallets.V1.UpdateWallet(cmd.Context(), operations.UpdateWalletRequest{ - RequestBody: &operations.UpdateWalletRequestBody{ - Metadata: metadata, - }, - ID: args[0], - }) - if err != nil { - return nil, fmt.Errorf("updating wallet: %w", err) - } - - c.store.Success = true - return c, nil -} - -func (c *UpdateController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Wallet updated successfully!") - return nil -} diff --git a/cmd/webhooks/activate.go b/cmd/webhooks/activate.go deleted file mode 100644 index 5cedf46c..00000000 --- a/cmd/webhooks/activate.go +++ /dev/null @@ -1,77 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ActivateWebhookStore struct { - Success bool `json:"success"` -} -type ActivateWebhookController struct { - store *ActivateWebhookStore -} - -func NewDefaultVersionStore() *ActivateWebhookStore { - return &ActivateWebhookStore{ - Success: true, - } -} -func NewActivateWebhookController() *ActivateWebhookController { - return &ActivateWebhookController{ - store: NewDefaultVersionStore(), - } -} -func (c *ActivateWebhookController) GetStore() *ActivateWebhookStore { - return c.store -} - -func (c *ActivateWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckStackApprobation(cmd, "You are about to activate a webhook") { - return nil, fctl.ErrMissingApproval - } - - request := operations.ActivateConfigRequest{ - ID: args[0], - } - - _, err = stackClient.Webhooks.V1.ActivateConfig(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("activating config: %w", err) - } - - return c, nil -} - -func (*ActivateWebhookController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Config activated successfully") - - return nil -} - -func NewActivateCommand() *cobra.Command { - return fctl.NewCommand("activate ", - fctl.WithShortDescription("Activate a config"), - fctl.WithAliases("ac", "a"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ActivateWebhookStore](NewActivateWebhookController()), - ) -} diff --git a/cmd/webhooks/create.go b/cmd/webhooks/create.go deleted file mode 100644 index 2fb66a63..00000000 --- a/cmd/webhooks/create.go +++ /dev/null @@ -1,97 +0,0 @@ -package webhooks - -import ( - "fmt" - "net/url" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - secretFlag = "secret" -) - -type CreateWebhookController struct { - store *CreateWebhookStore -} - -type CreateWebhookStore struct { - Webhook webhooksmodels.WebhooksConfig `json:"webhook"` -} - -var _ fctl.Controller[*CreateWebhookStore] = (*CreateWebhookController)(nil) - -func NewDefaultCreateWebhookStore() *CreateWebhookStore { - return &CreateWebhookStore{ - Webhook: webhooksmodels.WebhooksConfig{}, - } -} - -func NewCreateWebhookController() *CreateWebhookController { - return &CreateWebhookController{ - store: NewDefaultCreateWebhookStore(), - } -} -func (c *CreateWebhookController) GetStore() *CreateWebhookStore { - return c.store -} - -func (c *CreateWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to create a webhook") { - return nil, fctl.ErrMissingApproval - } - - if _, err := url.Parse(args[0]); err != nil { - return nil, fmt.Errorf("invalid endpoint URL: %w", err) - } - - secret := fctl.GetString(cmd, secretFlag) - - response, err := stackClient.Webhooks.V1.InsertConfig(cmd.Context(), webhooksmodels.ConfigUser{ - Endpoint: args[0], - EventTypes: args[1:], - Secret: &secret, - }) - - if err != nil { - return nil, fmt.Errorf("creating config: %w", err) - } - - c.store.Webhook = response.ConfigResponse.WebhooksConfig - - return c, nil -} - -func (c *CreateWebhookController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Config created successfully") - return nil -} - -func NewCreateCommand() *cobra.Command { - - return fctl.NewCommand("create [...]", - fctl.WithShortDescription("Create a new config. At least one event type is required."), - fctl.WithAliases("cr"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.MinimumNArgs(2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(secretFlag, "", "Bring your own webhooks signing secret. If not passed or empty, a secret is automatically generated. The format is a string of bytes of size 24, base64 encoded. (larger size after encoding)"), - fctl.WithController[*CreateWebhookStore](NewCreateWebhookController()), - ) -} diff --git a/cmd/webhooks/deactivate.go b/cmd/webhooks/deactivate.go deleted file mode 100644 index 95048e29..00000000 --- a/cmd/webhooks/deactivate.go +++ /dev/null @@ -1,84 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DesactivateWebhookStore struct { - Success bool `json:"success"` -} - -type DesactivateWebhookController struct { - store *DesactivateWebhookStore -} - -var _ fctl.Controller[*DesactivateWebhookStore] = (*DesactivateWebhookController)(nil) - -func NewDefaultDesactivateWebhookStore() *DesactivateWebhookStore { - return &DesactivateWebhookStore{ - Success: true, - } -} - -func NewDesactivateWebhookController() *DesactivateWebhookController { - return &DesactivateWebhookController{ - store: NewDefaultDesactivateWebhookStore(), - } -} -func (c *DesactivateWebhookController) GetStore() *DesactivateWebhookStore { - return c.store -} - -func (c *DesactivateWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to deactivate a webhook") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeactivateConfigRequest{ - ID: args[0], - } - response, err := stackClient.Webhooks.V1.DeactivateConfig(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("deactivating config: %w", err) - } - - c.store.Success = !response.ConfigResponse.WebhooksConfig.Active - - return c, nil -} - -func (c *DesactivateWebhookController) Render(cmd *cobra.Command, args []string) error { - - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Config deactivated successfully") - - return nil -} - -func NewDeactivateCommand() *cobra.Command { - return fctl.NewCommand("deactivate ", - fctl.WithShortDescription("Deactivate a config"), - fctl.WithConfirmFlag(), - fctl.WithAliases("deac"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*DesactivateWebhookStore](NewDesactivateWebhookController()), - ) -} diff --git a/cmd/webhooks/delete.go b/cmd/webhooks/delete.go deleted file mode 100644 index 46c1c2ce..00000000 --- a/cmd/webhooks/delete.go +++ /dev/null @@ -1,96 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type DeleteWebhookStore struct { - ErrorResponse *webhooksmodels.ErrorResponse `json:"error"` - Success bool `json:"success"` -} - -type DeleteWebhookController struct { - store *DeleteWebhookStore -} - -var _ fctl.Controller[*DeleteWebhookStore] = (*DeleteWebhookController)(nil) - -func NewDefaultDeleteWebhookStore() *DeleteWebhookStore { - return &DeleteWebhookStore{ - Success: true, - } -} - -func NewDeleteWebhookController() *DeleteWebhookController { - return &DeleteWebhookController{ - store: NewDefaultDeleteWebhookStore(), - } -} - -func (c *DeleteWebhookController) GetStore() *DeleteWebhookStore { - return c.store -} - -func (c *DeleteWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to delete a webhook") { - return nil, fctl.ErrMissingApproval - } - - request := operations.DeleteConfigRequest{ - ID: args[0], - } - _, err = stackClient.Webhooks.V1.DeleteConfig(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("deleting config: %w", err) - } - - c.store.Success = true - - return c, nil -} - -func (c *DeleteWebhookController) Render(cmd *cobra.Command, args []string) error { - if !c.store.Success { - pterm.Warning.WithShowLineNumber(false).Printfln("Config %s not found", args[0]) - return nil - } - - if c.store.ErrorResponse != nil { - pterm.Warning.WithShowLineNumber(false).Printf("%s\r\n", c.store.ErrorResponse.ErrorMessage) - return nil - } - - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Config deleted successfully") - - return nil -} - -func NewDeleteCommand() *cobra.Command { - return fctl.NewCommand("delete ", - fctl.WithShortDescription("Delete a config"), - fctl.WithConfirmFlag(), - fctl.WithAliases("del"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController(NewDeleteWebhookController()), - ) -} diff --git a/cmd/webhooks/deliveries.go b/cmd/webhooks/deliveries.go deleted file mode 100644 index bde44757..00000000 --- a/cmd/webhooks/deliveries.go +++ /dev/null @@ -1,346 +0,0 @@ -package webhooks - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -const ( - deliveryStatusFlag = "status" - configIDFlag = "config-id" - createdAtFromFlag = "created-at-from" - createdAtToFlag = "created-at-to" - idempotencyKeyFlag = "idempotency-key" - endpointFlag = "endpoint" - maxDeliveryPageSize = 1000 -) - -type DeliveryStatus string - -const ( - DeliveryStatusPending DeliveryStatus = "pending" - DeliveryStatusDelivering DeliveryStatus = "delivering" - DeliveryStatusSucceeded DeliveryStatus = "succeeded" - DeliveryStatusFailed DeliveryStatus = "failed" - DeliveryStatusCancelled DeliveryStatus = "cancelled" -) - -type Delivery struct { - ID string `json:"id"` - EventID string `json:"eventID"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - ConfigID string `json:"configID"` - EventType string `json:"eventType"` - Payload string `json:"payload,omitempty"` - Status DeliveryStatus `json:"status"` - AttemptCount int `json:"attemptCount"` - ReplayGeneration int `json:"replayGeneration"` - CycleStartedAt *time.Time `json:"cycleStartedAt,omitempty"` - NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` - ClaimedAt *time.Time `json:"claimedAt,omitempty"` - LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"` - LastStatusCode *int `json:"lastStatusCode,omitempty"` - LastError string `json:"lastError,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type DeliveryAttempt struct { - ID string `json:"id"` - DeliveryID string `json:"deliveryID"` - AttemptNumber int `json:"attemptNumber"` - ReplayGeneration int `json:"replayGeneration"` - Endpoint string `json:"endpoint"` - Outcome string `json:"outcome"` - StatusCode int `json:"statusCode"` - Error string `json:"error,omitempty"` - DurationMillis int64 `json:"durationMillis,omitempty"` - ResponseExcerpt string `json:"responseExcerpt,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -type deliveryCursor[T any] struct { - PageSize int `json:"pageSize"` - HasMore bool `json:"hasMore"` - Next *string `json:"next,omitempty"` - Data []T `json:"data"` -} - -type deliveriesResponse struct { - Cursor deliveryCursor[Delivery] `json:"cursor"` -} - -type deliveryResponse struct { - Data Delivery `json:"data"` -} - -type deliveryAttemptsResponse struct { - Cursor deliveryCursor[DeliveryAttempt] `json:"cursor"` -} - -type replayDeliveriesRequest struct { - CreatedAtFrom time.Time `json:"createdAtFrom"` - CreatedAtTo *time.Time `json:"createdAtTo,omitempty"` - Statuses []DeliveryStatus `json:"statuses,omitempty"` - ConfigIDs []string `json:"configIds,omitempty"` - Cursor *string `json:"cursor,omitempty"` - PageSize int `json:"pageSize,omitempty"` -} - -type ReplayDeliveriesResult struct { - Replayed int `json:"replayed"` - Expedited int `json:"expedited"` - Skipped int `json:"skipped"` - HasMore bool `json:"hasMore"` - NextCursor *string `json:"nextCursor,omitempty"` - CreatedAtTo time.Time `json:"createdAtTo"` -} - -type replayDeliveriesResponse struct { - Data ReplayDeliveriesResult `json:"data"` -} - -type listDeliveriesParams struct { - ConfigID string - Status DeliveryStatus - CreatedAtFrom *time.Time - CreatedAtTo *time.Time - Cursor string - PageSize int -} - -type deliveriesAPI struct { - baseURL string - httpClient *http.Client -} - -func newDeliveriesAPI(baseURL string, httpClient *http.Client) *deliveriesAPI { - return &deliveriesAPI{baseURL: baseURL, httpClient: httpClient} -} - -func (c *deliveriesAPI) list(ctx context.Context, params listDeliveriesParams) (*deliveriesResponse, error) { - query := url.Values{} - query.Set("pageSize", strconv.Itoa(params.PageSize)) - if params.ConfigID != "" { - query.Set("configId", params.ConfigID) - } - if params.Status != "" { - query.Set("status", string(params.Status)) - } - if params.CreatedAtFrom != nil { - query.Set("createdAtFrom", params.CreatedAtFrom.Format(time.RFC3339)) - } - if params.CreatedAtTo != nil { - query.Set("createdAtTo", params.CreatedAtTo.Format(time.RFC3339)) - } - if params.Cursor != "" { - query.Set("cursor", params.Cursor) - } - - response := &deliveriesResponse{} - if err := c.do(ctx, http.MethodGet, "/api/webhooks/deliveries", query, "", nil, response); err != nil { - return nil, err - } - return response, nil -} - -func (c *deliveriesAPI) get(ctx context.Context, id string) (*deliveryResponse, error) { - response := &deliveryResponse{} - if err := c.do(ctx, http.MethodGet, "/api/webhooks/deliveries/"+url.PathEscape(id), nil, "", nil, response); err != nil { - return nil, err - } - return response, nil -} - -func (c *deliveriesAPI) attempts(ctx context.Context, id, cursor string, pageSize int) (*deliveryAttemptsResponse, error) { - query := url.Values{"pageSize": []string{strconv.Itoa(pageSize)}} - if cursor != "" { - query.Set("cursor", cursor) - } - response := &deliveryAttemptsResponse{} - if err := c.do(ctx, http.MethodGet, "/api/webhooks/deliveries/"+url.PathEscape(id)+"/attempts", query, "", nil, response); err != nil { - return nil, err - } - return response, nil -} - -func (c *deliveriesAPI) replay(ctx context.Context, id, idempotencyKey string) (*deliveryResponse, error) { - response := &deliveryResponse{} - if err := c.do(ctx, http.MethodPost, "/api/webhooks/deliveries/"+url.PathEscape(id)+"/replay", nil, idempotencyKey, nil, response); err != nil { - return nil, err - } - return response, nil -} - -func (c *deliveriesAPI) replayMany(ctx context.Context, idempotencyKey string, body replayDeliveriesRequest) (*replayDeliveriesResponse, error) { - response := &replayDeliveriesResponse{} - if err := c.do(ctx, http.MethodPost, "/api/webhooks/deliveries/replay", nil, idempotencyKey, body, response); err != nil { - return nil, err - } - return response, nil -} - -func (c *deliveriesAPI) do(ctx context.Context, method, path string, query url.Values, idempotencyKey string, body, result any) error { - endpoint, err := url.JoinPath(c.baseURL, path) - if err != nil { - return fmt.Errorf("building webhooks URL: %w", err) - } - if len(query) > 0 { - endpoint += "?" + query.Encode() - } - - var reader io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("encoding request: %w", err) - } - reader = bytes.NewReader(data) - } - req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) - if err != nil { - return fmt.Errorf("creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if idempotencyKey != "" { - req.Header.Set("Idempotency-Key", idempotencyKey) - } - - response, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("calling webhooks: %w", err) - } - defer response.Body.Close() - - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - data, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024)) - if readErr != nil { - return fmt.Errorf("webhooks returned status %d (reading response: %w)", response.StatusCode, readErr) - } - message := strings.TrimSpace(string(data)) - if message == "" { - return fmt.Errorf("webhooks returned status %d", response.StatusCode) - } - return fmt.Errorf("webhooks returned status %d: %s", response.StatusCode, message) - } - - if err := json.NewDecoder(response.Body).Decode(result); err != nil { - return fmt.Errorf("decoding webhooks response: %w", err) - } - return nil -} - -func authenticatedDeliveriesAPI(cmd *cobra.Command) (*deliveriesAPI, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - clients, err := fctl.NewStackClientsFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - return newDeliveriesAPI(clients.URI, clients.HTTPClient), nil -} - -func NewDeliveriesCommand() *cobra.Command { - return fctl.NewCommand("deliveries", - fctl.WithAliases("delivery", "dlvs"), - fctl.WithShortDescription("Inspect and replay webhook deliveries"), - fctl.WithChildCommands( - NewListDeliveriesCommand(), - NewShowDeliveryCommand(), - NewListDeliveryAttemptsCommand(), - NewReplayDeliveryCommand(), - NewReplayDeliveriesCommand(), - ), - ) -} - -func deliveryStatus(value string, replayOnly bool) (DeliveryStatus, error) { - status := DeliveryStatus(value) - switch status { - case "": - return "", nil - case DeliveryStatusFailed, DeliveryStatusPending: - return status, nil - case DeliveryStatusDelivering, DeliveryStatusSucceeded, DeliveryStatusCancelled: - if !replayOnly { - return status, nil - } - } - if replayOnly { - return "", fmt.Errorf("invalid delivery status %q: expected failed or pending", value) - } - return "", fmt.Errorf("invalid delivery status %q", value) -} - -func deliveryPageSize(cmd *cobra.Command) (int, error) { - pageSize, err := fctl.GetPageSize(cmd) - if err != nil { - return 0, err - } - if pageSize > maxDeliveryPageSize { - return 0, fmt.Errorf("page size must not exceed %d", maxDeliveryPageSize) - } - return int(pageSize), nil -} - -func requiredIdempotencyKey(cmd *cobra.Command) (string, error) { - key := fctl.GetString(cmd, idempotencyKeyFlag) - if key == "" { - return "", fmt.Errorf("--%s is required", idempotencyKeyFlag) - } - return key, nil -} - -func optionalTime(value *time.Time) string { - if value == nil { - return "" - } - return value.Format(time.RFC3339) -} - -func optionalInt(value *int) string { - if value == nil { - return "" - } - return strconv.Itoa(*value) -} - -func renderDelivery(cmd *cobra.Command, delivery Delivery) error { - return pterm.DefaultTable.WithWriter(cmd.OutOrStdout()).WithData(pterm.TableData{ - {"ID", delivery.ID}, - {"Event ID", delivery.EventID}, - {"Idempotency key", delivery.IdempotencyKey}, - {"Config ID", delivery.ConfigID}, - {"Event type", delivery.EventType}, - {"Status", string(delivery.Status)}, - {"Attempt count", strconv.Itoa(delivery.AttemptCount)}, - {"Replay generation", strconv.Itoa(delivery.ReplayGeneration)}, - {"Cycle started at", optionalTime(delivery.CycleStartedAt)}, - {"Next attempt at", optionalTime(delivery.NextAttemptAt)}, - {"Claimed at", optionalTime(delivery.ClaimedAt)}, - {"Last attempt at", optionalTime(delivery.LastAttemptAt)}, - {"Last status code", optionalInt(delivery.LastStatusCode)}, - {"Last error", delivery.LastError}, - {"Created at", delivery.CreatedAt.Format(time.RFC3339)}, - {"Updated at", delivery.UpdatedAt.Format(time.RFC3339)}, - {"Payload", delivery.Payload}, - }).Render() -} diff --git a/cmd/webhooks/deliveries_attempts.go b/cmd/webhooks/deliveries_attempts.go deleted file mode 100644 index 52b462b6..00000000 --- a/cmd/webhooks/deliveries_attempts.go +++ /dev/null @@ -1,84 +0,0 @@ -package webhooks - -import ( - "fmt" - "strconv" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListDeliveryAttemptsStore struct { - Attempts []DeliveryAttempt `json:"attempts"` - Cursor fctl.Cursor `json:"cursor"` -} - -type ListDeliveryAttemptsController struct { - store *ListDeliveryAttemptsStore -} - -func NewListDeliveryAttemptsController() *ListDeliveryAttemptsController { - return &ListDeliveryAttemptsController{store: &ListDeliveryAttemptsStore{Attempts: []DeliveryAttempt{}}} -} - -func (c *ListDeliveryAttemptsController) GetStore() *ListDeliveryAttemptsStore { return c.store } - -func (c *ListDeliveryAttemptsController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - pageSize, err := deliveryPageSize(cmd) - if err != nil { - return nil, err - } - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - api, err := authenticatedDeliveriesAPI(cmd) - if err != nil { - return nil, err - } - response, err := api.attempts(cmd.Context(), args[0], cursor, pageSize) - if err != nil { - return nil, fmt.Errorf("listing delivery attempts: %w", err) - } - c.store.Attempts = response.Cursor.Data - c.store.Cursor = fctl.Cursor{ - HasMore: response.Cursor.HasMore, - PageSize: int64(response.Cursor.PageSize), - Next: response.Cursor.Next, - } - return c, nil -} - -func (c *ListDeliveryAttemptsController) Render(cmd *cobra.Command, _ []string) error { - table := pterm.TableData{{"ID", "Attempt", "Replay gen.", "Outcome", "Status", "Duration (ms)", "Created at", "Error"}} - for _, attempt := range c.store.Attempts { - table = append(table, []string{ - attempt.ID, - strconv.Itoa(attempt.AttemptNumber), - strconv.Itoa(attempt.ReplayGeneration), - attempt.Outcome, - strconv.Itoa(attempt.StatusCode), - strconv.FormatInt(attempt.DurationMillis, 10), - attempt.CreatedAt.Format(time.RFC3339), - attempt.Error, - }) - } - if err := pterm.DefaultTable.WithHasHeader(true).WithWriter(cmd.OutOrStdout()).WithData(table).Render(); err != nil { - return fmt.Errorf("rendering delivery attempts: %w", err) - } - return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) -} - -func NewListDeliveryAttemptsCommand() *cobra.Command { - return fctl.NewCommand("attempts ", - fctl.WithShortDescription("List the attempts for a webhook delivery"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListDeliveryAttemptsStore](NewListDeliveryAttemptsController()), - ) -} diff --git a/cmd/webhooks/deliveries_list.go b/cmd/webhooks/deliveries_list.go deleted file mode 100644 index e9a30fa3..00000000 --- a/cmd/webhooks/deliveries_list.go +++ /dev/null @@ -1,110 +0,0 @@ -package webhooks - -import ( - "fmt" - "strconv" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListDeliveriesStore struct { - Deliveries []Delivery `json:"deliveries"` - Cursor fctl.Cursor `json:"cursor"` -} - -type ListDeliveriesController struct { - store *ListDeliveriesStore -} - -func NewListDeliveriesController() *ListDeliveriesController { - return &ListDeliveriesController{store: &ListDeliveriesStore{Deliveries: []Delivery{}}} -} - -func (c *ListDeliveriesController) GetStore() *ListDeliveriesStore { return c.store } - -func (c *ListDeliveriesController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - status, err := deliveryStatus(fctl.GetString(cmd, deliveryStatusFlag), false) - if err != nil { - return nil, err - } - createdAtFrom, err := fctl.GetDateTime(cmd, createdAtFromFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", createdAtFromFlag, err) - } - createdAtTo, err := fctl.GetDateTime(cmd, createdAtToFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", createdAtToFlag, err) - } - pageSize, err := deliveryPageSize(cmd) - if err != nil { - return nil, err - } - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - - api, err := authenticatedDeliveriesAPI(cmd) - if err != nil { - return nil, err - } - response, err := api.list(cmd.Context(), listDeliveriesParams{ - ConfigID: fctl.GetString(cmd, configIDFlag), - Status: status, - CreatedAtFrom: createdAtFrom, - CreatedAtTo: createdAtTo, - Cursor: cursor, - PageSize: pageSize, - }) - if err != nil { - return nil, fmt.Errorf("listing deliveries: %w", err) - } - - c.store.Deliveries = response.Cursor.Data - c.store.Cursor = fctl.Cursor{ - HasMore: response.Cursor.HasMore, - PageSize: int64(response.Cursor.PageSize), - Next: response.Cursor.Next, - } - return c, nil -} - -func (c *ListDeliveriesController) Render(cmd *cobra.Command, _ []string) error { - table := pterm.TableData{{"ID", "Config ID", "Event type", "Status", "Attempts", "Replay gen.", "Last status", "Created at"}} - for _, delivery := range c.store.Deliveries { - table = append(table, []string{ - delivery.ID, - delivery.ConfigID, - delivery.EventType, - string(delivery.Status), - strconv.Itoa(delivery.AttemptCount), - strconv.Itoa(delivery.ReplayGeneration), - optionalInt(delivery.LastStatusCode), - delivery.CreatedAt.Format(time.RFC3339), - }) - } - if err := pterm.DefaultTable.WithHasHeader(true).WithWriter(cmd.OutOrStdout()).WithData(table).Render(); err != nil { - return fmt.Errorf("rendering deliveries: %w", err) - } - return fctl.RenderCursor(cmd.OutOrStdout(), c.store.Cursor) -} - -func NewListDeliveriesCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithAliases("ls", "l"), - fctl.WithShortDescription("List webhook deliveries"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(configIDFlag, "", "Filter by webhook config ID"), - fctl.WithStringFlag(deliveryStatusFlag, "", "Filter by status (pending, delivering, succeeded, failed, cancelled)"), - fctl.WithStringFlag(createdAtFromFlag, "", "Filter deliveries created at or after this RFC3339 timestamp"), - fctl.WithStringFlag(createdAtToFlag, "", "Filter deliveries created at or before this RFC3339 timestamp"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithController[*ListDeliveriesStore](NewListDeliveriesController()), - ) -} diff --git a/cmd/webhooks/deliveries_replay.go b/cmd/webhooks/deliveries_replay.go deleted file mode 100644 index 7e5e7514..00000000 --- a/cmd/webhooks/deliveries_replay.go +++ /dev/null @@ -1,60 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ReplayDeliveryStore struct { - Delivery Delivery `json:"delivery"` -} - -type ReplayDeliveryController struct { - store *ReplayDeliveryStore -} - -func NewReplayDeliveryController() *ReplayDeliveryController { - return &ReplayDeliveryController{store: &ReplayDeliveryStore{}} -} - -func (c *ReplayDeliveryController) GetStore() *ReplayDeliveryStore { return c.store } - -func (c *ReplayDeliveryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - idempotencyKey, err := requiredIdempotencyKey(cmd) - if err != nil { - return nil, err - } - if !fctl.CheckStackApprobation(cmd, "You are about to replay webhook delivery %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - api, err := authenticatedDeliveriesAPI(cmd) - if err != nil { - return nil, err - } - response, err := api.replay(cmd.Context(), args[0], idempotencyKey) - if err != nil { - return nil, fmt.Errorf("replaying delivery: %w", err) - } - c.store.Delivery = response.Data - return c, nil -} - -func (c *ReplayDeliveryController) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Delivery queued for replay") - return renderDelivery(cmd, c.store.Delivery) -} - -func NewReplayDeliveryCommand() *cobra.Command { - return fctl.NewCommand("replay ", - fctl.WithShortDescription("Replay one failed or pending webhook delivery"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(idempotencyKeyFlag, "", "Idempotency key for this replay request"), - fctl.WithConfirmFlag(), - fctl.WithController[*ReplayDeliveryStore](NewReplayDeliveryController()), - ) -} diff --git a/cmd/webhooks/deliveries_replay_bulk.go b/cmd/webhooks/deliveries_replay_bulk.go deleted file mode 100644 index 5693c91a..00000000 --- a/cmd/webhooks/deliveries_replay_bulk.go +++ /dev/null @@ -1,134 +0,0 @@ -package webhooks - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ReplayDeliveriesStore struct { - Result ReplayDeliveriesResult `json:"result"` -} - -type ReplayDeliveriesController struct { - store *ReplayDeliveriesStore -} - -func NewReplayDeliveriesController() *ReplayDeliveriesController { - return &ReplayDeliveriesController{store: &ReplayDeliveriesStore{}} -} - -func (c *ReplayDeliveriesController) GetStore() *ReplayDeliveriesStore { return c.store } - -func (c *ReplayDeliveriesController) Run(cmd *cobra.Command, _ []string) (fctl.Renderable, error) { - createdAtFrom, err := fctl.GetDateTime(cmd, createdAtFromFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", createdAtFromFlag, err) - } - if createdAtFrom == nil { - return nil, fmt.Errorf("--%s is required", createdAtFromFlag) - } - createdAtTo, err := fctl.GetDateTime(cmd, createdAtToFlag) - if err != nil { - return nil, fmt.Errorf("parsing --%s: %w", createdAtToFlag, err) - } - pageSize, err := deliveryPageSize(cmd) - if err != nil { - return nil, err - } - idempotencyKey, err := requiredIdempotencyKey(cmd) - if err != nil { - return nil, err - } - cursor, err := fctl.GetCursor(cmd) - if err != nil { - return nil, err - } - - statuses, err := resolvedReplayStatuses(cmd) - if err != nil { - return nil, err - } - - request := replayDeliveriesRequest{ - CreatedAtFrom: *createdAtFrom, - CreatedAtTo: createdAtTo, - Statuses: statuses, - ConfigIDs: fctl.GetStringSlice(cmd, configIDFlag), - PageSize: pageSize, - } - if cursor != "" { - request.Cursor = &cursor - } - - if !fctl.CheckStackApprobation(cmd, "You are about to replay a page of webhook deliveries") { - return nil, fctl.ErrMissingApproval - } - api, err := authenticatedDeliveriesAPI(cmd) - if err != nil { - return nil, err - } - response, err := api.replayMany(cmd.Context(), idempotencyKey, request) - if err != nil { - return nil, fmt.Errorf("replaying deliveries: %w", err) - } - c.store.Result = response.Data - return c, nil -} - -func resolvedReplayStatuses(cmd *cobra.Command) ([]DeliveryStatus, error) { - values := fctl.GetStringSlice(cmd, deliveryStatusFlag) - if len(values) == 0 { - values = []string{string(DeliveryStatusFailed), string(DeliveryStatusPending)} - } - statuses := make([]DeliveryStatus, 0, len(values)) - for _, value := range values { - status, err := deliveryStatus(strings.ToLower(value), true) - if err != nil { - return nil, err - } - statuses = append(statuses, status) - } - return statuses, nil -} - -func (c *ReplayDeliveriesController) Render(cmd *cobra.Command, _ []string) error { - result := c.store.Result - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Delivery replay page processed") - return pterm.DefaultTable.WithWriter(cmd.OutOrStdout()).WithData(pterm.TableData{ - {"Replayed", strconv.Itoa(result.Replayed)}, - {"Expedited", strconv.Itoa(result.Expedited)}, - {"Skipped", strconv.Itoa(result.Skipped)}, - {"Has more", strconv.FormatBool(result.HasMore)}, - {"Next cursor", func() string { - if result.NextCursor == nil { - return "" - } - return *result.NextCursor - }()}, - {"Created at to", result.CreatedAtTo.Format(time.RFC3339)}, - }).Render() -} - -func NewReplayDeliveriesCommand() *cobra.Command { - return fctl.NewCommand("replay-bulk", - fctl.WithShortDescription("Replay a bounded page of failed or pending webhook deliveries"), - fctl.WithArgs(cobra.NoArgs), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(createdAtFromFlag, "", "Replay deliveries created at or after this RFC3339 timestamp"), - fctl.WithStringFlag(createdAtToFlag, "", "Replay deliveries created at or before this RFC3339 timestamp"), - fctl.WithStringSliceFlag(deliveryStatusFlag, []string{}, "Delivery statuses to replay (failed, pending; default: both)"), - fctl.WithStringSliceFlag(configIDFlag, []string{}, "Restrict replay to webhook config IDs"), - fctl.WithStringFlag(idempotencyKeyFlag, "", "Idempotency key for this replay request"), - fctl.WithCursorFlag(), - fctl.WithPageSizeFlag(), - fctl.WithConfirmFlag(), - fctl.WithController[*ReplayDeliveriesStore](NewReplayDeliveriesController()), - ) -} diff --git a/cmd/webhooks/deliveries_show.go b/cmd/webhooks/deliveries_show.go deleted file mode 100644 index c579bdc3..00000000 --- a/cmd/webhooks/deliveries_show.go +++ /dev/null @@ -1,50 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ShowDeliveryStore struct { - Delivery Delivery `json:"delivery"` -} - -type ShowDeliveryController struct { - store *ShowDeliveryStore -} - -func NewShowDeliveryController() *ShowDeliveryController { - return &ShowDeliveryController{store: &ShowDeliveryStore{}} -} - -func (c *ShowDeliveryController) GetStore() *ShowDeliveryStore { return c.store } - -func (c *ShowDeliveryController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - api, err := authenticatedDeliveriesAPI(cmd) - if err != nil { - return nil, err - } - response, err := api.get(cmd.Context(), args[0]) - if err != nil { - return nil, fmt.Errorf("getting delivery: %w", err) - } - c.store.Delivery = response.Data - return c, nil -} - -func (c *ShowDeliveryController) Render(cmd *cobra.Command, _ []string) error { - return renderDelivery(cmd, c.store.Delivery) -} - -func NewShowDeliveryCommand() *cobra.Command { - return fctl.NewCommand("show ", - fctl.WithAliases("get"), - fctl.WithShortDescription("Show a webhook delivery, including its payload"), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ShowDeliveryStore](NewShowDeliveryController()), - ) -} diff --git a/cmd/webhooks/deliveries_test.go b/cmd/webhooks/deliveries_test.go deleted file mode 100644 index 0e15ea31..00000000 --- a/cmd/webhooks/deliveries_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package webhooks - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "testing" - "time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func TestDeliveriesAPIReadEndpoints(t *testing.T) { - from := time.Date(2026, time.July, 1, 10, 0, 0, 0, time.UTC) - to := from.Add(time.Hour) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/webhooks/deliveries": - if r.Method != http.MethodGet { - t.Fatalf("method = %s, want GET", r.Method) - } - wantQuery := map[string]string{ - "configId": "config-1", - "status": "failed", - "createdAtFrom": from.Format(time.RFC3339), - "createdAtTo": to.Format(time.RFC3339), - "cursor": "cursor-1", - "pageSize": "25", - } - for key, want := range wantQuery { - if got := r.URL.Query().Get(key); got != want { - t.Errorf("query %s = %q, want %q", key, got, want) - } - } - _, _ = w.Write([]byte(`{"cursor":{"pageSize":25,"hasMore":true,"next":"cursor-2","data":[{"id":"delivery-1","eventID":"event-1","configID":"config-1","eventType":"ledger.committed_transactions","status":"failed","attemptCount":3,"replayGeneration":0,"createdAt":"2026-07-01T10:00:00Z","updatedAt":"2026-07-01T10:01:00Z"}]}}`)) - case "/api/webhooks/deliveries/delivery-1": - _, _ = w.Write([]byte(`{"data":{"id":"delivery-1","eventID":"event-1","configID":"config-1","eventType":"ledger.committed_transactions","payload":"{}","status":"failed","attemptCount":3,"replayGeneration":0,"createdAt":"2026-07-01T10:00:00Z","updatedAt":"2026-07-01T10:01:00Z"}}`)) - case "/api/webhooks/deliveries/delivery-1/attempts": - if got := r.URL.Query().Get("cursor"); got != "attempt-cursor" { - t.Errorf("cursor = %q, want attempt-cursor", got) - } - if got := r.URL.Query().Get("pageSize"); got != "10" { - t.Errorf("pageSize = %q, want 10", got) - } - _, _ = w.Write([]byte(`{"cursor":{"pageSize":10,"hasMore":false,"data":[{"id":"attempt-1","deliveryID":"delivery-1","attemptNumber":1,"replayGeneration":0,"endpoint":"https://example.com/hook","outcome":"permanent_failure","statusCode":404,"durationMillis":12,"createdAt":"2026-07-01T10:01:00Z"}]}}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - api := newDeliveriesAPI(server.URL, server.Client()) - list, err := api.list(context.Background(), listDeliveriesParams{ - ConfigID: "config-1", - Status: DeliveryStatusFailed, - CreatedAtFrom: &from, - CreatedAtTo: &to, - Cursor: "cursor-1", - PageSize: 25, - }) - if err != nil { - t.Fatalf("list() error = %v", err) - } - if len(list.Cursor.Data) != 1 || list.Cursor.Data[0].ID != "delivery-1" || list.Cursor.Next == nil || *list.Cursor.Next != "cursor-2" { - t.Fatalf("list() response = %#v", list) - } - - delivery, err := api.get(context.Background(), "delivery-1") - if err != nil { - t.Fatalf("get() error = %v", err) - } - if delivery.Data.Payload != "{}" { - t.Fatalf("get() payload = %q, want {}", delivery.Data.Payload) - } - - attempts, err := api.attempts(context.Background(), "delivery-1", "attempt-cursor", 10) - if err != nil { - t.Fatalf("attempts() error = %v", err) - } - if len(attempts.Cursor.Data) != 1 || attempts.Cursor.Data[0].StatusCode != 404 || attempts.Cursor.Data[0].DurationMillis != 12 { - t.Fatalf("attempts() response = %#v", attempts) - } -} - -func TestDeliveriesAPIReplayEndpoints(t *testing.T) { - from := time.Date(2026, time.July, 1, 10, 0, 0, 0, time.UTC) - to := from.Add(24 * time.Hour) - var calls int - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/webhooks/deliveries/delivery-1/replay": - if r.Method != http.MethodPost { - t.Fatalf("method = %s, want POST", r.Method) - } - if got := r.Header.Get("Idempotency-Key"); got != "individual-key" { - t.Fatalf("Idempotency-Key = %q", got) - } - _, _ = w.Write([]byte(`{"data":{"id":"delivery-1","eventID":"event-1","configID":"config-1","eventType":"event","status":"pending","attemptCount":0,"replayGeneration":1,"createdAt":"2026-07-01T10:00:00Z","updatedAt":"2026-07-01T10:02:00Z"}}`)) - case "/api/webhooks/deliveries/replay": - if got := r.Header.Get("Idempotency-Key"); got != "bulk-key" { - t.Fatalf("Idempotency-Key = %q", got) - } - var body replayDeliveriesRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - if !body.CreatedAtFrom.Equal(from) || body.CreatedAtTo == nil || !body.CreatedAtTo.Equal(to) { - t.Errorf("window = %s..%v, want %s..%s", body.CreatedAtFrom, body.CreatedAtTo, from, to) - } - if !reflect.DeepEqual(body.Statuses, []DeliveryStatus{DeliveryStatusFailed, DeliveryStatusPending}) { - t.Errorf("statuses = %v", body.Statuses) - } - if !reflect.DeepEqual(body.ConfigIDs, []string{"config-1"}) || body.Cursor == nil || *body.Cursor != "cursor-1" || body.PageSize != 1000 { - t.Errorf("bulk body = %#v", body) - } - _, _ = w.Write([]byte(`{"data":{"replayed":2,"expedited":1,"skipped":0,"hasMore":true,"nextCursor":"cursor-2","createdAtTo":"2026-07-02T10:00:00Z"}}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - api := newDeliveriesAPI(server.URL, server.Client()) - replayed, err := api.replay(context.Background(), "delivery-1", "individual-key") - if err != nil { - t.Fatalf("replay() error = %v", err) - } - if replayed.Data.Status != DeliveryStatusPending || replayed.Data.ReplayGeneration != 1 { - t.Fatalf("replay() response = %#v", replayed) - } - - cursor := "cursor-1" - bulk, err := api.replayMany(context.Background(), "bulk-key", replayDeliveriesRequest{ - CreatedAtFrom: from, - CreatedAtTo: &to, - Statuses: []DeliveryStatus{DeliveryStatusFailed, DeliveryStatusPending}, - ConfigIDs: []string{"config-1"}, - Cursor: &cursor, - PageSize: 1000, - }) - if err != nil { - t.Fatalf("replayMany() error = %v", err) - } - if bulk.Data.Replayed != 2 || bulk.Data.NextCursor == nil || *bulk.Data.NextCursor != "cursor-2" { - t.Fatalf("replayMany() response = %#v", bulk) - } - if calls != 2 { - t.Fatalf("calls = %d, want 2", calls) - } -} - -func TestDeliveriesAPIIncludesErrorResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"errorCode":"VALIDATION","errorMessage":"delivery cannot be replayed"}`)) - })) - defer server.Close() - - _, err := newDeliveriesAPI(server.URL, server.Client()).replay(context.Background(), "delivery-1", "key") - if err == nil { - t.Fatal("replay() expected error") - } - for _, want := range []string{"status 409", "delivery cannot be replayed"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error = %q, want substring %q", err, want) - } - } -} - -func TestDeliveriesCommandExposesAllEndpoints(t *testing.T) { - cmd := NewDeliveriesCommand() - for _, name := range []string{"list", "show", "attempts", "replay", "replay-bulk"} { - child, _, err := cmd.Find([]string{name}) - if err != nil || child == cmd || child.Name() != name { - t.Errorf("command %q not found", name) - } - } - for _, name := range []string{"replay", "replay-bulk"} { - child, _, _ := cmd.Find([]string{name}) - if child.Flag(idempotencyKeyFlag) == nil { - t.Errorf("command %q is missing --%s", name, idempotencyKeyFlag) - } - } -} - -func TestWebhooksCommandExposesConfigEndpoints(t *testing.T) { - cmd := NewCommand() - for _, name := range []string{"create", "list", "update", "test", "activate", "deactivate", "delete", "change-secret", "deliveries"} { - child, _, err := cmd.Find([]string{name}) - if err != nil || child == cmd || child.Name() != name { - t.Errorf("command %q not found", name) - } - } -} - -func TestWebhooksCommandAliasesDoNotConflict(t *testing.T) { - cmd := NewCommand() - - deleteCommand, _, err := cmd.Find([]string{"del"}) - if err != nil || deleteCommand.Name() != "delete" { - t.Fatalf("alias del resolved to %q, want delete (err = %v)", deleteCommand.Name(), err) - } - - deliveriesCommand, _, err := cmd.Find([]string{"dlvs"}) - if err != nil || deliveriesCommand.Name() != "deliveries" { - t.Fatalf("alias dlvs resolved to %q, want deliveries (err = %v)", deliveriesCommand.Name(), err) - } -} - -func TestDeliveryStatusValidation(t *testing.T) { - if _, err := deliveryStatus("succeeded", true); err == nil { - t.Fatal("bulk replay accepted succeeded status") - } - if status, err := deliveryStatus("succeeded", false); err != nil || status != DeliveryStatusSucceeded { - t.Fatalf("list status = %q, err = %v", status, err) - } -} - -func TestReplayCommandsResolveRequiredValuesFromEnvironment(t *testing.T) { - t.Setenv("IDEMPOTENCY_KEY", "environment-key") - t.Setenv("CREATED_AT_FROM", "2026-08-01T10:00:00Z") - - individual := NewReplayDeliveryCommand() - key, err := requiredIdempotencyKey(individual) - if err != nil || key != "environment-key" { - t.Fatalf("idempotency key = %q, err = %v", key, err) - } - - bulk := NewReplayDeliveriesCommand() - from, err := fctl.GetDateTime(bulk, createdAtFromFlag) - if err != nil || from == nil || from.Format(time.RFC3339) != "2026-08-01T10:00:00Z" { - t.Fatalf("created-at-from = %v, err = %v", from, err) - } -} - -func TestRequiredIdempotencyKeyRejectsMissingValue(t *testing.T) { - t.Setenv("IDEMPOTENCY_KEY", "") - _, err := requiredIdempotencyKey(NewReplayDeliveryCommand()) - if err == nil { - t.Fatal("requiredIdempotencyKey() expected an error") - } -} - -func TestReplayStatusesUseEnvironmentBeforeDefaults(t *testing.T) { - t.Setenv("STATUS", "failed") - statuses, err := resolvedReplayStatuses(NewReplayDeliveriesCommand()) - if err != nil { - t.Fatalf("resolvedReplayStatuses() error = %v", err) - } - if !reflect.DeepEqual(statuses, []DeliveryStatus{DeliveryStatusFailed}) { - t.Fatalf("statuses = %v, want [failed]", statuses) - } -} - -func TestReplayStatusesDefaultToFailedAndPending(t *testing.T) { - t.Setenv("STATUS", "") - statuses, err := resolvedReplayStatuses(NewReplayDeliveriesCommand()) - if err != nil { - t.Fatalf("resolvedReplayStatuses() error = %v", err) - } - want := []DeliveryStatus{DeliveryStatusFailed, DeliveryStatusPending} - if !reflect.DeepEqual(statuses, want) { - t.Fatalf("statuses = %v, want %v", statuses, want) - } -} diff --git a/cmd/webhooks/list.go b/cmd/webhooks/list.go deleted file mode 100644 index 54977353..00000000 --- a/cmd/webhooks/list.go +++ /dev/null @@ -1,105 +0,0 @@ -package webhooks - -import ( - "fmt" - "strings" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ListWebhookStore struct { - Webhooks []webhooksmodels.WebhooksConfig `json:"webhooks"` -} -type ListWebhookController struct { - store *ListWebhookStore -} - -var _ fctl.Controller[*ListWebhookStore] = (*ListWebhookController)(nil) - -func NewDefaultListWebhookStore() *ListWebhookStore { - return &ListWebhookStore{ - Webhooks: []webhooksmodels.WebhooksConfig{}, - } -} - -func NewListWebhookController() *ListWebhookController { - return &ListWebhookController{ - store: NewDefaultListWebhookStore(), - } -} -func (c *ListWebhookController) GetStore() *ListWebhookStore { - return c.store -} - -func (c *ListWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - request := operations.GetManyConfigsRequest{} - if configID := fctl.GetString(cmd, configIDFlag); configID != "" { - request.ID = &configID - } - if endpoint := fctl.GetString(cmd, endpointFlag); endpoint != "" { - request.Endpoint = &endpoint - } - response, err := stackClient.Webhooks.V1.GetManyConfigs(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("listing all config: %w", err) - } - - c.store.Webhooks = response.ConfigsResponse.Cursor.Data - - return c, nil -} - -func (c *ListWebhookController) Render(cmd *cobra.Command, args []string) error { - // TODO: WebhooksConfig is missing ? - if err := pterm.DefaultTable. - WithHasHeader(true). - WithWriter(cmd.OutOrStdout()). - WithData( - fctl.Prepend( - fctl.Map(c.store.Webhooks, - func(src webhooksmodels.WebhooksConfig) []string { - return []string{ - src.ID, - src.CreatedAt.Format(time.RFC3339), - src.Secret, - src.Endpoint, - fctl.BoolToString(src.Active), - strings.Join(src.EventTypes, ","), - } - }), - []string{"ID", "Created at", "Secret", "Endpoint", "Active", "Event types"}, - ), - ).Render(); err != nil { - return fmt.Errorf("rendering table: %w", err) - } - return nil -} - -func NewListCommand() *cobra.Command { - return fctl.NewCommand("list", - fctl.WithShortDescription("List webhook configs"), - fctl.WithAliases("ls", "l"), - fctl.WithArgs(cobra.ExactArgs(0)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(configIDFlag, "", "Filter by config ID"), - fctl.WithStringFlag(endpointFlag, "", "Filter by endpoint URL"), - fctl.WithController[*ListWebhookStore](NewListWebhookController()), - ) -} diff --git a/cmd/webhooks/root.go b/cmd/webhooks/root.go deleted file mode 100644 index ef38be5e..00000000 --- a/cmd/webhooks/root.go +++ /dev/null @@ -1,25 +0,0 @@ -package webhooks - -import ( - "github.com/spf13/cobra" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func NewCommand() *cobra.Command { - return fctl.NewStackCommand("webhooks", - fctl.WithAliases("web", "wh"), - fctl.WithShortDescription("Manage webhooks"), - fctl.WithChildCommands( - NewCreateCommand(), - NewListCommand(), - NewUpdateCommand(), - NewTestCommand(), - NewDeliveriesCommand(), - NewDeactivateCommand(), - NewActivateCommand(), - NewDeleteCommand(), - NewChangeSecretCommand(), - ), - ) -} diff --git a/cmd/webhooks/secret.go b/cmd/webhooks/secret.go deleted file mode 100644 index 713ef94a..00000000 --- a/cmd/webhooks/secret.go +++ /dev/null @@ -1,92 +0,0 @@ -package webhooks - -import ( - "fmt" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type ChangeSecretStore struct { - Secret string `json:"secret"` - ID string `json:"id"` -} - -type ChangeSecretWebhookController struct { - store *ChangeSecretStore -} - -var _ fctl.Controller[*ChangeSecretStore] = (*ChangeSecretWebhookController)(nil) - -func NewDefaultChangeSecretStore() *ChangeSecretStore { - return &ChangeSecretStore{ - Secret: "", - ID: "", - } -} -func NewChangeSecretWebhookController() *ChangeSecretWebhookController { - return &ChangeSecretWebhookController{ - store: NewDefaultChangeSecretStore(), - } -} -func (c *ChangeSecretWebhookController) GetStore() *ChangeSecretStore { - return c.store -} -func (c *ChangeSecretWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - - if !fctl.CheckStackApprobation(cmd, "You are about to change a webhook secret") { - return nil, fctl.ErrMissingApproval - } - secret := "" - if len(args) > 1 { - secret = args[1] - } - - response, err := stackClient.Webhooks.V1. - ChangeConfigSecret(cmd.Context(), operations.ChangeConfigSecretRequest{ - ConfigChangeSecret: &webhooksmodels.ConfigChangeSecret{ - Secret: secret, - }, - ID: args[0], - }) - if err != nil { - return nil, fmt.Errorf("changing secret: %w", err) - } - - c.store.ID = response.ConfigResponse.WebhooksConfig.ID - c.store.Secret = response.ConfigResponse.WebhooksConfig.Secret - - return c, nil -} - -func (c *ChangeSecretWebhookController) Render(cmd *cobra.Command, args []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln( - "Config '%s' updated successfully with new secret", c.store.ID) - return nil -} - -func NewChangeSecretCommand() *cobra.Command { - return fctl.NewCommand("change-secret ", - fctl.WithShortDescription("Change the signing secret of a webhook config. Optionally provide your own secret; if omitted, one is generated automatically (24-byte, base64-encoded string)."), - fctl.WithConfirmFlag(), - fctl.WithAliases("cs"), - fctl.WithArgs(cobra.RangeArgs(1, 2)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*ChangeSecretStore](NewChangeSecretWebhookController()), - ) -} diff --git a/cmd/webhooks/test.go b/cmd/webhooks/test.go deleted file mode 100644 index f95d7f39..00000000 --- a/cmd/webhooks/test.go +++ /dev/null @@ -1,80 +0,0 @@ -package webhooks - -import ( - "fmt" - "strconv" - "time" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type TestWebhookStore struct { - Attempt webhooksmodels.Attempt `json:"attempt"` -} - -type TestWebhookController struct { - store *TestWebhookStore -} - -func NewTestWebhookController() *TestWebhookController { - return &TestWebhookController{store: &TestWebhookStore{}} -} - -func (c *TestWebhookController) GetStore() *TestWebhookStore { return c.store } - -func (c *TestWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckStackApprobation(cmd, "You are about to send a test event to webhook config %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - response, err := stackClient.Webhooks.V1.TestConfig(cmd.Context(), operations.TestConfigRequest{ID: args[0]}) - if err != nil { - return nil, fmt.Errorf("testing config: %w", err) - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("testing config: unexpected status code %d", response.StatusCode) - } - if response.AttemptResponse == nil { - return nil, fmt.Errorf("testing config: empty response") - } - c.store.Attempt = response.AttemptResponse.Attempt - return c, nil -} - -func (c *TestWebhookController) Render(cmd *cobra.Command, _ []string) error { - attempt := c.store.Attempt - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Test event sent") - return pterm.DefaultTable.WithWriter(cmd.OutOrStdout()).WithData(pterm.TableData{ - {"Attempt ID", attempt.ID}, - {"Webhook ID", attempt.WebhookID}, - {"Status", attempt.Status}, - {"Status code", strconv.FormatInt(attempt.StatusCode, 10)}, - {"Retry attempt", strconv.FormatInt(attempt.RetryAttempt, 10)}, - {"Next retry after", optionalTime(attempt.NextRetryAfter)}, - {"Created at", attempt.CreatedAt.Format(time.RFC3339)}, - {"Updated at", attempt.UpdatedAt.Format(time.RFC3339)}, - }).Render() -} - -func NewTestCommand() *cobra.Command { - return fctl.NewCommand("test ", - fctl.WithShortDescription("Send a test event to a webhook config"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.ExactArgs(1)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithController[*TestWebhookStore](NewTestWebhookController()), - ) -} diff --git a/cmd/webhooks/update.go b/cmd/webhooks/update.go deleted file mode 100644 index 0ef38f75..00000000 --- a/cmd/webhooks/update.go +++ /dev/null @@ -1,88 +0,0 @@ -package webhooks - -import ( - "fmt" - "net/url" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - - "github.com/formancehq/formance-sdk-go/v4/pkg/models/operations" - webhooksmodels "github.com/formancehq/formance-sdk-go/v4/pkg/models/webhooks" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -type UpdateWebhookStore struct { - Success bool `json:"success"` -} - -type UpdateWebhookController struct { - store *UpdateWebhookStore -} - -func NewUpdateWebhookController() *UpdateWebhookController { - return &UpdateWebhookController{store: &UpdateWebhookStore{}} -} - -func (c *UpdateWebhookController) GetStore() *UpdateWebhookStore { return c.store } - -func (c *UpdateWebhookController) Run(cmd *cobra.Command, args []string) (fctl.Renderable, error) { - _, profile, profileName, relyingParty, err := fctl.LoadAndAuthenticateCurrentProfile(cmd) - if err != nil { - return nil, err - } - stackClient, err := fctl.NewStackClientFromFlags(cmd, relyingParty, fctl.NewPTermDialog(), profileName, *profile) - if err != nil { - return nil, err - } - if !fctl.CheckStackApprobation(cmd, "You are about to update webhook config %s", args[0]) { - return nil, fctl.ErrMissingApproval - } - if _, err := url.ParseRequestURI(args[1]); err != nil { - return nil, fmt.Errorf("invalid endpoint URL: %w", err) - } - - request := newUpdateConfigRequest(cmd, args) - response, err := stackClient.Webhooks.V1.UpdateConfig(cmd.Context(), request) - if err != nil { - return nil, fmt.Errorf("updating config: %w", err) - } - if response.StatusCode >= 300 { - return nil, fmt.Errorf("updating config: unexpected status code %d", response.StatusCode) - } - c.store.Success = true - return c, nil -} - -func newUpdateConfigRequest(cmd *cobra.Command, args []string) operations.UpdateConfigRequest { - request := operations.UpdateConfigRequest{ - ID: args[0], - ConfigUser: webhooksmodels.ConfigUser{ - Endpoint: args[1], - EventTypes: args[2:], - }, - } - secret := fctl.GetString(cmd, secretFlag) - if cmd.Flags().Changed(secretFlag) || secret != "" { - request.ConfigUser.Secret = &secret - } - return request -} - -func (c *UpdateWebhookController) Render(cmd *cobra.Command, _ []string) error { - pterm.Success.WithWriter(cmd.OutOrStdout()).Printfln("Config updated successfully") - return nil -} - -func NewUpdateCommand() *cobra.Command { - return fctl.NewCommand("update [...]", - fctl.WithShortDescription("Update a webhook config. At least one event type is required."), - fctl.WithAliases("up"), - fctl.WithConfirmFlag(), - fctl.WithArgs(cobra.MinimumNArgs(3)), - fctl.WithValidArgsFunction(cobra.NoFileCompletions), - fctl.WithStringFlag(secretFlag, "", "Set the webhook signing secret. If omitted, Webhooks generates a new one"), - fctl.WithController[*UpdateWebhookStore](NewUpdateWebhookController()), - ) -} diff --git a/cmd/webhooks/update_test.go b/cmd/webhooks/update_test.go deleted file mode 100644 index 48457b2b..00000000 --- a/cmd/webhooks/update_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package webhooks - -import "testing" - -func TestNewUpdateConfigRequestIncludesSecretFromEnvironment(t *testing.T) { - t.Setenv("SECRET", "environment-secret") - cmd := NewUpdateCommand() - - request := newUpdateConfigRequest(cmd, []string{"config-1", "https://example.com", "event"}) - if request.ConfigUser.Secret == nil || *request.ConfigUser.Secret != "environment-secret" { - t.Fatalf("secret = %v, want environment-secret", request.ConfigUser.Secret) - } -} - -func TestNewUpdateConfigRequestSecretFlagOverridesEnvironment(t *testing.T) { - t.Setenv("SECRET", "environment-secret") - cmd := NewUpdateCommand() - if err := cmd.Flags().Set(secretFlag, "flag-secret"); err != nil { - t.Fatalf("set secret flag: %v", err) - } - - request := newUpdateConfigRequest(cmd, []string{"config-1", "https://example.com", "event"}) - if request.ConfigUser.Secret == nil || *request.ConfigUser.Secret != "flag-secret" { - t.Fatalf("secret = %v, want flag-secret", request.ConfigUser.Secret) - } -} - -func TestNewUpdateConfigRequestOmitsUnspecifiedSecret(t *testing.T) { - t.Setenv("SECRET", "") - cmd := NewUpdateCommand() - - request := newUpdateConfigRequest(cmd, []string{"config-1", "https://example.com", "event"}) - if request.ConfigUser.Secret != nil { - t.Fatalf("secret = %q, want nil", *request.ConfigUser.Secret) - } -} diff --git a/completions/fctl.bash b/completions/fctl.bash index 16b2505d..23a56e04 100644 --- a/completions/fctl.bash +++ b/completions/fctl.bash @@ -290,7 +290,7 @@ __fctl_handle_standard_completion_case() { local longest=0 local compline # Look for the longest completion so that we can format things nicely - for compline in "${completions[@]}"; do + while IFS='' read -r compline; do [[ -z $compline ]] && continue # Before checking if the completion matches what the user typed, @@ -316,7 +316,7 @@ __fctl_handle_standard_completion_case() { if ((${#comp}>longest)); then longest=${#comp} fi - done + done < <(printf "%s\n" "${completions[@]}") # If there is a single completion left, remove the description text and escape any special characters if ((${#COMPREPLY[*]} == 1)); then diff --git a/completions/fctl.fish b/completions/fctl.fish index bf3ab458..22e33cc9 100644 --- a/completions/fctl.fish +++ b/completions/fctl.fish @@ -12,14 +12,14 @@ function __fctl_perform_completion # Extract all args except the last one set -l args (commandline -opc) - # Extract the last arg and escape it in case it is a space or wildcard + # Extract the last arg and escape it in case it is a space set -l lastArg (string escape -- (commandline -ct)) __fctl_debug "args: $args" __fctl_debug "last arg: $lastArg" # Disable ActiveHelp which is not supported for fish shell - set -l requestComp "FCTL_ACTIVE_HELP=0 $args[1] __complete $(string join ' ' -- (string escape -- $args[2..-1])) $lastArg" + set -l requestComp "FCTL_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" __fctl_debug "Calling $requestComp" set -l results (eval $requestComp 2> /dev/null) diff --git a/docs/README.md b/docs/README.md index 72837b52..9d7451ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,26 @@ -# fctl docs +# fctl technical documentation -Technical notes on `fctl` behavior for maintainers and integrators, kept in -the same PRs as the code they describe (RFC 0004). +- [V4 foundation](v4-foundation.md): module identity, command behavior, + command boundaries and migration from v3. +- [Profiles and authentication](profiles.md): local services, OAuth2, + Cloud login and private saved profiles. +- [Auth and Ledger modules](modules.md): supported operations, API revisions + and external-plugin requirements. +- [Connectivity](connectivity.md): preserved host settings, pending independent + distribution and historical command/API contract. +- [Cloud management](cloud.md): Membership, stack administration, hosted helpers + and experimental Deploy Apps. +- [Interactive input](interaction.md): forms, searchable selections and automation. +- [Output and diagnostics](output.md): terminal tables, machine JSON, colors + and redacted HTTP tracing. +- [Implementation validation](validation.md): local server evidence, test + coverage boundaries and reproducible checks. +- [Plugin contract](plugins.md): public SDK, embedded and external providers, + and host ownership. +- [Plugin distribution](plugin-distribution.md): external-only Auth and Ledger, + official Auth discovery, pending Ledger product publication, local builds, + offline metadata and exact-version target locks. +- [Agent skills](agent-skills.md): versioned plugin development guidance, + release distribution and local installation. -- [Ledger import](ledger-import.md): `fctl ledger import`, batching, and - resuming an interrupted import. +- [Historical service plugin](legacy-plugin.md): compatibility, routing and builds. diff --git a/docs/agent-skills.md b/docs/agent-skills.md new file mode 100644 index 00000000..587cd18d --- /dev/null +++ b/docs/agent-skills.md @@ -0,0 +1,69 @@ +# Agent skills + +The `formance-fctl-plugin` skill helps an AI coding agent create or extend +embedded fctl service plugins. It covers the public SDK, declarative forms, +API validation, plugin independence and CLI verification. + +The versioned sources live in `.agents/skills/formance-fctl-plugin/`. They are the +distribution source; installed copies do not update automatically. Developing +a plugin requires an fctl source checkout and the requested service's API +contract. The skill instructs the agent to inspect those sources before coding. + +## Use in this repository + +Codex discovers the skill in the repository's `.agents/skills/` directory when +working in this checkout. No personal installation is needed. See the +[official skill discovery documentation](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills). + +For example, with this checkout available: + +```text +Use $formance-fctl-plugin to add a service plugin to fctl v4 with declarative +forms and contract tests. +``` + +## Distribution + +Each GoReleaser `.tar.gz` or `.zip` release archive includes: + +- `.agents/skills/formance-fctl-plugin/SKILL.md` +- `.agents/skills/formance-fctl-plugin/agents/openai.yaml` +- `.agents/skills/formance-fctl-plugin/references/inputs.md` +- `.agents/skills/formance-fctl-plugin/references/verification.md` +- `docs/agent-skills.md` and `LICENSE` + +The directory layout is identical in the repository and an extracted release +archive. The skill is distributed under the repository's MIT license. Extracting +an archive or installing fctl does not install the skill in an agent directory. + +## Optional personal installation + +From the repository root or an extracted release archive, run this POSIX shell +command to make the skill available in other repositories through the shared +local agent skills directory: + +```sh +( + skill_destination="$HOME/.agents/skills/formance-fctl-plugin" + if [ -e "$skill_destination" ] || [ -L "$skill_destination" ]; then + printf 'Skill already exists at %s; compare it before replacing it.\n' "$skill_destination" >&2 + exit 1 + fi + mkdir -p "$HOME/.agents/skills" && + cp -R .agents/skills/formance-fctl-plugin "$skill_destination" +) +``` + +The command refuses to overwrite an existing directory or symlink. To compare +an installed copy with the distributed source before updating it: + +```sh +diff -ru .agents/skills/formance-fctl-plugin "$HOME/.agents/skills/formance-fctl-plugin" +``` + +Use the installed copy in an agent that supports `SKILL.md` and loads +`~/.agents/skills`. Codex can display both the repository and personal copies +when both are present. + +This directory contains development instructions for an AI agent. The service +plugin it helps produce is Go code integrated with fctl through the public SDK. diff --git a/docs/cloud.md b/docs/cloud.md new file mode 100644 index 00000000..7a1687cb --- /dev/null +++ b/docs/cloud.md @@ -0,0 +1,114 @@ +# Cloud commands and hosted helpers + +`cloud` is the control-plane command root. Its embedded plugin uses Membership +for identity, organizations, regions and stack metadata. Auth and Ledger remain +separate module roots. This page describes the current implementation; live +validation evidence belongs in [validation.md](validation.md). + +| Command group | Purpose | Access boundary | +| --- | --- | --- | +| `cloud me` | Identity and personal invitations | Membership identity | +| `cloud organizations` | Organizations, users, invitations, policies, OAuth clients, authentication providers and enabled applications | Membership organization grant | +| `cloud regions` | Regions and available stack versions | Membership organization grant | +| `cloud stack` | Stack lifecycle, modules, users and history | Membership organization grant | +| `cloud apps` | Experimental apps, manifests, deployments and variables | Separate Deploy application grant | + +Cloud metadata commands require a logged-in Cloud profile. Organization and +stack selection use explicit flags, saved defaults, or a unique available ID. +They do not need a stack gateway connection to read Membership metadata. +See [profiles and authentication](profiles.md) for authentication and defaults. + +```sh +fctl login --profile cloud +fctl cloud me info +fctl cloud organizations list +fctl cloud regions list --organization ORGANIZATION_ID +fctl cloud stack list --organization ORGANIZATION_ID +fctl cloud stack show STACK_ID --organization ORGANIZATION_ID -o json +fctl cloud stack modules list STACK_ID --organization ORGANIZATION_ID +``` + +## Organization updates + +`cloud organizations update` preserves an existing positive `defaultPolicyID` +when omitted. If the current policy is absent, `null` or `0`, explicitly choose +a positive ID using `--default-policy-id` or JSON `defaultPolicyID`. Membership +defaults an unset policy to Guest (ID `4`, with read access), so the CLI requires +that choice instead of sending an unset value. Explicit `null` or `0` cannot +clear the policy and is rejected before PUT. + +## Stack lifecycle + +`cloud stack` provides `create`, `list`, `show`, `update`, `delete`, `disable`, +`enable`, `restore`, `upgrade`, `history`, `info` and `version`. Nested `modules` +commands list, enable or disable modules; nested `users` commands list, link or +unlink users. Consult each command's `--help` for its arguments, JSON body and +confirmation requirements. + +Stack creation defaults to version `v4.0`. Version selection must match the +region's version catalog; it does not silently fall back to another version. +Creation, restoration and upgrades wait for Membership to report `READY` and +the requested version. `--no-wait` returns after acceptance; `--wait-timeout` +defaults to `10m`. These checks do not prove gateway or module runtime health. + +`list --all` includes deleted and disabled stacks. History supports page size, +cursor, action, user and data filters; a cursor cannot be combined with the +other history filters. `info` and `version` report Membership metadata, rather +than querying each running stack component. + +## Hosted helpers + +The host attaches these utilities to the Cloud command tree: + +| Command | Behavior | +| --- | --- | +| `cloud stack proxy` | Authenticated gateway proxy on `127.0.0.1`, port `55001` by default; `--port 0` selects an available port | +| `cloud stack mcp serve` | Bridges stdin/stdout MCP messages to the gateway's `/api/mcp`; only `--transport stdio` is supported | +| `cloud generate-personal-token` (alias `gpt`) | Prints the selected stack bearer token as JSON, with color disabled | + +Helpers use the host's stack connection resolver. The proxy removes incoming +authentication headers and cookies before the authenticated transport sends the +request. `--allowed-origins` configures browser access. Its listening address is +written to stderr. Proxy and MCP operation follow command cancellation; MCP +stdout is reserved for protocol messages. Personal-token output contains a +credential and must be handled accordingly. + +## Experimental Apps + +`cloud apps` is distinct from `cloud organizations applications`, which manages +applications enabled for an organization. Every Apps command requires +`--experimental=true`, including reads. The default-false gate is checked before +connection resolution or body-file access. Destructive commands also require +`--confirm=true`. + +```sh +fctl cloud apps list --experimental --organization ORGANIZATION_ID +fctl cloud apps deployments list --experimental --organization ORGANIZATION_ID +``` + +The host resolves `cloud-apps` through a separate application grant. The default +`--deploy-app-alias` is `deploy`; the grant uses `app://ALIAS` and granted +`apps:Read`/`apps:Write` scopes. The backend endpoint comes from the signed +application token's audience. The Membership root credential is not sent to +Deploy, and the stack Auth token exchange is not used for Apps. + +Apps supports apps, manifests and versions, deployments, and variables. +Manifest uploads accept the declared `--path` flag or a JSON body containing +`yaml`. Downloads support `--out`. Deployment creation waits by default; +`--wait=false` disables waiting. The default wait timeout is `30m`, with a +maximum of `24h`. These are implementation contracts, not a claim that a live +deployment has been verified. + +## Migration from v3 + +| Previous command | Current command | +| --- | --- | +| `fctl stack list` | `fctl cloud stack list` | +| `fctl stack show STACK_ID` | `fctl cloud stack show STACK_ID` | +| `fctl stack proxy` | `fctl cloud stack proxy` | +| `fctl stack mcp serve` | `fctl cloud stack mcp serve` | + +V3 profiles are not migrated automatically. Create a v4 profile and log in; +use explicit organization/stack flags or saved defaults. Auth and Ledger still +use their own module roots. For automation, select `-o json` explicitly; see +[output.md](output.md) for terminal defaults and diagnostics. diff --git a/docs/connectivity.md b/docs/connectivity.md new file mode 100644 index 00000000..cb2439e9 --- /dev/null +++ b/docs/connectivity.md @@ -0,0 +1,129 @@ +# Connectivity plugin + +The local `plugins/connectivity` adapter has been removed. Connectivity service +commands are absent from the current host. Independent plugin distribution and +later host integration are still required; the Auth/Ledger plugin management +commands do not install Connectivity today. Host connection settings remain +available. This removal does not establish a completed migration. + +## Historical adapter contract + +The following command examples and behavior describe the former embedded +adapter, not currently available commands. That adapter implemented all 14 +operations in +[connectivity-api/openapi.yaml](https://github.com/formancehq/connectivity/blob/ce2324887f5b5ec4e3c2ec934ac874656d4c5348/misc/connectivity-api/openapi.yaml), +API version `0.1.0`, pinned to commit `ce2324887f5b5ec4e3c2ec934ac874656d4c5348`. +The repository also provides a generated client under `pkg/client/http`. +The former plugin used the public `pluginsdk/httpclient` adapter to preserve raw +request/response JSON, including merge-patch nulls, unknown fields and exact +numbers, without introducing typed-model defaults such as `replicas`. +It imported no server, Cobra, UI or core packages. +The host owns authentication, endpoints, storage, forms and presentation. + +## Preserved host connection settings + +```bash +fctl profiles add connectivity-local --auth-mode none \ + --connectivity-url http://localhost:8080 +``` + +`--connectivity-url` / `FCTL_CONNECTIVITY_URL` select a standalone API base URL, +including any deployment prefix. Profiles persist `connectivityURL`. This URL +takes precedence over `--stack-url`, which appends `/api/connectivity`. +Cloud uses the existing scoped Stack client; its gateway must expose Connectivity. +Direct endpoint flags cannot override Cloud routes. +See [profiles and authentication](profiles.md) for profile management, overrides +and session storage. + +For OAuth2 credentials, configure `--auth-mode client-credentials`, `--token-url`, +`--client-id`, `FCTL_CLIENT_SECRET` and `--scopes`. With OIDC enabled, the API +accepts `connectivity:read` or `connectivity:write` for reads and requires +`connectivity:write` for writes. Health and info probes are public; query +capabilities use the read authorization boundary. + +## Historical catalogue and diagnostics + +The former adapter accepted standalone and Cloud targets: + +```bash +fctl -p connectivity-local connectivity info +fctl --organization ORG --stack STACK connectivity connectors list +``` + +```bash +fctl connectivity info +fctl connectivity health +fctl connectivity query capabilities +fctl connectivity connectors list --page-size 30 +fctl connectivity connectors facets +fctl connectivity connectors show stripe +fctl connectivity connectors versions list stripe +fctl connectivity connectors versions show stripe stable +``` + +Version reads accept exact versions and `latest`, `stable`, `rc`, `beta`, `alpha` +aliases. Lists of connectors, versions and instances accept `--page-size` from +1 to 100 (default 15), `--cursor` and `--query`. Facets accept `--query`. +Supply a JSON query object using the fields and operators advertised by +`query capabilities`; the server validates its query grammar. + +Responses retain the cursor envelope. Continue with `cursor.next` while +`cursor.hasMore` is true, preserving query and page size. Selectors enumerate +all pages. Versions retain the server's ascending order. JSON retains complete +metadata/spec/status and exact numbers. Tables show names and reconciliation phase. + +## Historical instance commands + +```bash +fctl connectivity instances create +fctl connectivity instances create --data @instance.json +fctl connectivity instances list +fctl connectivity instances show ingestion +fctl connectivity instances patch ingestion --data '{"suspend":true}' +fctl connectivity instances patch ingestion --data '{"version":null,"channel":"stable"}' +fctl connectivity instances replace ingestion --confirm --data @replacement.json +fctl connectivity instances delete ingestion --confirm +``` + +Creation opens a form for a DNS-label name, catalogue connector, ledger, +optional version, channel, configuration, start sequence, poll interval and +suspension. The form defaults channel to `stable` and suspension to false. +Explicit bodies bypass forms and gain no defaults. Bodies accept inline JSON, +`@file` and stdin (`--data -`), up to 1 MiB. Creation requires a nonempty connector +and ledger in `spec`. A version pin takes precedence over a channel. Example: + +```json +{"name":"ingestion","spec":{"connector":"stripe","ledger":"books","channel":"stable","config":{"env":{"API_KEY":{"secretRef":{"name":"stripe-credentials","key":"api-key"}}}}}} +``` + +`replace` sends PUT with a required `spec` object and replaces the whole spec. +The server preserves omitted labels/annotations and status. Confirmation is +required; its form asks for the complete replacement spec. + +`patch` sends `application/merge-patch+json` directly to **spec**, without a +`spec` wrapper. Omitted fields are preserved; explicit false/empty values stay; +null removes fields. Its form asks for the patch JSON after selecting an instance. +Connector immutability, dynamic configSchema validation, reference resolution +and reconciliation remain server responsibilities. The server owns +`connectivityRef` and `replicas`; use `suspend` to pause ingestion. + +Use `secretRef` for sensitive configuration. Unlike schema-managed config, +inline `additionalEnv` and `additionalFiles` values remain on the resource. +The CLI invents no idempotency keys and never retries writes. Read back mutations +and inspect status; write acceptance does not prove healthy ingestion. +Deletion maps the empty 204 response to JSON null. + +## Historical verification + +These checks describe the adapter before removal. They do not validate a +distributed replacement. The paths below are historical references and are no +longer present in this checkout. + +Local fixtures covered all routes/methods, media types, errors, cursor encoding, +int64 precision, size limits, forms, selection and authentication boundaries. +The pinned operation snapshot was `plugins/connectivity/testdata/operations.json`. +An independent consumer verified the public SDK boundary without core or UI +dependencies. A real PTY check covered creation, resource selection, default-No +deletion confirmation, styled forms and plain tables. Creation produced one +write and exact JSON on stdout even with forced terminal colors. +Deployment availability and ingestion require separate live runtime checks. diff --git a/docs/interaction.md b/docs/interaction.md new file mode 100644 index 00000000..487ecae2 --- /dev/null +++ b/docs/interaction.md @@ -0,0 +1,63 @@ +# Interactive command input + +On a terminal, commands ask for missing required values. Ledger examples +require an installed matching external plugin; Ledger is not bundled with fctl. +`fctl ledger create` +opens a form; `fctl ledger stats` lists available ledgers; `fctl cloud stack create` +selects an organization, asks for a name, then lists regions and their exact +catalog versions. Searchable menus show resource names and IDs. Forms use one +field per step, retain values while moving back, and show keyboard help. + +Explicit arguments, flags, saved profile defaults and environment settings +remain authoritative. A complete invocation executes directly. An explicit +`--data` body bypasses payload forms, including `@file` and stdin bodies. Partial +forms ask only for omitted values; blank optional fields remain omitted. + +Press `Ctrl+C` to cancel. No service mutation occurs until all required +inputs are accepted and the plugin validates the request. Destructive operations +ask for confirmation, defaulting to No. `--confirm` permits the operation directly; +`--confirm=false` rejects it. App commands still require `--experimental`. + +## Automation and streams + +Use `--no-input`, `FCTL_NO_INPUT=1`, or `CI=true` to disable interaction. +A non-terminal stdin or stderr also disables forms. Missing inputs then produce +a nonzero exit code and the same explicit-argument contract used by SDK callers. + +Forms and selections write to stderr. Results write to stdout, so a terminal +input can be combined with `-o json > result.json`. JSON remains free of terminal +controls even with `--color always` or `--color never`. `NO_COLOR` disables form +colors unless overridden by `--color always`. Password fields mask their input. + +## Plugin ownership + +Each runnable leaf declares serializable `CommandSpec.Inputs` in the public SDK. +The plugin specifies field names, types, defaults, choices, request bindings and +read operations for discovery. It owns payload validation and service behavior. +The core owns terminal rendering, field validation, cancellation and pagination. +Plugins do not import the UI library or core packages. + +`CommandSpec.Target` declares inherited identity, organization or stack context. +`InputSpec` binds exactly one flag, argument, JSON pointer or host resource ID. +Alternative arguments and flags bypass a field when the caller provides an +explicit alternative. Explicit body input bypasses JSON-pointer fields, regardless of the declared +body flag name. Body contents never appear in confirmation prompts. + +Choice sources run through a client that rejects non-GET requests. Cursor lists +follow `next` while `hasMore` is true. Keyset lists declare `AfterField`; the core +requests pages of 100 and uses the final row's exact value as `--after` until a +shorter page. Plugins can exclude true boolean fields and match exact state fields to offer +only eligible resources. Pagination uses the original rows before filtering. +Duplicate IDs are removed. Invalid or repeated continuation values +fail with guidance to supply an explicit ID. Enumeration is bounded to 1,000 +pages and 10,000 unique choices; it never silently truncates the menu. + +Choice fields accept dot paths such as `metadata.name` and `status.phase`. +An exact field name takes precedence when the response has a literal dotted key. +The former Connectivity adapter used this for resource names and opaque cursor +pages. Connectivity commands are currently absent pending independent plugin +distribution and later integration. + +A preferred version prefix orders suggestions; it does not select a fallback +version without user input. V4 versions appear first in the stack create menu. +The exact selected version is validated again by the Cloud plugin before create. diff --git a/docs/ledger-import.md b/docs/ledger-import.md deleted file mode 100644 index 205a0bd7..00000000 --- a/docs/ledger-import.md +++ /dev/null @@ -1,67 +0,0 @@ -# Ledger import - -`fctl ledger import` sends a file of ledger logs to the ledger's v2 -`ImportLogs` endpoint. The code lives in `cmd/ledger/import.go`. - -```bash -fctl ledger import [--resume-from-last-log] -``` - -## Input file - -One JSON log per line, as exported by the ledger, in ascending log ID -order. Lines have no length limit. A final line without a trailing newline -is still imported. - -## Flags - -| Flag | Default | Effect | -| --- | --- | --- | -| `--resume-from-last-log` | `false` | Skip the logs the ledger already has and continue after its last log. See [Resuming](#resuming-an-interrupted-import). | -| `--file` | `""` | Declared but not read. The file always comes from the `` argument. | - -## Batching and errors - -The file is sent in requests of 100 lines each, plus one for any remainder. -The command stops at the first read error or failed request and returns it. -"Ledger imported!" is printed only after every line has been sent. - -Batches sent before a failure stay in the ledger. Running the same import -again without `--resume-from-last-log` then fails: the ledger rejects any -log whose ID is not greater than its last log, with a 400 `IMPORT` error -whose message reads like: - -```text -importing logs: log 99 already exists -``` - -## Resuming an interrupted import - -With `--resume-from-last-log`, `fctl`: - -1. Fetches the ledger's most recent log. -2. If the ledger has no logs, imports the whole file. -3. Otherwise reads the file from the start until it finds the line whose - `id` equals that log's ID, then imports everything after it. - -The input file must therefore contain the ledger's last log ID, and every -line up to it must be valid JSON. Use the same file as the interrupted -import. - -If the ID is not in the file, the command fails without sending anything: - -```text -log 4521 not found in ./ledger-export.jsonl -``` - -This means the file does not match the ledger's current state, for example -a different export or one that ends before the ledger's last log. If the -ledger's last log is the file's last line, there is nothing left to import -and the command succeeds. - -### Migration - -Before fctl PR #191, a missing ID made the command skip to the end -of the file, import nothing, and print "Ledger imported!". Scripts that -relied on that exit code now get a non-zero exit and should check that the -file and ledger match. diff --git a/docs/legacy-plugin.md b/docs/legacy-plugin.md new file mode 100644 index 00000000..bdb53f17 --- /dev/null +++ b/docs/legacy-plugin.md @@ -0,0 +1,97 @@ +# Historical service plugin + +The legacy plugin preserves service commands from fctl v3 in the independent +`github.com/formancehq/fctl/misc/fctl-plugin` module. Its source baseline is +`e00243b3e2e56aae6a09d7010b0c17890134388c` on the historical `main` branch. +The command implementations are adapted to the public SDK; they do not import +Cobra, core packages, profiles, token storage or terminal libraries. + +## Source organization + +The module root assembles the public bundle and applies compatibility guards. +Each historical service has its own package under `misc/fctl-plugin/internal`; +its domain files own command metadata, payloads, aliases and contract tests. +Shared command helpers and HTTP test fixtures contain no service dispatch. +See [the module layout](../misc/fctl-plugin/README.md) for package ownership and +how to extend an operation. + +## Provider selection + +The first implementation embeds the SDK service facades in fctl and also builds +an independent `fctl-plugin-legacy` executable. Both call the same implementations. +The executable exposes a `legacy` command tree with per-command service boundaries. +A future distribution change can move these facades into the executable without +porting the service operations again. + +The core resolves the authenticated endpoint and inspects `/_info`. For Cloud +stacks it also reads the exact stack compatibility line from Membership. +Stacks in lines v1 through v3 can select legacy service facades; v4 prereleases +and later select modern plugins. Auth versions can be identical across stack +lines, so Auth version alone cannot identify a historical Cloud stack. + +For direct service connections, selection uses the service API family. An +installed modern plugin or `FCTL_PLUGIN_CATALOGUE` selects the modern provider. +A prepared modern provider stays modern after a service upgrade; its version +guard or catalogue resolution rejects an unavailable release. A missing modern release, failed download or invalid +checksum never activates a legacy fallback. + +| Legacy service | Supported API majors | +| --- | --- | +| Ledger | 1, 2 | +| Auth | 1, 2 | +| Payments | 1, 2, 3 | +| Orchestration, Reconciliation, Wallets, Webhooks | 1, 2 | + +Compatibility belongs to the plugin. Its own revision (`1.0.0`) is independent +of the target service version. An unknown development version is rejected. +The service version is checked again before each legacy execution, including +choice queries. The Cloud resolver also checks the stack line before execution. +This prevents a cached historical command from mutating an upgraded v4 stack. +Historical and current `/_info` envelopes are read by `pluginsdk.ServiceVersion`; +conflicting or absent version values are errors. + +## Metadata cache + +The core records selected providers under the private configuration directory: +`plugins/selections/.json`. Each record includes the +profile, organization, stack, endpoint, provider, service version, stack line +and plugin revision. Credentials and transient gateway URLs are excluded. +Records are atomically replaced, bounded and validated. Another target never +inherits a record. The modern OCI locks and binary cache keep their existing +format and are not replaced by this metadata. + +`fctl plugins selections` shows these records without contacting the service. + +Help and completion use local metadata. `fctl legacy --help` always describes +the historical bundle without contacting a service. Normal module roots such +as `ledger` select their prepared facade for the exact target. Execute a +command once to prepare a previously unseen target. + +```sh +fctl ledger list --organization ORGANIZATION_ID --stack LEGACY_STACK_ID +fctl legacy auth clients list --organization ORGANIZATION_ID --stack LEGACY_STACK_ID +fctl ledger list --organization ORGANIZATION_ID --stack V4_STACK_ID +``` + +## Build and validation + +```sh +nix develop --impure --command just build-legacy-plugin +nix develop --impure --command just pre-commit +nix develop --impure --command just tests +``` + +The root Just recipes include the separate SDK and legacy modules. GoReleaser +builds the independent executable for Linux, macOS and Windows on amd64 and +arm64, in its own archive. This change does not publish a release or advertise +compatibility ranges in the exact-version product catalogue. + +Mutation payloads and Numscript stay in the plugin. The host reads `--data` +inline JSON, `@file` or stdin and renders the response. Historical file operations +use the SDK body/result boundary instead of accessing the user's filesystem in +the plugin. Delete/revert operations require explicit confirmation. + +## Validation record + +See [the sandbox validation record](legacy-validation.md) for the tested Stack +v3.2 module versions, operations, authentication modes and remaining limits. diff --git a/docs/legacy-validation.md b/docs/legacy-validation.md new file mode 100644 index 00000000..c2d72fcc --- /dev/null +++ b/docs/legacy-validation.md @@ -0,0 +1,237 @@ +# Legacy plugin validation + +The legacy SDK plugin was exercised against a real Cloud sandbox on Stack line +**v3.2** on 2026-10-10. The recorded checks cover service commands, host forms, +file handling, authentication and provider selection. They establish the results +listed below; they do not establish complete coverage of every historical command. + +See [legacy plugin architecture](legacy-plugin.md) for provider selection and +compatibility. The [synthetic fixture guide](../misc/fctl-plugin/docs/live-fixtures.md) +describes candidate commands and prerequisites. Its prepared examples are not, +by themselves, evidence that an operation ran successfully. + +## Live target + +These are the actual module versions on the tested v3.2 sandbox: + +| Service | Version | +| --- | --- | +| Ledger | v2.4.15 | +| Auth | v2.5.1 | +| Payments | v3.4.8 | +| Orchestration | v2.7.0 | +| Reconciliation | v2.5.0 | +| Wallets | v2.2.1 | +| Webhooks | v2.5.4 | + +Only this historical Stack line received live validation. Other declared +compatibility ranges have contract-test coverage, not equivalent live coverage. +A separate **v4.0-beta** Cloud target was used to check the modern-provider boundary. + +## Recorded results + +The following local reports are in `/private/tmp/`. Counts are checks in each +report, not distinct commands or percentages of the command tree. Some reports +repeat operations from earlier runs. `PASS` must be interpreted with the stated +assertion boundary. + +| Report | Recorded result | Coverage | +| --- | --- | --- | +| `fctl-legacy-live-report.json` | 36 PASS | Ledger create/list/stats, metadata, large-integer send, account and transaction reads/metadata, volumes and revert; Auth client/secret lifecycle and user listing; reads across the other five services; provider selections. | +| `fctl-legacy-payment-webhook-report.json` | 11 PASS | Payments pool create/get/balances/delete, bank-account create/get/metadata; Webhook create/deactivate/update/delete. | +| `fctl-legacy-auth-modes-report.json` | 3 PASS | Standalone client-credentials authentication against real Auth, synthetic credential cleanup, and standalone `none` mode through the real CLI. Cloud session authentication is exercised by the main run. | +| `fctl-legacy-form-report.json` | 3 PASS | Ledger QA sequence: create and stats use terminal forms; list uses explicit arguments. Form results are valid stdout JSON. | +| `fctl-legacy-auth-form-report.json` | 2 PASS | Auth QA sequence: client creation uses a terminal form; deletion uses explicit arguments and confirmation. Creation returns valid stdout JSON. | +| `fctl-legacy-workflow-reconciliation-report.json` | 18 HTTP/CLI PASS | Workflow YAML create/show/run, instance show/history, trigger create/show/test/occurrences/delete and workflow delete; Reconciliation pool/policy create/read/reconcile/result read/delete and pool cleanup. Initial `--wait` completion semantics needed the follow-up below. | +| `fctl-legacy-workflow-assertions-report.json` | 3 PASS | Additional workflow create/run/delete checks. These do not replace the terminal-state assertion. | +| `fctl-legacy-workflow-wait-report.json` | 5 PASS | Final direct Cloud workflow create, run with `--wait`, terminal-state assertion (`terminated=true`), instance show readback and workflow delete. | +| `fctl-legacy-https-schema-report.json` | 2 PASS | Schema insertion from the published HTTPS fixture, then an exact version and chart readback. An empty creation response is valid. | +| `fctl-legacy-extra-report.json` | 9 PASS, 1 FAIL | Wallet create/show/update, balance create, credit/read/debit/transactions; bank-account create. The initial Payments pool payload failed validation because `query` was required. The later pool run passed. | +| `fctl-legacy-wallet-holds-report.json` | 7 PASS, 1 FAIL | Pending hold create/show/void, a second pending hold and confirm, and balance reads. An initial drain failed with insufficient funds; a subsequent balance read alone was not proof of zero. | +| `fctl-legacy-wallet-drain-report.json` | 6 PASS | Final reads, drain operations and explicit zero-balance checks for both tested wallet balances (`main` and the QA balance). | +| `fctl-legacy-clarity-report.json` | 8 PASS, 1 FAIL | Initial Clarity lifecycle attempt: evaluation failed because the rule was disabled. Its workflow-state label does not establish the original `--wait` semantics. | +| `fctl-legacy-clarity-enabled-report.json` | 8 PASS | With Clarity enabled: pool create, rule create/show/update/evaluate, evaluations list, rule delete and pool delete. | + +The Ledger and Auth form sequences used real PTY interaction. Their totals +include the explicit read or cleanup commands shown above; they are not five +separate interactive forms. + +The validation also confirmed the following live checks, which are not +given separate per-case counts in the reports above: + +- Ledger file handling for Numscript execution, export, import and schema + operations. The host reads source files and presents or writes results through + the SDK body/result boundary. +- Modern Auth and Ledger reads on the v4.0-beta target, with explicit legacy + execution rejected on that target. +- A native macOS `fctl-plugin-legacy` executable launched through the public SDK + transport completed a real service read. This checks the independent process + and host HTTP callback, in addition to the embedded service facades. + +## Package layout verification + +The service package refactor was checked against fctl commit `477182a2`. +All seven service manifests and the complete bundle manifest remained +byte-for-byte identical, including command ordering, aliases, forms and file +metadata. The 112 named historical contract cases were accounted for after +moving tests into their service packages. + +| Follow-up report | Result | Assertion boundary | +| --- | --- | --- | +| `fctl-legacy-refactor-smoke-report.json` | 20 PASS | Stack v3.2 readback and reads across all seven services, including Payments underscore aliases and Wallet selection by name. | +| `fctl-legacy-refactor-mutations-report.json` | 18 PASS | Ledger metadata write/read/delete; Auth client create/update/read/delete with omitted fields preserved; workflow YAML creation, terminal-state wait and readback; modern Auth/Ledger reads and explicit legacy rejection; cleanup readback. | + +These follow-ups used the same v3.2 sandbox and v4.0-beta comparison target. +Synthetic clients, workflows and metadata were removed and read back after +cleanup. Existing ledger, wallet and transaction fixtures were retained. + +The final refactor passed `just pre-commit`, `just tests` with `-race`, and +`goreleaser check`. Aggregate legacy-module statement coverage was **91.7%** +with `go test -coverpkg=./...`. GoReleaser built all six supported plugin targets; +the native macOS executable performed another real Ledger read through the +public SDK transport and the host's loopback stack proxy. + +The core pins the reorganized module at `v0.0.0-20261010170918-e99d7416fd49`. +The public module root retains the bundle and service factory API; service +implementations are internal packages. + +## Workflow wait correction + +Orchestration v2.7.0 initially returned an active instance from the run request +even with `wait=true`. A successful HTTP response from that request did not prove +that the workflow had terminated. This explains the boundary of the original +18-check workflow/Reconciliation report. + +The plugin now follows the returned instance with terminal-state polling when +`--wait` is set. It polls every **500 ms**, with a **five-minute polling deadline**, +and honors context cancellation. The deadline applies to the follow-up wait; +the initial run request still has the host's HTTP timeout. Polling reads the same +instance and does not start another run. Missing state, an unexpected instance ID, +a reported workflow error or a failed read produces an error. + +The final direct Cloud run records five PASS checks in +`fctl-legacy-workflow-wait-report.json`. It explicitly verifies `terminated=true` +and reads the instance back with `instances show`. The terminal-state check took +4.264 seconds. The original server behavior remains relevant context, but the +plugin correction and this final run resolve the workflow-wait validation gap. + +## Automated validation + +The final checkout passed `just pre-commit` (tidy, generation and lint across +the core, SDK and legacy modules), `just tests` with the race detector, and +`goreleaser check`. GoReleaser built the legacy executable for all six Linux, +macOS and Windows amd64/arm64 targets. The rebuilt native macOS executable +then repeated a real Ledger read through the SDK transport. These build checks +do not establish runtime validation on the other platforms. + +After the branch was pushed, a fresh independent Go consumer downloaded +`github.com/formancehq/fctl/misc/fctl-plugin` at +`v0.0.0-20261010100529-b96046bc717b`, without a local `replace` or workspace. +It resolved the public SDK dependency and read the seven-service manifest. +This verifies that consumers can import the published module independently +of the fctl checkout; it is separate from binary release publication. + +## Reproduce selected checks + +Use the built binary and an authenticated sandbox profile. Replace the +variables with a dedicated disposable target and fixture names. These examples +contain no organization IDs, Stack IDs or credentials. Keep shell tracing and +HTTP debug tracing disabled when recording results. + +```sh +: "${FCTL_BIN:?Set the path to the built fctl binary}" +: "${QA_PROFILE:?Set the saved sandbox profile name}" +: "${QA_ORGANIZATION:?Set the sandbox organization ID}" +: "${QA_STACK:?Set the v3.2 sandbox Stack ID}" +: "${QA_LEDGER:?Set the dedicated QA ledger name}" +fctlqa() { + "$FCTL_BIN" --profile "$QA_PROFILE" \ + --organization "$QA_ORGANIZATION" --stack "$QA_STACK" \ + --no-input --output json "$@" +} + +fctlqa ledger list +fctlqa ledger stats --ledger "$QA_LEDGER" +fctlqa legacy auth clients list +fctlqa legacy payments pools list +fctlqa plugins selections +``` + +| Flag | Purpose in validation | +| --- | --- | +| `--profile` | Use an existing saved profile and its host-managed authentication. | +| `--organization`, `--stack` | Select the exact Cloud target. | +| `--no-input` | Disable forms and selectors for scripted checks. | +| `--output json` | Preserve machine-readable results for assertions. | +| `--ledger` | Scope Ledger operations to the dedicated fixture. | +| `--confirm` | Explicitly authorize commands that declare confirmation. | +| `--data` | Supply inline JSON, `@file` or stdin (`-`) for commands that accept an SDK body. | +| `--auth-mode` | Select `cloud`, `client-credentials` or `none`; use separate prepared profiles for standalone checks. | + +For a PTY form check, invoke the built binary in a terminal, omit `--no-input` +and leave the required field unset. The recorded examples are `ledger create`, +`ledger stats` without a ledger selection, and `auth clients create`, with +`--color always` and `--output json`. Read stdout separately from terminal prompts. + +To repeat the file checks, prepare synthetic `transfer.num`, `schema.yaml` and +an empty import target using the host's file boundary. Set `QA_DIR`, +`QA_IMPORT_LEDGER` and `QA_SCHEMA_VERSION` before running: + +```sh +fctlqa ledger transactions num "$QA_DIR/transfer.num" --ledger "$QA_LEDGER" --confirm +fctlqa ledger export --ledger "$QA_LEDGER" --file "$QA_DIR/logs.ndjson" +fctlqa ledger create "$QA_IMPORT_LEDGER" --confirm +fctlqa ledger import "$QA_IMPORT_LEDGER" "$QA_DIR/logs.ndjson" +fctlqa ledger schemas insert "$QA_SCHEMA_VERSION" "$QA_DIR/schema.yaml" \ + --ledger "$QA_LEDGER" --confirm +fctlqa ledger schemas get "$QA_SCHEMA_VERSION" --ledger "$QA_LEDGER" +``` + +For the wait check, create a synthetic workflow from a YAML file containing +`stages: [{delay: {duration: 1s}}]` and a synthetic name. With `jq` available: + +```sh +workflow_json="$(fctlqa legacy orchestration workflows create "$QA_DIR/workflow.yaml")" +workflow_id="$(printf '%s' "$workflow_json" | jq -er '.data.id')" +instance_json="$(fctlqa legacy orchestration workflows run "$workflow_id" --wait)" +printf '%s' "$instance_json" | jq -e '.data.terminated == true' +instance_id="$(printf '%s' "$instance_json" | jq -er '.data.id')" +fctlqa legacy orchestration instances show "$instance_id" \ + | jq -e '.instance.data.terminated == true' +fctlqa legacy orchestration workflows delete "$workflow_id" --confirm +``` + +For the modern boundary, repeat the normal Auth and Ledger reads with a prepared +v4.0-beta target and its modern plugins. An explicit `legacy` service command +must return an error on that target. For standalone authentication, select saved +profiles prepared separately for `client-credentials` and `none`; keep credentials +in the host configuration rather than copied into commands or reports. + +## Coverage and delivery boundaries + +- **PSP-linked Payments mutations remain untested.** No PSP was available. + Pool and bank-account checks do not establish connector installation, + PSP account/payment flows, transfers or transfer-initiation mutations. The + conditional DUMMYPAY sequence in the fixture guide is not a successful live run. +- **No outside webhook send was performed.** Webhook lifecycle checks used a + non-routable `.invalid` destination. Test-send, replay and external delivery + success are outside this record. +- Reconciliation policy operations and enabled Clarity rule evaluation passed + the recorded checks. They do not establish reconciliation against real PSP + balance history or every rule template and workflow stage. +- Wallet pending debit, confirm and void were exercised. Final tested balances + are verified as zero by the drain report. The wallet and transaction history + remain; the historical API has no wallet-delete command. Cleanup of reported + fixtures does not imply removal of all sandbox data. +- The initial integration embeds the SDK facades in fctl. The independent + executable's macOS read passed, but no registry publication or release + publication is established. This record does not claim live execution on + Linux, Windows or every advertised architecture. +- Contract tests validate declared API families and failure cases separately + from live QA. Automated checks and live QA establish separate results; commit, push, + merge and deployment remain separate delivery states. + +Report future results by target version, operation, assertion and remaining +boundary. Preserve failed attempts and their successful follow-ups. Keep secrets, +generated Auth/Webhook secrets and raw authenticated responses out of shared +reports. Do not describe these selected checks as “100% of all commands tested.” diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 00000000..1bb88df4 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,132 @@ +# Auth and Ledger modules + +The root registers embedded Cloud and loads Auth and Ledger commands from +external plugins. The local Ledger and Connectivity adapters have been removed. +Auth has official discovery; Ledger has loader and management support but no +official release in the catalogue. Connectivity commands are absent pending +independent distribution and later integration. Host connection settings remain +available; the migration is not complete. +This page describes the service modules; [Cloud management](cloud.md) describes +control-plane commands. Each plugin implements the public `pluginsdk.Plugin` manifest/execution contract. The core builds Cobra +commands from manifests and supplies an authenticated HTTP client and endpoint. +Plugins own service routes, payloads and command descriptions. They import no +Cobra or core internal packages and do not read profiles or implement Cloud +authentication. See the [plugin contract](plugins.md) and +[plugin distribution](plugin-distribution.md) for discovery, installation and +exact-version selection. See [Connectivity](connectivity.md) for its API and +commands. + +## Ledger + +Ledger commands require a trusted exact-version external executable installed +for the selected target. A future Ledger product release must publish the +executable and promote its catalogue entries before official sync is available. +The examples below describe the previously validated HTTP command contract; +they do not advertise commands bundled with fctl. Consult the installed product +manifest for its current command tree. + +### Historical HTTP contract + +The former embedded adapter's HTTP contract was taken from `formancehq/ledger`, +branch `release/v3.0`, +commit `0f4656d1efbcac42705839daccb34012e70d783a`. Business requests use `/v3`; +server information uses `/_info`. This revision does not contain a public +`pkg/client` module. The former adapter used a small HTTP client with route and +payload tests +rather than importing server packages or the legacy v2 SDK. That local adapter +is removed; external command implementations belong to the Ledger product. + +```bash +fctl ledger create books +fctl ledger list +fctl ledger show books +fctl ledger --ledger books accounts list --page-size 20 +fctl ledger --ledger books accounts list --page-size 20 --after users:001 +fctl ledger --ledger books accounts show users:001 +fctl ledger --ledger books transactions create \ + --data @transaction.json --idempotency-key payment-42 +fctl ledger --ledger books transactions list +fctl ledger --ledger books transactions list --after 42 +fctl ledger --ledger books logs list --after 42 +fctl ledger --ledger books transactions revert 1 --idempotency-key revert-42 +``` + +Request bodies accept inline JSON, `@file`, or `-` for stdin. Use the v3 JSON +schema for postings and Numscript transactions. Amounts remain exact JSON +integers; no floating-point conversion is performed. For example: + +```json +{"postings":[{"source":"world","destination":"users:001","asset":"USD/2","amount":1000}]} +``` + +JSON output preserves the complete server envelope; terminal tables summarize +lists and display their pagination fields. Use `-o json` for complete payloads. +`ledger list` returns all ledgers and has no pagination or reverse flags. +Accounts, transactions and logs return one page. Continue with `--after` using +the last returned account address or unsigned transaction/log ID; it is an +exclusive boundary sent as the backend's `after` parameter. Keep the same filters +and order. Accounts and transactions support `--reverse`; logs do not. + +Only `ledger indexes inspect` uses opaque `--cursor` tokens, taken from its +`nextCursor` response. Regular pages use `--page-size` (default 100; 0 means 100, +with a server cap of 1000). Index inspection accepts page sizes from 1 to 10000. +Use `--filter` for the v3 filter syntax and +`--consistency linearizable|stale` for supported reads. Continuation examples +above illustrate the boundary value; use the last address or ID from your own +previous response. + +Other commands cover ledger/account/transaction metadata, balances, stats, +logs, indexes and bulk operations. Use `--help` on each group for its exact +options. Metadata reads return the resource envelope because v3 has no separate +GET metadata endpoint. Log reads require the corresponding index. Bulk results +must be inspected per element; business failures return a nonzero exit code. +There is no v2 log import/export or backup restore compatibility command. + +Deletes require `--confirm`. Idempotency keys are supplied by the caller and +never generated automatically. Reuse the same key only for the same logical +operation; inspect server errors before rerunning a mutation. + +## Auth + +Auth commands belong to the independent +`github.com/formancehq/auth/misc/fctl-plugin` product module. That module imports +`github.com/formancehq/auth/pkg/client` at +`v0.0.0-20251106135031-5373fa4eaeba`. The Auth product plugin module is +not a compile-time dependency of fctl. The external executable uses the host HTTP broker for +authentication; its generated client does not obtain credentials itself. + +Auth has no embedded provider. Before executing an Auth command, fctl uses an +installed exact-version executable or discovers and installs a matching release +from the official catalogue. An unavailable official catalogue or missing exact +release produces an explicit error; it does not enable an embedded fallback. +A trusted local build can be installed instead. See the +[local build and official sync examples](plugin-distribution.md#auth-installation). + +On a fresh target, `fctl auth --help` performs no network access and displays a +placeholder with sync/install guidance. Once that target is prepared, its full +Auth help and completion use cached command and form metadata offline. The +following commands assume a matching plugin has been installed or is available +for automatic discovery: + +```bash +fctl auth info +fctl auth discovery +fctl auth clients list +fctl auth clients create --data '{"name":"automation","scopes":["ledger:read"]}' +fctl auth clients show CLIENT_ID +fctl auth clients update CLIENT_ID --confirm --data '{"description":"Read-only automation"}' +fctl auth clients secrets create CLIENT_ID --data '{"name":"ci"}' +fctl auth clients secrets list CLIENT_ID +fctl auth users list +fctl auth users show USER_ID +``` + +Updates read the current client and preserve omitted options. Explicit empty +arrays/objects or `false` clear the corresponding settings. Client and secret +deletions require `--confirm`. A newly created secret is deliberately written +to stdout once so the caller can save it. List/show commands expose secret +metadata. Auth has no user CRUD or list pagination in this client contract. + +Auth administration and Cloud login are separate operations. Auth's server +does not implement the Membership device-code flow; Cloud login targets +Membership and exchanges its assertion with Auth afterward. diff --git a/docs/output.md b/docs/output.md new file mode 100644 index 00000000..5053337a --- /dev/null +++ b/docs/output.md @@ -0,0 +1,75 @@ +# Output and diagnostics + +The host renders command JSON through `presentation.Render`. Plugins return +raw JSON; terminal styling does not change service requests or plugin schemas. + +| Setting | Default | Behavior | +| --- | --- | --- | +| `-o`, `--output` | `auto` | `auto` selects tables on a terminal and JSON otherwise; explicit values are `json` and `table` | +| `--color` | `auto` | `auto` colors terminal output; `always` forces color and `never` disables it | +| `NO_COLOR` | Unset | A nonempty value disables automatic color | +| `TERM=dumb` | Environment-dependent | Disables automatic color | +| `-d`, `--debug` | `false` | Writes HTTP diagnostics to stderr | + +`--color always` overrides automatic color suppression. JSON output never adds +ANSI styling, even when color is forced. Full Cobra help remains available; +color emphasizes its standard section labels, including Additional Commands, +and every declared Cobra command group, including Plugins. Only complete +heading lines are styled; descriptions and examples retain their original text. + +```sh +fctl cloud stack list +fctl cloud stack list -o json > stacks.json +fctl ledger list -o table --color never +NO_COLOR=1 fctl cloud organizations list +``` + +## JSON and human tables + +JSON output preserves all response fields and exact JSON numbers, with +indentation. Use it for scripts and complete payload inspection. + +Human lists select at most six scalar columns that fit the terminal width, +favoring IDs, names, state/status, region and version. Nested configuration and +additional fields are omitted from list cells. Details display top-level fields; +large nested objects and arrays use bounded summaries, with total item counts +for arrays. Tables do not add omitted-field counts or JSON reminders. Empty +nested containers display `—`. Empty `schema`, `mirrorSource` and +`mirrorSyncProgress` configurations also display `—`, including null values or +objects containing only empty strings, nulls and empty containers. Booleans and +numbers, including `false` and `0`, remain meaningful details. Top-level scalar +values and numeric tokens are preserved and wrapped. Empty lists have a friendly +message; pagination displays cursor and `hasMore` information. + +Plugin lists display the service identity as the first `Module` column. JSON +output keeps the original `service` field and the complete plugin summary. + +Plain tables use ASCII borders. Colored tables add a title and status colors. +Control characters in values are escaped; width calculations account for wide +characters and generated ANSI styling. The renderer is a presentation layer, +so human lists are summaries rather than a replacement for complete JSON. + +## HTTP diagnostics + +```sh +fctl login --no-browser -d +fctl cloud stack list -o json -d +``` + +Debug traces include method, sanitized URL, headers, response status and bounded +body previews. Each preview is limited to 64 KiB. Unknown, malformed, incomplete +or oversized bodies are summarized instead of printed raw. Request bodies are +inspected through `GetBody` when available; tracing does not consume a request +stream. Response tracing follows normal reads and close, preserving the stream. +Concurrent trace output is synchronized. + +Authorization, cookies and sensitive query, form and nested JSON fields are +redacted, including tokens, secrets, passwords, device codes and Auth's `clear` +secret field. Device verification URLs receive the same query filtering. +Known OAuth `error` codes remain visible, including `invalid_grant`, +`invalid_client`, `server_error` and `temporarily_unavailable`; arbitrary error +text and `error_description` are redacted. + +Diagnostic redaction does not alter command results. Commands that intentionally +return credentials, such as secret creation or personal-token generation, still +write those results to stdout. diff --git a/docs/plugin-distribution.md b/docs/plugin-distribution.md new file mode 100644 index 00000000..1c8f3840 --- /dev/null +++ b/docs/plugin-distribution.md @@ -0,0 +1,322 @@ +# External service plugin distribution + +Auth and Ledger run only as external native executables. Their generic host +loader and plugin management already exist; the local Ledger embedded fallback +has been removed. Auth official discovery is available. No official Ledger +release is currently advertised in the catalogue: a future Ledger product +release must publish executables and promote their entries. Trusted local +Ledger builds can be installed in the meantime. + +Connectivity commands are absent after removal of the local adapter. An +independently distributed Connectivity plugin must be integrated later; +`plugins install/sync/show` currently support only Auth and Ledger. Host +connection settings are preserved. These changes do not complete the migration. + +The Auth and Ledger product modules own their commands and executable entry points: + +| Service | Product module | Executable | +| --- | --- | --- | +| Ledger | `github.com/formancehq/ledger/misc/fctl-plugin` | `fctl-plugin-ledger` | +| Auth | `github.com/formancehq/auth/misc/fctl-plugin` | `fctl-plugin-auth` | + +Auth product sources remain in the Auth repository. Public executable distribution +is a separate publication step and does not require publishing private sources. +The public SDK is a separate module at +`github.com/formancehq/fctl/pkg/pluginsdk`. Service plugins depend on this SDK; +they do not depend on fctl's commands, profiles, forms or authentication code. +Auth and Ledger commands are not compiled into fctl through local service +adapters. Their command manifests come from separately installed executables; +Cloud remains embedded. + +## Try a local executable + +Build from the Ledger checkout using the exact service version on your target: + +```sh +nix develop --command just build-fctl-plugin 3.0.0-beta.10 1 +./build/fctl-plugin-ledger --version +./build/fctl-plugin-ledger --manifest +``` + +From fctl, prepare the selected target and then use ordinary Ledger commands: + +```sh +fctl plugins install --binary /path/to/ledger/build/fctl-plugin-ledger \ + --organization ORGANIZATION_ID --stack STACK_ID +fctl ledger list --organization ORGANIZATION_ID --stack STACK_ID +fctl ledger create --organization ORGANIZATION_ID --stack STACK_ID +fctl plugins show --organization ORGANIZATION_ID --stack STACK_ID +fctl plugins list +``` + +Installation discovers the version through the selected Ledger's `/_info` and +rejects a binary whose manifest targets another version. Authentication and +debug traces still belong to fctl. Local endpoints support `none` and +`client-credentials`, and Cloud stacks use the existing Cloud profile. +Terminal forms and tables use the external plugin manifest. No plugin-specific +flags +are added to ordinary Ledger commands. + +## Auth installation + +Build the Auth executable from its independent product module with Go 1.26 or +the product's Nix environment. You need access to an Auth checkout containing +the merged plugin changes. Use the exact deployed Auth version from `/_info`, +without its leading `v`, and a positive plugin revision. The version below is +an example: replace it with your target's version. From the Auth checkout root: + +```sh +export AUTH_SERVICE_VERSION=2.5.0-beta.1 +export PLUGIN_REVISION=1 +cd misc/fctl-plugin +GOWORK=off go build \ + -ldflags "-X main.serviceVersion=$AUTH_SERVICE_VERSION -X main.revision=$PLUGIN_REVISION" \ + -o /tmp/fctl-plugin-auth ./cmd/fctl-plugin-auth +/tmp/fctl-plugin-auth --manifest +/tmp/fctl-plugin-auth --version +``` + +Alternatively, from the Auth checkout root: + +```sh +nix develop --impure --command just build-fctl-plugin 2.5.0-beta.1 1 +./build/fctl-plugin-auth --manifest +./build/fctl-plugin-auth --version +``` + +Install the module-built executable for a local Auth target. This example +assumes Auth is running at `http://localhost:8080` and permits anonymous access: + +```sh +fctl plugins install --service auth --binary /tmp/fctl-plugin-auth \ + --auth-mode none --auth-url http://localhost:8080 +fctl auth clients list --auth-mode none --auth-url http://localhost:8080 +fctl plugins show --service auth \ + --auth-mode none --auth-url http://localhost:8080 +fctl auth --help --auth-mode none --auth-url http://localhost:8080 +fctl completion zsh --auth-mode none --auth-url http://localhost:8080 +``` + +For the Nix build, use `--binary ./build/fctl-plugin-auth` instead. Installation +reads the binary's manifest and discovers the deployed version; it rejects a +mismatch. To prepare without contacting Auth, add +`--service-version "$AUTH_SERVICE_VERSION"` to the install command. The supplied +version must match the binary; execution still verifies the deployed version. +The last two commands use the installed target's complete cached metadata and +remain usable with Auth offline. Without that cache, `fctl auth --help` shows +only a placeholder guiding `plugins sync --service auth` or +`plugins install --service auth`; it never contacts a catalogue or Auth. + +Auth official discovery and explicit sync are available for matching published +versions; the catalogue advertises Auth `2.5.2`, revision `1`. Sync it explicitly +for a target running that exact version. These commands force the official +catalogue, +even if an environment override or saved custom catalogue exists: + +```sh +fctl plugins sync --service auth \ + --catalogue https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml \ + --auth-mode none --auth-url http://localhost:8080 +fctl plugins sync --service auth \ + --catalogue https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml \ + --organization ORGANIZATION_ID --stack STACK_ID +``` + +These examples require a matching published Auth version on the target. +See [Auth release availability](#auth-release-availability) for the recorded +publication evidence. Local installation works independently of publication. +For a custom test catalogue, pass `--catalogue ./registry.yaml` with the same +Auth target flags. + +`plugins install`, `plugins sync` and `plugins show` accept `--service ledger` +or `--service auth`; the default remains `ledger`. Unknown services are rejected +before installation. The selected service must match the manifest service, name +and command root. The manifest version must equal the exact service version. +Without `--service-version`, installation and sync discover that version through +the selected service's `/_info`. An explicit version supports preparation without +contacting the service; execution still verifies the deployed version. + +## Public OCI distribution + +For each product service version, build six executables for Linux, macOS and +Windows on amd64 and arm64. Publish them as public OCI artifacts. No container +engine is required on the user's machine. Keeping service sources private does +not require keeping their CLI binaries private; package visibility must be +configured explicitly in the registry. + +The product's publisher generates catalogue entries from the actual manifest, +platform builds, checksums and published OCI digests. Publish every artifact +before advertising its mapping. The catalogue has `schemaVersion: 1` and a +`releases` array. Each entry contains: + +| Field | Meaning | +| --- | --- | +| `service` | `ledger` or `auth` | +| `serviceVersion` | Exact version reported by the selected service; identical to `manifest.version` | +| `revision` | Positive plugin revision for that service version | +| `platform` | Operating system and architecture | +| `artifact` | Registry origin, repository and immutable `sha256:` manifest digest | +| `sha256` | SHA-256 of the raw executable layer | +| `manifest` | Complete SDK command and form manifest, including protocol version | + +The pilot accepts a trusted YAML or JSON catalogue file or HTTPS URL. HTTP is +allowed only for loopback test registries and catalogues. The default catalogue +is the public `registry.yaml` in `formancehq/fctl-plugin-registry`: + +```text +https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml +``` + +The central registry can use `schemaVersion: 2` to reference product release +catalogues instead of copying their command manifests: + +```yaml +schemaVersion: 2 +plugins: + auth: + releases: + - serviceVersion: "2.5.2" + catalogue: https://github.com/formancehq/auth/releases/download/v2.5.2/fctl-plugin-catalogue.json + sha256: "" +``` + +Each product publishes its existing schema-1 catalogue with its release. fctl +fetches only the catalogue matching the selected service and exact version, +checks its raw-byte SHA-256 before decoding it, and selects the platform and +plugin revision using the existing rules. All entries in that catalogue must +belong to the indexed service and version. Nested catalogue references are +rejected. An index contains one reference per service version; a catalogue may +contain multiple plugin revisions for that same version. + +Direct schema-1 YAML/JSON catalogues remain supported. OCI artifacts, plugin +protocols and target-lock schema stay unchanged. The target lock retains the +central index URL and complete selected manifest, so later service-version +changes resolve through the same index and cached help remains offline. The +product publisher must publish and verify artifacts before submitting a central +registry update containing the catalogue URL and checksum. Release the compatible +fctl loader before migrating a central registry from schema 1 to schema 2. + +The default Ledger sync below requires a future official Ledger release. +Today, use a trusted matching custom catalogue or the local installation +procedure above; a missing official entry cannot prepare commands. + +```sh +fctl plugins sync --profile local +fctl plugins sync --catalogue ./registry.yaml --profile local +fctl ledger list --profile local +``` + +Before Auth execution on a fresh target, fctl discovers the official catalogue, +resolves the target's exact service version and platform, and installs the +matching executable. An unavailable official catalogue, an empty catalogue or +no exact matching Auth release fails explicitly before command execution. Auth +never falls back to an embedded provider. A prepared target uses its installed +executable and cached metadata; ordinary execution still checks its version. + +Ledger has no embedded provider. The existing external loader and management +commands can use a trusted installed executable or a matching custom catalogue. +The official catalogue currently has no Ledger release, so official sync cannot +prepare Ledger commands. Install a trusted exact-version binary until the Ledger +product publishes and promotes a release. Missing releases or unavailable +metadata do not restore an embedded command tree. + +Set `FCTL_PLUGIN_CATALOGUE` to override the catalogue used for automatic +preparation. Explicit `plugins sync` prefers `--catalogue`, then the environment +variable, then the selected service's saved catalogue, then the official URL. +The core reads `/_info`, selects the exact version and current platform, checks +protocol compatibility, downloads the digest-addressed artifact and verifies +the manifest, config and executable checksums. An exact matching release is +required for discovery or sync. A trusted local +installation must also match the target version; there is no embedded fallback. +The stack supplies its service version; it never +supplies an executable URL. + +Checksums verify bytes against the trusted catalogue. They do not independently +authenticate its publisher. Protect catalogue publication and registry write +access. Only explicitly trusted local binaries should be installed. + +## Locks and updates + +Locks are identified by service plus profile, organization, stack and stable +connection endpoint. Auth and Ledger keep independent locks for the same target. +Two stacks in the same profile can use different service versions. Executables +are cached by digest and platform under the private v4 +configuration directory's `plugins` folder. + +Normal commands retain the locked plugin revision. A changed service version +selects the matching plugin automatically when the target has a catalogue; +fctl prints a preparation notice on stderr. A locally installed binary instead +reports the mismatch and must be replaced. Version checks run before writes. + +An explicit `plugins sync` selects the highest plugin revision for the exact +service version. A CLI-only fix increments that revision without requiring a +new product server release. CI can pin both values: + +```sh +fctl plugins sync --catalogue ./catalogue.json --profile local \ + --service-version 3.0.0 --revision 1 --no-input +``` + +Help and completion read the selected target's cached manifest, including its +full command tree and declarative forms, without network access or a running +plugin process. A fresh Auth or Ledger target exposes only sync/install guidance; help +and completion do not trigger automatic discovery. Prepared commands can use +cached executables without downloading from the registry again. Service operations still need +access to the selected service and its version endpoint. + +## Validation + +`just pc` checks the core and SDK modules. `just tests` runs both race suites. +The CLI tests publish synthetic public-SDK Ledger and Auth fixture executables +to HTTP OCI test registries on localhost, +download and execute two service versions, retain and upgrade plugin revisions, +and check offline help and completion. Auth tests also reject mismatched catalogue +identity and exact versions before downloads or lock writes, and verify independent +Auth and Ledger locks. These fixtures validate the host transport and rendering, +including explicit partial-result errors; they do not validate Ledger business +behavior. Opt-in product tests separately exercise the independently published +Auth executable. Additional tests cover host OAuth2 and +debug redaction, exact large JSON integers, partial bulk errors, cancellation, +endpoint restrictions, corrupt downloads and concurrent cache installation. + +The SDK and product module can also be built and tested independently. Production +OCI publication and updating the official catalogue are separate release +steps; snapshot packaging does not perform either action. + +## Auth release availability + +Auth PR #162 is merged and the `v2.5.2` tagged release has published all six +Auth executables at revision 1. Anonymous GHCR downloads and executable +checksums have been verified. The generated entries were merged through +[registry PR #2](https://github.com/formancehq/fctl-plugin-registry/pull/2), +so the default catalogue now advertises Auth `2.5.2`, revision `1`. + +For future releases, the tag must include the plugin module and release tooling +on the tagged branch. + +Official availability requires separate release steps: + +1. The Auth tagged-release workflow builds the six executables and publishes + digest-addressed OCI artifacts to `ghcr.io/formancehq/fctl-plugin-auth`. +2. The package administrator enables public access, and maintainers verify + anonymous downloads and executable checksums. A successful upload or a + GitHub release asset alone does not prove public download availability. +3. Maintainers promote the generated catalogue entries into + `formancehq/fctl-plugin-registry/registry.yaml`. Auth's release workflow does + not update that official catalogue. + +Snapshot packaging performs neither GHCR publication nor official catalogue +promotion. Auth `2.5.2` revision `1` has passed anonymous download checks +for all six platforms and fresh-cache discovery through the official catalogue. +The execution check used the published binary with a local API fixture; it did +not upgrade or mutate a deployed Auth service. Targets running other exact +versions need a matching release or a trusted local executable. + +## Historical stacks + +[The legacy module](legacy-plugin.md) preserves historical service commands in +one independently versioned bundle. Its first integration embeds SDK facades +in fctl and builds a separate release archive. The core selects it explicitly +for compatible historical targets; it is never a fallback for a failed modern +plugin download. Modern exact-version catalogue entries and OCI locks keep +this document's existing resolution rules. diff --git a/docs/plugin-streaming.md b/docs/plugin-streaming.md new file mode 100644 index 00000000..99309e1b --- /dev/null +++ b/docs/plugin-streaming.md @@ -0,0 +1,85 @@ +# Plugin streams and progress + +Streaming is an optional extension of the public plugin SDK. Unary plugins keep +their `GetManifest` and `Execute` methods and protocol version 1. A command with +`CommandSpec.Streams` uses `StreamingPlugin.ExecuteStream`; an implementation +without that method fails explicitly, before the host reads an input stream. + +## Contract + +`StreamSpec.Input` and `StreamSpec.Output` declare raw byte streams. +`ExecutionStreams` supplies an optional reader, input size, writer and progress +reporter. They exist only during that invocation. The host owns sources and +destinations; a plugin must never open the user's file or print terminal output. + +`FileSpec` selects the source and destination. Streaming does not convert raw +output into JSON or YAML: its format must match the declared stream format. +Bounded inline `Body` remains available where the plugin supports it, but cannot +be combined with a streamed input. Final `ExecuteResponse.Data` remains a bounded +JSON result and is distinct from streamed output. + +For an input stream, a required Body flag describes the inline alternative; it +does not require a second inline body alongside the reader. Plugins validate +that their required stream or supported inline alternative is present. + +The external runtime adds a bidirectional `ExecuteStream` RPC. The plugin pulls +input in chunks of at most 64 KiB; the host does not upload or read ahead of its +requests. Output uses bounded chunks and transport backpressure. Progress and +final results have their own frames. There is no automatic retry or replay. +Existing unary requests keep the 4 MiB Body and 32 MiB result limits. + +Streaming HTTP callbacks preserve host authentication, endpoint and redirect +boundaries, credential-header filtering, request timeout and diagnostics. They +transfer bodies incrementally instead of buffering the complete HTTP response. + +Canceling an external invocation terminates and reaps the executable. Plugins +must propagate cancellation and input, output or reporter failures. An +interruptible input reader or output writer must unblock on Close; the native +transport closes such host streams on cancellation. The CLI interrupts its own +stdin and stdout pipes. Plugins must not retain streams after returning. + +## Progress + +`ProgressEvent` carries `phase`, `completed`, optional `total`, and `unit`. +Counters are absolute. Absence of a total means indeterminate progress. Labels +are bounded and cannot contain terminal control characters. A total of zero is +valid for an empty operation. + +The host renders progress on stderr in an interactive terminal and throttles +updates. Explicit JSON output, CI and disabled interactive input suppress terminal +progress. stdout remains the command result or raw export. Reading input and +committing work are separate phases: bytes consumed do not prove server +acceptance. Report errors must stop execution. + +## Legacy Ledger + +`legacy ledger import NAME FILE` and `legacy ledger export --ledger NAME` +use streams. Files and stdin no longer pass through a whole-file JSON Body. +The plugin keeps bounded NDJSON records and batches, preserves exact integers +and unknown fields, and reports confirmed import counts. Resume scans to the +backend's latest log ID; a missing checkpoint causes no import writes. + +An import can commit successful batches before a later malformed record or +transport failure. Its final partial result counts only acknowledged batches; +a failed or ambiguous batch is never automatically retried. Use an explicit +resume after inspecting the service's state. + +For an export to a file, the host streams into a private temporary file beside +the destination. Only successful completion replaces the destination. Failure +or cancellation removes the temporary file and preserves a previous export. +stdout exports are incremental and cannot be rolled back; they contain no +final summary or progress, including on failure. + +Modern Ledger APIs do not gain historical log import/export endpoints from this +change. The extension belongs to the plugin contract; individual products must +implement their supported operations. + +## Validation + +The transport tests exchange 80 MiB through a real plugin executable and check +content, incremental delivery, progress, rejection before input reads, reporter +and writer failures, and process cleanup. Integration fixtures use real files +larger than 64 MiB, verify import/export SHA-256 and stdout separation, and run +the independently built legacy executable through the HTTP callback. + +These are local API fixtures, not additional Cloud sandbox validation. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..2e3f6a01 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,146 @@ +# Plugin contract + +The optional [streaming and progress extension](plugin-streaming.md) adds raw +byte streams and structured progress without changing ordinary unary plugins. + +Cloud and external service plugins implement `pkg/pluginsdk.Plugin`. Cloud has +an embedded provider. Modern Auth and Ledger use external executables. +Historical stacks use the SDK facades from the independent +[`misc/fctl-plugin` legacy module](legacy-plugin.md). The local +`plugins/ledger` and `plugins/connectivity` adapters have been removed. The public SDK +is a separate Go module, `github.com/formancehq/fctl/pkg/pluginsdk`. It has no Cobra, +profile-store or core authentication dependency. Each plugin exposes two methods: + +- `GetManifest` describes its command tree, arguments, typed flags and declarative + interactive inputs. +- `Execute` accepts a command path, arguments, resolved flags, changed flags, + a JSON body, a resolved service endpoint and non-secret host context. It returns + raw JSON and an error. + +The manifest and execution messages are JSON-serializable. Amounts remain raw +JSON integers. A response can accompany an error so bulk partial results are +preserved. Protocol version 1 identifies this contract; it is inspired by the +manifest/execution split in Geoffrey's [proposal](https://github.com/formancehq/fctl/pull/126). +It is not wire-compatible with that draft's protobuf messages. + +An independent Go caller can use the product-owned Ledger module +`github.com/formancehq/ledger/misc/fctl-plugin` without constructing a CLI. +This example requires that separate module, not a local `plugins/ledger` adapter: + +```go +p := ledger.New(authenticatedHTTPClient) +manifest, err := p.GetManifest(ctx) +// Handle err before using manifest. +response, err := p.Execute(ctx, pluginsdk.ExecuteRequest{ + CommandPath: []string{manifest.Name, "list"}, + Endpoint: "https://stack.example/api/ledger", +}) +// Inspect response.Data even when err is non-nil for bulk operations. +``` + +For writes, supply already-read JSON in `Body`. Direct SDK callers do not need +CLI-only values such as `--data @file`; `NormalizeRequest` validates the payload +boundary and resolved flags. + +## Service selection and gates + +`Manifest.Service` identifies the default host connection boundary. +`CommandSpec.Service` overrides it for a command and its descendants; the nearest +override wins. `pluginsdk.CommandService` resolves this value. Cloud normally +uses `cloud` (Membership), while `cloud apps` declares `cloud-apps` (Deploy). +The host resolves the authenticated client for that service, then injects its +endpoint and context into the request. + +`FlagSpec.RequireTrue` applies only to boolean flags and requires the resolved +value `true`. Apps uses it for its inherited `--experimental` flag. An omitted +default-false flag or `--experimental=false` is rejected before body-file access, +connection resolution or plugin construction. Destructive commands separately +declare `CommandSpec.Confirm` and require `--confirm=true`. + +`ExecuteRequest.Context` is a copied `map[string]string` of non-secret host +metadata. The Cloud host supplies selected and available organization/stack IDs; +the application host supplies organization and application alias. Tokens, +credentials and profile paths do not belong in this map. Authentication stays +in the injected HTTP transport. + +`CommandSpec.Aliases` preserves command aliases. The SDK resolves them to +canonical command paths; the host checks aliases for command collisions. + +`CommandSpec.Files` declares optional host file effects. The host reads local +files, stdin or explicit HTTPS sources into `Body`, then returns JSON, YAML or +NDJSON to stdout or an atomic private file. It enforces confirmation before +file access. Plugins never read a path or access the terminal themselves. + +See [interactive input](interaction.md) for terminal fields, choice discovery and +automation. Input metadata does not relax direct SDK validation. + +## Host ownership + +The core registers plugin factories and builds Cobra commands from their +manifests. It owns profiles, authentication, private token persistence, signal +cancellation, reading `--data @file` and stdin bodies, and [output rendering](output.md). It +resolves an authenticated HTTP client and the endpoint before invoking a plugin. +The plugin owns service routes, payload schemas, idempotency headers and business +validation. Both the CLI adapter and direct SDK execution validate arguments and +flags. Explicit changed flags preserve the difference between an omitted boolean +and `--flag=false`. + +Plugins receive an HTTP client through their constructor. This is the embedded +adapter's dependency injection point; plugin code does not obtain credentials +or read saved profiles. The adapter validates and reads the body, resolves +the declared service once, copies endpoint/context, executes the plugin and +renders any returned data, including partial results accompanied by an error. +Service command trees and schemas remain in plugins rather than in the host. +Apps file upload/download operations additionally own their declared `--path` +and `--out` effects; they are not connection configuration. The shared public +HTTP client performs requests without automatic mutation retries and preserves +JSON numbers. + +Cloud remains embedded. Auth and Ledger are external-only: the host loads +cached manifests and executables through its generic loader. Auth supports +official exact-version discovery before execution. Ledger loader and management +support already exist, but no official Ledger release is currently in the +catalogue; use a trusted matching local executable until a future product +release publishes and promotes it. Neither service has an embedded fallback. +Connectivity commands are absent until an independently distributed plugin is +integrated later; host connection settings remain available. This removal does +not complete the service-plugin migration. Both external providers use the +same two-method SDK contract, +forms, body validation, authentication and output renderer. Exactly one provider +owns each service command root. + +External executables use the SDK's `transport.Serve` and the host's +`transport.Open`. The gRPC broker supplies a scoped HTTP callback; the host +retains authentication credentials and follows requests through its own HTTP +client. Plugin requests and redirects must stay within the resolved endpoint's +origin and path prefix. The child inherits no host environment or credential +arguments. A separate process is not an operating-system sandbox: only trusted +executables should be installed. + +The host checks the binary checksum and compares its runtime manifest with the +installed manifest before execution. It also checks the exact selected service +version before service operations. Canceling an RPC closes the plugin process. +Help and completion use cached metadata, without spawning a plugin or calling +the service or catalogue. On a fresh target, `fctl auth --help` shows only a +placeholder directing the caller to `plugins sync --service auth` or +`plugins install --service auth`. Full Auth help and completion become available +offline after preparation of that target. + +See [plugin distribution](plugin-distribution.md) for Auth and Ledger, exact +version selection, public OCI packages, target locks and local testing. + +## Product-owned Auth plugin + +The independent module `github.com/formancehq/auth/misc/fctl-plugin` owns Auth +commands, API validation and declarative forms in the Auth repository. fctl has +no `plugins/auth` adapter and no compile-time dependency on the Auth product +module (`github.com/formancehq/auth/misc/fctl-plugin`). +The product module exposes `New` for direct SDK callers and `NewVersion` for +exact-version native executables. It depends on the public plugin SDK and +generated Auth client, without fctl core, Cobra, profile storage or terminal +libraries. Host authentication remains in fctl and does not require embedding +Auth commands. + +Auth and Ledger external providers use the same generic host loader. The host +accepts only the selected service's command root and connection boundary, +including descendant service overrides. Locks are keyed by target and service. diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 00000000..49fd0df3 --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,169 @@ +# Profiles and authentication + +The CLI supports standalone service endpoints and Stack gateway endpoints. +All service commands use the same connection boundary. They do not require a +Cloud profile when accessing a local service. Auth and Ledger examples require +a matching external plugin. Official discovery exists for Auth; Ledger currently +requires a trusted local executable or custom catalogue because its official +product release is still pending. Connection settings alone do not install +commands. Connectivity endpoint settings remain available while its service +commands are absent pending independent distribution and later integration. + +## Local services without authentication + +```bash +fctl profiles add local --auth-mode none \ + --ledger-url http://localhost:9000 --auth-url http://localhost:8080 +fctl profiles use local +fctl ledger list +fctl auth info +``` + +To use a gateway, replace the individual URLs with `--stack-url URL`. +The CLI appends `/api/ledger`, `/api/auth` or `/api/connectivity`. For direct URLs, include any +deployment prefix but not the service's API version: Ledger appends `/v3`. +A configured service URL takes precedence over the gateway URL for that service. +See [Connectivity](connectivity.md) for preserved standalone settings and the +historical command contract. + +One-off commands can pass endpoint and authentication flags directly: + +```bash +fctl --auth-mode none --ledger-url http://localhost:9000 ledger list +``` + +## OAuth2 client credentials + +```bash +fctl profiles add staging --auth-mode client-credentials \ + --ledger-url https://ledger.example.com \ + --auth-url https://auth.example.com \ + --token-url https://auth.example.com/oauth/token \ + --client-id automation --scopes 'ledger:read auth:read' +# Supply the client secret through your shell or secret manager. +fctl --profile staging ledger list +``` + +Set `FCTL_CLIENT_SECRET` for the command. The CLI has no client-secret flag and +never saves this secret in a profile. It obtains and renews access tokens +through the configured token endpoint. Select scopes supported by the target +services; writes require corresponding permissions. Token response bodies are +not included in authentication errors. Authenticated service and token endpoints +require HTTPS; plaintext HTTP is allowed only on loopback for local development. + +## Cloud user login + +```bash +fctl login +fctl cloud organizations list +fctl cloud stack list --organization ORGANIZATION_ID +fctl ledger list --organization ORGANIZATION_ID --stack STACK_ID +fctl auth clients list --organization ORGANIZATION_ID --stack STACK_ID +fctl logout +``` + +`login` uses `https://app.formance.cloud/api` and creates a profile named +`cloud` when no Cloud profile is selected. An active Cloud profile is +reused; an active local profile is preserved. Explicitly selecting a local +profile for login is an error. Successful login selects the Cloud profile. +A failed or canceled login leaves saved settings, tokens and selection unchanged. + +Login opens the default browser and also prints a verification URL and code on +stderr. Use `--no-browser` to display instructions without opening a browser. +If the browser cannot be opened, the printed instructions remain usable. +Results stay on stdout; their format follows [output settings](output.md). +Login verifies the signed Membership identity and +saves its available accesses without requiring an organization or stack. + +Membership refresh responses may omit an ID token. When the saved ID token has +expired, its verified signature still binds the historical login subject. A +current verified access JWT and authenticated UserInfo from the same issuer +establish current permissions; all three subjects must match. UserInfo claims +are fetched again rather than persisted as unsigned identity claims. + +Service commands resolve a target from explicit flags, saved profile defaults, +then a unique available match in signed Membership claims or current trusted +UserInfo. Ambiguous or missing targets produce a short error with the selection +flags and commands for listing organizations and stacks; +the CLI does not choose an arbitrary target. The first use of a stack may open +the browser again for stack-scoped authorization. Only that scoped Membership +token is exchanged with the stack's Auth service. Per-target tokens are cached +and renewed independently; the root identity token is never sent to the stack. + +Current stack grants can contain an ordinary ID token without organization +access claims. The CLI verifies the resource-bound access JWT's signature, +audience (`STACK_URL/api/auth`), organization, stack and subject, and requires +valid granted read/write scopes. The scoped subject must match the root identity. +Historical providers with signed organization/stack access claims remain +supported; their target URL and permissions must match the selected target. + +To save target defaults while logging in, use: + +```bash +fctl login --organization ORGANIZATION_ID --stack STACK_ID +# Another Membership environment, with its own named profile: +fctl login --profile staging --issuer https://app.staging.formance.cloud/api +``` + +Target flags on service commands override defaults for that command and do not +change saved settings. `--issuer` and `--client-id` select an identity at login; +changing them for a service command requires logging in again. Direct endpoint +flags cannot override Cloud routes. `logout` removes the root identity and all +cached stack, organization and application tokens locally; it does not revoke +sessions at the provider. + +Cloud administration uses Membership and organization grants. Ledger/Auth/Connectivity and +hosted stack helpers use a stack-scoped grant and the gateway. Experimental +`cloud apps` obtains a separate application grant and signed backend audience; +it does not reuse the stack Auth token. See [Cloud management](cloud.md). + +## Profiles and overrides + +`fctl profiles add NAME` saves a new profile; `--replace` replaces settings and +clears any Cloud login. `fctl profiles use NAME` selects the default profile. +`fctl profiles list` and `fctl profiles show` expose settings without tokens. +Deletion requires `fctl profiles delete NAME --confirm`. + +Settings use this precedence: explicit flag, `FCTL_*` environment variable, +saved profile. Examples include `FCTL_LEDGER_URL`, `FCTL_AUTH_URL`, `FCTL_CONNECTIVITY_URL`, +`FCTL_STACK_URL`, `FCTL_AUTH_MODE`, `FCTL_TOKEN_URL` and `FCTL_CLIENT_ID`. +Use `--profile` (`-p`) or `FCTL_PROFILE` to select a saved profile without +changing the default. + +```bash +fctl -p staging ledger list +FCTL_PROFILE=staging fctl ledger list +``` + +The v4 profile store remains `formance/fctl/v4/connections.json` under the +operating system's user configuration directory (`$XDG_CONFIG_HOME` or `~/.config` on Linux, +`~/Library/Application Support` on macOS). Override it with `--config-dir` or +`FCTL_CONFIG_DIR`. The filename is historical: the public name `profiles` does +not change the storage path, JSON format (including the `connections` key), or +locking scheme. Existing v4 profiles and saved sessions are retained without +migration. Cloud tokens are secrets: the file is created with mode 0600 and +updates use atomic replacement. V3 profiles are still not migrated automatically; +the v3 profile directory is not read or modified. Do not put this file in source +control. + +Updates are serialized with the existing portable `connections.lock` file lock. +Each session save checks the profile revision, so an in-flight login or refresh +cannot restore tokens after logout, replacement or deletion. If concurrent +commands conflict, retry with the current profile settings. + +Cloud token renewal is serialized per profile. Each command reloads the +current session before renewal, so concurrent commands do not consume the same +rotating refresh token. If cancellation follows a successful token rotation, +the CLI allows up to five seconds to save the renewed credentials locally. + +HTTP requests use a 30-second timeout, configurable with `--timeout`. Redirects +are refused. The CLI does not automatically retry service writes. A canceled +command cancels its HTTP and authentication work. Results use stdout; errors +and login instructions use stderr. Output defaults to tables on terminals and +JSON elsewhere. Failures return a nonzero exit code. + +Use `-d` or `--debug` to trace HTTP requests and responses on stderr. JSON and +form bodies are bounded and credentials are redacted, including authorization +headers, tokens and device codes. Other body formats are summarized. +Results remain on stdout. See [diagnostic redaction](output.md#http-diagnostics) +for the preview limit and retained OAuth error codes. diff --git a/docs/v4-foundation.md b/docs/v4-foundation.md new file mode 100644 index 00000000..34fbf764 --- /dev/null +++ b/docs/v4-foundation.md @@ -0,0 +1,110 @@ +# V4 foundation + +The repository now builds the Go module `github.com/formancehq/fctl/v4`. +The executable remains named `fctl`. This change prepares the next major; +it does not create a tag, GitHub release or Homebrew publication. + +## Command boundary + +`main.go` delegates to `cmd.Execute`. Each execution constructs a fresh Cobra +command tree. Errors propagate to the entrypoint, which writes them to stderr +and exits with status 1. Signal cancellation is propagated through the command +context. + +`cmd/root.go` assembles the command tree. Each command group lives in its own +directory under `cmd`, including `cmd/plugins` for plugin management. +`cmd/factory` prepares and registers the root's service providers. Shared +service preparation, target selection and manifest validation live in +`internal/pluginhost`; `internal/pluginmanager` owns the catalogue, downloads +and local cache. CLI integration tests live in `tests/integration` and run in +the default test suite. + +The root registers `version`, `profiles` management, login/logout and plugin +management. Cloud remains embedded. Auth and Ledger use external plugins with +no embedded fallback. Connectivity commands are absent until independently +distributed plugin integration; host connection settings remain available. +The core builds available plugin commands from +public SDK manifests. Cobra provides help, `--version` and shell completion +commands. Help and completion perform no network access. A fresh Auth target +has only a placeholder with sync/install guidance; a prepared target supplies +full offline metadata. Select the same target to inspect its cached commands. +See [plugin distribution](plugin-distribution.md). + +Cloud control-plane operations live under `cloud`; stack administration lives +under `cloud stack`. Host utilities add the authenticated proxy, stdio MCP +bridge and stack token helper. Experimental Apps resolve their own Deploy +audience. See [Cloud management](cloud.md). + +The host selects table output on terminals and JSON elsewhere. Presentation is +separate from plugin execution; [output options](output.md) also cover colors +and HTTP diagnostics. + +The `version` command prints the CLI version, commit and build date. +The root `--version` flag prints the CLI version. Development defaults are +`v4.0.0-dev`, `-` for the commit and `-` for the build date. GoReleaser injects +these variables under `github.com/formancehq/fctl/v4/cmd/version`. + +## Dependencies and generation + +The command layer uses upstream Cobra. The previous Formance Cobra fork is +not required by this foundation. The aggregate SDK, go-libs, terminal UI, +Membership and deployment clients have been removed from the dependency graph. +The Auth executable owns its commands and uses the generated service client. +The Auth product plugin module is not a compile-time dependency of fctl, and +no `plugins/auth` adapter is registered. The local `plugins/ledger` and +`plugins/connectivity` adapters are removed; service commands belong to +independently distributed product plugins. The connection layer handles OAuth2 +and verifies Cloud identities +with the coreos OIDC library. + +`just pre-commit` runs module tidy, Go generation and lint. `just tests` runs +the race-enabled test suite. Shell completions are generated from the current +command tree, so archives do not advertise removed v3 commands. + +## Migration + +V4 replaces the v3 command tree and profile format. Cloud management, stack +administration, proxy, MCP and token helpers are implemented with the v4 +connection boundary. Terminal forms and searchable selections replace missing-input errors for +interactive callers; scripts can disable them with `--no-input`. No v3 +configuration file is read or migrated. + +Move old `fctl stack ...` calls to `fctl cloud stack ...`. Stack-scoped Auth and +Ledger commands use `auth` and `ledger` once their exact-version external +plugins are prepared; select their Cloud target with +`--organization` and `--stack`. Use `cloud stack --help` to check changed arguments, +confirmation flags and request bodies. See [migration examples](cloud.md#migration-from-v3). + +Prepare an exact-version Auth plugin with `plugins sync --service auth` or +`plugins install --service auth --binary PATH` before relying on its full help +or completion. Automatic discovery before execution requires a matching +official release; an unavailable catalogue or missing release is an error. +Auth `2.5.2` revision `1` is available through official discovery. Ledger loader +and management support exist, but there is no official Ledger release in the +catalogue. Install a trusted matching local Ledger executable; a future product +release must publish and promote it. The service-plugin migration is incomplete. + +Existing automation must adapt to the [v4 module commands](modules.md) and +[profile settings](profiles.md), or keep using a compatible v3 binary. +Connectivity commands require a future independently distributed plugin and +later integration. [Connectivity](connectivity.md) preserves host settings and +the historical adapter contract; it does not advertise an available replacement. + +## Homebrew release migration + +GoReleaser generates `Casks/fctl.rb` and `Casks/fctl@.rb` in +`formancehq/homebrew-tap`. Both casks include macOS and Linux archives for +amd64 and arm64, and the Bash, Zsh and Fish completions. Downloads use +`brew.formance.com`; the previous formula-specific GitHub mirrors are removed. +The macOS post-install hook removes quarantine from the unsigned CLI binary. + +Before publishing the first v4 cask, coordinate the tap change with its release +PR: remove `Formula/fctl.rb` and add `"fctl": "fctl"` to the tap's root +`tap_migrations.json`, preserving any existing entries. This lets Homebrew +migrate existing formula installations to the cask. Retain the versioned v3 +formula for users who still need v3 commands. New installations can explicitly +select `brew install --cask formancehq/tap/fctl` or +`brew install --cask formancehq/tap/fctl@4`. + +The tap migration is a separate release step; this repository does not modify +or publish the tap. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 00000000..f854179e --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,370 @@ +# V4 implementation validation + +## Scope and evidence + +This record summarizes the sanitized validation reports from October 8–9, 2026. +It records completed checks and remaining gaps separately. These campaigns +are historical evidence from before removal of the local Ledger and Connectivity +adapters. They do not validate the current external-only service loading or establish +completion of the migration. Current availability and preparation requirements +are documented in [plugin distribution](plugin-distribution.md). Historical failures +and preparation fields in the reports do not override their final status. + +The approved live target was stack `bwxm` in `eu-sandbox`, targeting +`v4.0-beta`. Observed services were Auth `v2.5.1` and Ledger +`3.0.0-beta.5` (runtime commit `36d580949`). These component versions do not +establish an exact final v4 runtime release. + +The Ledger source contract used for that campaign was `release/v3.0` at +`0f4656d1efbcac42705839daccb34012e70d783a`. The deployed beta has a different wire +contract; source compatibility and deployed behavior must be assessed separately. + +| Evidence | Final result | +|---|---| +| `/tmp/fctl-ledger-live-report.json` | `VALIDATED_AND_CLEANED_UP`; 31 positive command paths; no current open findings | +| `/tmp/fctl-auth-live-report.json` | `COMPLETE_WITH_GAPS`; 11 of 12 positive command paths; cleanup complete | +| `/tmp/fctl-auth-modes-report.json` | Five cases `PASS`, including expected authentication failures | +| `/tmp/fctl-cloud-read-report.json` | 18 cases `PASS`, one `FAIL` | +| `/tmp/fctl-cloud-crud-20261008/report.md` | 46 commands, 44 `PASS`, 29 assertions passed; one entitlement refusal and one preservation assertion failure | +| `/tmp/fctl-stack-tools-report.json` | Five cases `PASS`; MCP initialization `BACKEND_UNAVAILABLE` | +| `/tmp/fctl-presentation-live-report.json` | Four real PTY cases `PASS`; no backend writes | +| `/tmp/fctl-cloud-lifecycle-report.json` | `VALIDATED_AND_RETAINED_READY`; temporary organization absent, stack retained `ACTIVE`/`READY` | +| `/tmp/fctl-apps-live-report.json` | `PASS_WITH_COVERAGE_LIMITS`; 35 successful calls, five expected errors, 15 assertions passed; cleanup verified | + +These paths identify local evidence artifacts, not files shipped with the +repository. Later validation results and fixes are recorded below separately +from the historical report results. + +## Ledger live validation + +All 31 Ledger command paths were positively validated, including ledger and +index deletion. The campaign recorded 338 live command cases. Its final +follow-up has 105 passing assertions, zero failures and zero blocked assertions; +cleanup adds 48 passing assertions. All dedicated ledger and index fixtures were +deleted and their absence verified. The stack was not deleted or changed by +Ledger cleanup. + +The final checks cover ledger CRUD, accounts, transactions, metadata, balances, +statistics, indexes, logs and bulk operations. Idempotent replay, conflicting +keys, bulk stop/continue behavior, reversals, filters and pagination were +exercised. Earlier pagination findings and the metadata fixture oracle error +were resolved by subsequent validation; the historical failure counters remain +in the report for traceability. + +The deployed beta accepts posting amounts as JSON numbers and rejects quoted +decimal strings. The exact posting value `184467440737095516170` was verified. +Metadata retained the exact integer `9007199254740993`, the unsigned 64-bit +maximum and the signed 64-bit minimum. The CLI preserves raw JSON tokens rather +than converting these amounts through floating point. + +Deployed metadata conversion omits null values; the initial null-preserving +oracle was corrected. Ledger log continuation uses `payload.apply.log.id`, not +the outer global log sequence. The full paginated log oracle passed after this +correction. The deployed asset precision range is 1 through 255; a valid mixed +precision fixture passed after the rejected `EUR/0` case and its changed-body +idempotency conflict. + +Positive `scriptReference` use remains unverified because the campaign did not +provision a library. High transaction IDs and actual server 5xx responses were +not safely generated. Passing command-path coverage does not remove these +variant limitations. + +## Auth and authentication modes + +Auth completed 63 recorded cases with no live functional failures. Eleven +positive command paths passed: service information, discovery, client CRUD, +secret list/create/delete and users list. Positive `auth users show` remains +blocked: the service returned no users and no synthetic user or invitation was +created. The missing-user 404 case passed. The runner's final exit code of 1 +reflects `COMPLETE_WITH_GAPS`, even though cleanup succeeded. + +Client updates verified omitted-value preservation and explicit option clearing. +Confirmation checks and readbacks verified secret revocation and client deletion. +Both created secrets were revoked, the fixture client was absent, and the private +credentials file was removed. A harness assertion that initially rejected the +server's stored secret hash was corrected without replaying completed mutations. + +Client and secret readbacks exposed a nonempty stored `hash` field but no clear +secret. The CLI preserves the raw server JSON. The report classifies this as an +Auth read-model observation, not a missing CLI command. Local source explains +the serialization mechanism, but the deployed Auth commit was not verified. +Secret values are not included in the sanitized report. + +All five authentication-mode cases passed: anonymous discovery, rejection of +anonymous protected Ledger access, client-credentials Auth and Ledger reads, +and rejection of an invalid client secret. Diagnostics hid credentials in all +five cases. The initial approved browser login completed, and cached root grants +were exercised earlier. The latest service reports used the existing profile +and cached stack grant; they do not establish a new device authorization or +fresh browser login. + +## Cloud reads and CRUD + +Cloud reads passed for identity, invitations list, organization list/describe/ +history, users, policies, OAuth clients, applications list, regions and versions, +and stack list/info/version/history/users/modules. The read report records one +unsuccessful `organizations authentication-provider show` case as `FAIL`. Initial +validation confirmed an expected missing-provider 404, but the raw read report +contains no error reason. The separate isolated CRUD campaign positively +validated provider configure/show/delete and the expected missing-provider 404 +after deletion. + +In the isolated QA organization, policy CRUD and scope add/remove passed. OAuth +client CRUD passed, with final absence checked from captured cursor data. The +dummy Google authentication provider was removed. Successfully created nested +fixtures were deleted. The lifecycle campaign subsequently deleted the temporary +QA organization and verified its absence. + +Private region creation was refused by the paid-feature entitlement check +(HTTP 400 `VALIDATION`). No private region was created and the region IDs were +unchanged. This is a blocked positive validation, not a successful region CRUD +campaign. Users link/unlink were skipped because no own or synthetic user was +available; no invitations were sent. + +The earlier organization name update and restoration passed, but strict preservation of +`defaultPolicyID: null` failed: Membership updates default it to Guest policy +ID `4`. The report's source review confirmed explicit API defaulting when the CLI +sent preserved null in its request. This is not established as semantically neutral: +the source gives future users different scopes under Guest than under an unset +policy. Existing member permissions are not recomputed by this update. The +future-user effect was not tested live. The original organization name and +domain were preserved or restored; the default policy was not restored to null +in that campaign. + +The CLI protection was subsequently fixed: an organization update with an unset +or zero default policy is refused before PUT unless an explicit positive policy +ID is selected. Regression tests cover this refusal and preservation of an +existing positive policy. The earlier live preservation failure is a historical +finding addressed by this guard; no subsequent live preservation result is +recorded here. + +Applications list passed with the latest rebuilt CLI, including an empty cursor. +The scope broker backend audience handling was fixed and passed earlier tests. +Successful private region operations and user link/unlink remain outside the +completed evidence. + +## Applications + +The Apps campaign completed with `PASS_WITH_COVERAGE_LIMITS`: 35 successful +calls, five expected errors and 15 passing assertions, with no failed assertions +or code changes. Application creation, variables, manifests, versions, +pagination, manifest binding/unbinding and downloads were verified. Variable +redaction, distinct second pages and exact downloaded bytes passed. Cleanup +verified that the owned application, manifest and variables were absent; no +owned resources remained. + +No deployment was created: actual infrastructure creation was outside the +approved QA scope. Deployment lists were empty before and after the campaign, +and missing-deployment reads returned the expected HTTP 403 with exit code 1 +and empty stdout. Positive deployment show/logs/download paths remain +unverified. Deployment creation and its polling with `--wait` were not tested. +Metadata cleanup does not establish a working deployment lifecycle. + +## Stack lifecycle + +The lifecycle campaign completed with `VALIDATED_AND_RETAINED_READY`. Stack +disable/enable, deletion without force and restoration passed. Deletion without +force retained resources with stack state `DELETED` and status `DISABLED`; +restoration returned the stack to `ACTIVE`/`READY`. The October 8 campaign did +not test forced deletion. The later form campaign force-deleted only its own +new, empty sandbox fixture. +The temporary organization was deleted and absent from subsequent lists. Ledger +fixture absence was verified again after restoration. + +Same-version upgrade passed as an idempotent CLI no-op on a ready stack; no +backend version transition was tested. Two independent reviews found the same +P2 issue: this path bypassed requested waiting when the stack was `PROGRESSING`. +The issue is resolved by `waitExistingStackVersion`: default waiting requires +both `READY` and the requested version, `--no-wait` returns the current stack, +disabled stacks are rejected, and cancellation is honored. Meaningful regression +tests cover these cases; the race-enabled `TestStack` checks passed. No new live +version transition is claimed. + +Restoration briefly returned `READY` before the stack became `PROGRESSING` again +and a Ledger list request returned HTTP 503 `NO_LEADER`. Subsequent read-only +checks confirmed recovery, with two successful empty Ledger lists and repeated +stack readiness. No mutation was retried. The final recorded stack is `ACTIVE`, +`READY`, reachable and synchronised at `v4.0-beta` in `eu-sandbox`. + +## Stack tools + +Personal token acquisition passed without reporting the credential. The proxy +returned Ledger information for `3.0.0-beta.5` and replaced incoming credentials. +Host and origin rejection each returned HTTP 403. Proxy shutdown was classified +`PASS` by the report with exit code 1. + +Before disablement, the MCP module's backend status was `PROGRESSING`, with an explicit +`DependencyVersionMismatch`: effective Ledger version `v3.0.0-beta.5` must be +before `v3.0.0-0` to satisfy its dependency requirement. MCP initialization +could not complete on this backend. The recorded case is `BACKEND_UNAVAILABLE`: the backend returned +HTTP 404, surfaced as RPC error `-32000` while preserving the request ID. +The process exited 0, which does not establish a working MCP backend. No +successful initialization or MCP session is claimed. +The lifecycle campaign then disabled MCP and verified state `DISABLED` and +status `DELETED`; the retained stack recovered to `READY`. + +## CLI output and errors + +All four real PTY cases passed at a terminal width of 100 columns: help used +automatic ANSI color, `NO_COLOR` suppressed it, automatic region listing rendered +a colored ASCII table with wrapped fields, and explicit JSON remained valid and +free of ANSI even with color forced. These checks made no backend writes. +Explicit JSON output and non-TTY output tests also passed, including exact +preservation of large integers. + +The actual `auth info` error reproduction produced three lines and exit code 1, +without listing target IDs. The full suite includes the regression check that +bounds target error output. + +## External plugin SDK consumer + +A separate consumer at `/tmp/fctl-plugin-consumer-5b2bsq26` compiled with +`GOWORK=off` and executed the Cloud, Auth and Ledger manifests. It preserved the +exact integer `9007199254740993`. Its `go list -deps` output contains no Cobra, +pflag or core `internal`/`cmd` dependencies. The repository's dependency tests +also passed. + +## Repository race checks and coverage + +The full race-enabled repository suite associated with +`/tmp/fctl-cloud-final4-coverage-20261008.out` passed with exit code 0, including +the bounded target error checks and same-version upgrade readiness regressions. +The upgrade fix was independently reviewed and the finding was closed. +Aggregate statement coverage computed from that artifact is **87.7%**. + +The final `just pc` check passed with zero lint issues. The final GoReleaser snapshot +successfully built all six targets: Linux, macOS (`darwin`) and Windows, each on +amd64 and arm64. No release was published. + +To reproduce the suite and compute coverage: + +```bash +nix develop --impure --command go test -count=1 -race -timeout 120s \ + -coverpkg=./... -coverprofile=/tmp/fctl-v4-coverage.out ./... +nix develop --impure --command go tool cover -func=/tmp/fctl-v4-coverage.out +``` + +Snapshot builds do not establish signing, notarization, release publication or +deployment validation. +Snapshot version metadata still derives from the existing v3 Git tags; an +official v4 release requires a v4 tag. Development builds report `v4.0.0-dev`. + + +## Interactive command validation (2026-10-09) + +Terminal forms were exercised in a 100-column, 24-line pseudo-terminal, with +stdin and stderr on the terminal and stdout captured separately. The checks +covered real keyboard navigation, searchable resource menus, cancellation, +confirmation defaulting to No, and JSON free of terminal controls with forced +color profiles. A local HTTP capture verified that the form sends exact JSON +numbers, including `9007199254740993`. + +Live Cloud validation used organization `jdxmvkvwlyiy` and the retained v4 beta +stack `bwxm`. It covered: + +- Ledger creation through all form steps, resource selection for stats, + cancellation before creation, declined deletion, and confirmed cleanup. +- Auth client creation with metadata, client selection, secret creation, and + removal of the dedicated client and secret. +- Stack creation through name, region and catalog selection in `eu-sandbox`; + exact `v4.0-beta` was recorded and the temporary stack was verified `DELETED`. +- `auth info` without organization or stack flags: both menus selected the + authorized sandbox and the service read completed. +- Ledger metadata editing with exact integer `9007199254740993`, Numscript + transaction creation, reference readback, and verified fixture deletion. + +A further terminal check used stdin, stdout and stderr on the same terminal. +Selecting the user ledger for `stats` produced the automatic table output; +`NO_COLOR` suppressed colors, and neither omitted-field counts nor JSON +reminders appeared. + +The final full suite passed with the race detector and `-coverpkg=./...`. +Aggregate statement coverage in `/tmp/fctl-interactive-final-coverage.out` is +**88.3%**. `just pc` and the final lint check reported zero issues. An independent +review found no remaining P1 or P2 defects. A separate SDK consumer compiled and +executed all three plugin manifests without a core or Cobra dependency. + +The local GoReleaser snapshot succeeded with +`goreleaser release --snapshot --clean --skip=publish,docker`: all six binaries, +archives, Linux packages and Homebrew cask files were generated. Docker images +remain unverified because the local daemon was unavailable. Nothing was +published. + +The user ledger `toot` and the retained stack `bwxm` were preserved. No real +identity provider, invitation or production resource was changed by form QA. + +### Ledger beta creation metadata + +The sandbox runs Ledger `3.0.0-beta.5` (`36d580949`). Its creation HTTP and +protobuf models omit `metadata`, so that server ignores metadata in a create +request. The current `release/v3.0` contract persists it atomically after Ledger +[PR #2184](https://github.com/formancehq/ledger/pull/2184), included in beta.10. +The CLI's flat creation body is correct for that contract. HTTP server tests +and a terminal capture verified the field shape and exact numeric value. + +The form explains the beta.10 prerequisite. On the older sandbox, use +`ledger metadata set` after creation; creation does not claim that the old +server persisted this field. The other creation options, including enforcement +mode, initial schema and account type models, exist in both revisions. + +## External Ledger plugin pilot (2026-10-09) + +At the time of this pilot, the Ledger implementation and its contract tests +lived in the public Go +module `github.com/formancehq/ledger/fctl-plugin`, on branch +`feat/fctl-ledger-plugin` at `885e135ef98a232aa9e4c05a5cb2895ce11c4238`. +The branch started from `release/v3.0`. fctl then retained an embedded factory +shim and could also execute the separately built plugin over the isolated SDK +protocol. That shim has since been removed; this is historical validation. + +A standalone Go consumer downloaded both public modules without a local +replacement and ran the Ledger manifest successfully. Its dependencies contain +neither the fctl core nor Cobra. The host retains authentication, HTTP diagnostics, +profiles, forms and rendering; the plugin owns Ledger commands and payloads. + +The following checks passed: + +- The complete host and SDK suites with the race detector, plus lint checks. +- Ledger's `agent-check`, plugin race tests and lint checks. +- Six-platform plugin packaging, both Ledger GoReleaser configurations and the + fctl snapshot, including archives and Linux packages. +- Exact-version OCI resolution, executable integrity, cached metadata and + offline help through actual plugin processes. +- Publisher rejection of stale manifests, mixed service versions and wrong + revisions before publication. +- Four independent review regressions: implicit Cloud target preservation, + cancellation without panic, publisher validation and help without a binary. + +Fresh live reads observed Ledger `3.0.0-beta.10` (`b7c2ec613`, protocol 20) and +Auth `v2.5.1` on organization `jdxmvkvwlyiy`, stack `bwxm`. This supersedes the +historical beta.5 runtime observations above. A real terminal exercised the +five-step Ledger creation form and the Ledger selector for statistics. Creation +metadata persisted. A transaction and balance readback retained the exact +integer `9007199254740993`; JSON output remained parseable. + +The dedicated ledger `fctlplugin20261009` was deleted, and a subsequent list +confirmed its absence. The stack and installed plugin remain available for +manual testing. This campaign did not create Auth fixtures or modify other +stacks. + +No production release tag, production OCI publication or official default +catalogue was created. Snapshot versions still derive from existing v3 tags. +The optional Jev review was unavailable because `JEV_API_KEY` was unset; +independent review and repository checks completed instead. + + +## Product-owned Ledger plugin layout (2026-10-09) + +At the time of this check, the embedded Ledger adapter imported +`github.com/formancehq/ledger/misc/fctl-plugin` at +`v0.0.0-20261009143954-060fabc5af8d` from the Ledger branch +`feat/fctl-shared-commands`. This is a public module pin, without a local +Ledger replacement. That embedded adapter has since been removed. The plugin +remains independent of fctl core, terminal UI, +profiles, credentials, and Ledger server internals. + +The same command manifest runs through HTTP in fctl and through the native +gRPC adapter in ledgerctl. A local single-node Ledger was used to check fctl +listing and `after` pagination over HTTP, alongside ledgerctl's 38-case matrix +covering all 31 shared operations. The isolated node advertised +`3.0.0-beta.10`; these checks do not establish a deployed service release. diff --git a/flake.nix b/flake.nix index a06fd20e..f3475d73 100644 --- a/flake.nix +++ b/flake.nix @@ -37,58 +37,11 @@ f { pkgs = pkgs; pkgs-unstable = pkgs-unstable; system = system; } ); - speakeasyVersion = "1.759.2"; - speakeasyPlatforms = { - "x86_64-linux" = "linux_amd64"; - "aarch64-linux" = "linux_arm64"; - "x86_64-darwin" = "darwin_amd64"; - "aarch64-darwin" = "darwin_arm64"; - }; - # Checksums from https://github.com/speakeasy-api/speakeasy/releases/download/v1.759.2/checksums.txt - speakeasyHashes = { - "x86_64-linux" = "9234e2e9138f03ac18f0ca034d0c5a0a7b8749ea91b16814b4a643afd680d8fd"; - "aarch64-linux" = "ba92a8c5799ed14acba94d317694ed32e35883e9439a07b28c58f7c8c0ea16f5"; - "x86_64-darwin" = "b4cfe13627e8822718b5820c68898f51b6381e604c9578650c9b0c3f40f800b0"; - "aarch64-darwin" = "dda057dbbd83bdaa47f9ccf3311e455013d957d11f45d8336b97b91ba2a56d6d"; - }; - in { - packages = forEachSupportedSystem ({ pkgs, pkgs-unstable, system }: - { - speakeasy = pkgs.stdenv.mkDerivation { - pname = "speakeasy"; - version = speakeasyVersion; - - src = pkgs.fetchurl { - url = "https://github.com/speakeasy-api/speakeasy/releases/download/v${speakeasyVersion}/speakeasy_${speakeasyPlatforms.${system}}.zip"; - sha256 = speakeasyHashes.${system}; - }; - - nativeBuildInputs = [ pkgs.unzip ]; - dontUnpack = true; - - installPhase = '' - mkdir -p $out/bin - unzip $src - ls -al - install -m755 speakeasy $out/bin/ - ''; - - name = "speakeasy"; - }; - } - ); - - defaultPackage.x86_64-linux = self.packages.x86_64-linux.speakeasy; - defaultPackage.aarch64-linux = self.packages.aarch64-linux.speakeasy; - defaultPackage.x86_64-darwin = self.packages.x86_64-darwin.speakeasy; - defaultPackage.aarch64-darwin = self.packages.aarch64-darwin.speakeasy; - devShells = forEachSupportedSystem ({ pkgs, pkgs-unstable, system }: let stablePackages = with pkgs; [ - ginkgo go_1_26 go-tools goperf @@ -99,11 +52,9 @@ ]; unstablePackages = with pkgs-unstable; [ golangci-lint - openapi-generator-cli ]; otherPackages = [ (pkgs.callPackage "${goreleaser}/pkgs/goreleaser-pro" { }) - self.packages.${system}.speakeasy ]; in { diff --git a/go.mod b/go.mod index c325a5ec..11124fbb 100644 --- a/go.mod +++ b/go.mod @@ -1,100 +1,70 @@ -module github.com/formancehq/fctl/v3 +module github.com/formancehq/fctl/v4 go 1.26.0 toolchain go1.26.1 require ( - github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 - github.com/c-bata/go-prompt v0.2.6 - github.com/formancehq/fctl/internal/deployserverclient/v3 v3.1.1 - github.com/formancehq/fctl/internal/membershipclient/v3 v3.1.1 - github.com/formancehq/formance-sdk-go/v4 v4.0.0 - github.com/formancehq/go-libs/v4 v4.1.1 + charm.land/bubbletea/v2 v2.0.2 + charm.land/huh/v2 v2.0.3 + github.com/charmbracelet/colorprofile v0.4.2 + github.com/charmbracelet/x/term v0.2.2 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/go-jose/go-jose/v4 v4.1.4 - github.com/iancoleman/strcase v0.3.0 - github.com/mattn/go-shellwords v1.0.12 - github.com/pkg/errors v0.9.1 - github.com/pterm/pterm v0.12.83 - github.com/segmentio/ksuid v1.0.4 + github.com/gofrs/flock v0.12.1 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.12.1 - github.com/zitadel/oidc/v2 v2.12.2 - golang.org/x/mod v0.40.0 + github.com/spf13/pflag v1.0.9 golang.org/x/oauth2 v0.36.0 - gopkg.in/yaml.v3 v3.0.1 + golang.org/x/sys v0.47.0 + sigs.k8s.io/yaml v1.6.0 ) -replace ( - github.com/formancehq/fctl/internal/deployserverclient/v3 => ./internal/deployserverclient - github.com/formancehq/fctl/internal/membershipclient/v3 => ./internal/membershipclient - github.com/spf13/cobra => github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b - github.com/zitadel/oidc/v2 v2.6.1 => github.com/formancehq/oidc/v2 v2.6.2-0.20230526075055-93dc5ecb0149 - gopkg.in/go-jose/go-jose.v4 => github.com/go-jose/go-jose/v4 v4.1.4 +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.8.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) require ( - atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/keyboard v0.2.9 // indirect - atomicgo.dev/schedule v0.1.0 // indirect - dario.cat/mergo v1.0.2 // indirect - github.com/ThreeDotsLabs/watermill v1.5.1 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.2 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/lipgloss/v2 v2.0.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/containerd/console v1.0.5 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/go-chi/chi/v5 v5.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gookit/color v1.6.0 // indirect - github.com/gorilla/schema v1.4.1 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261012095244-0f003a93e374 github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/lithammer/shortuuid/v3 v3.0.7 // indirect - github.com/mailru/easyjson v0.9.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect - github.com/mattn/go-tty v0.0.7 // indirect - github.com/muhlemmer/gu v0.3.1 // indirect - github.com/oklog/ulid v1.3.1 // indirect - github.com/pkg/term v1.2.0-beta.2 // indirect - github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect - github.com/uptrace/bun v1.2.18 // indirect - github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 // indirect - github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/cancelreader v0.2.2 + github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/zitadel/oidc/v3 v3.45.5 // indirect - github.com/zitadel/schema v1.3.2 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/log v0.18.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect - go.uber.org/mock v0.5.2 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.56.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect - gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) + +replace github.com/formancehq/fctl/pkg/pluginsdk => ./pkg/pluginsdk + +require github.com/formancehq/fctl/misc/fctl-plugin v0.0.0-20261012095245-3945adb8388b + +replace github.com/formancehq/fctl/misc/fctl-plugin => ./misc/fctl-plugin diff --git a/go.sum b/go.sum index 8b2f8432..65f9a89a 100644 --- a/go.sum +++ b/go.sum @@ -1,411 +1,152 @@ -atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= -atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= -atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= -atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= -atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= -atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= -atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= -atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= -filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= -github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= -github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= -github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= -github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= -github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= -github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= -github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= -github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= -github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= -github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4= -github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= -github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= -github.com/aws/aws-sdk-go-v2 v1.39.0 h1:xm5WV/2L4emMRmMjHFykqiA4M/ra0DJVSWUkDyBjbg4= -github.com/aws/aws-sdk-go-v2 v1.39.0/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/config v1.31.7 h1:zS1O6hr6t0nZdBCMFc/c9OyZFyLhXhf/B2IZ9Y0lRQE= -github.com/aws/aws-sdk-go-v2/config v1.31.7/go.mod h1:GpHmi1PQDdL5pP4JaB00pU0ek4EXVcYH7IkjkUadQmM= -github.com/aws/aws-sdk-go-v2/credentials v1.18.11 h1:1Fnb+7Dk96/VYx/uYfzk5sU2V0b0y2RWZROiMZCN/Io= -github.com/aws/aws-sdk-go-v2/credentials v1.18.11/go.mod h1:iuvn9v10dkxU4sDgtTXGWY0MrtkEcmkUmjv4clxhuTc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7 h1:Is2tPmieqGS2edBnmOJIbdvOA6Op+rRpaYR60iBAwXM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.7/go.mod h1:F1i5V5421EGci570yABvpIXgRIBPb5JM+lSkHF6Dq5w= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.14 h1:jC4xN9XwabWPEHcUojwxrXUPLKwizRJyzTqcnHd7q+M= -github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.14/go.mod h1:Fr7lAii+im8B1NOejSXDG0WXkOH2C0DsnDKCOoxDw8s= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7 h1:UCxq0X9O3xrlENdKf1r9eRJoKz/b0AfGkpp3a7FPlhg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.7/go.mod h1:rHRoJUNUASj5Z/0eqI4w32vKvC7atoWR0jC+IkmVH8k= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7 h1:Y6DTZUn7ZUC4th9FMBbo8LVE+1fyq3ofw+tRwkUd3PY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.7/go.mod h1:x3XE6vMnU9QvHN/Wrx2s44kwzV2o2g5x/siw4ZUJ9g8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7 h1:mLgc5QIgOy26qyh5bvW+nDoAppxgn3J2WV3m9ewq7+8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.7/go.mod h1:wXb/eQnqt8mDQIQTTmcw58B5mYGxzLGZGK8PWNFZ0BA= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.2 h1:rcoTaYOhGE/zfxE1uR6X5fvj+uKkqeCNRE0rBbiQM34= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.2/go.mod h1:Ql6jE9kyyWI5JHn+61UT/Y5Z0oyVJGmgmJbZD5g4unY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.3 h1:BSIfeFtU9tlSt8vEYS7KzurMoAuYzYPWhcZiMtxVf2M= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.3/go.mod h1:XclEty74bsGBCr1s0VSaA11hQ4ZidK4viWK7rRfO88I= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.3 h1:yEiZ0ztgji2GsCb/6uQSITXcGdtmWMfLRys0jJFiUkc= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.3/go.mod h1:Z+Gd23v97pX9zK97+tX4ppAgqCt3Z2dIXB02CtBncK8= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= -github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= -github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= -github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= -github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= -github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +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.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b h1:8sPsvBsI0CJEzskLvdRMSBSdQKUlYTRGRdTiuL1HTfI= -github.com/formancehq/cobra v1.10.3-0.20260226173701-7f22399f993b/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/formancehq/formance-sdk-go/v4 v4.0.0 h1:VaDHdPdy2oLU80rhWsG7ll4M5fc5zFgr5KgZlZ7Zy74= -github.com/formancehq/formance-sdk-go/v4 v4.0.0/go.mod h1:dkPQPeS133IQcePjTkgfKJEeX3IAbBFYMEwcYBf/WYM= -github.com/formancehq/go-libs/v4 v4.1.1 h1:D4LRPyssaq/2rPyNlmvAfypY5CsjLSQLLX2N4I/PsR4= -github.com/formancehq/go-libs/v4 v4.1.1/go.mod h1:iVE2pTLa4KNhqXkCpBAGzpGO8TdHTEjdJMAiCkkeb0A= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/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/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -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/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= -github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= -github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= -github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= -github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= -github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= -github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= -github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= -github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= -github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= -github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= -github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= -github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= -github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= -github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= -github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0= -github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= -github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs= -github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= -github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= -github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runc v1.2.8 h1:RnEICeDReapbZ5lZEgHvj7E9Q3Eex9toYmaGBsbvU5Q= -github.com/opencontainers/runc v1.2.8/go.mod h1:cC0YkmZcuvr+rtBZ6T7NBoVbMGNAdLa/21vIElJDOzI= -github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= -github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= -github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= -github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= -github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= -github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= -github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= -github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= -github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= -github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= -github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= -github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= -github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= -github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU= -github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y= -github.com/uptrace/bun/dialect/pgdialect v1.2.15 h1:er+/3giAIqpfrXJw+KP9B7ujyQIi5XkPnFmgjAVL6bA= -github.com/uptrace/bun/dialect/pgdialect v1.2.15/go.mod h1:QSiz6Qpy9wlGFsfpf7UMSL6mXAL1jDJhFwuOVacCnOQ= -github.com/uptrace/bun/extra/bunotel v1.2.15 h1:6KAvKRpH9BC/7n3eMXVgDYLqghHf2H3FJOvxs/yjFJM= -github.com/uptrace/bun/extra/bunotel v1.2.15/go.mod h1:qnASdcJVuoEE+13N3Gd8XHi5gwCydt2S1TccJnefH2k= -github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2 h1:H8wwQwTe5sL6x30z71lUgNiwBdeCHQjrphCfLwqIHGo= -github.com/uptrace/opentelemetry-go-extra/otellogrus v0.3.2/go.mod h1:/kR4beFhlz2g+V5ik8jW+3PMiMQAPt29y6K64NNY53c= -github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c= -github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ= -github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= -github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xo/dburl v0.23.8 h1:NwFghJfjaUW7tp+WE5mTLQQCfgseRsvgXjlSvk7x4t4= -github.com/xo/dburl v0.23.8/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4= -github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zitadel/oidc/v2 v2.12.2 h1:3kpckg4rurgw7w7aLJrq7yvRxb2pkNOtD08RH42vPEs= -github.com/zitadel/oidc/v2 v2.12.2/go.mod h1:vhP26g1g4YVntcTi0amMYW3tJuid70nxqxf+kb6XKgg= -github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrDQ= -github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE= -github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= -github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= -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/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= -go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= -go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= -go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= -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/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -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.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/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-20190902080502-41f04d3bba15/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/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= -gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 00000000..3d046dcb --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,16 @@ +// Package api adapts the public HTTP client to the core's connection layer. +package api + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type Client = httpclient.Client +type Error = httpclient.Error + +func New(base string, client *http.Client) (*Client, error) { return httpclient.New(base, client) } +func Path(segments ...string) string { return httpclient.Path(segments...) } +func ValidateURL(value string) error { return httpclient.ValidateURL(value) } +func ValidateSecureURL(value string) error { return httpclient.ValidateSecureURL(value) } diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 00000000..6f4f8c3f --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,79 @@ +// Package browser opens login verification URLs in the operating system's default browser. +package browser + +import ( + "context" + "fmt" + "net/url" + "os/exec" + "runtime" + "time" + + httpclient "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const openTimeout = 5 * time.Second + +type commandRunner func(context.Context, string, ...string) error + +// Open launches the default browser quietly. HTTPS and loopback HTTP URLs are +// accepted, including verification query parameters. The caller handles fallback. +func Open(ctx context.Context, target string) error { + return open(ctx, target, runtime.GOOS, runCommand) +} + +func open(ctx context.Context, target, goos string, run commandRunner) error { + if err := ctx.Err(); err != nil { + return err + } + if err := validateURL(target); err != nil { + return err + } + name, args, err := browserCommand(goos, target) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, openTimeout) + defer cancel() + err = run(ctx, name, args...) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return fmt.Errorf("open default browser: %w", err) + } + return nil +} + +func validateURL(target string) error { + u, err := url.Parse(target) + if err != nil || u.Hostname() == "" || u.User != nil { + return fmt.Errorf("browser URL must be an absolute HTTP(S) URL without credentials") + } + // Shared endpoint validation forbids queries; browser verification URLs need them. + // Validate the same origin/path while passing the full URL to the OS opener. + u.RawQuery = "" + u.ForceQuery = false + return httpclient.ValidateSecureURL(u.String()) +} + +func browserCommand(goos, target string) (string, []string, error) { + switch goos { + case "darwin": + return "open", []string{target}, nil + case "linux": + return "xdg-open", []string{target}, nil + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", target}, nil + default: + return "", nil, fmt.Errorf("default browser opening is unsupported on %s", goos) + } +} + +func runCommand(ctx context.Context, name string, args ...string) error { + // Both the executable and argument structure are selected from a fixed OS table. + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // Fixed OS opener; validated URL is a separate argument, never shell code. + // Nil output streams go directly to the null device, avoiding pipe-copy + // goroutines that could outlive cancellation when an opener forks. + return cmd.Run() +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 00000000..baeb45f4 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,148 @@ +package browser + +import ( + "context" + "errors" + "os" + "reflect" + "testing" + "testing/synctest" + "time" +) + +func TestOSCommands(t *testing.T) { + target := "https://example.test/verify?user_code=ABCD&next=%2Fhome" + tests := []struct { + goos, name string + args []string + }{ + {"darwin", "open", []string{target}}, + {"linux", "xdg-open", []string{target}}, + {"windows", "rundll32", []string{"url.dll,FileProtocolHandler", target}}, + } + for _, test := range tests { + t.Run(test.goos, func(t *testing.T) { + calls := 0 + err := open(t.Context(), target, test.goos, func(ctx context.Context, name string, args ...string) error { + calls++ + if name != test.name || !reflect.DeepEqual(args, test.args) { + t.Fatalf("command=%s %q", name, args) + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > openTimeout || time.Until(deadline) <= 0 { + t.Fatal("missing bounded deadline") + } + return nil + }) + if err != nil || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + }) + } +} + +func TestURLs(t *testing.T) { + for _, target := range []string{ + "https://example.test/verify?user_code=ABCD&next=%2Fhome", + "https://example.test/verify?code=$(touch%20file);echo%20x", + "http://localhost:8080/verify?user_code=ABCD", + "http://127.0.0.1:8080/verify?user_code=ABCD", + "http://[::1]:8080/verify?user_code=ABCD", + } { + t.Run(target, func(t *testing.T) { + calls := 0 + err := open(t.Context(), target, "linux", func(_ context.Context, _ string, args ...string) error { + calls++ + if len(args) != 1 || args[0] != target { + t.Fatal("URL must be one unchanged argument") + } + return nil + }) + if err != nil || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + }) + } +} + +func TestInvalidURLsNeverExecute(t *testing.T) { + for _, target := range []string{"", "/verify", "https:///verify", "https://", "https://user:secret@example.test/verify", "http://example.test/verify", "file:///etc/passwd", "javascript:alert(1)", "https://example.test/%zz", "--help", "https://example.test/verify#fragment"} { + t.Run(target, func(t *testing.T) { + err := open(t.Context(), target, "linux", unexpectedRunner(t)) + if err == nil { + t.Fatal("expected URL validation error") + } + }) + } +} + +func unexpectedRunner(t *testing.T) commandRunner { + t.Helper() + return func(context.Context, string, ...string) error { t.Fatal("unexpected OS command"); return nil } +} + +func TestCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := open(ctx, "https://example.test/verify", "linux", unexpectedRunner(t)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } +} + +func TestCancelDuringRun(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + err := open(ctx, "https://example.test/verify", "linux", func(ctx context.Context, _ string, _ ...string) error { + cancel() + <-ctx.Done() + return errors.New("process killed") + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } +} + +func TestTimeout(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + err := open(t.Context(), "https://example.test/verify", "linux", func(ctx context.Context, _ string, _ ...string) error { + <-ctx.Done() + return errors.New("process killed") + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err=%v", err) + } + }) +} + +func TestCommandFailure(t *testing.T) { + failure := errors.New("opener unavailable") + err := open(t.Context(), "https://example.test/verify", "linux", func(context.Context, string, ...string) error { return failure }) + if !errors.Is(err, failure) { + t.Fatalf("err=%v", err) + } +} + +func TestUnsupportedOS(t *testing.T) { + err := open(t.Context(), "https://example.test/verify", "plan9", unexpectedRunner(t)) + if err == nil { + t.Fatal("expected unsupported OS error") + } +} + +func TestOpenValidation(t *testing.T) { + // Exercise the exported entry point without starting any OS process. + if err := Open(t.Context(), "file:///etc/passwd"); err == nil { + t.Fatal("expected invalid URL error") + } +} + +func TestCommandContextCanceledBeforeStart(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + // A canceled CommandContext must not start even an available executable. + err := runCommand(ctx, os.Args[0]) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } +} diff --git a/internal/cloud/application.go b/internal/cloud/application.go new file mode 100644 index 00000000..c235f409 --- /dev/null +++ b/internal/cloud/application.go @@ -0,0 +1,429 @@ +package cloud + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "path" + "slices" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// ApplicationClient obtains a legacy application grant from Membership. The +// backend URL comes exclusively from the signed access-token audiences. Shared +// sessions require a coordinator holding the reload/refresh/save lock. +func ApplicationClient(ctx context.Context, base *http.Client, root *Session, organization, alias string, out io.Writer, open func(context.Context, string) error, coordinator Coordinator) (*http.Client, string, error) { + if root == nil || root.MembershipToken == nil { + return nil, "", errors.New("missing cloud identity; log in again") + } + if !validIdentifier(organization) || !validIdentifier(alias) { + return nil, "", errors.New("application requires valid organization and alias") + } + options, err := identityOptions(root.Options) + if err != nil { + return nil, "", err + } + provider, config, err := membership(ctx, base, options) + if err != nil { + return nil, "", err + } + config.Scopes = identityScopes() + m := &targetManager{root: root, options: options, base: base, provider: provider, config: config, out: out, open: open, coordinator: coordinator} + if coordinator == nil { + m.coordinator = m.inMemory + } + s := &applicationTransport{ctx: ctx, manager: m, organization: organization, alias: alias} + if _, err := s.token(ctx, ""); err != nil { + return nil, "", err + } + client := cloneClient(base) + client.Transport, client.CheckRedirect = s, noRedirect + return client, s.endpoint, nil +} + +type applicationTransport struct { + mu sync.Mutex + ctx context.Context + manager *targetManager + organization, alias, endpoint string +} + +func applicationGrant(claims identityClaims, organization, alias string) (applicationAccess, error) { + var found *applicationAccess + count := 0 + for _, org := range claims.Organizations { + if org.ID != organization { + continue + } + count++ + for _, app := range org.Applications { + if app.Alias != alias { + continue + } + if found != nil || !validIdentifier(app.ID) { + return applicationAccess{}, errors.New("invalid or duplicate application grant") + } + value := app + found = &value + } + } + if count != 1 || found == nil { + return applicationAccess{}, errors.New("identity does not authorize the requested application") + } + scopes := filterApplicationScopes(found.Scopes) + if len(scopes) == 0 { + return applicationAccess{}, errors.New("identity grants no supported application scopes") + } + found.Scopes = scopes + return *found, nil +} + +func filterApplicationScopes(granted []string) []string { + var result []string + for _, scope := range []string{"apps:Read", "apps:Write"} { + if slices.Contains(granted, scope) { + result = append(result, scope) + } + } + return result +} + +func applicationResource(alias string, scopes []string) string { + return "app://" + alias + "|" + strings.Join(scopes, " ") +} + +func (s *applicationTransport) token(ctx context.Context, method string) (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + m := s.manager + if m.pendingSave { + return nil, errors.New("cloud identity save failed; log in again") + } + return m.coordinator(ctx, func(current *Session, save func(*Session) error) (*oauth2.Token, error) { + if save == nil { + return nil, errors.New("cloud coordinator requires a session save function") + } + root, claims, err := m.currentIdentity(ctx, current, save) + if err != nil { + return nil, err + } + grant, err := applicationGrant(claims, s.organization, s.alias) + if err != nil { + return nil, err + } + required := "apps:Read" + if method != "" && !slices.Contains([]string{http.MethodGet, http.MethodHead, http.MethodOptions}, method) { + required = "apps:Write" + } + if method != "" && !slices.Contains(grant.Scopes, required) { + return nil, errors.New("identity does not grant the required application scope") + } + *m.root = *copyRoot(root) + return s.applicationToken(ctx, root, grant, save) + }) +} + +func (s *applicationTransport) applicationToken(ctx context.Context, root *Session, grant applicationAccess, save func(*Session) error) (*oauth2.Token, error) { + // An alias can be reassigned. Only the current signed application ID selects its cache. + key := s.organization + "/" + grant.ID + "/" + s.alias + scopes := grant.Scopes + child, err := s.cached(ctx, root.Applications[key], scopes) + if err != nil { + return nil, err + } + if child == nil { + var err error + child, err = s.authorize(ctx, root, scopes) + if err != nil { + return nil, err + } + if err := s.save(root, child, key, save); err != nil { + return nil, err + } + } + if !usable(child.MembershipToken) { + child, err = s.refreshAndSave(ctx, root, child, scopes, key, save) + if err != nil { + return nil, err + } + } + if s.endpoint != "" && child.StackURL != s.endpoint { + return nil, errors.New("application endpoint changed; recreate the client") + } + if s.endpoint == "" { + s.endpoint = child.StackURL + } + return cleanToken(child.MembershipToken), nil +} + +func (s *applicationTransport) refreshAndSave(ctx context.Context, root, child *Session, scopes []string, key string, save func(*Session) error) (*Session, error) { + renewed, refreshErr := s.refresh(ctx, child, scopes) + if renewed != nil { + if err := s.save(root, renewed, key, save); err != nil { + return nil, err + } + } + return renewed, refreshErr +} + +func (s *applicationTransport) cached(ctx context.Context, cached *Session, scopes []string) (*Session, error) { + child := copyRoot(cached) + if child == nil { + return nil, nil + } + matches, err := s.verify(ctx, child, scopes, true) + if err != nil { + return nil, err + } + if !matches { + return nil, nil + } + return child, nil +} + +func (s *applicationTransport) save(root, child *Session, key string, save func(*Session) error) error { + if root.Applications == nil { + root.Applications = make(map[string]*Session) + } + root.Applications[key] = copyRoot(child) + return s.manager.saveRoot(root, save) +} + +func (s *applicationTransport) authorize(ctx context.Context, root *Session, scopes []string) (*Session, error) { + m := s.manager + config := *m.config + config.Scopes = []string{"openid", "offline_access"} + resource := applicationResource(s.alias, scopes) + token, raw, err := deviceLogin(ctx, m.base, m.options, &config, m.out, m.open, + []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("organization_id", s.organization), oauth2.SetAuthURLParam("id_token_hint", root.IDToken), oauth2.SetAuthURLParam("resource", resource)}, + []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("resource", resource)}) + if err != nil { + return nil, err + } + child := &Session{Options: m.options, Application: s.alias, IDToken: raw, MembershipToken: cleanToken(token)} + child.Options.Organization = s.organization + matches, err := s.verify(ctx, child, scopes, false) + if err != nil { + return nil, err + } + if !matches { + return nil, errors.New("application grant permissions differ from root identity") + } + return child, nil +} + +func (s *applicationTransport) verify(ctx context.Context, child *Session, scopes []string, stored bool) (bool, error) { + return s.verifyWithProofExpiry(ctx, child, scopes, stored, stored) +} + +func (s *applicationTransport) verifyWithProofExpiry(ctx context.Context, child *Session, scopes []string, stored, storedProof bool) (bool, error) { + m := s.manager + expected := m.options + expected.Organization = s.organization + if child.Options != expected || child.Application != s.alias || child.StackToken != nil || child.IDToken == "" || child.MembershipToken == nil { + return false, errors.New("invalid application session; log in again") + } + subject, proofScopes, err := s.verifyProof(ctx, child.IDToken, storedProof) + if err != nil { + return false, err + } + // Resource access tokens are issued to the application backend, not fctl. + // The separate ID proof still requires fctl; custom audience validation + // below accepts only a unique secure backend after signature/issuer checks. + access, err := m.provider.Verifier(&oidc.Config{SkipClientIDCheck: true, SkipExpiryCheck: stored}).Verify(ctx, child.MembershipToken.AccessToken) + if err != nil { + return false, safeError(ctx, "verify application access", err) + } + var grant struct { + Organization string `json:"organization_id"` + Scope string `json:"scope"` + } + if err := access.Claims(&grant); err != nil { + return false, errors.New("invalid application access claims") + } + if grant.Organization != s.organization || access.Subject == "" || access.Subject != subject { + return false, errors.New("application grant identity differs from root identity") + } + endpoint, err := applicationEndpoint(access.Audience, m.options.ClientID) + if err != nil { + return false, err + } + if child.StackURL != "" && child.StackURL != endpoint { + return false, errors.New("application endpoint differs from signed audience") + } + if s.endpoint != "" && s.endpoint != endpoint { + return false, errors.New("application endpoint changed; recreate the client") + } + child.StackURL = endpoint + child.MembershipToken.Expiry = minExpiry(child.MembershipToken.Expiry, access.Expiry) + if err := boundSessionTokens(child); err != nil { + return false, err + } + // A permission change is a cache miss only after the stored proof and access + // agree with each other and all signature, subject and endpoint checks pass. + if !equalApplicationScopes(strings.Fields(grant.Scope), proofScopes) { + return false, errors.New("application access permissions differ from signed identity proof") + } + return slices.Equal(proofScopes, scopes), nil +} + +func (s *applicationTransport) verifyProof(ctx context.Context, raw string, stored bool) (string, []string, error) { + m := s.manager + _, id, err := verifyIdentity(ctx, m.provider, m.options, raw, stored) + if err != nil { + return "", nil, err + } + var proof struct { + Resources []string `json:"resources"` + } + if err := id.Claims(&proof); err != nil { + return "", nil, errors.New("invalid application identity proof") + } + if len(proof.Resources) != 1 { + return "", nil, errors.New("application identity proof requires one resource") + } + _, granted, _ := strings.Cut(proof.Resources[0], "|") + scopes := filterApplicationScopes(strings.Fields(granted)) + if len(scopes) == 0 || !matchesApplicationResource(proof.Resources[0], s.alias, scopes) { + return "", nil, errors.New("application identity proof differs from requested resource") + } + _, rootID, err := verifyIdentity(ctx, m.provider, m.options, m.root.IDToken, true) + if err != nil { + return "", nil, err + } + if id.Expiry.IsZero() || id.Subject == "" || id.Subject != rootID.Subject { + return "", nil, errors.New("application identity proof differs from root subject") + } + return id.Subject, scopes, nil +} + +func matchesApplicationResource(resource, alias string, scopes []string) bool { + target, granted, ok := strings.Cut(resource, "|") + return ok && target == "app://"+alias && equalApplicationScopes(strings.Fields(granted), scopes) +} + +func equalApplicationScopes(granted, expected []string) bool { + filtered := filterApplicationScopes(granted) + return len(filtered) == len(granted) && slices.Equal(filtered, expected) +} + +func applicationEndpoint(audiences []string, clientID string) (string, error) { + var endpoint string + seenClient := false + for _, audience := range audiences { + if audience == clientID { + if seenClient { + return "", errors.New("duplicate application client audience") + } + seenClient = true + continue + } + uri, err := url.Parse(audience) + if err != nil || uri.Scheme != "https" || validateEndpoint(audience) != nil || uri.RawPath != "" || uri.Opaque != "" || uri.ForceQuery || strings.ContainsAny(uri.Path, "\\%\x00") || path.Clean(uri.Path) != strings.TrimRight(uri.Path, "/") && strings.TrimRight(uri.Path, "/") != "" || endpoint != "" { + return "", errors.New("application access requires one secure backend audience") + } + endpoint = strings.TrimRight(audience, "/") + } + if endpoint == "" { + return "", errors.New("application access is missing a secure backend audience") + } + return endpoint, nil +} + +func (s *applicationTransport) refresh(ctx context.Context, child *Session, scopes []string) (*Session, error) { + if child.MembershipToken.RefreshToken == "" { + return nil, errors.New("application session expired; log in again") + } + m := s.manager + config := *m.config + config.Scopes = nil + old := cleanToken(child.MembershipToken) + old.Expiry = time.Now().Add(-time.Hour) + authCtx := context.WithValue(ctx, oauth2.HTTPClient, authClient(ctx, m.base, m.options.Issuer)) + fresh, err := config.TokenSource(authCtx, old).Token() + if err != nil { + return nil, safeError(ctx, "application refresh", err) + } + child.MembershipToken = cleanToken(fresh) + // A refresh may omit id_token. The previously verified ID token remains + // authorization evidence after expiry; fresh access still requires expiry. + storedProof := true + if raw, ok := fresh.Extra("id_token").(string); ok && raw != "" { + child.IDToken = raw + storedProof = false + } + if err := s.validateRefresh(ctx, child, scopes, storedProof); err != nil { + child.IDToken = "" + return child, err + } + return child, nil +} + +func (s *applicationTransport) validateRefresh(ctx context.Context, child *Session, scopes []string, storedProof bool) error { + if err := boundToken(child.MembershipToken, time.Now()); err != nil { + return err + } + matches, err := s.verifyWithProofExpiry(ctx, child, scopes, false, storedProof) + if err != nil { + return err + } + if !matches { + return errors.New("refreshed application permissions differ from root identity; log in again") + } + return nil +} + +func (s *applicationTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := s.route(req.URL); err != nil { + return nil, err + } + if req.Host != "" && !strings.EqualFold(req.Host, req.URL.Host) { + return nil, errors.New("application client refused a different request Host") + } + ctx, cancel := context.WithCancel(req.Context()) + //nolint:contextcheck // The client lifetime also cancels independent request contexts. + stop := context.AfterFunc(s.ctx, cancel) + cleanup := func() { stop(); cancel() } + if err := s.ctx.Err(); err != nil { + cleanup() + return nil, err + } + token, err := s.token(ctx, req.Method) + if err != nil { + cleanup() + return nil, err + } + transport := &oauth2.Transport{Source: oauth2.StaticTokenSource(token), Base: baseTransport(s.manager.base)} + resp, err := transport.RoundTrip(req.Clone(ctx)) + if err != nil { + cleanup() + return nil, err + } + resp.Body = &cancelBody{ReadCloser: resp.Body, cleanup: cleanup} + return resp, nil +} + +func (s *applicationTransport) route(uri *url.URL) error { + if uri == nil || uri.User != nil || uri.RawPath != "" || uri.Opaque != "" || !sameOrigin(s.endpoint, uri.String()) || strings.ContainsAny(uri.Path, "\\%\x00") || path.Clean(uri.Path) != strings.TrimSuffix(uri.Path, "/") && uri.Path != "/" { + return errors.New("application client refused an unsafe request URL") + } + endpoint, err := url.Parse(s.endpoint) + if err != nil { + return errors.New("invalid application endpoint") + } + prefix := strings.TrimRight(endpoint.Path, "/") + if uri.Path != prefix && !strings.HasPrefix(uri.Path, prefix+"/") { + return errors.New("application client refused a request outside its backend path") + } + return nil +} diff --git a/internal/cloud/application_test.go b/internal/cloud/application_test.go new file mode 100644 index 00000000..5f407ebf --- /dev/null +++ b/internal/cloud/application_test.go @@ -0,0 +1,774 @@ +package cloud + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/oauth2" +) + +type applicationFixture struct { + identity *identityFixture + client *http.Client + mu sync.Mutex + forms []url.Values + backendTokens []string + scopes []string + mutateAccess func(map[string]any) + mutateID func(map[string]any) + invalidSignature, invalidRefresh bool + omitRefreshID bool + devices, refreshes, backend atomic.Int32 +} + +func newApplicationFixture(t *testing.T) *applicationFixture { + t.Helper() + f := &applicationFixture{identity: newIdentityFixture(t), scopes: []string{"apps:Read", "apps:Write"}} + f.identity.accesses[0].Applications = []applicationAccess{{ID: "application-id", Alias: "deploy", Scopes: slices.Clone(f.scopes)}} + f.client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "backend.test" { + return f.backendResponse(t, r), nil + } + if !sameOrigin(f.identity.f.options.Issuer, r.URL.String()) { + return nil, errors.New("unexpected network target") + } + if r.URL.Path != "/membership/device" && r.URL.Path != "/membership/token" { + return f.identity.client.Transport.RoundTrip(r) + } + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.Form.Get("refresh_token") == "root-refresh" { + return f.identity.client.Transport.RoundTrip(r) + } + return f.response(t, r), nil + })} + return f +} + +func (f *applicationFixture) backendResponse(t *testing.T, r *http.Request) *http.Response { + t.Helper() + f.backend.Add(1) + f.mu.Lock() + f.backendTokens = append(f.backendTokens, r.Header.Get("Authorization")) + f.mu.Unlock() + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ey") { + t.Error("backend did not receive signed application access token") + } + w := httptest.NewRecorder() + if r.URL.Path == "/api/redirect" { + w.Header().Set("Location", "https://other.test/apps") + w.WriteHeader(http.StatusFound) + return w.Result() + } + writeJSON(t, w, map[string]bool{"ok": true}) + return w.Result() +} + +func (f *applicationFixture) response(t *testing.T, r *http.Request) *http.Response { + t.Helper() + f.mu.Lock() + f.forms = append(f.forms, r.Form) + f.mu.Unlock() + w := httptest.NewRecorder() + if r.URL.Path == "/membership/device" { + f.devices.Add(1) + writeJSON(t, w, map[string]any{"device_code": "application-code", "user_code": "ABCD", "verification_uri": f.identity.f.server.URL + "/verify", "expires_in": 600, "interval": 1}) + return w.Result() + } + if r.Form.Has("scope") { + t.Error("application token request must omit scope") + } + if r.Form.Get("grant_type") != "refresh_token" && r.Form.Get("resource") != applicationResource("deploy", f.scopes) { + t.Error("application poll must carry scoped resource") + } + refresh := "application-refresh" + if r.Form.Get("grant_type") == "refresh_token" { + f.refreshes.Add(1) + refresh = "rotated-application-refresh" + } + access := map[string]any{"iss": f.identity.f.options.Issuer, "aud": []string{"https://backend.test/api"}, "sub": "user", "exp": time.Now().Add(time.Hour).Unix(), "organization_id": "org", "scope": strings.Join(f.scopes, " "), "jti": fmt.Sprintf("application-grant-%d", f.devices.Load())} + id := map[string]any{"iss": f.identity.f.options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(time.Hour).Unix(), "resources": []string{applicationResource("deploy", f.scopes)}} + if f.mutateAccess != nil { + f.mutateAccess(access) + } + if f.mutateID != nil { + f.mutateID(id) + } + if f.invalidRefresh && refresh == "rotated-application-refresh" { + access["organization_id"] = "other" + } + raw := signMembershipClaims(t, f.identity, access) + if f.invalidSignature { + raw += "invalid" + } + tokens := map[string]any{"access_token": raw, "id_token": signMembershipClaims(t, f.identity, id), "token_type": "Bearer", "expires_in": 3600, "refresh_token": refresh} + if f.omitRefreshID && refresh == "rotated-application-refresh" { + delete(tokens, "id_token") + } + writeJSON(t, w, tokens) + return w.Result() +} + +func (f *applicationFixture) login(t *testing.T, root *Session, coordinator Coordinator) (*http.Client, string, error) { + t.Helper() + return ApplicationClient(t.Context(), f.client, root, "org", "deploy", io.Discard, nil, coordinator) +} + +func TestApplicationGrantAndCache(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + root.Applications = map[string]*Session{"other/other": {Application: "cached-other"}} + root.Organizations = map[string]*Session{"other": {Application: "sentinel"}} + root.Targets = map[string]*Session{"other/stack": {StackURL: "sentinel"}} + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + if endpoint != "https://backend.test/api" { + t.Fatalf("unexpected endpoint %s", endpoint) + } + if err := membershipRequest(t, client, endpoint+"/apps"); err != nil { + t.Fatal(err) + } + if _, _, err := f.login(t, root, nil); err != nil { + t.Fatal(err) + } + if f.devices.Load() != 1 || f.identity.f.exchanges.Load() != 0 { + t.Fatal("application cache missed or stack token exchange occurred") + } + child := root.Applications["org/application-id/deploy"] + if child == nil || child.Application != "deploy" || child.Options.Stack != "" || child.Options.Organization != "org" || child.StackToken != nil { + t.Fatal("invalid application cache") + } + if root.Applications["other/other"].Application != "cached-other" || root.Organizations["other"].Application != "sentinel" || root.Targets["other/stack"].StackURL != "sentinel" { + t.Fatal("unrelated child cache lost") + } + form := f.forms[0] + if form.Get("scope") != "openid offline_access" || form.Get("organization_id") != "org" || form.Get("id_token_hint") != root.IDToken || form.Get("resource") != "app://deploy|apps:Read apps:Write" { + t.Fatal("invalid application authorization parameters") + } +} + +func TestApplicationRejectsInvalidGrants(t *testing.T) { + tests := []struct { + name string + change func(*applicationFixture) + }{ + {"signature", func(f *applicationFixture) { f.invalidSignature = true }}, + {"issuer", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["iss"] = "https://other.test" } + }}, + {"expiry", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["exp"] = time.Now().Add(-time.Hour).Unix() } + }}, + {"no backend audience", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"fctl"} } + }}, + {"missing audience", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { delete(c, "aud") } + }}, + {"foreign client audience", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"other-client", "https://backend.test/api"} } + }}, + {"duplicate client audience", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"fctl", "fctl", "https://backend.test/api"} } + }}, + {"duplicate backend audience", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"https://backend.test/api", "https://backend.test/api"} } + }}, + {"ambiguous endpoint", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"fctl", "https://backend.test/api", "https://other.test"} } + }}, + {"http endpoint", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["aud"] = []string{"fctl", "http://localhost/api"} } + }}, + {"organization", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["organization_id"] = "other" } + }}, + {"subject", func(f *applicationFixture) { f.mutateAccess = func(c map[string]any) { c["sub"] = "other" } }}, + {"scope escalation", func(f *applicationFixture) { + f.mutateAccess = func(c map[string]any) { c["scope"] = "apps:Read apps:Write system:Admin" } + }}, + {"resource alias", func(f *applicationFixture) { + f.mutateID = func(c map[string]any) { c["resources"] = []string{"app://other|apps:Read apps:Write"} } + }}, + {"missing proof", func(f *applicationFixture) { f.mutateID = func(c map[string]any) { delete(c, "resources") } }}, + {"id audience", func(f *applicationFixture) { f.mutateID = func(c map[string]any) { c["aud"] = "other" } }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newApplicationFixture(t) + tt.change(f) + root := f.identity.root(t) + if _, _, err := f.login(t, root, nil); err == nil { + t.Fatal("invalid grant accepted") + } + if f.backend.Load() != 0 || len(root.Applications) != 0 { + t.Fatal("invalid grant used or saved") + } + }) + } +} + +func TestApplicationRequestBoundsAndRevocation(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + for _, uri := range []string{"http://backend.test/api/apps", "https://other.test/api/apps", "https://backend.test/outside", "https://backend.test/api2/apps", "https://backend.test/api/../outside", "https://backend.test/api/%2e%2e/outside", "https://user@backend.test/api/apps"} { + if err := membershipRequest(t, client, uri); err == nil { + t.Fatalf("unsafe URL accepted: %s", uri) + } + } + hostRequest := testRequest(t, http.MethodGet, endpoint+"/apps", nil) + hostRequest.Host = "other.test" + hostResponse, hostErr := client.Do(hostRequest) + if hostResponse != nil { + if closeErr := hostResponse.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + if hostErr == nil { + t.Fatal("request Host override accepted") + } + if f.backend.Load() != 0 { + t.Fatal("unsafe request reached backend") + } + f.identity.accesses[0].Applications = nil + root.IDToken = f.identity.signedID(t) + if err := membershipRequest(t, client, endpoint+"/apps"); err == nil { + t.Fatal("revoked root application used") + } + if f.backend.Load() != 0 { + t.Fatal("revoked request reached backend") + } +} + +func TestApplicationReadOnly(t *testing.T) { + f := newApplicationFixture(t) + f.scopes = []string{"apps:Read"} + f.identity.accesses[0].Applications[0].Scopes = slices.Clone(f.scopes) + client, endpoint, err := f.login(t, f.identity.root(t), nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(testRequest(t, http.MethodGet, endpoint+"/apps", nil)) + if err != nil { + t.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, client, endpoint+"/apps"); err == nil { + t.Fatal("read-only identity authorized write") + } + if f.backend.Load() != 1 { + t.Fatal("unexpected backend calls") + } +} + +func TestApplicationInvalidRefreshPersistsRotation(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + root.Applications["org/application-id/deploy"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.invalidRefresh = true + if err := membershipRequest(t, client, endpoint+"/apps"); err == nil { + t.Fatal("invalid refreshed grant accepted") + } + child := root.Applications["org/application-id/deploy"] + if child.IDToken != "" || child.MembershipToken.RefreshToken != "rotated-application-refresh" { + t.Fatal("rotation or invalid-proof marker lost") + } + if err := membershipRequest(t, client, endpoint+"/apps"); err == nil { + t.Fatal("invalid proof reused") + } + if _, _, err := f.login(t, root, nil); err == nil { + t.Fatal("invalid persisted proof reused by new client") + } + if f.refreshes.Load() != 1 || f.backend.Load() != 0 { + t.Fatal("rotation retried or invalid grant forwarded") + } +} + +func TestApplicationCASFailureStopsClient(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + var saves int + coordinator := func(ctx context.Context, work func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return work(copyRoot(root), func(next *Session) error { + saves++ + if saves > 1 { + return errors.New("save-secret") + } + *root = *copyRoot(next) + return nil + }) + } + client, endpoint, err := f.login(t, root, coordinator) + if err != nil { + t.Fatal(err) + } + root.Applications["org/application-id/deploy"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + for range 2 { + err := membershipRequest(t, client, endpoint+"/apps") + if err == nil || strings.Contains(err.Error(), "save-secret") { + t.Fatal("CAS failure accepted or leaked") + } + } + if f.refreshes.Load() != 1 || saves != 2 || f.backend.Load() != 0 { + t.Fatal("CAS failure retried OAuth or forwarded a request") + } +} + +func TestApplicationCoordinatedRefresh(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + stored := copyRoot(root) + var mu sync.Mutex + coordinator := func(ctx context.Context, work func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + mu.Lock() + defer mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + return work(copyRoot(stored), func(next *Session) error { stored = copyRoot(next); return nil }) + } + first, endpoint, err := f.login(t, copyRoot(root), coordinator) + if err != nil { + t.Fatal(err) + } + second, _, err := f.login(t, copyRoot(root), coordinator) + if err != nil { + t.Fatal(err) + } + stored.Applications["org/application-id/deploy"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + failures := make(chan error, 8) + var wg sync.WaitGroup + for i := range 8 { + client := first + if i%2 != 0 { + client = second + } + wg.Go(func() { failures <- membershipRequest(t, client, endpoint+"/apps") }) + } + wg.Wait() + close(failures) + for err := range failures { + if err != nil { + t.Fatal(err) + } + } + if f.refreshes.Load() != 1 || f.devices.Load() != 1 || f.backend.Load() != 8 { + t.Fatal("coordinator did not serialize child refresh across clients") + } +} + +func TestApplicationRootRefresh(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + if err := membershipRequest(t, client, endpoint+"/apps"); err != nil { + t.Fatal(err) + } + if f.identity.rootRefresh.Load() != 1 || f.devices.Load() != 1 || root.Applications["org/application-id/deploy"] == nil { + t.Fatal("root refresh lost application cache") + } +} + +func TestApplicationCanceledLifetimeAndRedirect(t *testing.T) { + f := newApplicationFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client, endpoint, err := ApplicationClient(ctx, f.client, f.identity.root(t), "org", "deploy", io.Discard, nil, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(testRequest(t, http.MethodGet, endpoint+"/redirect", nil)) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + if err == nil || f.backend.Load() != 1 { + t.Fatal("application redirect was followed") + } + cancel() + if err := membershipRequest(t, client, endpoint+"/apps"); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled lifetime allowed request: %v", err) + } + if f.backend.Load() != 1 { + t.Fatal("canceled request reached backend") + } +} + +func TestApplicationTamperedCache(t *testing.T) { + for _, field := range []string{"endpoint", "alias", "organization", "stack", "proof", "access"} { + t.Run(field, func(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + _, _, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + child := root.Applications["org/application-id/deploy"] + switch field { + case "endpoint": + child.StackURL = "https://other.test" + case "alias": + child.Application = "other" + case "organization": + child.Options.Organization = "other" + case "stack": + child.Options.Stack = "stack" + case "proof": + child.IDToken = f.identity.signedID(t) + case "access": + child.MembershipToken.AccessToken = "root-secret" + } + if _, _, err := f.login(t, root, nil); err == nil { + t.Fatal("tampered application cache accepted") + } + if f.devices.Load() != 1 || f.backend.Load() != 0 { + t.Fatal("tampered cache triggered OAuth or backend") + } + }) + } +} + +func TestApplicationScopesAndEndpointValidation(t *testing.T) { + for _, claims := range []identityClaims{ + {}, + {Organizations: []organizationAccess{{ID: "org", Applications: []applicationAccess{{ID: "id", Alias: "deploy", Scopes: []string{"other"}}}}}}, + {Organizations: []organizationAccess{{ID: "org", Applications: []applicationAccess{{ID: "id", Alias: "deploy", Scopes: []string{"apps:Read"}}, {ID: "id2", Alias: "deploy", Scopes: []string{"apps:Read"}}}}}}, + } { + if _, err := applicationGrant(claims, "org", "deploy"); err == nil { + t.Fatal("invalid root application access accepted") + } + } + for _, endpoint := range []string{"http://localhost/api", "https://user@backend.test/api", "https://backend.test/api?token=x", "https://backend.test/api#fragment", "https://backend.test/api/../outside", "https://backend.test/api%2foutside"} { + if _, err := applicationEndpoint([]string{"fctl", endpoint}, "fctl"); err == nil { + t.Fatalf("invalid audience endpoint accepted: %s", endpoint) + } + } + for _, endpoint := range []string{"https://backend.test", "https://backend.test/", "https://backend.test/api/"} { + if _, err := applicationEndpoint([]string{"fctl", endpoint}, "fctl"); err != nil { + t.Fatalf("valid audience endpoint rejected: %v", err) + } + } + if !matchesApplicationResource("app://deploy|apps:Write apps:Read", "deploy", []string{"apps:Read", "apps:Write"}) { + t.Fatal("scope order changed grant semantics") + } + if matchesApplicationResource("app://deploy|apps:Read apps:Read", "deploy", []string{"apps:Read"}) { + t.Fatal("duplicate scope accepted") + } +} + +func TestApplicationRefreshKeepsStoredProof(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + child := root.Applications["org/application-id/deploy"] + proof := signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(-time.Hour).Unix(), "resources": []string{"app://deploy|apps:Read apps:Write"}}) + child.IDToken = proof + child.MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.omitRefreshID = true + if err := membershipRequest(t, client, endpoint+"/apps"); err != nil { + t.Fatal(err) + } + if root.Applications["org/application-id/deploy"].IDToken != proof || f.refreshes.Load() != 1 || f.backend.Load() != 1 { + t.Fatal("refresh lost the verified stored authorization proof") + } +} + +func TestApplicationEndpointChangeDuringRefresh(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + root.Applications["org/application-id/deploy"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.mutateAccess = func(claims map[string]any) { claims["aud"] = []string{"fctl", "https://other.test/api"} } + if err := membershipRequest(t, client, endpoint+"/apps"); err == nil { + t.Fatal("refresh changed the client backend") + } + child := root.Applications["org/application-id/deploy"] + if child.IDToken != "" || child.MembershipToken.RefreshToken != "rotated-application-refresh" || f.backend.Load() != 0 { + t.Fatal("endpoint change did not persist rotation and invalidate proof") + } +} + +func TestApplicationAliasReuseRequiresNewGrant(t *testing.T) { + for _, endpoint := range []string{"https://backend.test/api", "https://backend.test/replacement"} { + t.Run(endpoint, func(t *testing.T) { checkApplicationAliasReuse(t, endpoint) }) + } +} +func checkApplicationAliasReuse(t *testing.T, endpoint string) { + t.Helper() + f := newApplicationFixture(t) + root := f.identity.root(t) + first, originalEndpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + oldToken := root.Applications["org/application-id/deploy"].MembershipToken.AccessToken + f.identity.accesses[0].Applications[0].ID = "replacement-id" + root.IDToken = f.identity.signedID(t) + f.mutateAccess = func(claims map[string]any) { claims["aud"] = []string{"fctl", endpoint} } + if endpoint == originalEndpoint { + applicationGet(t, first, originalEndpoint+"/apps") + applicationAssertNoToken(t, f, oldToken) + } + second, newEndpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + if newEndpoint != endpoint || f.devices.Load() != 2 { + t.Fatal("alias reassignment reused its previous application grant") + } + if root.Applications["org/replacement-id/deploy"] == nil { + t.Fatal("replacement grant not bound to its application ID") + } + applicationGet(t, second, newEndpoint+"/apps") + applicationAssertNoToken(t, f, oldToken) + if endpoint != originalEndpoint { + if err := membershipRequest(t, first, originalEndpoint+"/apps"); err == nil { + t.Fatal("existing client changed backend") + } + if f.backend.Load() != 1 { + t.Fatal("old client forwarded a request after backend replacement") + } + } +} + +func TestApplicationLegacyAliasCacheIsNotMigrated(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + if _, _, err := f.login(t, root, nil); err != nil { + t.Fatal(err) + } + cached := root.Applications["org/application-id/deploy"] + root.Applications["org/deploy"] = cached + delete(root.Applications, "org/application-id/deploy") + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + applicationGet(t, client, endpoint+"/apps") + if f.devices.Load() != 2 { + t.Fatal("unbound legacy cache was migrated without fresh authorization") + } + applicationAssertNoToken(t, f, cached.MembershipToken.AccessToken) +} + +func TestApplicationScopeChangesReauthorize(t *testing.T) { + for _, test := range []struct { + name string + before, after []string + }{ + {"downgrade", []string{"apps:Read", "apps:Write"}, []string{"apps:Read"}}, + {"upgrade", []string{"apps:Read"}, []string{"apps:Read", "apps:Write"}}, + } { + t.Run(test.name, func(t *testing.T) { + checkApplicationScopeChange(t, test.before, test.after) + }) + } +} + +func checkApplicationScopeChange(t *testing.T, before, after []string) { + t.Helper() + f := newApplicationFixture(t) + f.scopes = slices.Clone(before) + f.identity.accesses[0].Applications[0].Scopes = slices.Clone(before) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + previous := root.Applications["org/application-id/deploy"].MembershipToken.AccessToken + f.scopes = slices.Clone(after) + f.identity.accesses[0].Applications[0].Scopes = slices.Clone(after) + root.IDToken = f.identity.signedID(t) + applicationGet(t, client, endpoint+"/apps") + applicationAssertNoToken(t, f, previous) + if f.devices.Load() != 2 || f.refreshes.Load() != 0 { + t.Fatal("scope change must use fresh authorization, not the old refresh grant") + } + writeErr := membershipRequest(t, client, endpoint+"/apps") + if (writeErr == nil) != slices.Contains(after, "apps:Write") { + t.Fatalf("write permission after scope change: %v", writeErr) + } + applicationAssertNoToken(t, f, previous) + if _, _, err := f.login(t, root, nil); err != nil { + t.Fatal(err) + } + if f.devices.Load() != 2 { + t.Fatal("replacement grant was not cached") + } +} + +func TestApplicationScopeChangeKeepsEndpointImmutable(t *testing.T) { + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + before := root.Applications["org/application-id/deploy"] + f.scopes = []string{"apps:Read"} + f.identity.accesses[0].Applications[0].Scopes = slices.Clone(f.scopes) + root.IDToken = f.identity.signedID(t) + f.mutateAccess = func(claims map[string]any) { claims["aud"] = []string{"fctl", "https://backend.test/other"} } + requestErr := applicationGetError(t, client, endpoint+"/apps") + if requestErr == nil || !strings.Contains(requestErr.Error(), "endpoint changed") { + t.Fatalf("endpoint mutation accepted: %v", requestErr) + } + if f.devices.Load() != 2 || f.backend.Load() != 0 || root.Applications["org/application-id/deploy"].MembershipToken.AccessToken != before.MembershipToken.AccessToken { + t.Fatal("failed replacement was used or saved") + } +} + +func TestApplicationScopeChangeDoesNotHideInvalidCache(t *testing.T) { + for _, field := range []string{"proof signature", "access signature", "proof resource", "proof scopes", "invalidated proof", "JWKS"} { + t.Run(field, func(t *testing.T) { + checkInvalidApplicationCache(t, field) + }) + } +} + +func checkInvalidApplicationCache(t *testing.T, field string) { + t.Helper() + f := newApplicationFixture(t) + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + child := root.Applications["org/application-id/deploy"] + f.scopes = []string{"apps:Read"} + f.identity.accesses[0].Applications[0].Scopes = slices.Clone(f.scopes) + root.IDToken = f.identity.signedID(t) + if corruptApplicationCache(t, f, root, child, field) { + client = nil + } + + if client == nil { + if _, _, err := f.login(t, root, nil); err == nil { + t.Fatal("JWKS failure accepted") + } + } else if err := applicationGetError(t, client, endpoint+"/apps"); err == nil { + t.Fatal("invalid cached proof accepted as a scope change") + } + if f.devices.Load() != 1 || f.refreshes.Load() != 0 || f.backend.Load() != 0 { + t.Fatal("invalid cache triggered fallback authorization or sent a token") + } +} + +func corruptApplicationCache(t *testing.T, f *applicationFixture, root, child *Session, field string) bool { + t.Helper() + switch field { + case "proof signature": + child.IDToken += "invalid" + case "access signature": + child.MembershipToken.AccessToken += "invalid" + case "proof resource": + child.IDToken = f.identity.signedID(t) + case "proof scopes": + child.IDToken = signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(time.Hour).Unix(), "resources": []string{"app://deploy|apps:Read"}}) + case "invalidated proof": + child.IDToken = "" + case "JWKS": + base := f.client.Transport + f.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/membership/keys" { + return nil, errors.New("synthetic-jwks-failure") + } + return base.RoundTrip(req) + }) + // The existing verifier has a warm key cache. A fresh client has to load JWKS. + return true + } + return false +} + +func applicationGet(t *testing.T, client *http.Client, uri string) { + t.Helper() + if err := applicationGetError(t, client, uri); err != nil { + t.Fatal(err) + } +} +func applicationGetError(t *testing.T, client *http.Client, uri string) error { + t.Helper() + response, err := client.Do(testRequest(t, http.MethodGet, uri, nil)) + if response != nil { + if closeErr := response.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + return err +} + +func applicationAssertNoToken(t *testing.T, f *applicationFixture, old string) { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + for _, token := range f.backendTokens { + if token == "Bearer "+old { + t.Fatal("previous application's access token reached the backend") + } + } +} + +// Membership's application resource resolves to the backend audience, while its +// separate ID token is issued to fctl. Do not verify an access token as an ID token. +func TestApplicationBackendOnlyAccessAudience(t *testing.T) { + f := newApplicationFixture(t) + f.mutateAccess = func(claims map[string]any) { claims["aud"] = []string{"https://backend.test/api"} } + root := f.identity.root(t) + client, endpoint, err := f.login(t, root, nil) + if err != nil { + t.Fatal(err) + } + applicationGet(t, client, endpoint+"/apps") + if root.Applications["org/application-id/deploy"] == nil { + t.Fatal("backend-only audience grant was not cached by organization/ID/alias") + } +} + +func TestApplicationLegacyClientAndBackendAccessAudience(t *testing.T) { + f := newApplicationFixture(t) + f.mutateAccess = func(claims map[string]any) { claims["aud"] = []string{"fctl", "https://backend.test/api"} } + client, endpoint, err := f.login(t, f.identity.root(t), nil) + if err != nil { + t.Fatal(err) + } + applicationGet(t, client, endpoint+"/apps") +} diff --git a/internal/cloud/cloud.go b/internal/cloud/cloud.go new file mode 100644 index 00000000..ab76fe4f --- /dev/null +++ b/internal/cloud/cloud.go @@ -0,0 +1,486 @@ +// Package cloud authenticates a user through Membership and exchanges the +// stack-scoped Membership token for a data-plane token. Sessions contain secrets +// and must be persisted by the caller with restrictive permissions. +package cloud + +import ( + "context" + "errors" + "io" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const refreshMargin = 30 * time.Second +const maxTokenLifetime = 24 * time.Hour + +// DefaultIssuer is the Membership issuer used by the public Cloud service. +const DefaultIssuer = "https://app.formance.cloud/api" + +// Options identifies the Membership issuer and the one stack to authorize. +type Options struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Organization string `json:"organization"` + Stack string `json:"stack"` +} + +// Session is serializable authentication state. Do not log it. StackURL is +// checked against signed Membership claims when constructing a client. +type Session struct { + Options Options `json:"options"` + IDToken string `json:"id_token"` + MembershipToken *oauth2.Token `json:"membership_token"` + StackURL string `json:"stack_url"` + StackToken *oauth2.Token `json:"stack_token,omitzero"` + Targets map[string]*Session `json:"targets,omitzero"` + Organizations map[string]*Session `json:"organizations,omitzero"` + Applications map[string]*Session `json:"applications,omitzero"` + Application string `json:"application,omitzero"` + // Historical ID claims must not replace current authorization after renewal. + RequireUserInfo bool `json:"require_userinfo,omitzero"` + // Set only from the verified root on each coordinated reload, never from JSON. + allowedScopes []string + rootSubject string +} + +// Coordinator serializes authentication for one saved connection. While holding +// its authentication lock, it reloads the current session and invokes work with +// that session and a revision-checked save function. The lock must cover work, +// including all refresh, exchange and save operations. Logout and replacement +// can independently invalidate that save through the caller's revision check. +// The coordinator must invoke work exactly once and return its result. +type Coordinator func(context.Context, func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) + +func (o Options) normalized() (Options, error) { + o.Issuer = strings.TrimRight(o.Issuer, "/") + if o.ClientID == "" { + o.ClientID = "fctl" + } + if err := validateEndpoint(o.Issuer); err != nil { + return o, err + } + for _, id := range []string{o.ClientID, o.Organization, o.Stack} { + if id == "" || strings.ContainsAny(id, " /|\t\r\n?#\\") { + return o, errors.New("cloud requires a client ID, organization and stack with valid identifiers") + } + } + return o, nil +} + +func (o Options) resource() string { + return "stack://" + o.Organization + "/" + o.Stack + "|stack:Read stack:Write" +} + +// Login displays the device verification URI and user code on out (normally +// stderr). It never opens a browser or requests organization-wide permissions. +func Login(ctx context.Context, httpClient *http.Client, options Options, out io.Writer) (*Session, error) { + options, err := options.normalized() + if err != nil { + return nil, err + } + provider, config, err := membership(ctx, httpClient, options) + if err != nil { + return nil, err + } + authorization := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("organization_id", options.Organization), oauth2.SetAuthURLParam("resource", options.resource())} + token, idToken, err := deviceLogin(ctx, httpClient, options, config, out, nil, authorization, []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("resource", options.resource())}) + if err != nil { + return nil, err + } + stackURL, err := verifiedStack(ctx, provider, options, idToken, false) + if err != nil { + return nil, err + } + return &Session{Options: options, IDToken: idToken, MembershipToken: cleanToken(token), StackURL: stackURL}, nil +} + +// Client validates the session, refreshes Membership when needed and exchanges +// its assertion for a stack token. The transport repeats this only on expiry; +// it never retries service operations after authentication errors. +// An optional coordinator surrounds every token acquisition, including the +// eager acquisition here and cached-token reads by subsequent requests. Its +// save function replaces save. After a coordinated save failure this client +// stops; recreating it requires the parent to resolve the saved session state. +// The caller must not mutate session concurrently with the returned client. +func Client(ctx context.Context, httpClient *http.Client, session *Session, save func(*Session) error, coordinators ...Coordinator) (*http.Client, string, error) { + if len(coordinators) > 1 || (len(coordinators) == 1 && coordinators[0] == nil) { + return nil, "", errors.New("cloud client requires at most one non-nil coordinator") + } + if session == nil || session.MembershipToken == nil { + return nil, "", errors.New("missing cloud session; log in again") + } + options, err := session.Options.normalized() + if err != nil { + return nil, "", err + } + provider, config, err := membership(ctx, httpClient, options) + if err != nil { + return nil, "", err + } + state, err := validatedSession(ctx, provider, options, session) + if err != nil { + return nil, "", err + } + stackURL := state.StackURL + source := &sessionTransport{ctx: ctx, base: httpClient, state: state, session: session, save: save, provider: provider, config: config, origin: stackURL} + if len(coordinators) == 1 { + source.coordinator = coordinators[0] + } + if _, err := source.token(ctx); err != nil { + return nil, "", err + } + client := cloneClient(httpClient) + client.Transport = source + client.CheckRedirect = noRedirect + return client, stackURL, nil +} + +type sessionTransport struct { + mu sync.Mutex + ctx context.Context + base *http.Client + state Session + session *Session + save func(*Session) error + provider *oidc.Provider + config *oauth2.Config + origin string + pendingSave bool + coordinator Coordinator +} + +func (s *sessionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if !sameOrigin(s.origin, req.URL.String()) || req.URL.User != nil { + return nil, errors.New("cloud client refused a request outside its stack origin") + } + ctx, cancel := context.WithCancel(req.Context()) + //nolint:contextcheck // The client lifetime must cancel operations with independent request contexts. + stop := context.AfterFunc(s.ctx, cancel) + if err := s.ctx.Err(); err != nil { + stop() + cancel() + return nil, err + } + token, err := s.token(ctx) + if err != nil { + stop() + cancel() + return nil, err + } + transport := &oauth2.Transport{Source: oauth2.StaticTokenSource(token), Base: baseTransport(s.base)} + resp, err := transport.RoundTrip(req.Clone(ctx)) + if err != nil { + stop() + cancel() + return nil, err + } + resp.Body = &cancelBody{ReadCloser: resp.Body, cleanup: func() { stop(); cancel() }} + return resp, nil +} + +func (s *sessionTransport) token(ctx context.Context) (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + if s.coordinator != nil { + token, err := s.coordinator(ctx, func(current *Session, save func(*Session) error) (*oauth2.Token, error) { + return s.coordinatedToken(ctx, current, save) + }) + if err != nil { + return nil, safeError(ctx, "coordinate cloud authentication", err) + } + return token, nil + } + return s.tokenLocked(ctx) +} + +// token already holds mu; the coordinator holds the connection authentication +// lock before entering here. Never reacquire mu from its callback. +func (s *sessionTransport) coordinatedToken(ctx context.Context, current *Session, save func(*Session) error) (*oauth2.Token, error) { + if s.pendingSave { + // The in-memory credentials may contain a consumed/rotated refresh token. + // A failed CAS can mean logout or replacement. Never reload an older token + // or retry a save against a new revision; this client must stop. + return nil, errors.New("cloud session save failed; recreate the client or log in again") + } + if save == nil { + return nil, errors.New("cloud coordinator requires a session save function") + } + state, err := validatedSession(ctx, s.provider, s.state.Options, current) + if err != nil { + return nil, err + } + if state.StackURL != s.origin { + return nil, errors.New("coordinated cloud session targets a different stack URL") + } + s.state = state + *s.session = state + s.save = save + return s.tokenLocked(ctx) +} + +func (s *sessionTransport) tokenLocked(ctx context.Context) (*oauth2.Token, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if s.pendingSave { + if err := s.persist(s.state); err != nil { + return nil, err + } + } + next := s.state + changed := false + if !usable(next.MembershipToken) { + if next.MembershipToken.RefreshToken == "" { + return nil, errors.New("cloud session expired; log in again") + } + refreshed, err := s.refresh(ctx, next) + if err != nil { + return nil, err + } + next = *refreshed + changed = true + } + if !usable(next.StackToken) { + exchanged, err := s.exchange(ctx, next, changed) + if err != nil { + return nil, err + } + next = *exchanged + changed = true + } + if changed { + if err := s.persist(next); err != nil { + return nil, err + } + } + return cleanToken(s.state.StackToken), nil +} + +func (s *sessionTransport) refresh(ctx context.Context, next Session) (*Session, error) { + authCtx := context.WithValue(ctx, oauth2.HTTPClient, authClient(ctx, s.base, next.Options.Issuer)) + // Force refresh within our early-expiry margin. TokenSource preserves an + // existing refresh token when the provider omits it from the response. + old := cleanToken(next.MembershipToken) + old.Expiry = time.Now().Add(-time.Hour) + fresh, err := s.config.TokenSource(authCtx, old).Token() + if err != nil { + return nil, safeError(ctx, "Membership refresh", err) + } + if err := boundToken(fresh, time.Now()); err != nil { + return s.rejectRefreshedIdentity(next, fresh, err) + } + next.MembershipToken = cleanToken(fresh) + if raw, ok := fresh.Extra("id_token").(string); ok && raw != "" { + if err := s.verifyRefreshedIdentity(ctx, next, raw); err != nil { + return s.rejectRefreshedIdentity(next, fresh, err) + } + next.IDToken = raw + } else if next.allowedScopes != nil { + target := targetAccess{Options: next.Options, URI: next.StackURL, Scopes: next.allowedScopes, Subject: next.rootSubject} + matches, err := verifyTargetSession(ctx, s.provider, target, &next, true, false) + if err != nil { + return s.rejectRefreshedIdentity(next, fresh, err) + } + if !matches { + return s.rejectRefreshedIdentity(next, fresh, errors.New("refreshed session permissions differ from root identity; log in again")) + } + } + next.MembershipToken = cleanToken(fresh) + next.StackToken = nil + return &next, nil +} + +func (s *sessionTransport) verifyRefreshedIdentity(ctx context.Context, next Session, raw string) error { + next.IDToken = raw + target := targetAccess{Options: next.Options, URI: next.StackURL, Scopes: next.allowedScopes, Subject: next.rootSubject} + matches, err := verifyTargetSession(ctx, s.provider, target, &next, false, false) + if err != nil { + return err + } + if !matches { + return errors.New("refreshed session permissions differ from root identity; log in again") + } + return nil +} + +func (s *sessionTransport) rejectRefreshedIdentity(next Session, fresh *oauth2.Token, verificationErr error) (*Session, error) { + if next.allowedScopes != nil { + // Keep consumed refresh rotation, but prevent every client from using the + // old identity proof or an existing stack token after verification fails. + next.MembershipToken = cleanToken(fresh) + next.IDToken, next.StackToken = "", nil + if err := s.persist(next); err != nil { + return nil, err + } + } + return nil, verificationErr +} + +func (s *sessionTransport) exchange(ctx context.Context, next Session, refreshed bool) (*Session, error) { + token, err := exchange(ctx, s.base, next.StackURL, next.MembershipToken.AccessToken) + if err != nil { + // Preserve rotated refresh credentials even if the stack is unavailable. + if refreshed { + if saveErr := s.persist(next); saveErr != nil { + return nil, saveErr + } + } + return nil, err + } + token.Expiry = minExpiry(token.Expiry, next.MembershipToken.Expiry) + next.StackToken = token + return &next, nil +} + +func (s *sessionTransport) persist(next Session) error { + // Keep rotated credentials in memory even when persistence fails. + s.state = next + *s.session = next + if s.save != nil { + s.pendingSave = true + snapshot := next + snapshot.MembershipToken = cleanToken(next.MembershipToken) + snapshot.StackToken = cleanToken(next.StackToken) + if err := s.save(&snapshot); err != nil { + return errors.New("cannot save cloud session") + } + s.pendingSave = false + } + return nil +} + +func usable(t *oauth2.Token) bool { + return t != nil && t.AccessToken != "" && !t.Expiry.IsZero() && t.Expiry.After(time.Now().Add(refreshMargin)) +} +func cleanToken(t *oauth2.Token) *oauth2.Token { + if t == nil { + return nil + } + return &oauth2.Token{AccessToken: t.AccessToken, TokenType: t.TokenType, RefreshToken: t.RefreshToken, Expiry: t.Expiry} +} +func boundToken(t *oauth2.Token, now time.Time) error { + if t == nil || t.AccessToken == "" || t.Expiry.IsZero() || !t.Expiry.After(now) || (t.TokenType != "" && !strings.EqualFold(t.TokenType, "Bearer")) || strings.ContainsAny(t.AccessToken, "\r\n") { + return errors.New("invalid or expired OAuth token") + } + t.TokenType = "Bearer" + t.Expiry = minExpiry(t.Expiry, now.Add(maxTokenLifetime)) + return nil +} +func minExpiry(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} + +func verifiedStack(ctx context.Context, provider *oidc.Provider, options Options, raw string, stored bool) (string, error) { + token, err := provider.Verifier(&oidc.Config{ClientID: options.ClientID, SkipExpiryCheck: stored}).Verify(ctx, raw) + if err != nil { + return "", safeError(ctx, "verify Membership identity", err) + } + var claims identityClaims + if err := token.Claims(&claims); err != nil { + return "", errors.New("invalid Membership organization claims") + } + return selectStack(options, claims) +} + +type stackAccess struct { + ID string `json:"id"` + URI string `json:"uri"` + Scopes []string `json:"scopes"` +} +type organizationAccess struct { + ID string `json:"id"` + Scopes []string `json:"scopes"` + Stacks []stackAccess `json:"stacks"` + Applications []applicationAccess `json:"applications"` +} +type applicationAccess struct { + ID string `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Scopes []string `json:"scopes"` +} +type identityClaims struct { + Organizations []organizationAccess `json:"org"` +} + +func selectStack(options Options, claims identityClaims) (string, error) { + for _, organization := range claims.Organizations { + if organization.ID != options.Organization { + continue + } + index := slices.IndexFunc(organization.Stacks, func(stack stackAccess) bool { return stack.ID == options.Stack }) + if index < 0 { + break + } + stack := organization.Stacks[index] + if !slices.Contains(stack.Scopes, "stack:Read") && !slices.Contains(stack.Scopes, "stack:Write") { + return "", errors.New("identity grants no access to the requested stack") + } + uri := strings.TrimRight(stack.URI, "/") + if err := validateEndpoint(uri); err != nil { + return "", errors.New("identity contains an invalid stack URL") + } + return uri, nil + } + return "", errors.New("identity does not authorize the requested organization and stack") +} + +// Stored ID tokens prove the original authorization after expiry. Fresh ID +// tokens received by Login or refresh still require a valid expiry. +func validatedSession(ctx context.Context, provider *oidc.Provider, expected Options, session *Session) (Session, error) { + if session == nil || session.MembershipToken == nil { + return Session{}, errors.New("missing cloud session; log in again") + } + options, err := session.Options.normalized() + if err != nil { + return Session{}, err + } + if options != expected { + return Session{}, errors.New("cloud session options changed; recreate the client") + } + target := targetAccess{Options: options, URI: session.StackURL, Scopes: session.allowedScopes, Subject: session.rootSubject} + matches, err := verifyTargetSession(ctx, provider, target, session, true, true) + if err != nil { + return Session{}, err + } + if !matches { + return Session{}, errors.New("session permissions differ from root identity; log in again") + } + state := *session + state.allowedScopes = slices.Clone(session.allowedScopes) + state.Options = options + state.MembershipToken = cleanToken(session.MembershipToken) + state.StackToken = cleanToken(session.StackToken) + if err := boundSessionTokens(&state); err != nil { + return Session{}, err + } + return state, nil +} + +func boundSessionTokens(state *Session) error { + if err := boundToken(state.MembershipToken, time.Now()); err != nil { + if state.MembershipToken.RefreshToken == "" { + return err + } + state.MembershipToken.Expiry = time.Time{} + } + if state.StackToken != nil { + if err := boundToken(state.StackToken, time.Now()); err != nil { + state.StackToken = nil + } + } + return nil +} diff --git a/internal/cloud/cloud_test.go b/internal/cloud/cloud_test.go new file mode 100644 index 00000000..182715c4 --- /dev/null +++ b/internal/cloud/cloud_test.go @@ -0,0 +1,628 @@ +package cloud + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "golang.org/x/oauth2" +) + +// Provider fixtures can share an immutable signing key. Signature rejection +// tests still generate a separate key, avoiding RSA key generation per case. +var fixtureSigningKey = sync.OnceValues(func() (*rsa.PrivateKey, error) { + return rsa.GenerateKey(rand.Reader, 2048) +}) + +type fixture struct { + server *httptest.Server + key *rsa.PrivateKey + options Options + audience, stack, organization string + signingKey *rsa.PrivateKey + pollError, refreshError, exchangeError string + refreshedID string + omitID bool + deviceRedirect string + tokenRedirect string + claimsURI string + deviceWait bool + identityIssuer string + noScopes bool + pollResults []string + waitPath string + polls, refreshes, exchanges, writes atomic.Int32 +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + key, err := fixtureSigningKey() + if err != nil { + t.Fatal(err) + } + f := &fixture{key: key, audience: "fctl", stack: "stack", organization: "org"} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.serve(t, w, r) })) + t.Cleanup(f.server.Close) + f.options = Options{Issuer: f.server.URL + "/membership", Organization: "org", Stack: "stack"} + return f +} +func writeJSON(t *testing.T, w http.ResponseWriter, v any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Error(err) + } +} +func (f *fixture) id(t *testing.T) string { + t.Helper() + uri := f.server.URL + "/stack" + if f.claimsURI != "" { + uri = f.claimsURI + } + issuer := f.options.Issuer + if f.identityIssuer != "" { + issuer = f.identityIssuer + } + scopes := []string{"stack:Read", "stack:Write"} + if f.noScopes { + scopes = []string{"organization:Read"} + } + claims := map[string]any{"iss": issuer, "aud": f.audience, "sub": "user", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), "org": []any{map[string]any{"id": f.organization, "stacks": []any{map[string]any{"id": f.stack, "uri": uri, "scopes": scopes}}}}} + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key"}`)) + unsigned := header + "." + base64.RawURLEncoding.EncodeToString(payload) + hash := sha256.Sum256([]byte(unsigned)) + key := f.key + if f.signingKey != nil { + key = f.signingKey + } + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig) +} +func (f *fixture) session(t *testing.T) *Session { + return &Session{Options: f.options, IDToken: f.id(t), StackURL: f.server.URL + "/stack", MembershipToken: &oauth2.Token{AccessToken: "membership-secret", TokenType: "Bearer", RefreshToken: "refresh-secret", Expiry: time.Now().Add(time.Hour)}} +} +func (f *fixture) serve(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.URL.Path == f.waitPath { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + return + } + <-r.Context().Done() + return + } + switch r.URL.Path { + case "/membership/.well-known/openid-configuration": + writeJSON(t, w, map[string]any{"issuer": f.options.Issuer, "token_endpoint": f.server.URL + "/membership/token", "device_authorization_endpoint": f.server.URL + "/membership/device", "jwks_uri": f.server.URL + "/membership/keys", "id_token_signing_alg_values_supported": []string{"RS256"}}) + case "/membership/keys": + writeJSON(t, w, map[string]any{"keys": []any{map[string]any{"kty": "RSA", "kid": "key", "alg": "RS256", "use": "sig", "n": base64.RawURLEncoding.EncodeToString(f.key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(f.key.E)).Bytes())}}}) + case "/membership/device": + f.serveDevice(t, w, r) + case "/membership/token": + f.serveMembershipToken(t, w, r) + case "/stack/api/auth/.well-known/openid-configuration": + writeJSON(t, w, map[string]any{"issuer": f.server.URL + "/stack/api/auth", "token_endpoint": f.server.URL + "/stack/api/auth/token"}) + case "/stack/api/auth/token": + f.serveExchange(t, w, r) + case "/stack/ledger": + f.writes.Add(1) + if r.Header.Get("Authorization") != "Bearer stack-secret" { + t.Error("service received wrong bearer") + } + w.WriteHeader(http.StatusUnauthorized) + default: + http.NotFound(w, r) + } +} + +func TestLoginAndClient(t *testing.T) { + f := newFixture(t) + var out bytes.Buffer + session, err := Login(t.Context(), f.server.Client(), f.options, &out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "ABCD") || strings.Contains(out.String(), "secret") { + t.Fatalf("unexpected device output: %s", out.String()) + } + restored := roundTripSession(t, session) + saves := 0 + client, stackURL, err := Client(t.Context(), f.server.Client(), restored, func(s *Session) error { + saves++ + if s.StackToken == nil { + t.Error("missing saved stack token") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if stackURL != f.server.URL+"/stack" || saves != 1 { + t.Fatal("client did not exchange and persist") + } + for range 2 { + req := testRequest(t, http.MethodPost, stackURL+"/ledger", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + } + if f.writes.Load() != 2 || f.exchanges.Load() != 1 { + t.Fatal("service writes were retried or stack token was not cached") + } +} + +func TestLoginRejectsIdentity(t *testing.T) { + for _, kind := range []string{"signature", "audience", "organization", "stack", "missingID", "unsafeURI", "issuer", "scope"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newFixture(t) + switch kind { + case "signature": + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f.signingKey = key + case "audience": + f.audience = "other" + case "organization": + f.organization = "other" + case "stack": + f.stack = "other" + case "issuer": + f.identityIssuer = "https://other.example" + case "scope": + f.noScopes = true + case "missingID": + f.omitID = true + case "unsafeURI": + f.claimsURI = "https://user:secret@example.com" + } + if _, err := Login(t.Context(), f.server.Client(), f.options, io.Discard); err == nil { + t.Fatal("accepted invalid identity") + } else if strings.Contains(err.Error(), "secret") { + t.Fatal("leaked token") + } + if f.exchanges.Load() != 0 { + t.Fatal("exchanged an unverified identity") + } + }) + } +} + +func TestRefreshPreservesRefreshToken(t *testing.T) { + f := newFixture(t) + session := f.session(t) + session.MembershipToken.Expiry = time.Now().Add(10 * time.Second) + session.StackToken = &oauth2.Token{AccessToken: "old-stack", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} + saves := 0 + _, _, err := Client(t.Context(), f.server.Client(), session, func(s *Session) error { saves++; return nil }) + if err != nil { + t.Fatal(err) + } + if f.refreshes.Load() != 1 || f.exchanges.Load() != 1 || saves != 1 || session.MembershipToken.RefreshToken != "refresh-secret" || session.MembershipToken.AccessToken != "refreshed-secret" { + t.Fatal("refresh or persistence failed") + } +} + +func TestSafeTokenErrors(t *testing.T) { + for _, kind := range []string{"device", "refresh", "exchange"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + f := newFixture(t) + secret := `{"error":"access_denied","error_description":"membership-secret refresh-secret device-secret"}` + var err error + switch kind { + case "device": + f.pollError = secret + _, err = Login(t.Context(), f.server.Client(), f.options, io.Discard) + case "refresh": + f.refreshError = secret + s := f.session(t) + s.MembershipToken.Expiry = time.Now().Add(-time.Minute) + _, _, err = Client(t.Context(), f.server.Client(), s, nil) + case "exchange": + f.exchangeError = secret + _, _, err = Client(t.Context(), f.server.Client(), f.session(t), nil) + } + if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "description") { + t.Fatalf("unsafe error: %v", err) + } + }) + } +} + +func TestLoginCancellation(t *testing.T) { + for _, phase := range []string{"device", "poll"} { + t.Run(phase, func(t *testing.T) { + f := newFixture(t) + f.deviceWait = phase == "device" + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + start := time.Now() + _, err := Login(ctx, f.server.Client(), f.options, io.Discard) + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second { + t.Fatalf("cancellation failed: %v", err) + } + }) + } +} + +func TestDeviceRedirect(t *testing.T) { + f := newFixture(t) + var reached atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Add(1) })) + defer other.Close() + f.deviceRedirect = other.URL + if _, err := Login(t.Context(), f.server.Client(), f.options, io.Discard); err == nil { + t.Fatal("accepted device redirect") + } + if reached.Load() != 0 { + t.Fatal("redirect leaked device request") + } +} + +func TestTokenRedirect(t *testing.T) { + f := newFixture(t) + var reached atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Add(1) })) + defer other.Close() + f.tokenRedirect = other.URL + if _, err := Login(t.Context(), f.server.Client(), f.options, io.Discard); err == nil { + t.Fatal("accepted token redirect") + } + if reached.Load() != 0 || f.polls.Load() != 1 { + t.Fatal("token request redirected or retried") + } +} + +func TestDataPlaneOrigin(t *testing.T) { + f := newFixture(t) + client, _, err := Client(t.Context(), f.server.Client(), f.session(t), nil) + if err != nil { + t.Fatal(err) + } + var reached atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Add(1) })) + defer other.Close() + req := testRequest(t, http.MethodPost, other.URL, nil) + resp, err := client.Do(req) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + } + if err == nil { + t.Fatal("accepted cross-origin service request") + } + if reached.Load() != 0 { + t.Fatal("leaked service token") + } +} + +func TestClientRejectsChangedStackURL(t *testing.T) { + f := newFixture(t) + session := f.session(t) + session.StackURL = "https://other.example" + if _, _, err := Client(t.Context(), f.server.Client(), session, nil); err == nil { + t.Fatal("trusted unsigned stack URI") + } + if f.exchanges.Load() != 0 { + t.Fatal("exchanged for unsigned stack URI") + } +} + +func TestRefreshRejectsNewIdentity(t *testing.T) { + f := newFixture(t) + session := f.session(t) + session.MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.audience = "other" + f.refreshedID = f.id(t) + if _, _, err := Client(t.Context(), f.server.Client(), session, nil); err == nil { + t.Fatal("accepted invalid refreshed ID token") + } + if f.exchanges.Load() != 0 { + t.Fatal("exchanged after invalid refreshed identity") + } +} + +func TestRefreshSavedOnExchangeFailure(t *testing.T) { + f := newFixture(t) + session := f.session(t) + session.MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.exchangeError = "refreshed-secret" + saves := 0 + _, _, err := Client(t.Context(), f.server.Client(), session, func(s *Session) error { saves++; return nil }) + if err == nil || saves != 1 || session.MembershipToken.AccessToken != "refreshed-secret" { + t.Fatal("rotated refresh credentials were lost on exchange failure") + } +} + +func TestSaveFailureRetried(t *testing.T) { + f := newFixture(t) + session := f.session(t) + calls := 0 + client, uri, err := Client(t.Context(), f.server.Client(), session, func(s *Session) error { + calls++ + if calls == 2 { + return errors.New("refresh-secret") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + transport, ok := client.Transport.(*sessionTransport) + if !ok { + t.Fatal("unexpected cloud transport") + } + transport.mu.Lock() + transport.state.StackToken.Expiry = time.Now().Add(-time.Hour) + transport.mu.Unlock() + req := testRequest(t, http.MethodGet, uri+"/ledger", nil) + resp, err := client.Do(req) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + } + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe save failure: %v", err) + } + resp, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + if calls != 3 || f.writes.Load() != 1 { + t.Fatal("failed save was not retried before service operation") + } +} + +func TestTokenBounds(t *testing.T) { + now := time.Now() + for _, token := range []*oauth2.Token{nil, {AccessToken: "x"}, {AccessToken: "x", Expiry: now.Add(-time.Second)}, {AccessToken: "x", TokenType: "MAC", Expiry: now.Add(time.Hour)}, {AccessToken: "x\n", Expiry: now.Add(time.Hour)}} { + if err := boundToken(token, now); err == nil { + t.Fatal("accepted invalid token") + } + } + token := &oauth2.Token{AccessToken: "x", Expiry: now.Add(365 * 24 * time.Hour)} + if err := boundToken(token, now); err != nil { + t.Fatal(err) + } + if token.Expiry != now.Add(maxTokenLifetime) || token.TokenType != "Bearer" { + t.Fatal("token lifetime was not bounded") + } +} + +func TestClientCancellation(t *testing.T) { + f := newFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + client, uri, err := Client(ctx, f.server.Client(), f.session(t), nil) + if err != nil { + t.Fatal(err) + } + cancel() + req := testRequest(t, http.MethodPost, uri+"/ledger", nil) + resp, err := client.Do(req) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("client lifetime cancellation failed: %v", err) + } + if f.writes.Load() != 0 { + t.Fatal("wrote after client cancellation") + } +} + +func (f *fixture) serveDevice(t *testing.T, w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if f.deviceWait { + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + return + } + <-r.Context().Done() + return + } + if f.deviceRedirect != "" { + http.Redirect(w, r, f.deviceRedirect, http.StatusTemporaryRedirect) + return + } + if err := r.ParseForm(); err != nil { + t.Error(err) + return + } + id, _, ok := r.BasicAuth() + if !ok || id != "fctl" { + t.Error("missing public client Basic authentication") + } + if r.Form.Get("client_id") != "fctl" || r.Form.Get("organization_id") != "org" || r.Form.Get("resource") != f.options.resource() || r.Form.Get("scope") != "openid offline_access" { + t.Errorf("incorrect device parameters: %v", r.Form) + } + writeJSON(t, w, map[string]any{"device_code": "device-secret", "user_code": "ABCD", "verification_uri": f.server.URL + "/verify", "expires_in": 600, "interval": 1}) +} + +func (f *fixture) serveMembershipToken(t *testing.T, w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + t.Error(err) + return + } + id, _, ok := r.BasicAuth() + if !ok || id != "fctl" { + t.Error("missing token client authentication") + } + grant := r.Form.Get("grant_type") + if grant == "refresh_token" { + f.serveRefresh(t, w, r) + return + } + if r.Form.Has("scope") { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, map[string]string{"error": "server_error"}) + return + } + poll := f.polls.Add(1) + if int(poll) <= len(f.pollResults) && f.pollResults[poll-1] != "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, map[string]string{"error": f.pollResults[poll-1]}) + return + } + if grant != "urn:ietf:params:oauth:grant-type:device_code" || r.Form.Get("resource") != f.options.resource() || r.Form.Get("device_code") != "device-secret" { + t.Error("incorrect polling grant or resource") + } + if f.tokenRedirect != "" { + http.Redirect(w, r, f.tokenRedirect, http.StatusTemporaryRedirect) + return + } + if f.pollError != "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + if _, err := io.WriteString(w, f.pollError); err != nil { + t.Error(err) + } + return + } + result := map[string]any{"access_token": "membership-secret", "refresh_token": "refresh-secret", "token_type": "Bearer", "expires_in": 3600} + if !f.omitID { + result["id_token"] = f.id(t) + } + writeJSON(t, w, result) +} + +func (f *fixture) serveRefresh(t *testing.T, w http.ResponseWriter, r *http.Request) { + + f.refreshes.Add(1) + if r.Form.Get("refresh_token") != "refresh-secret" { + t.Error("wrong refresh token") + } + if f.refreshError != "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + if _, err := io.WriteString(w, f.refreshError); err != nil { + t.Error(err) + } + return + } + result := map[string]any{"access_token": "refreshed-secret", "token_type": "Bearer", "expires_in": 3600} + if f.refreshedID != "" { + result["id_token"] = f.refreshedID + } + writeJSON(t, w, result) +} + +func (f *fixture) serveExchange(t *testing.T, w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + f.exchanges.Add(1) + if err := r.ParseForm(); err != nil { + t.Error(err) + return + } + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" || r.Form.Get("scope") != "openid email" || (r.Form.Get("assertion") != "membership-secret" && r.Form.Get("assertion") != "refreshed-secret") { + t.Error("incorrect stack exchange") + } + if f.exchangeError != "" { + w.WriteHeader(401) + if _, err := io.WriteString(w, f.exchangeError); err != nil { + t.Error(err) + } + return + } + writeJSON(t, w, map[string]any{"access_token": "stack-secret", "token_type": "Bearer", "expires_in": 3600}) +} + +func testRequest(t *testing.T, method, uri string, body io.Reader) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), method, uri, body) + if err != nil { + t.Fatal(err) + } + return req +} + +func roundTripSession(t *testing.T, session *Session) *Session { + t.Helper() + encoded, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored Session + if err := json.Unmarshal(encoded, &restored); err != nil { + t.Fatal(err) + } + return &restored +} + +// The OAuth library owns RFC 8628 polling. Fake time checks pending and slow_down +// without adding seconds of real waiting to the suite. +func TestPollingIntervals(t *testing.T) { + f := newFixture(t) + f.pollResults = []string{"authorization_pending", "slow_down", ""} + synctest.Test(t, func(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + f.serve(t, recorder, r) + return recorder.Result(), nil + })} + start := time.Now() + if _, err := Login(t.Context(), client, f.options, io.Discard); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed != 8*time.Second || f.polls.Load() != 3 { + t.Fatalf("incorrect polling schedule: %v, polls=%d", elapsed, f.polls.Load()) + } + }) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestExchangeAndKeysCancellation(t *testing.T) { + for _, path := range []string{"/membership/keys", "/stack/api/auth/token"} { + t.Run(path, func(t *testing.T) { + f := newFixture(t) + f.waitPath = path + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, _, err := Client(ctx, f.server.Client(), f.session(t), nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("authentication cancellation failed: %v", err) + } + }) + } +} diff --git a/internal/cloud/coordinator_test.go b/internal/cloud/coordinator_test.go new file mode 100644 index 00000000..9c9e8cc7 --- /dev/null +++ b/internal/cloud/coordinator_test.go @@ -0,0 +1,301 @@ +package cloud + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/oauth2" +) + +// The authentication lock and store lock are deliberately separate, matching +// the parent's lock contract. Each callback save carries its loaded revision. +type coordinatedStore struct { + auth chan struct{} + mu sync.Mutex + session *Session + revision int + saves int + callbacks int + failSave bool +} + +func cloneSession(session *Session) *Session { return copyRoot(session) } + +func newCoordinatedStore(session *Session) *coordinatedStore { + return &coordinatedStore{auth: make(chan struct{}, 1), session: cloneSession(session)} +} + +func (s *coordinatedStore) coordinate(ctx context.Context, work func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + select { + case s.auth <- struct{}{}: + defer func() { <-s.auth }() + case <-ctx.Done(): + return nil, ctx.Err() + } + s.mu.Lock() + current, revision := cloneSession(s.session), s.revision + s.callbacks++ + s.mu.Unlock() + return work(current, func(next *Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.saves++ + if s.failSave || revision != s.revision { + return errors.New("revision changed") + } + s.session = cloneSession(next) + s.revision++ + revision = s.revision + return nil + }) +} + +func (s *coordinatedStore) expire() { + s.mu.Lock() + defer s.mu.Unlock() + s.session.MembershipToken.Expiry = time.Now().Add(-time.Hour) + s.session.StackToken = nil + s.revision++ +} + +func (s *coordinatedStore) snapshot() *Session { + s.mu.Lock() + defer s.mu.Unlock() + return cloneSession(s.session) +} + +// The provider consumes refresh-secret exactly once. A second use fails as a +// real rotating-refresh provider would; successful refresh returns a new token. +func rotatingClient(t *testing.T, f *fixture) *http.Client { + t.Helper() + base := f.server.Client() + return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/membership/token" { + return baseTransport(base).RoundTrip(r) + } + if err := r.ParseForm(); err != nil { + return nil, err + } + recorder := httptest.NewRecorder() + if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "refresh-secret" || f.refreshes.Add(1) != 1 { + recorder.WriteHeader(http.StatusBadRequest) + writeJSON(t, recorder, map[string]string{"error": "invalid_grant"}) + } else { + writeJSON(t, recorder, map[string]any{"access_token": "refreshed-secret", "refresh_token": "rotated-secret", "token_type": "Bearer", "expires_in": 3600}) + } + return recorder.Result(), nil + })} +} + +func runParallel(t *testing.T, work func() error) { + t.Helper() + start := make(chan struct{}) + failures := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Go(func() { <-start; failures <- work() }) + } + close(start) + workers.Wait() + close(failures) + for err := range failures { + if err != nil { + t.Fatal(err) + } + } +} + +func TestCoordinatorSerializesEagerRefresh(t *testing.T) { + f := newFixture(t) + seed := f.session(t) + seed.MembershipToken.Expiry = time.Now().Add(-time.Hour) + store := newCoordinatedStore(seed) + base := rotatingClient(t, f) + runParallel(t, func() error { + _, _, err := Client(t.Context(), base, cloneSession(seed), nil, store.coordinate) + return err + }) + if f.refreshes.Load() != 1 || f.exchanges.Load() != 1 || store.saves != 1 { + t.Fatal("concurrent constructors repeated rotation or exchange") + } + if store.snapshot().MembershipToken.RefreshToken != "rotated-secret" { + t.Fatal("disk retained the consumed refresh token") + } +} + +func TestCoordinatorSerializesLazyRefresh(t *testing.T) { + f := newFixture(t) + seed := f.session(t) + seed.StackToken = &oauth2.Token{AccessToken: "stack-secret", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} + store := newCoordinatedStore(seed) + base := rotatingClient(t, f) + var clients []*http.Client + for range 2 { + client, _, err := Client(t.Context(), base, cloneSession(seed), nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + clients = append(clients, client) + } + store.expire() + start := make(chan struct{}) + failures := make(chan error, 2) + var workers sync.WaitGroup + for _, client := range clients { + workers.Go(func() { + <-start + resp, err := client.Do(testRequest(t, http.MethodPost, seed.StackURL+"/ledger", nil)) + if err != nil { + failures <- err + return + } + failures <- resp.Body.Close() + }) + } + close(start) + workers.Wait() + close(failures) + for err := range failures { + if err != nil { + t.Fatal(err) + } + } + if f.refreshes.Load() != 1 || f.exchanges.Load() != 1 || store.saves != 1 || f.writes.Load() != 2 { + t.Fatal("lazy authentication did not share the newly rotated tokens") + } + if store.callbacks != 4 || store.snapshot().MembershipToken.RefreshToken != "rotated-secret" { + t.Fatal("lazy calls bypassed coordination or rewrote a consumed refresh token") + } +} + +func TestCoordinatorSaveFailureStopsReload(t *testing.T) { + f := newFixture(t) + seed := f.session(t) + seed.StackToken = &oauth2.Token{AccessToken: "stack-secret", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} + store := newCoordinatedStore(seed) + client, _, err := Client(t.Context(), rotatingClient(t, f), cloneSession(seed), nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + store.expire() + store.failSave = true + for range 2 { + resp, err := client.Do(testRequest(t, http.MethodPost, seed.StackURL+"/ledger", nil)) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe save failure: %v", err) + } + } + transport, ok := client.Transport.(*sessionTransport) + if !ok { + t.Fatal("unexpected transport") + } + if !transport.pendingSave || transport.state.MembershipToken.RefreshToken != "rotated-secret" { + t.Fatal("reload overwrote pending rotated credentials") + } + if f.refreshes.Load() != 1 || store.saves != 1 || f.writes.Load() != 0 { + t.Fatal("save failure retried a consumed token or sent a service write") + } +} + +func TestCoordinatorValidatesReloadedSession(t *testing.T) { + for _, kind := range []string{"missing", "options", "signature", "audience", "scope", "stackURL", "issuer", "stack", "signedURI"} { + t.Run(kind, func(t *testing.T) { + f := newFixture(t) + seed := f.session(t) + store := newCoordinatedStore(seed) + client, _, err := Client(t.Context(), f.server.Client(), cloneSession(seed), nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + changeCoordinatedSession(t, f, store, kind) + assertCoordinatedRequestFails(t, client, seed.StackURL) + if f.refreshes.Load() != 0 || f.exchanges.Load() != 1 || f.writes.Load() != 0 { + t.Fatal("invalid identity triggered authentication or service operations") + } + }) + } +} + +func assertCoordinatedRequestFails(t *testing.T, client *http.Client, stackURL string) { + t.Helper() + resp, err := client.Do(testRequest(t, http.MethodPost, stackURL+"/ledger", nil)) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + if err == nil { + t.Fatal("accepted invalid reloaded session") + } +} + +func changeCoordinatedSession(t *testing.T, f *fixture, store *coordinatedStore, kind string) { + t.Helper() + switch kind { + case "missing": + store.session = nil + case "options": + store.session.Options.Organization = "other" + case "signature": + store.session.IDToken = "invalid" + case "audience": + f.audience = "other" + store.session.IDToken = f.id(t) + case "scope": + f.noScopes = true + store.session.IDToken = f.id(t) + case "stackURL": + store.session.StackURL = "https://other.example" + case "issuer": + f.identityIssuer = "https://other.example" + store.session.IDToken = f.id(t) + case "stack": + f.stack = "other" + store.session.IDToken = f.id(t) + case "signedURI": + f.claimsURI = f.server.URL + "/other" + store.session.IDToken = f.id(t) + store.session.StackURL = f.claimsURI + } +} + +func TestCoordinatorWaitCancellation(t *testing.T) { + f := newFixture(t) + store := newCoordinatedStore(f.session(t)) + client, stackURL, err := Client(t.Context(), f.server.Client(), store.snapshot(), nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + // Simulate another process holding the authentication lock. + store.auth <- struct{}{} + defer func() { <-store.auth }() + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, stackURL+"/ledger", nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if resp != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("coordinator ignored request cancellation: %v", err) + } + if f.writes.Load() != 0 { + t.Fatal("service write escaped cancellation") + } +} diff --git a/internal/cloud/http.go b/internal/cloud/http.go new file mode 100644 index 00000000..1e4a666c --- /dev/null +++ b/internal/cloud/http.go @@ -0,0 +1,292 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/formancehq/fctl/v4/internal/api" +) + +const requestTimeout = 30 * time.Second +const maxResponse = 1 << 20 + +type discovery struct { + Issuer string `json:"issuer"` + TokenEndpoint string `json:"token_endpoint"` + DeviceEndpoint string `json:"device_authorization_endpoint"` + JWKS string `json:"jwks_uri"` + UserInfo string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + +func discover(ctx context.Context, base *http.Client, issuer string) (*discovery, error) { + client := authClient(ctx, base, issuer) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer+"/.well-known/openid-configuration", nil) + if err != nil { + return nil, errors.New("invalid discovery URL") + } + resp, err := client.Do(req) + if err != nil { + return nil, safeError(ctx, "OIDC discovery", err) + } + defer closeResponse(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC discovery failed (HTTP %d)", resp.StatusCode) + } + var metadata discovery + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return nil, errors.New("invalid OIDC discovery response") + } + if metadata.Issuer != issuer { + return nil, errors.New("OIDC discovery issuer mismatch") + } + if err := validateEndpoint(metadata.TokenEndpoint); err != nil || !sameOrigin(issuer, metadata.TokenEndpoint) { + return nil, errors.New("invalid or cross-origin OIDC token endpoint") + } + return &metadata, nil +} + +func membership(ctx context.Context, base *http.Client, options Options) (*oidc.Provider, *oauth2.Config, error) { + metadata, err := discover(ctx, base, options.Issuer) + if err != nil { + return nil, nil, err + } + for _, endpoint := range []string{metadata.DeviceEndpoint, metadata.JWKS} { + if err := validateEndpoint(endpoint); err != nil || !sameOrigin(options.Issuer, endpoint) { + return nil, nil, errors.New("invalid or cross-origin Membership endpoint") + } + } + if metadata.UserInfo != "" { + if err := validateEndpoint(metadata.UserInfo); err != nil || !sameOrigin(options.Issuer, metadata.UserInfo) { + return nil, nil, errors.New("invalid or cross-origin Membership userinfo endpoint") + } + } + client := authClient(ctx, base, options.Issuer) + // Membership's historical device endpoint authenticates the public client + // using Basic auth. DeviceAuth otherwise sends client_id in the form. + client.Transport = &deviceTransport{base: client.Transport, endpoint: metadata.DeviceEndpoint, clientID: options.ClientID} + provider := (&oidc.ProviderConfig{IssuerURL: metadata.Issuer, TokenURL: metadata.TokenEndpoint, DeviceAuthURL: metadata.DeviceEndpoint, JWKSURL: metadata.JWKS, UserInfoURL: metadata.UserInfo, Algorithms: metadata.Algorithms}).NewProvider(oidc.ClientContext(ctx, client)) + config := &oauth2.Config{ClientID: options.ClientID, Scopes: []string{oidc.ScopeOpenID, "offline_access"}, Endpoint: oauth2.Endpoint{TokenURL: metadata.TokenEndpoint, DeviceAuthURL: metadata.DeviceEndpoint, AuthStyle: oauth2.AuthStyleInHeader}} + return provider, config, nil +} + +type deviceTransport struct { + base http.RoundTripper + endpoint, clientID string +} + +func (t *deviceTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.String() == t.endpoint && req.Method == http.MethodPost { + req = req.Clone(req.Context()) + req.SetBasicAuth(t.clientID, "") + } + return t.base.RoundTrip(req) +} + +func exchange(ctx context.Context, base *http.Client, stackURL, assertion string) (*oauth2.Token, error) { + metadata, err := discover(ctx, base, stackURL+"/api/auth") + if err != nil { + return nil, err + } + form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "assertion": {assertion}, "scope": {"openid email"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, errors.New("invalid stack token endpoint") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + now := time.Now() + resp, err := authClient(ctx, base, stackURL).Do(req) + if err != nil { + return nil, safeError(ctx, "stack token exchange", err) + } + defer closeResponse(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, authenticationFailure(fmt.Sprintf("stack token exchange failed (HTTP %d)", resp.StatusCode)) + } + var result struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, errors.New("invalid stack token response") + } + if result.ExpiresIn <= 0 { + return nil, errors.New("stack token response is missing a positive expires_in") + } + seconds := min(result.ExpiresIn, int64(maxTokenLifetime/time.Second)) + token := &oauth2.Token{AccessToken: result.AccessToken, TokenType: result.TokenType, Expiry: now.Add(time.Duration(seconds) * time.Second)} + if err := boundToken(token, time.Now()); err != nil { + return nil, err + } + return token, nil +} + +func validateEndpoint(endpoint string) error { + if err := api.ValidateURL(endpoint); err != nil { + return err + } + u, err := url.Parse(endpoint) + if err != nil { + return errors.New("invalid endpoint") + } + if u.Scheme == "http" { + ip := net.ParseIP(u.Hostname()) + if u.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback()) { + return errors.New("cloud endpoints require HTTPS (except loopback)") + } + } + if strings.ContainsAny(endpoint, "\r\n\x1b") { + return errors.New("invalid endpoint") + } + return nil +} +func sameOrigin(a, b string) bool { + ua, ea := url.Parse(a) + ub, eb := url.Parse(b) + return ea == nil && eb == nil && ua.Host != "" && strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host) +} +func cloneClient(base *http.Client) *http.Client { + if base == nil { + base = http.DefaultClient + } + clone := *base + if clone.Timeout == 0 { + clone.Timeout = requestTimeout + } + return &clone +} +func baseTransport(base *http.Client) http.RoundTripper { + if base != nil && base.Transport != nil { + return base.Transport + } + return http.DefaultTransport +} +func noRedirect(_ *http.Request, _ []*http.Request) error { + return errors.New("cloud HTTP redirects are disabled") +} +func authClient(ctx context.Context, base *http.Client, origin string) *http.Client { + client := cloneClient(base) + timeout := min(client.Timeout, requestTimeout) + // Enforce this inside RoundTrip: DeviceAuth does not close its response, + // which would leave http.Client's cancellation timer alive until expiry. + client.Timeout = 0 + client.CheckRedirect = noRedirect + client.Transport = &authTransport{ctx: ctx, base: baseTransport(base), origin: origin, timeout: timeout} + return client +} + +type authTransport struct { + ctx context.Context + base http.RoundTripper + origin string + timeout time.Duration +} + +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if validateEndpoint(req.URL.String()) != nil || !sameOrigin(t.origin, req.URL.String()) { + return nil, errors.New("authentication request refused an invalid or cross-origin endpoint") + } + // oauth2.DeviceAuth does not attach its supplied context to the request. + // Bound requests here also constrain background JWKS fetches by coreos. + ctx, cancel := context.WithTimeout(req.Context(), t.timeout) + //nolint:contextcheck // Bind library background/device requests to the caller lifetime as well as the request context. + stop := context.AfterFunc(t.ctx, cancel) + defer cancel() + defer stop() + if err := t.ctx.Err(); err != nil { + return nil, err + } + resp, err := t.base.RoundTrip(req.Clone(ctx)) + if err != nil { + return nil, safeError(ctx, "authentication HTTP request", err) + } + originalBody := resp.Body + defer closeResponse(originalBody) + body, err := io.ReadAll(io.LimitReader(originalBody, maxResponse+1)) + if err != nil { + return nil, safeError(ctx, "authentication response", err) + } + if len(body) > maxResponse { + return nil, errors.New("authentication response exceeds size limit") + } + // Buffer and close all auth responses, including DeviceAuth responses (the + // upstream library does not close these). Never expose their bodies in errors. + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil +} + +func safeError(ctx context.Context, operation string, err error) error { + if ctx.Err() != nil { + return fmt.Errorf("%s: %w", operation, ctx.Err()) + } + if errors.Is(err, context.Canceled) { + return fmt.Errorf("%s: %w", operation, context.Canceled) + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%s: %w", operation, context.DeadlineExceeded) + } + if trusted, ok := errors.AsType[*authenticationError](err); ok { + return authenticationFailure(operation + ": " + trusted.Error()) + } + if retrieve, ok := errors.AsType[*oauth2.RetrieveError](err); ok { + suffix := safeOAuthCode(retrieve.ErrorCode) + if retrieve.Response != nil { + return authenticationFailure(fmt.Sprintf("%s failed (HTTP %d)%s", operation, retrieve.Response.StatusCode, suffix)) + } + return authenticationFailure(fmt.Sprintf("%s failed%s", operation, suffix)) + } + return authenticationFailure(operation + " failed") +} + +// Only package-generated labels/status/code enter this type. Never retain the +// original provider error, whose body or description can contain credentials. +type authenticationError struct{ message string } + +func (e *authenticationError) Error() string { return e.message } + +func authenticationFailure(message string) error { return &authenticationError{message: message} } + +func safeOAuthCode(code string) string { + switch code { + case "invalid_grant", "invalid_client", "invalid_scope", "invalid_request", + "access_denied", "expired_token", "unauthorized_client", "unsupported_grant_type", + "authorization_pending", "slow_down", "server_error", "temporarily_unavailable": + return ": " + code + default: + return "" + } +} + +// cancelBody keeps the client lifetime and request cancellation active through +// streaming reads, then releases the context hook when the caller closes it. +type cancelBody struct { + io.ReadCloser + cleanup func() +} + +func (b *cancelBody) Close() error { + defer b.cleanup() + return b.ReadCloser.Close() +} + +// The response is already consumed or rejected; a close error cannot change the +// authentication outcome. Discard it without logging response or credentials. +func closeResponse(body io.Closer) { + if err := body.Close(); err != nil { + return + } +} diff --git a/internal/cloud/http_test.go b/internal/cloud/http_test.go new file mode 100644 index 00000000..acfa2f83 --- /dev/null +++ b/internal/cloud/http_test.go @@ -0,0 +1,63 @@ +package cloud + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "golang.org/x/oauth2" +) + +func TestSafeOAuthErrorCodes(t *testing.T) { + for _, code := range []string{ + "invalid_grant", "invalid_client", "invalid_scope", "invalid_request", + "access_denied", "expired_token", "unauthorized_client", "unsupported_grant_type", + "authorization_pending", "slow_down", "server_error", "temporarily_unavailable", + "", "provider-secret", "invalid_grant\nsecret", + } { + t.Run(code, func(t *testing.T) { + wantSuffix := ": " + code + if code == "" || code == "provider-secret" || code == "invalid_grant\nsecret" { + wantSuffix = "" + } + checkSafeOAuthCode(t, code, wantSuffix) + }) + } +} + +func checkSafeOAuthCode(t *testing.T, code, wantSuffix string) { + t.Helper() + for _, response := range []*http.Response{nil, {StatusCode: http.StatusBadRequest}} { + retrieve := &oauth2.RetrieveError{Response: response, ErrorCode: code, + ErrorDescription: "description-secret", ErrorURI: "https://secret.invalid", Body: []byte("body-secret")} + wrapped := fmt.Errorf("wrapper-secret: %w", retrieve) + want := "token failed" + if response != nil { + want += " (HTTP 400)" + } + if got := safeError(t.Context(), "token", wrapped).Error(); got != want+wantSuffix { + t.Fatalf("unsafe or incorrect OAuth error: %q", got) + } + } +} + +func TestSafeOAuthErrorCancellationTakesPrecedence(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := safeError(ctx, "token", &oauth2.RetrieveError{ErrorCode: "invalid_grant"}) + if !errors.Is(err, context.Canceled) || err.Error() != "token: context canceled" { + t.Fatalf("OAuth code masked cancellation: %v", err) + } +} + +func TestSafeErrorPreservesOnlyTrustedInnerLabels(t *testing.T) { + provider := &oauth2.RetrieveError{Response: &http.Response{StatusCode: http.StatusBadRequest}, ErrorCode: "invalid_grant", ErrorDescription: "description-secret", Body: []byte("body-secret")} + inner := safeError(t.Context(), "Membership refresh", provider) + wrapped := fmt.Errorf("unsafe wrapper-secret: %w", inner) + got := safeError(t.Context(), "coordinate cloud authentication", wrapped) + if got.Error() != "coordinate cloud authentication: Membership refresh failed (HTTP 400): invalid_grant" { + t.Fatalf("trusted error lost its label or leaked provider/wrapper data: %v", got) + } +} diff --git a/internal/cloud/identity.go b/internal/cloud/identity.go new file mode 100644 index 00000000..04947bfd --- /dev/null +++ b/internal/cloud/identity.go @@ -0,0 +1,602 @@ +package cloud + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + "unicode" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// LoginIdentity logs in without choosing an organization or stack. out is +// normally stderr. The optional browser callback is called once; its failure +// leaves the printed instructions available and does not prevent device login. +// Organization and Stack are ignored here; targeting is deferred to ClientForTarget. +func LoginIdentity(ctx context.Context, httpClient *http.Client, options Options, out io.Writer, open func(context.Context, string) error) (*Session, error) { + options.Organization, options.Stack = "", "" + options, err := identityOptions(options) + if err != nil { + return nil, err + } + provider, config, err := membership(ctx, httpClient, options) + if err != nil { + return nil, err + } + config.Scopes = identityScopes() + token, raw, err := deviceLogin(ctx, httpClient, options, config, out, open, []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("prompt", "no-org")}, nil) + if err != nil { + return nil, err + } + claims, _, err := verifyIdentity(ctx, provider, options, raw, false) + if err != nil { + return nil, err + } + if _, err := availableTargets(options, claims); err != nil { + return nil, err + } + return &Session{Options: options, IDToken: raw, MembershipToken: cleanToken(token), Targets: make(map[string]*Session)}, nil +} + +func identityScopes() []string { return []string{"openid", "offline_access", "accesses", "on_behalf"} } + +func identityOptions(options Options) (Options, error) { + if options.Organization != "" || options.Stack != "" { + return Options{}, errors.New("identity login does not select an organization or stack") + } + if options.Issuer == "" { + options.Issuer = DefaultIssuer + } + options.Issuer = strings.TrimRight(options.Issuer, "/") + if options.ClientID == "" { + options.ClientID = "fctl" + } + if err := validateEndpoint(options.Issuer); err != nil { + return Options{}, err + } + if !validIdentifier(options.ClientID) { + return Options{}, errors.New("invalid cloud client ID") + } + return options, nil +} + +func validIdentifier(value string) bool { + return value != "" && !strings.ContainsAny(value, "/|?#\\") && !strings.ContainsFunc(value, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) +} + +func deviceLogin(ctx context.Context, base *http.Client, options Options, config *oauth2.Config, out io.Writer, open func(context.Context, string) error, authorization, poll []oauth2.AuthCodeOption) (*oauth2.Token, string, error) { + if out == nil { + return nil, "", errors.New("device authorization requires an output writer") + } + client := authClient(ctx, base, options.Issuer) + client.Transport = &deviceTransport{base: client.Transport, endpoint: config.Endpoint.DeviceAuthURL, clientID: options.ClientID} + authCtx := context.WithValue(ctx, oauth2.HTTPClient, client) + device, err := config.DeviceAuth(authCtx, authorization...) + if err != nil { + return nil, "", safeError(ctx, "device authorization", err) + } + if err := displayDevice(ctx, device, out, open); err != nil { + return nil, "", err + } + // Membership already binds the authorized scopes to the device code. + // Its token endpoint does not decode OAuth's space-delimited scope string. + pollConfig := *config + pollConfig.Scopes = nil + token, err := pollConfig.DeviceAccessToken(authCtx, device, poll...) + if err != nil { + return nil, "", safeError(ctx, "device token", err) + } + if err := boundToken(token, time.Now()); err != nil { + return nil, "", err + } + raw, ok := token.Extra("id_token").(string) + if !ok || raw == "" { + return nil, "", errors.New("membership token response is missing an ID token") + } + return token, raw, nil +} + +func displayDevice(ctx context.Context, device *oauth2.DeviceAuthResponse, out io.Writer, open func(context.Context, string) error) error { + if device.DeviceCode == "" || device.UserCode == "" || device.Interval < 0 || device.Interval > 3600 || !device.Expiry.After(time.Now()) || device.Expiry.After(time.Now().Add(time.Hour)) { + return errors.New("invalid device authorization response") + } + if len(device.UserCode) > 256 || strings.ContainsFunc(device.UserCode, unicode.IsControl) { + return errors.New("invalid device user code") + } + uri, err := verificationURI(device.VerificationURI) + if err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Open %s and enter code %s\n", device.VerificationURI, device.UserCode); err != nil { + return errors.New("cannot display device authorization") + } + if open == nil { + return ctx.Err() + } + query := uri.Query() + query.Set("user_code", device.UserCode) + uri.RawQuery = query.Encode() + // Browser errors are deliberately not included in logs or the returned error. + if err := open(ctx, uri.String()); err != nil { + return ctx.Err() + } + return ctx.Err() +} + +func verificationURI(raw string) (*url.URL, error) { + uri, err := url.Parse(raw) + if err != nil || strings.ContainsFunc(raw, unicode.IsControl) { + return nil, errors.New("invalid device verification URI") + } + endpoint := *uri + endpoint.RawQuery, endpoint.ForceQuery = "", false + if err := validateEndpoint(endpoint.String()); err != nil { + return nil, errors.New("invalid device verification URI") + } + if _, err := url.ParseQuery(uri.RawQuery); err != nil { + return nil, errors.New("invalid device verification query") + } + return uri, nil +} + +func verifyIdentity(ctx context.Context, provider *oidc.Provider, options Options, raw string, stored bool) (identityClaims, *oidc.IDToken, error) { + token, err := provider.Verifier(&oidc.Config{ClientID: options.ClientID, SkipExpiryCheck: stored}).Verify(ctx, raw) + if err != nil { + return identityClaims{}, nil, safeError(ctx, "verify Membership identity", err) + } + var claims identityClaims + if err := token.Claims(&claims); err != nil { + return identityClaims{}, nil, errors.New("invalid Membership organization claims") + } + return claims, token, nil +} + +type targetAccess struct { + Options Options + URI string + Scopes []string + Subject string +} + +func availableTargets(options Options, claims identityClaims) ([]targetAccess, error) { + var targets []targetAccess + seen := make(map[string]bool) + for _, organization := range claims.Organizations { + if !validIdentifier(organization.ID) { + return nil, errors.New("identity contains an invalid organization ID") + } + for _, stack := range organization.Stacks { + target, err := availableTarget(options, organization.ID, stack) + if err != nil { + return nil, err + } + if target == nil { + continue + } + key := targetKey(target.Options) + if seen[key] { + return nil, errors.New("identity contains a duplicate stack target") + } + seen[key] = true + targets = append(targets, *target) + } + } + slices.SortFunc(targets, func(a, b targetAccess) int { return strings.Compare(targetKey(a.Options), targetKey(b.Options)) }) + return targets, nil +} + +func availableTarget(options Options, organization string, stack stackAccess) (*targetAccess, error) { + if !validIdentifier(stack.ID) { + return nil, errors.New("identity contains an invalid stack ID") + } + var scopes []string + for _, scope := range []string{"stack:Read", "stack:Write"} { + if slices.Contains(stack.Scopes, scope) { + scopes = append(scopes, scope) + } + } + if len(scopes) == 0 { + return nil, nil + } + uri := strings.TrimRight(stack.URI, "/") + if err := validateEndpoint(uri); err != nil { + return nil, errors.New("identity contains an invalid stack URL") + } + options.Organization, options.Stack = organization, stack.ID + return &targetAccess{Options: options, URI: uri, Scopes: scopes}, nil +} + +func resolveTarget(options Options, claims identityClaims, desired Options) (targetAccess, error) { + if desired.Issuer != "" && strings.TrimRight(desired.Issuer, "/") != options.Issuer { + return targetAccess{}, errors.New("target issuer differs from cloud identity") + } + if desired.ClientID != "" && desired.ClientID != options.ClientID { + return targetAccess{}, errors.New("target client ID differs from cloud identity") + } + targets, err := availableTargets(options, claims) + if err != nil { + return targetAccess{}, err + } + var matches []targetAccess + for _, target := range targets { + if desired.Organization != "" && desired.Organization != target.Options.Organization { + continue + } + if desired.Stack != "" && desired.Stack != target.Options.Stack { + continue + } + matches = append(matches, target) + } + if len(matches) == 1 { + return matches[0], nil + } + if len(targets) == 0 { + return targetAccess{}, errors.New("cloud identity grants no available stack access") + } + if len(matches) == 0 { + return targetAccess{}, errors.New("selected Cloud stack is unavailable; check --organization and --stack\nList stacks: fctl cloud stack list --organization ORGANIZATION_ID") + } + return targetAccess{}, errors.New("choose a Cloud stack with --organization ORGANIZATION_ID --stack STACK_ID\nList organizations: fctl cloud organizations list\nList stacks: fctl cloud stack list --organization ORGANIZATION_ID") +} + +func targetKey(options Options) string { return options.Organization + "/" + options.Stack } + +// ClientForTarget resolves one stack from verified root identity claims, then +// obtains a stack-scoped Membership session. The root token is never exchanged +// at the stack. A coordinator reloads/saves the whole root and holds its auth +// lock across both root and scoped refreshes. nil is for a single in-memory +// caller; shared persisted sessions require the parent's coordinator. +func ClientForTarget(ctx context.Context, httpClient *http.Client, rootSession *Session, desired Options, out io.Writer, open func(context.Context, string) error, coordinator Coordinator) (*http.Client, string, error) { + client, endpoint, _, err := ClientForResolvedTarget(ctx, httpClient, rootSession, desired, out, open, coordinator) + return client, endpoint, err +} + +// ClientForResolvedTarget also returns the verified target identity. Callers +// must retain this identity when automatic resolution selected a sole grant. +func ClientForResolvedTarget(ctx context.Context, httpClient *http.Client, rootSession *Session, desired Options, out io.Writer, open func(context.Context, string) error, coordinator Coordinator) (*http.Client, string, Options, error) { + if rootSession == nil || rootSession.MembershipToken == nil { + return nil, "", Options{}, errors.New("missing cloud identity; log in again") + } + options, err := identityOptions(rootSession.Options) + if err != nil { + return nil, "", Options{}, err + } + provider, config, err := membership(ctx, httpClient, options) + if err != nil { + return nil, "", Options{}, err + } + config.Scopes = identityScopes() + manager := &targetManager{root: rootSession, options: options, desired: desired, base: httpClient, provider: provider, config: config, out: out, open: open, coordinator: coordinator} + if coordinator == nil { + manager.coordinator = manager.inMemory + } + if _, err := manager.coordinator(ctx, func(current *Session, save func(*Session) error) (*oauth2.Token, error) { + child, err := manager.prepare(ctx, current, save) + if err != nil { + return nil, err + } + manager.child = child + return cleanToken(child.MembershipToken), nil + }); err != nil { + return nil, "", Options{}, err + } + client, endpoint, err := Client(ctx, httpClient, manager.child, nil, manager.coordinateChild) + if err != nil { + return nil, "", Options{}, err + } + if manager.selected == nil { + return nil, "", Options{}, errors.New("cloud stack resolution did not return a target") + } + return client, endpoint, manager.selected.Options, nil +} + +type targetManager struct { + mu sync.Mutex + root *Session + options, desired Options + selected *targetAccess + child *Session + base *http.Client + provider *oidc.Provider + config *oauth2.Config + out io.Writer + open func(context.Context, string) error + coordinator Coordinator + pendingSave bool + resolvedRefresh bool + refreshClaimsNeeded bool +} + +func (m *targetManager) inMemory(ctx context.Context, work func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + return work(copyRoot(m.root), func(next *Session) error { *m.root = *copyRoot(next); return nil }) +} + +func (m *targetManager) prepare(ctx context.Context, current *Session, save func(*Session) error) (*Session, error) { + if m.pendingSave { + return nil, errors.New("cloud identity save failed; log in again") + } + if save == nil { + return nil, errors.New("cloud coordinator requires a session save function") + } + root, claims, err := m.currentIdentity(ctx, current, save) + if err != nil { + return nil, err + } + root, target, err := m.resolveCurrentTarget(ctx, root, claims, save) + if err != nil { + return nil, err + } + if m.selected != nil && (target.Options != m.selected.Options || target.URI != m.selected.URI) { + return nil, errors.New("cloud target changed; recreate the client") + } + _, rootID, err := verifyIdentity(ctx, m.provider, m.options, root.IDToken, true) + if err != nil { + return nil, err + } + target.Subject = rootID.Subject + m.selected = &target + child, err := m.cachedOrAuthorize(ctx, root, target, save) + if err != nil { + return nil, err + } + guarded := copyRoot(child) + guarded.allowedScopes, guarded.rootSubject = slices.Clone(target.Scopes), target.Subject + state, err := validatedSession(ctx, m.provider, target.Options, guarded) + if err != nil { + return nil, err + } + if state.StackURL != target.URI { + return nil, errors.New("cached target URL differs from root identity") + } + state.allowedScopes = slices.Clone(target.Scopes) + *m.root = *copyRoot(root) + return &state, nil +} + +func (m *targetManager) resolveCurrentTarget(ctx context.Context, root *Session, claims identityClaims, save func(*Session) error) (*Session, targetAccess, error) { + if _, err := availableTargets(m.options, claims); err != nil { + return root, targetAccess{}, err + } + target, err := resolveTarget(m.options, claims, m.desired) + if err == nil || m.resolvedRefresh || !refreshMissingTarget(m.options, claims, m.desired) { + return root, target, err + } + // Newly created stacks may be absent from the login snapshot. Refresh once + // while holding the coordinator, without starting another root device flow. + m.resolvedRefresh = true + m.refreshClaimsNeeded = true + root, claims, err = m.refreshAndSave(ctx, root, save) + if err != nil { + return nil, targetAccess{}, err + } + target, err = resolveTarget(m.options, claims, m.desired) + return root, target, err +} + +func refreshMissingTarget(options Options, claims identityClaims, desired Options) bool { + if !validIdentifier(desired.Stack) || (desired.Organization != "" && !validIdentifier(desired.Organization)) { + return false + } + if desired.Issuer != "" && strings.TrimRight(desired.Issuer, "/") != options.Issuer { + return false + } + if desired.ClientID != "" && desired.ClientID != options.ClientID { + return false + } + return missingExplicitStack(claims, desired) +} + +func missingExplicitStack(claims identityClaims, desired Options) bool { + for _, org := range claims.Organizations { + // A known stack paired with the wrong organization is an invalid target, + // not a newly created stack missing from the signed login snapshot. + if slices.ContainsFunc(org.Stacks, func(stack stackAccess) bool { return stack.ID == desired.Stack }) { + return false + } + } + return true +} + +func (m *targetManager) cachedOrAuthorize(ctx context.Context, root *Session, target targetAccess, save func(*Session) error) (*Session, error) { + child := root.Targets[targetKey(target.Options)] + matches, err := m.matchesTarget(ctx, child, target) + if err != nil { + return nil, err + } + if !matches { + var err error + child, err = m.authorize(ctx, root, target) + if err != nil { + return nil, err + } + if root.Targets == nil { + root.Targets = make(map[string]*Session) + } + root.Targets[targetKey(target.Options)] = child + if err := m.saveRoot(root, save); err != nil { + return nil, err + } + } + return child, nil +} + +func (m *targetManager) matchesTarget(ctx context.Context, child *Session, target targetAccess) (bool, error) { + if child == nil { + return false, nil + } + return verifyTargetSession(ctx, m.provider, target, child, true, true) +} + +func (m *targetManager) coordinateChild(ctx context.Context, work func(*Session, func(*Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + return m.coordinator(ctx, func(current *Session, save func(*Session) error) (*oauth2.Token, error) { + child, err := m.prepare(ctx, current, save) + if err != nil { + return nil, err + } + root := copyRoot(m.root) + return work(child, func(updated *Session) error { + root.Targets[targetKey(updated.Options)] = copyRoot(updated) + return m.saveRoot(root, save) + }) + }) +} + +func (m *targetManager) saveRoot(root *Session, save func(*Session) error) error { + m.pendingSave = true + *m.root = *copyRoot(root) + if err := save(copyRoot(root)); err != nil { + return errors.New("cannot save cloud identity") + } + m.pendingSave = false + return nil +} + +func (m *targetManager) currentIdentity(ctx context.Context, current *Session, save func(*Session) error) (*Session, identityClaims, error) { + if current == nil || current.MembershipToken == nil { + return nil, identityClaims{}, errors.New("missing cloud identity; log in again") + } + if current.IDToken == "" { + return nil, identityClaims{}, errors.New("cloud identity is not verified; log in again") + } + options, err := identityOptions(current.Options) + if err != nil || options != m.options { + return nil, identityClaims{}, errors.New("cloud identity options changed; recreate the client") + } + claims, id, err := verifyIdentity(ctx, m.provider, options, current.IDToken, true) + if err != nil { + return nil, identityClaims{}, err + } + root := copyRoot(current) + root.Options = options + if !usable(root.MembershipToken) { + return m.refreshAndSave(ctx, root, save) + } + if root.RequireUserInfo || m.refreshClaimsNeeded || !id.Expiry.After(time.Now().Add(refreshMargin)) { + return m.userInfoAndSave(ctx, root, save) + } + return root, claims, nil +} + +// Persist consumed refresh credentials even if identity verification fails. +// Save errors take precedence because retrying against an old revision is unsafe. +func (m *targetManager) refreshAndSave(ctx context.Context, root *Session, save func(*Session) error) (*Session, identityClaims, error) { + m.resolvedRefresh = true + renewed, claims, refreshErr := m.refreshRoot(ctx, root) + if renewed != nil { + if err := m.saveRoot(renewed, save); err != nil { + return nil, identityClaims{}, err + } + } + if refreshErr != nil { + return nil, identityClaims{}, refreshErr + } + return renewed, claims, nil +} + +func (m *targetManager) refreshRoot(ctx context.Context, root *Session) (*Session, identityClaims, error) { + if root.MembershipToken.RefreshToken == "" { + return nil, identityClaims{}, errors.New("cloud identity expired; log in again") + } + old := cleanToken(root.MembershipToken) + old.Expiry = time.Now().Add(-time.Hour) + authCtx := context.WithValue(ctx, oauth2.HTTPClient, authClient(ctx, m.base, m.options.Issuer)) + fresh, err := m.config.TokenSource(authCtx, old).Token() + if err != nil { + return nil, identityClaims{}, safeError(ctx, "identity refresh", err) + } + root.MembershipToken = cleanToken(fresh) + if err := boundToken(fresh, time.Now()); err != nil { + root.MembershipToken.Expiry = time.Time{} + root.IDToken = "" + return root, identityClaims{}, err + } + root.MembershipToken = cleanToken(fresh) + raw, ok := fresh.Extra("id_token").(string) + if !ok || raw == "" { + claims, _, err := verifyIdentity(ctx, m.provider, m.options, root.IDToken, false) + if err != nil || root.RequireUserInfo || m.refreshClaimsNeeded { + root.RequireUserInfo = true + claims, err = m.currentUserInfo(ctx, root) + return root, claims, err + } + return root, claims, nil + } + claims, _, err := verifyIdentity(ctx, m.provider, m.options, raw, false) + if err != nil { + // Retain rotated credentials, but invalidate the persisted identity proof. + // A subsequent client must not reuse claims from before this failed refresh. + root.IDToken = "" + return root, identityClaims{}, err + } + root.IDToken, root.MembershipToken = raw, cleanToken(fresh) + root.RequireUserInfo = false + m.refreshClaimsNeeded = false + return root, claims, nil +} + +func (m *targetManager) authorize(ctx context.Context, root *Session, target targetAccess) (*Session, error) { + config := *m.config + config.Scopes = []string{"openid", "offline_access", "accesses"} + resource := "stack://" + target.Options.Organization + "/" + target.Options.Stack + "|" + strings.Join(target.Scopes, " ") + authorization := []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("organization_id", target.Options.Organization), oauth2.SetAuthURLParam("resource", resource), oauth2.SetAuthURLParam("id_token_hint", root.IDToken)} + token, raw, err := deviceLogin(ctx, m.base, target.Options, &config, m.out, m.open, authorization, []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("resource", resource)}) + if err != nil { + return nil, err + } + child := &Session{Options: target.Options, IDToken: raw, MembershipToken: cleanToken(token), StackURL: target.URI} + matches, err := verifyTargetSession(ctx, m.provider, target, child, false, false) + if err != nil { + return nil, err + } + if !matches { + return nil, errors.New("scoped session permissions differ from root identity") + } + return child, nil +} + +func copyRoot(session *Session) *Session { + if session == nil { + return nil + } + cloned := *session + cloned.allowedScopes = slices.Clone(session.allowedScopes) + cloned.MembershipToken, cloned.StackToken = cleanToken(session.MembershipToken), cleanToken(session.StackToken) + cloned.Targets = maps.Clone(session.Targets) + cloned.Organizations = maps.Clone(session.Organizations) + cloned.Applications = maps.Clone(session.Applications) + cloneChildren(cloned.Targets) + cloneChildren(cloned.Organizations) + cloneChildren(cloned.Applications) + return &cloned +} + +func cloneChildren(children map[string]*Session) { + for key, child := range children { + if child == nil { + continue + } + value := *child + value.allowedScopes = slices.Clone(child.allowedScopes) + value.MembershipToken, value.StackToken = cleanToken(child.MembershipToken), cleanToken(child.StackToken) + value.Targets = nil + value.Organizations = nil + value.Applications = nil + children[key] = &value + } +} diff --git a/internal/cloud/identity_test.go b/internal/cloud/identity_test.go new file mode 100644 index 00000000..f7caf3ac --- /dev/null +++ b/internal/cloud/identity_test.go @@ -0,0 +1,703 @@ +package cloud + +import ( + "bytes" + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/oauth2" +) + +type identityFixture struct { + f *fixture + client *http.Client + accesses []organizationAccess + mu sync.Mutex + forms []url.Values + used map[string]bool + rootRefresh, childRefresh atomic.Int32 + invalidID, omitRefreshID bool + verification string + userCode string + idExpiry time.Time + refreshInvalidID bool + scopedID string + childRefreshID string +} + +func newIdentityFixture(t *testing.T) *identityFixture { + t.Helper() + f := newFixture(t) + fixture := &identityFixture{f: f, used: make(map[string]bool), accesses: []organizationAccess{{ID: "org", Stacks: []stackAccess{{ID: "stack", URI: f.server.URL + "/stack", Scopes: []string{"stack:Read", "stack:Write"}}}}}} + fixture.client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/membership/device" && r.URL.Path != "/membership/token" { + return baseTransport(f.server.Client()).RoundTrip(r) + } + return fixture.authResponse(t, r), nil + })} + return fixture +} + +func (f *identityFixture) signedID(t *testing.T) string { + t.Helper() + issuer := f.f.options.Issuer + if f.f.identityIssuer != "" { + issuer = f.f.identityIssuer + } + expiry := time.Now().Add(time.Hour) + if !f.idExpiry.IsZero() { + expiry = f.idExpiry + } + claims := map[string]any{"iss": issuer, "aud": f.f.audience, "sub": "user", "iat": time.Now().Unix(), "exp": expiry.Unix(), "org": f.accesses} + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key"}`)) + unsigned := header + "." + base64.RawURLEncoding.EncodeToString(payload) + hash := sha256.Sum256([]byte(unsigned)) + signature, err := rsa.SignPKCS1v15(rand.Reader, f.f.key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + raw := unsigned + "." + base64.RawURLEncoding.EncodeToString(signature) + if f.invalidID { + raw += "invalid" + } + return raw +} + +func (f *identityFixture) root(t *testing.T) *Session { + t.Helper() + return &Session{Options: Options{Issuer: f.f.options.Issuer, ClientID: "fctl"}, IDToken: f.signedID(t), MembershipToken: &oauth2.Token{AccessToken: "root-secret", RefreshToken: "root-refresh", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)}} +} + +func (f *identityFixture) authResponse(t *testing.T, r *http.Request) *http.Response { + t.Helper() + recorder := httptest.NewRecorder() + r.Body = http.MaxBytesReader(recorder, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + t.Error(err) + recorder.WriteHeader(400) + return recorder.Result() + } + clientID, _, ok := r.BasicAuth() + if !ok || clientID != "fctl" { + t.Error("missing public client authentication") + } + f.mu.Lock() + f.forms = append(f.forms, r.Form) + f.mu.Unlock() + if r.URL.Path == "/membership/device" { + code := "root-code" + if r.Form.Get("resource") != "" { + code = r.Form.Get("resource") + } + verification, userCode := f.verification, f.userCode + if verification == "" { + verification = f.f.server.URL + "/verify" + } + if userCode == "" { + userCode = "ABCD" + } + writeJSON(t, recorder, map[string]any{"device_code": code, "user_code": userCode, "verification_uri": verification, "expires_in": 600, "interval": 1}) + return recorder.Result() + } + if r.Form.Get("grant_type") == "refresh_token" { + return f.refreshResponse(t, recorder, r) + } + if r.Form.Has("scope") { + recorder.WriteHeader(http.StatusBadRequest) + writeJSON(t, recorder, map[string]string{"error": "server_error", "error_description": "requested scope not granted"}) + return recorder.Result() + } + access, refresh := "membership-secret", "refresh-secret" + if r.Form.Get("device_code") == "root-code" { + access, refresh = "root-secret", "root-refresh" + } + raw := f.signedID(t) + if access == "membership-secret" && f.scopedID != "" { + raw = f.scopedID + } + writeJSON(t, recorder, map[string]any{"access_token": access, "refresh_token": refresh, "token_type": "Bearer", "expires_in": 3600, "id_token": raw}) + return recorder.Result() +} + +func (f *identityFixture) refreshResponse(t *testing.T, w *httptest.ResponseRecorder, r *http.Request) *http.Response { + t.Helper() + refresh := r.Form.Get("refresh_token") + f.mu.Lock() + used := f.used[refresh] + f.used[refresh] = true + f.mu.Unlock() + if used { + w.WriteHeader(400) + writeJSON(t, w, map[string]string{"error": "invalid_grant", "error_description": "root-secret refresh-secret"}) + return w.Result() + } + access, rotated := "refreshed-secret", "rotated-secret" + if refresh == "root-refresh" { + f.rootRefresh.Add(1) + access, rotated = "refreshed-root-secret", "rotated-root-refresh" + } else { + f.childRefresh.Add(1) + } + result := map[string]any{"access_token": access, "refresh_token": rotated, "token_type": "Bearer", "expires_in": 3600} + if !f.omitRefreshID { + raw := f.signedID(t) + if refresh != "root-refresh" && f.childRefreshID != "" { + raw = f.childRefreshID + } + if f.refreshInvalidID { + raw += "invalid" + } + result["id_token"] = raw + } + writeJSON(t, w, result) + return w.Result() +} + +func TestIdentityDefaultOptions(t *testing.T) { + options, err := identityOptions(Options{}) + if err != nil { + t.Fatal(err) + } + if options.Issuer != "https://app.formance.cloud/api" || options.ClientID != "fctl" || options.Organization != "" || options.Stack != "" { + t.Fatalf("incorrect root defaults: %+v", options) + } + if _, err := identityOptions(Options{Organization: "org"}); err == nil { + t.Fatal("identity login selected an organization") + } +} + +func TestLoginIdentityFormsAndBrowser(t *testing.T) { + for _, kind := range []string{"zero access", "multiple accesses", "browser failure", "browser deadline", "browser canceled"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); checkIdentityLogin(t, kind) }) + } +} + +func checkIdentityLogin(t *testing.T, kind string) { + t.Helper() + fixture := newIdentityFixture(t) + switch kind { + case "zero access": + fixture.accesses = nil + case "multiple accesses": + fixture.accesses[0].Stacks = append(fixture.accesses[0].Stacks, stackAccess{ID: "other", URI: fixture.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}) + } + var out bytes.Buffer + calls := 0 + session, err := LoginIdentity(t.Context(), fixture.client, Options{Issuer: fixture.f.options.Issuer, Organization: "one-off-org", Stack: "one-off-stack"}, &out, func(_ context.Context, uri string) error { + calls++ + parsed, err := url.Parse(uri) + if err != nil { + return err + } + if parsed.Query().Get("user_code") != "ABCD" { + t.Error("browser URI omitted user code") + } + if kind == "browser deadline" { + return context.DeadlineExceeded + } + if kind == "browser canceled" { + return context.Canceled + } + return errors.New("browser-secret") + }) + if err != nil { + t.Fatal(err) + } + if calls != 1 || !strings.Contains(out.String(), "ABCD") || strings.Contains(out.String(), "secret") { + t.Fatal("incorrect browser fallback or leaked credentials") + } + if session.Options.Organization != "" || session.Options.Stack != "" || session.StackURL != "" { + t.Fatal("identity login selected a target") + } + assertIdentityForms(t, fixture.forms) +} + +func assertIdentityForms(t *testing.T, forms []url.Values) { + t.Helper() + if len(forms) != 2 { + t.Fatalf("unexpected number of root auth requests: %d", len(forms)) + } + for _, form := range forms { + if form.Get("organization_id") != "" || form.Get("resource") != "" { + t.Fatal("root authorization requested a scoped or incomplete token") + } + } + if forms[0].Get("scope") != "openid offline_access accesses on_behalf" || forms[1].Has("scope") { + t.Fatal("root authorization scopes missing or repeated during polling") + } + if forms[0].Get("prompt") != "no-org" { + t.Fatal("identity login omitted no-org prompt") + } +} + +func TestLoginIdentityRejectsUnverifiedClaims(t *testing.T) { + for _, kind := range []string{"signature", "audience", "issuer", "expired", "invalid organization"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + fixture := newIdentityFixture(t) + switch kind { + case "signature": + fixture.invalidID = true + case "audience": + fixture.f.audience = "other" + case "issuer": + fixture.f.identityIssuer = "https://other.example" + case "expired": + fixture.idExpiry = time.Now().Add(-time.Hour) + case "invalid organization": + fixture.accesses[0].ID = "org\nsecret" + } + _, err := LoginIdentity(t.Context(), fixture.client, Options{Issuer: fixture.f.options.Issuer}, io.Discard, nil) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe identity verification: %v", err) + } + }) + } +} + +func TestTargetResolution(t *testing.T) { + fixture := newIdentityFixture(t) + options := fixture.root(t).Options + claims := identityClaims{Organizations: []organizationAccess{ + {ID: "org", Stacks: []stackAccess{{ID: "first", URI: fixture.f.server.URL, Scopes: []string{"stack:Read"}}, {ID: "second", URI: fixture.f.server.URL, Scopes: []string{"stack:Write"}}}}, + {ID: "other", Stacks: []stackAccess{{ID: "second", URI: fixture.f.server.URL, Scopes: []string{"stack:Read"}}}}, + }} + for _, test := range []struct { + name string + desired Options + valid bool + key string + }{ + {"ambiguous all", Options{}, false, ""}, {"ambiguous organization", Options{Organization: "org"}, false, ""}, + {"ambiguous stack", Options{Stack: "second"}, false, ""}, {"unique stack", Options{Stack: "first"}, true, "org/first"}, + {"unique organization", Options{Organization: "other"}, true, "other/second"}, + {"explicit", Options{Organization: "org", Stack: "second"}, true, "org/second"}, + {"unknown", Options{Organization: "missing", Stack: "second"}, false, ""}, + } { + t.Run(test.name, func(t *testing.T) { + target, err := resolveTarget(options, claims, test.desired) + if (err == nil) != test.valid { + t.Fatalf("incorrect target selection: %v", err) + } + if test.valid && targetKey(target.Options) != test.key { + t.Fatal("wrong target selected") + } + if err != nil && (!strings.Contains(err.Error(), "--organization") || !strings.Contains(err.Error(), "cloud stack list") || strings.Contains(err.Error(), "org/first")) { + t.Fatal("target selection error must give a concise discovery command") + } + }) + } +} + +func TestTargetSelectionErrorRemainsBounded(t *testing.T) { + f := newIdentityFixture(t) + options := f.root(t).Options + claims := identityClaims{Organizations: []organizationAccess{{ID: "org"}}} + for i := range 1000 { + claims.Organizations[0].Stacks = append(claims.Organizations[0].Stacks, stackAccess{ + ID: "stack-" + strconv.Itoa(i), URI: f.f.server.URL, Scopes: []string{"stack:Read"}, + }) + } + _, err := resolveTarget(options, claims, Options{}) + if err == nil || len(err.Error()) > 250 || strings.Count(err.Error(), "\n") > 2 || strings.Contains(err.Error(), "stack-0") { + t.Fatalf("target selection must not dump the access catalog: %v", err) + } +} + +func TestClientForTargetScopedAuthorizationAndCache(t *testing.T) { + fixture := newIdentityFixture(t) + fixture.accesses[0].Stacks[0].Scopes = []string{"stack:Read", "unrelated"} + root := fixture.root(t) + store := newCoordinatedStore(root) + var out bytes.Buffer + client, uri, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, &out, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + for range 2 { + resp, err := client.Do(testRequest(t, http.MethodPost, uri+"/ledger", nil)) + if err != nil { + t.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + } + if _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{Organization: "org", Stack: "stack"}, io.Discard, nil, store.coordinate); err != nil { + t.Fatal(err) + } + saved := store.snapshot() + if saved.Options.Organization != "" || saved.Options.Stack != "" || saved.MembershipToken.AccessToken != "root-secret" { + t.Fatal("one-off target flags overwrote root identity") + } + if saved.Targets["org/stack"] == nil || saved.Targets["org/stack"].StackToken == nil { + t.Fatal("scoped session not cached in root") + } + if len(fixture.forms) != 2 || fixture.f.exchanges.Load() != 1 { + t.Fatal("cached target repeated device flow or stack exchange") + } + assertScopedForms(t, fixture.forms, root.IDToken, "stack://org/stack|stack:Read") +} + +func assertScopedForms(t *testing.T, forms []url.Values, id, resource string) { + t.Helper() + if forms[0].Get("id_token_hint") != id || forms[0].Get("organization_id") != "org" || forms[0].Get("resource") != resource { + t.Fatal("incorrect scoped authorization parameters") + } + for _, form := range forms { + if form.Get("resource") != resource { + t.Fatal("scoped flow requested broad scopes or lost resource") + } + } + if forms[0].Get("scope") != "openid offline_access accesses" || forms[1].Has("scope") { + t.Fatal("scoped authorization scopes missing or repeated during polling") + } +} + +func TestConcurrentTargetAuthorization(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + store := newCoordinatedStore(root) + runParallel(t, func() error { + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + return err + }) + if len(fixture.forms) != 2 || fixture.f.exchanges.Load() != 1 || len(store.snapshot().Targets) != 1 { + t.Fatal("concurrent clients duplicated scoped authorization") + } +} + +func TestIdentityAndScopedRefreshStaySeparate(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + store := newCoordinatedStore(root) + if _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate); err != nil { + t.Fatal(err) + } + store.mu.Lock() + store.session.MembershipToken.Expiry = time.Now().Add(-time.Hour) + store.session.Targets["org/stack"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + store.session.Targets["org/stack"].StackToken = nil + store.mu.Unlock() + runParallel(t, func() error { + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + return err + }) + saved := store.snapshot() + if fixture.rootRefresh.Load() != 1 || fixture.childRefresh.Load() != 1 { + t.Fatal("root/child refresh was omitted or repeated") + } + if saved.MembershipToken.RefreshToken != "rotated-root-refresh" || saved.Targets["org/stack"].MembershipToken.RefreshToken != "rotated-secret" { + t.Fatal("rotated root or child credentials were lost") + } +} + +func TestIdentityCancellation(t *testing.T) { + fixture := newIdentityFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, err := LoginIdentity(ctx, fixture.client, Options{Issuer: fixture.f.options.Issuer}, io.Discard, nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("identity login ignored cancellation: %v", err) + } + if fixture.f.exchanges.Load() != 0 { + t.Fatal("identity token was sent to stack auth") + } +} + +func TestVerificationQueryAndUserCode(t *testing.T) { + fixture := newIdentityFixture(t) + fixture.verification = fixture.f.server.URL + "/verify?locale=fr&flow=identity&user_code=old" + var out bytes.Buffer + calls := 0 + _, err := LoginIdentity(t.Context(), fixture.client, Options{Issuer: fixture.f.options.Issuer}, &out, func(_ context.Context, raw string) error { + calls++ + parsed, err := url.Parse(raw) + if err != nil { + return err + } + query := parsed.Query() + if query.Get("locale") != "fr" || query.Get("flow") != "identity" || query.Get("user_code") != "ABCD" || len(query["user_code"]) != 1 { + t.Error("browser query was lost or user code was duplicated") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if calls != 1 || !strings.Contains(out.String(), fixture.verification) { + t.Fatal("verification instructions or callback were omitted") + } +} + +func TestDeviceInstructionsRejectUnsafeInput(t *testing.T) { + for _, test := range []struct{ name, uri, code string }{ + {"credentials", "https://user:password@example.com/verify", "ABCD"}, + {"cleartext", "http://example.com/verify", "ABCD"}, + {"relative", "/verify", "ABCD"}, + {"URL control", "https://example.com/verify\nsecret", "ABCD"}, + {"code newline", "https://example.com/verify", "ABC\nsecret"}, + {"code tab", "https://example.com/verify", "ABC\tsecret"}, + {"code escape", "https://example.com/verify", "ABC\x1bsecret"}, + {"malformed query", "https://example.com/verify?x=%invalid", "ABCD"}, + } { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + opened := false + device := &oauth2.DeviceAuthResponse{DeviceCode: "device-secret", UserCode: test.code, VerificationURI: test.uri, Interval: 1, Expiry: time.Now().Add(time.Minute)} + err := displayDevice(t.Context(), device, &out, func(context.Context, string) error { opened = true; return nil }) + if err == nil || strings.Contains(err.Error(), "secret") || opened || out.Len() != 0 { + t.Fatal("unsafe device input was displayed or opened") + } + }) + } +} + +func TestRootRefreshOmittedIdentityUsesOnlyUnexpiredClaims(t *testing.T) { + for _, expired := range []bool{false, true} { + t.Run(strconv.FormatBool(expired), func(t *testing.T) { checkOmittedIdentityRefresh(t, expired) }) + } +} + +func checkOmittedIdentityRefresh(t *testing.T, expired bool) { + t.Helper() + fixture := newIdentityFixture(t) + if expired { + fixture.idExpiry = time.Now().Add(-time.Hour) + } + root := fixture.root(t) + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + fixture.omitRefreshID = true + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if expired && (err == nil || !strings.Contains(err.Error(), "log in again")) { + t.Fatalf("expired identity was accepted: %v", err) + } + if !expired && err != nil { + t.Fatal(err) + } + if store.snapshot().MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("omitted ID token lost root refresh rotation") + } +} + +func TestRootRefreshInvalidIdentityPreservesRotation(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + fixture.refreshInvalidID = true + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe identity refresh error: %v", err) + } + saved := store.snapshot() + if saved.MembershipToken.RefreshToken != "rotated-root-refresh" || saved.IDToken != "" || len(saved.Targets) != 0 { + t.Fatal("invalid fresh identity replaced claims or lost rotated credentials") + } + restored := roundTripSession(t, saved) + reopened := newCoordinatedStore(restored) + _, _, err = ClientForTarget(t.Context(), fixture.client, copyRoot(restored), Options{}, io.Discard, nil, reopened.coordinate) + if err == nil || !strings.Contains(err.Error(), "log in again") || strings.Contains(err.Error(), "secret") { + t.Fatalf("persisted invalid identity was accepted: %v", err) + } + if fixture.rootRefresh.Load() != 1 || len(fixture.forms) != 1 { + t.Fatal("blocked identity retried refresh or started scoped authorization") + } + if fixture.f.exchanges.Load() != 0 { + t.Fatal("root token was exchanged after invalid identity") + } +} + +func TestCachedTargetRenewsChangedPermissions(t *testing.T) { + for _, test := range []struct { + name string + before, after []string + }{ + {"downgrade", []string{"stack:Read", "stack:Write"}, []string{"stack:Read"}}, + {"upgrade", []string{"stack:Read"}, []string{"stack:Read", "stack:Write"}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + checkChangedPermissions(t, test.before, test.after) + }) + } +} + +func checkChangedPermissions(t *testing.T, before, after []string) { + t.Helper() + fixture := newIdentityFixture(t) + fixture.accesses[0].Stacks[0].Scopes = before + root := fixture.root(t) + store := newCoordinatedStore(root) + client, uri, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + oldID := store.snapshot().Targets["org/stack"].IDToken + fixture.accesses[0].Stacks[0].Scopes = after + newID := fixture.signedID(t) + store.mu.Lock() + store.session.IDToken = newID + store.revision++ + store.mu.Unlock() + // Exercise the existing client's lazy token path as well as a new client. + resp, err := client.Do(testRequest(t, http.MethodPost, uri+"/ledger", nil)) + if err != nil { + t.Fatal(err) + } + if err := resp.Body.Close(); err != nil { + t.Fatal(err) + } + runParallel(t, func() error { + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + return err + }) + if len(fixture.forms) != 4 || fixture.f.exchanges.Load() != 2 { + t.Fatal("permission change did not renew authorization exactly once") + } + assertScopedForms(t, fixture.forms[2:], newID, "stack://org/stack|"+strings.Join(after, " ")) + if store.snapshot().Targets["org/stack"].IDToken == oldID { + t.Fatal("cached child retained obsolete signed permissions") + } +} + +func TestLogoutDuringTargetAuthorizationCannotRestoreRoot(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, func(context.Context, string) error { + store.mu.Lock() + defer store.mu.Unlock() + store.session = nil + store.revision++ + return nil + }, store.coordinate) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("logout did not invalidate authentication save: %v", err) + } + if store.snapshot() != nil || fixture.f.exchanges.Load() != 0 { + t.Fatal("in-flight target login restored a logged-out root") + } +} + +func TestTargetAuthorizationCancellation(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + store := newCoordinatedStore(root) + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + _, _, err := ClientForTarget(ctx, fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("target device flow ignored cancellation: %v", err) + } + if len(store.snapshot().Targets) != 0 || fixture.f.exchanges.Load() != 0 { + t.Fatal("canceled scoped authorization persisted a target or reached stack auth") + } +} + +func TestRootSaveFailureStopsCachedClient(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + store := newCoordinatedStore(root) + client, uri, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + store.expire() + store.failSave = true + for range 2 { + assertCoordinatedRequestFails(t, client, uri) + } + if fixture.rootRefresh.Load() != 1 || fixture.f.writes.Load() != 0 { + t.Fatal("failed root CAS retried a consumed token or sent service writes") + } + if store.snapshot().MembershipToken.RefreshToken != "root-refresh" { + t.Fatal("failed CAS overwrote the stored root") + } +} + +func TestRootTargetCacheJSONRoundTrip(t *testing.T) { + fixture := newIdentityFixture(t) + root := fixture.root(t) + root.Targets = map[string]*Session{"org/stack": fixture.f.session(t)} + restored := roundTripSession(t, root) + if restored.Options.Organization != "" || restored.Targets["org/stack"].Options.Stack != "stack" || restored.Targets["org/stack"].MembershipToken.RefreshToken != "refresh-secret" { + t.Fatal("serialized root lost its identity or scoped child session") + } +} + +func TestFreshTargetRejectsMismatchedSignedPermissions(t *testing.T) { + fixture := newIdentityFixture(t) + fixture.scopedID = fixture.signedID(t) // Validly signed, but still grants Write. + fixture.accesses[0].Stacks[0].Scopes = []string{"stack:Read"} + root := fixture.root(t) + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err == nil || err.Error() != "scoped session permissions differ from root identity" { + t.Fatalf("fresh authorization accepted obsolete signed scopes: %v", err) + } + if len(store.snapshot().Targets) != 0 || fixture.f.exchanges.Load() != 0 || len(fixture.forms) != 2 { + t.Fatal("mismatched authorization was persisted, exchanged, or retried") + } +} + +func TestChildRefreshRejectsElevatedPermissionsAndPreservesRotation(t *testing.T) { + fixture := newIdentityFixture(t) + fixture.childRefreshID = fixture.signedID(t) // Read/Write, despite a Read-only root. + fixture.accesses[0].Stacks[0].Scopes = []string{"stack:Read"} + root := fixture.root(t) + store := newCoordinatedStore(root) + client, uri, err := ClientForTarget(t.Context(), fixture.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + store.mu.Lock() + child := store.session.Targets["org/stack"] + child.MembershipToken.Expiry = time.Now().Add(-time.Hour) + // A supplied in-memory guard is not authority: prepare must replace it. + child.allowedScopes = []string{"stack:Read", "stack:Write"} + store.revision++ + store.mu.Unlock() + for range 2 { + assertCoordinatedRequestFails(t, client, uri) + } + assertBlockedChildRotation(t, fixture, store.snapshot()) + restored := roundTripSession(t, store.snapshot()) + reopened := newCoordinatedStore(restored) + if _, _, err := ClientForTarget(t.Context(), fixture.client, copyRoot(restored), Options{}, io.Discard, nil, reopened.coordinate); err == nil { + t.Fatal("a new client accepted the blocked refreshed child") + } + assertBlockedChildRotation(t, fixture, reopened.snapshot()) +} + +func assertBlockedChildRotation(t *testing.T, fixture *identityFixture, saved *Session) { + t.Helper() + child := saved.Targets["org/stack"] + if child.MembershipToken.RefreshToken != "rotated-secret" || child.IDToken != "" || child.StackToken != nil { + t.Fatal("failed child verification lost rotation or retained usable identity") + } + if fixture.childRefresh.Load() != 1 || fixture.rootRefresh.Load() != 0 || fixture.f.exchanges.Load() != 1 || fixture.f.writes.Load() != 0 || len(fixture.forms) != 3 { + t.Fatal("rejected refresh retried, authorized, exchanged, or reached the service") + } +} diff --git a/internal/cloud/membership.go b/internal/cloud/membership.go new file mode 100644 index 00000000..2c391914 --- /dev/null +++ b/internal/cloud/membership.go @@ -0,0 +1,393 @@ +package cloud + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "path" + "slices" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// IdentityInfo contains only available IDs from verified root claims. It is a +// snapshot taken after any initial root refresh; requests reload the identity. +type IdentityInfo struct { + Organizations []string + Stacks map[string][]string +} + +// MembershipClient authenticates control-plane requests. Organization routes +// obtain separate organization grants; root credentials never reach a stack. +// A coordinator must cover reload, refresh and CAS saves for shared sessions. +func MembershipClient(ctx context.Context, base *http.Client, root *Session, out io.Writer, open func(context.Context, string) error, coordinator Coordinator) (*http.Client, string, IdentityInfo, error) { + if root == nil || root.MembershipToken == nil { + return nil, "", IdentityInfo{}, errors.New("missing cloud identity; log in again") + } + options, err := identityOptions(root.Options) + if err != nil { + return nil, "", IdentityInfo{}, err + } + provider, config, err := membership(ctx, base, options) + if err != nil { + return nil, "", IdentityInfo{}, err + } + config.Scopes = identityScopes() + m := &targetManager{root: root, options: options, base: base, provider: provider, config: config, out: out, open: open, coordinator: coordinator} + if coordinator == nil { + m.coordinator = m.inMemory + } + issuer, err := url.Parse(options.Issuer) + if err != nil { + return nil, "", IdentityInfo{}, errors.New("invalid Membership issuer") + } + s := &membershipTransport{ctx: ctx, manager: m, issuer: issuer} + _, info, err := s.token(ctx, "") + if err != nil { + return nil, "", IdentityInfo{}, err + } + client := cloneClient(base) + client.Transport, client.CheckRedirect = s, noRedirect + return client, options.Issuer, info, nil +} + +type membershipTransport struct { + mu sync.Mutex + ctx context.Context + manager *targetManager + issuer *url.URL +} + +func (s *membershipTransport) RoundTrip(req *http.Request) (*http.Response, error) { + organization, err := s.route(req.URL) + if err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(req.Context()) + //nolint:contextcheck // Client lifetime must cancel independent request contexts. + stop := context.AfterFunc(s.ctx, cancel) + if err := s.ctx.Err(); err != nil { + stop() + cancel() + return nil, err + } + token, _, err := s.token(ctx, organization) + if err != nil { + stop() + cancel() + return nil, err + } + transport := &oauth2.Transport{Source: oauth2.StaticTokenSource(token), Base: baseTransport(s.manager.base)} + resp, err := transport.RoundTrip(req.Clone(ctx)) + if err != nil { + stop() + cancel() + return nil, err + } + resp.Body = &cancelBody{ReadCloser: resp.Body, cleanup: func() { stop(); cancel() }} + return resp, nil +} + +func (s *membershipTransport) route(uri *url.URL) (string, error) { + if uri == nil || uri.User != nil || !sameOrigin(s.issuer.String(), uri.String()) || uri.RawPath != "" || uri.Opaque != "" { + return "", errors.New("membership client refused an unsafe request URL") + } + prefix := strings.TrimRight(s.issuer.Path, "/") + if !strings.HasPrefix(uri.Path, prefix+"/") { + return "", errors.New("membership client refused a request outside its issuer path") + } + route := strings.TrimSuffix(strings.TrimPrefix(uri.Path, prefix), "/") + if path.Clean(route) != route { + return "", errors.New("invalid Membership route") + } + if rootMembershipRoute(route) { + return "", nil + } + parts := strings.Split(route, "/") + if len(parts) < 3 || parts[1] != "organizations" || !validIdentifier(parts[2]) { + return "", errors.New("unsupported Membership route") + } + return parts[2], nil +} + +func rootMembershipRoute(route string) bool { + if slices.Contains([]string{"/me", "/organizations", "/me/invitations", "/_info", "/.well-known/scopes"}, route) { + return true + } + parts := strings.Split(route, "/") + return len(parts) == 5 && parts[1] == "me" && parts[2] == "invitations" && validIdentifier(parts[3]) && slices.Contains([]string{"accept", "reject", "decline"}, parts[4]) +} + +func (s *membershipTransport) token(ctx context.Context, organization string) (*oauth2.Token, IdentityInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + m := s.manager + if err := ctx.Err(); err != nil { + return nil, IdentityInfo{}, err + } + if m.pendingSave { + return nil, IdentityInfo{}, errors.New("cloud identity save failed; log in again") + } + var info IdentityInfo + token, err := m.coordinator(ctx, func(current *Session, save func(*Session) error) (*oauth2.Token, error) { + if save == nil { + return nil, errors.New("cloud coordinator requires a session save function") + } + root, claims, err := m.membershipIdentity(ctx, current, organization, save) + if err != nil { + return nil, err + } + info, err = verifiedIdentityInfo(m.options, claims) + if err != nil { + return nil, err + } + *m.root = *copyRoot(root) + if organization == "" { + token := cleanToken(root.MembershipToken) + if err := boundToken(token, time.Now()); err != nil { + return nil, err + } + return token, nil + } + return m.organizationToken(ctx, root, claims, organization, save) + }) + return token, info, err +} + +func (m *targetManager) membershipIdentity(ctx context.Context, current *Session, organization string, save func(*Session) error) (*Session, identityClaims, error) { + root, claims, err := m.currentIdentity(ctx, current, save) + if err != nil || organization == "" || slices.ContainsFunc(claims.Organizations, func(org organizationAccess) bool { return org.ID == organization }) { + return root, claims, err + } + if m.resolvedRefresh && m.refreshClaimsNeeded { + return root, claims, nil + } + // An explicit organization may have been created after login. Refresh once + // under the coordinator, then keep consulting current userinfo when the + // provider leaves the historical ID token unchanged. + m.refreshClaimsNeeded = true + if m.resolvedRefresh { + return m.userInfoAndSave(ctx, root, save) + } + return m.refreshAndSave(ctx, root, save) +} + +func verifiedIdentityInfo(options Options, claims identityClaims) (IdentityInfo, error) { + info := IdentityInfo{Stacks: make(map[string][]string)} + for _, org := range claims.Organizations { + if !validIdentifier(org.ID) || slices.Contains(info.Organizations, org.ID) { + return IdentityInfo{}, errors.New("identity contains invalid or duplicate organization IDs") + } + info.Organizations = append(info.Organizations, org.ID) + info.Stacks[org.ID] = []string{} + } + targets, err := availableTargets(options, claims) + if err != nil { + return IdentityInfo{}, err + } + for _, target := range targets { + info.Stacks[target.Options.Organization] = append(info.Stacks[target.Options.Organization], target.Options.Stack) + } + slices.Sort(info.Organizations) + return info, nil +} + +func organizationScopes(claims identityClaims, id string) ([]string, error) { + for _, org := range claims.Organizations { + if org.ID != id { + continue + } + // Historical root claims omit scopes. Ask only the known supported set; + // Membership consent still enforces the actual organization policy. + if org.Scopes == nil { + return slices.Clone(knownOrganizationScopes), nil + } + scopes := intersectOrganizationScopes(org.Scopes) + if len(scopes) == 0 { + return nil, errors.New("identity grants no organization scopes") + } + return scopes, nil + } + return nil, errors.New("identity does not authorize the requested organization") +} + +func intersectOrganizationScopes(scopes []string) []string { + var result []string + for _, scope := range knownOrganizationScopes { + if slices.Contains(scopes, scope) { + result = append(result, scope) + } + } + return result +} + +func (m *targetManager) organizationToken(ctx context.Context, root *Session, claims identityClaims, org string, save func(*Session) error) (*oauth2.Token, error) { + scopes, err := organizationScopes(claims, org) + if err != nil { + return nil, err + } + child, err := m.cachedOrganization(ctx, root, org, scopes) + if err != nil { + return nil, err + } + if child == nil { + child, err = m.authorizeOrganization(ctx, root, org, scopes) + if err != nil { + return nil, err + } + if err := m.saveOrganization(root, org, child, save); err != nil { + return nil, err + } + } + if !usable(child.MembershipToken) { + child, refreshErr := m.refreshOrganization(ctx, child, org, scopes) + if child != nil { + if err := m.saveOrganization(root, org, child, save); err != nil { + return nil, err + } + } + if refreshErr != nil { + return nil, refreshErr + } + return cleanToken(child.MembershipToken), nil + } + return cleanToken(child.MembershipToken), nil +} + +func (m *targetManager) cachedOrganization(ctx context.Context, root *Session, org string, scopes []string) (*Session, error) { + child := copyRoot(root.Organizations[org]) + if child == nil { + return nil, nil + } + matches, err := m.verifyOrganization(ctx, child, org, scopes, true) + if err != nil { + return nil, err + } + if !matches { + return nil, nil + } + if err := boundSessionTokens(child); err != nil { + return nil, err + } + return child, nil +} + +func (m *targetManager) saveOrganization(root *Session, org string, child *Session, save func(*Session) error) error { + if root.Organizations == nil { + root.Organizations = make(map[string]*Session) + } + root.Organizations[org] = copyRoot(child) + return m.saveRoot(root, save) +} + +func (m *targetManager) authorizeOrganization(ctx context.Context, root *Session, org string, scopes []string) (*Session, error) { + config := *m.config + config.Scopes = append([]string{"openid", "offline_access"}, scopes...) + token, raw, err := deviceLogin(ctx, m.base, m.options, &config, m.out, m.open, + []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("organization_id", org), oauth2.SetAuthURLParam("id_token_hint", root.IDToken)}, nil) + if err != nil { + return nil, err + } + child := &Session{Options: m.options, IDToken: raw, MembershipToken: cleanToken(token)} + child.Options.Organization = org + matches, err := m.verifyOrganization(ctx, child, org, scopes, false) + if err != nil { + return nil, err + } + if !matches { + return nil, errors.New("organization grant permissions differ from root identity") + } + return child, nil +} + +func (m *targetManager) verifyOrganization(ctx context.Context, child *Session, org string, scopes []string, stored bool) (bool, error) { + return m.verifyOrganizationProof(ctx, child, org, scopes, stored, stored) +} + +func (m *targetManager) verifyOrganizationProof(ctx context.Context, child *Session, org string, scopes []string, storedID, storedAccess bool) (bool, error) { + expected := m.options + expected.Organization = org + if child.Options != expected || child.StackURL != "" || child.StackToken != nil || child.MembershipToken == nil || child.IDToken == "" { + return false, errors.New("invalid organization session; log in again") + } + _, id, err := verifyIdentity(ctx, m.provider, m.options, child.IDToken, storedID) + if err != nil { + return false, err + } + access, err := m.provider.Verifier(&oidc.Config{ClientID: m.options.ClientID, SkipExpiryCheck: storedAccess}).Verify(ctx, child.MembershipToken.AccessToken) + if err != nil { + return false, safeError(ctx, "verify organization access", err) + } + var claims struct { + Organization string `json:"organization_id"` + Scope string `json:"scope"` + } + if err := access.Claims(&claims); err != nil { + return false, errors.New("invalid organization access claims") + } + if claims.Organization != org || access.Subject != id.Subject { + return false, errors.New("organization access identity differs from requested organization") + } + _, rootID, err := verifyIdentity(ctx, m.provider, m.options, m.root.IDToken, true) + if err != nil { + return false, err + } + if access.Subject != rootID.Subject { + return false, errors.New("organization access subject differs from root identity") + } + granted := strings.Fields(claims.Scope) + filtered := intersectOrganizationScopes(granted) + for _, scope := range granted { + if strings.HasPrefix(scope, "organization:") && !slices.Contains(knownOrganizationScopes, scope) { + return false, nil + } + } + child.MembershipToken.Expiry = minExpiry(child.MembershipToken.Expiry, access.Expiry) + return slices.Equal(filtered, scopes), nil +} + +func (m *targetManager) refreshOrganization(ctx context.Context, child *Session, org string, scopes []string) (*Session, error) { + if child.MembershipToken.RefreshToken == "" { + return nil, errors.New("organization session expired; log in again") + } + old := cleanToken(child.MembershipToken) + old.Expiry = time.Now().Add(-time.Hour) + config := *m.config + config.Scopes = nil + authCtx := context.WithValue(ctx, oauth2.HTTPClient, authClient(ctx, m.base, m.options.Issuer)) + fresh, err := config.TokenSource(authCtx, old).Token() + if err != nil { + return nil, safeError(ctx, "organization refresh", err) + } + child.MembershipToken = cleanToken(fresh) + storedID := true + if raw, ok := fresh.Extra("id_token").(string); ok && raw != "" { + child.IDToken = raw + storedID = false + } + if err := m.validateOrganizationRefresh(ctx, child, org, scopes, storedID); err != nil { + child.IDToken = "" + return child, err + } + return child, nil +} + +func (m *targetManager) validateOrganizationRefresh(ctx context.Context, child *Session, org string, scopes []string, storedID bool) error { + if err := boundToken(child.MembershipToken, time.Now()); err != nil { + return err + } + matches, err := m.verifyOrganizationProof(ctx, child, org, scopes, storedID, false) + if err != nil { + return err + } + if !matches { + return errors.New("refreshed organization permissions differ from root identity; log in again") + } + return nil +} diff --git a/internal/cloud/membership_test.go b/internal/cloud/membership_test.go new file mode 100644 index 00000000..f59d357c --- /dev/null +++ b/internal/cloud/membership_test.go @@ -0,0 +1,538 @@ +package cloud + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type membershipFixture struct { + identity *identityFixture + client *http.Client + mu sync.Mutex + forms []url.Values + grants map[string][]string + used map[string]bool + grantScopes, refreshScopes []string + wrongOrganization bool + accessAudience, accessSubject string + invalidAccess bool + devices, refreshes, rootCalls, orgCalls atomic.Int32 +} + +func newMembershipFixture(t *testing.T) *membershipFixture { + t.Helper() + f := &membershipFixture{identity: newIdentityFixture(t), grants: make(map[string][]string), used: make(map[string]bool)} + f.identity.accesses[0].Scopes = []string{"organization:Read", "organization:ListStacks", "unrelated"} + f.client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return f.roundTrip(t, r) + })} + return f +} + +func (f *membershipFixture) roundTrip(t *testing.T, r *http.Request) (*http.Response, error) { + t.Helper() + if r.URL.Path == "/membership/device" || r.URL.Path == "/membership/token" { + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.Form.Get("refresh_token") == "root-refresh" { + return f.identity.client.Transport.RoundTrip(r) + } + f.mu.Lock() + f.forms = append(f.forms, r.Form) + f.mu.Unlock() + return f.oauthResponse(t, r), nil + } + if strings.HasPrefix(r.URL.Path, "/membership/me") || r.URL.Path == "/membership/organizations" || strings.HasPrefix(r.URL.Path, "/membership/organizations/") { + return f.serviceResponse(t, r), nil + } + return f.identity.client.Transport.RoundTrip(r) +} + +func (f *membershipFixture) oauthResponse(t *testing.T, r *http.Request) *http.Response { + t.Helper() + w := httptest.NewRecorder() + id, _, ok := r.BasicAuth() + if !ok || id != "fctl" { + t.Error("missing organization client authentication") + } + if r.URL.Path == "/membership/device" { + f.devices.Add(1) + org := r.Form.Get("organization_id") + f.mu.Lock() + f.grants[org] = intersectOrganizationScopes(strings.Fields(r.Form.Get("scope"))) + f.mu.Unlock() + writeJSON(t, w, map[string]any{"device_code": org, "user_code": "ABCD", "verification_uri": f.identity.f.server.URL + "/verify", "expires_in": 600, "interval": 1}) + return w.Result() + } + if r.Form.Has("scope") || r.Form.Has("resource") { + t.Error("organization token request repeated scope or resource") + } + org, refresh := r.Form.Get("device_code"), "organization-refresh" + if r.Form.Get("grant_type") == "refresh_token" { + f.refreshes.Add(1) + org, refresh = "org", "rotated-organization-refresh" + f.mu.Lock() + used := f.used[r.Form.Get("refresh_token")] + f.used[r.Form.Get("refresh_token")] = true + f.mu.Unlock() + if used { + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, map[string]string{"error": "invalid_grant", "error_description": "rotation-secret"}) + return w.Result() + } + } + writeJSON(t, w, map[string]any{"access_token": f.accessToken(t, org, refresh), "refresh_token": refresh, "token_type": "Bearer", "expires_in": 3600, "id_token": f.identity.signedID(t)}) + return w.Result() +} + +func (f *membershipFixture) accessToken(t *testing.T, org, refresh string) string { + t.Helper() + f.mu.Lock() + scopes := slices.Clone(f.grants[org]) + f.mu.Unlock() + if f.grantScopes != nil { + scopes = f.grantScopes + } + if refresh == "rotated-organization-refresh" && f.refreshScopes != nil { + scopes = f.refreshScopes + } + if f.wrongOrganization { + org = "other" + } + audience, subject := f.accessAudience, f.accessSubject + if audience == "" { + audience = "fctl" + } + if subject == "" { + subject = "user" + } + raw := signMembershipClaims(t, f.identity, map[string]any{"iss": f.identity.f.options.Issuer, "aud": audience, "sub": subject, "exp": time.Now().Add(time.Hour).Unix(), "organization_id": org, "scope": strings.Join(scopes, " ")}) + if f.invalidAccess { + raw += "invalid" + } + return raw +} + +func signMembershipClaims(t *testing.T, f *identityFixture, claims map[string]any) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + unsigned := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"key"}`)) + "." + base64.RawURLEncoding.EncodeToString(payload) + hash := sha256.Sum256([]byte(unsigned)) + signature, err := rsa.SignPKCS1v15(rand.Reader, f.f.key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func (f *membershipFixture) serviceResponse(t *testing.T, r *http.Request) *http.Response { + t.Helper() + w := httptest.NewRecorder() + if strings.HasPrefix(r.URL.Path, "/membership/organizations/") { + f.orgCalls.Add(1) + if r.Header.Get("Authorization") == "Bearer root-secret" { + t.Error("organization request used root credentials") + } + } else { + f.rootCalls.Add(1) + if !slices.Contains([]string{"Bearer root-secret", "Bearer refreshed-root-secret"}, r.Header.Get("Authorization")) { + t.Error("root request used organization credentials") + } + } + writeJSON(t, w, map[string]bool{"ok": true}) + return w.Result() +} + +func membershipRequest(t *testing.T, client *http.Client, uri string) error { + t.Helper() + resp, err := client.Do(testRequest(t, http.MethodPost, uri, nil)) + if err != nil { + return err + } + return resp.Body.Close() +} + +func TestMembershipRoutingAndConcurrentCache(t *testing.T) { + f := newMembershipFixture(t) + root := f.identity.root(t) + root.Targets = map[string]*Session{"org/stack": f.identity.f.session(t)} + store := newCoordinatedStore(root) + client, issuer, info, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if issuer != root.Options.Issuer || !slices.Equal(info.Organizations, []string{"org"}) || !slices.Equal(info.Stacks["org"], []string{"stack"}) { + t.Fatal("incorrect verified metadata") + } + for _, route := range []string{"/me", "/organizations?limit=10&cursor=next", "/me/invitations?limit=10", "/me/invitations/invite/accept", "/me/invitations/invite/reject", "/me/invitations/invite/decline"} { + if err := membershipRequest(t, client, issuer+route); err != nil { + t.Fatal(err) + } + } + runParallel(t, func() error { + other, _, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + return err + } + return membershipRequest(t, other, issuer+"/organizations/org/stacks") + }) + assertOrganizationForms(t, f, root.IDToken) + saved := roundTripSession(t, store.snapshot()) + if saved.Organizations["org"] == nil || saved.Targets["org/stack"] == nil || f.devices.Load() != 1 || f.orgCalls.Load() != 2 || f.identity.f.exchanges.Load() != 0 { + t.Fatal("organization cache duplicated, lost targets, or reached stack exchange") + } + for _, uri := range []string{"https://other.invalid/organizations/org", issuer + "/organizations/unknown", issuer + "/organizations/org/../other", issuer + "/organizations/org%2Fother", f.identity.f.server.URL + "/me", issuer + "/me/unsupported", issuer + "/me/invitations/invite/unsupported"} { + if err := membershipRequest(t, client, uri); err == nil { + t.Fatal("unsafe or unavailable route was accepted") + } + } +} + +func assertOrganizationForms(t *testing.T, f *membershipFixture, hint string) { + t.Helper() + if len(f.forms) != 2 { + t.Fatal("unexpected organization auth request count") + } + if f.forms[0].Get("scope") != "openid offline_access organization:Read organization:ListStacks" || f.forms[0].Get("organization_id") != "org" || f.forms[0].Get("id_token_hint") != hint || f.forms[0].Has("resource") { + t.Fatal("incorrect organization authorization parameters") + } + if f.forms[1].Has("scope") || f.forms[1].Has("resource") { + t.Fatal("incorrect organization polling parameters") + } +} + +func TestMembershipInitialRefreshUpdatesMetadata(t *testing.T) { + f := newMembershipFixture(t) + f.identity.accesses[0].Stacks = nil + root := f.identity.root(t) + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.identity.accesses[0].Stacks = []stackAccess{{ID: "new", URI: f.identity.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}} + store := newCoordinatedStore(root) + _, _, info, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(info.Stacks["org"], []string{"new"}) || store.snapshot().MembershipToken.RefreshToken != "rotated-root-refresh" || f.devices.Load() != 0 { + t.Fatal("metadata preceded root refresh or started a device flow") + } +} + +func TestMembershipRejectsIncorrectGrant(t *testing.T) { + for _, kind := range []string{"organization", "scopes", "signature", "audience", "access signature", "access audience", "access subject"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); checkIncorrectOrganizationGrant(t, kind) }) + } +} + +func checkIncorrectOrganizationGrant(t *testing.T, kind string) { + t.Helper() + f := newMembershipFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + switch kind { + case "organization": + f.wrongOrganization = true + case "scopes": + f.grantScopes = []string{"organization:Read", "organization:Delete"} + case "signature": + f.identity.invalidID = true + case "audience": + f.identity.f.audience = "other" + case "access signature": + f.invalidAccess = true + case "access audience": + f.accessAudience = "other" + case "access subject": + f.accessSubject = "other" + } + if err := membershipRequest(t, client, issuer+"/organizations/org"); err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe organization grant result: %v", err) + } + if len(store.snapshot().Organizations) != 0 || f.orgCalls.Load() != 0 { + t.Fatal("invalid grant persisted or reached service") + } +} + +func TestMembershipRefreshRotationAndScopeGuard(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(map[bool]string{false: "valid", true: "elevated"}[invalid], func(t *testing.T) { t.Parallel(); checkOrganizationRefresh(t, invalid) }) + } +} + +func checkOrganizationRefresh(t *testing.T, invalid bool) { + t.Helper() + f := newMembershipFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + f.orgCalls.Store(0) + store.mu.Lock() + store.session.Organizations["org"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + store.revision++ + store.mu.Unlock() + if invalid { + f.refreshScopes = []string{"organization:Read", "organization:ListStacks", "organization:Delete"} + } + if invalid { + err = membershipRequest(t, client, issuer+"/organizations/org") + } else { + runParallel(t, func() error { + other, _, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + return err + } + return membershipRequest(t, other, issuer+"/organizations/org") + }) + } + if (err != nil) != invalid { + t.Fatalf("incorrect refresh result: %v", err) + } + saved := store.snapshot().Organizations["org"] + if saved.MembershipToken.RefreshToken != "rotated-organization-refresh" || f.refreshes.Load() != 1 { + t.Fatal("refresh rotation lost") + } + if invalid { + assertBlockedOrganization(t, f, store, issuer) + } else { + runParallel(t, func() error { return membershipRequest(t, client, issuer+"/organizations/org") }) + } + if f.refreshes.Load() != 1 || f.identity.f.exchanges.Load() != 0 { + t.Fatal("refresh retried or exchanged against stack") + } +} + +func assertBlockedOrganization(t *testing.T, f *membershipFixture, store *coordinatedStore, issuer string) { + t.Helper() + if store.snapshot().Organizations["org"].IDToken != "" || f.orgCalls.Load() != 0 { + t.Fatal("invalid refresh retained identity or reached service") + } + other, _, _, err := MembershipClient(t.Context(), f.client, roundTripSession(t, store.snapshot()), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, other, issuer+"/organizations/org"); err == nil { + t.Fatal("new client accepted blocked organization identity") + } +} + +func TestMembershipAuthorizationCancellationAndLogout(t *testing.T) { + for _, logout := range []bool{false, true} { + t.Run(map[bool]string{false: "cancellation", true: "logout"}[logout], func(t *testing.T) { t.Parallel(); checkOrganizationCancellation(t, logout) }) + } +} + +func checkOrganizationCancellation(t *testing.T, logout bool) { + t.Helper() + f := newMembershipFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client, issuer, _, err := MembershipClient(ctx, f.client, copyRoot(root), io.Discard, func(context.Context, string) error { + if logout { + store.mu.Lock() + store.session = nil + store.revision++ + store.mu.Unlock() + } else { + cancel() + } + return nil + }, store.coordinate) + if err != nil { + t.Fatal(err) + } + err = membershipRequest(t, client, issuer+"/organizations/org") + if err == nil || (!logout && !errors.Is(err, context.Canceled)) { + t.Fatalf("cancellation/logout not respected: %v", err) + } + if f.orgCalls.Load() != 0 || (logout && store.snapshot() != nil) { + t.Fatal("invalidated authorization reached service or restored root") + } +} + +func TestExplicitNewStackRefreshesRootOnce(t *testing.T) { + f := newIdentityFixture(t) + f.accesses[0].Stacks = nil + root := f.root(t) + f.accesses[0].Stacks = []stackAccess{{ID: "new", URI: f.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}} + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Stack: "new"}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if f.rootRefresh.Load() != 1 || len(f.forms) != 3 || store.snapshot().Targets["org/new"] == nil || store.snapshot().MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("new stack did not use a single root refresh followed by scoped authorization") + } +} + +func TestOrganizationCacheRenewsScopeChanges(t *testing.T) { + for _, upgrade := range []bool{false, true} { + t.Run(map[bool]string{false: "downgrade", true: "upgrade"}[upgrade], func(t *testing.T) { t.Parallel(); checkOrganizationScopeChange(t, upgrade) }) + } +} + +func checkOrganizationScopeChange(t *testing.T, upgrade bool) { + t.Helper() + f := newMembershipFixture(t) + if upgrade { + f.identity.accesses[0].Scopes = []string{"organization:Read"} + } + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + if upgrade { + f.identity.accesses[0].Scopes = []string{"organization:Read", "organization:ListStacks"} + } else { + f.identity.accesses[0].Scopes = []string{"organization:Read"} + } + rootID := f.identity.signedID(t) + store.mu.Lock() + store.session.IDToken = rootID + store.revision++ + store.mu.Unlock() + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + if f.devices.Load() != 2 || len(f.forms) != 4 || f.forms[2].Get("id_token_hint") != rootID || f.refreshes.Load() != 0 { + t.Fatal("scope change did not renew a scoped organization grant") + } + want := "openid offline_access " + strings.Join(intersectOrganizationScopes(f.identity.accesses[0].Scopes), " ") + if f.forms[2].Get("scope") != want { + t.Fatal("renewed organization scopes differ from signed root") + } +} + +func TestOrganizationScopeFallbackAndDenial(t *testing.T) { + claims := identityClaims{Organizations: []organizationAccess{{ID: "org"}}} + scopes, err := organizationScopes(claims, "org") + if err != nil || !slices.Equal(scopes, knownOrganizationScopes) { + t.Fatal("legacy root claim fallback omitted known organization scopes") + } + claims.Organizations[0].Scopes = []string{} + if _, err := organizationScopes(claims, "org"); err == nil { + t.Fatal("explicit empty scope grant was treated as legacy absence") + } + claims.Organizations[0].Scopes = []string{"unsupported:scope"} + if _, err := organizationScopes(claims, "org"); err == nil { + t.Fatal("unsupported scopes granted organization access") + } +} + +func TestIdentityInfoRejectsDuplicateOrganizations(t *testing.T) { + options := Options{Issuer: "https://example.invalid", ClientID: "fctl"} + claims := identityClaims{Organizations: []organizationAccess{{ID: "org"}, {ID: "org"}}} + if _, err := verifiedIdentityInfo(options, claims); err == nil { + t.Fatal("duplicate organizations were accepted") + } + info, err := verifiedIdentityInfo(options, identityClaims{}) + if err != nil || len(info.Organizations) != 0 || len(info.Stacks) != 0 { + t.Fatal("zero-access identity metadata was rejected") + } +} + +func TestMembershipSaveFailureStopsRotationRetry(t *testing.T) { + f := newMembershipFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + store.mu.Lock() + store.session.Organizations["org"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + store.failSave = true + store.mu.Unlock() + for range 2 { + if err := membershipRequest(t, client, issuer+"/organizations/org"); err == nil { + t.Fatal("failed CAS did not stop authenticated requests") + } + } + if f.refreshes.Load() != 1 || f.orgCalls.Load() != 1 || store.snapshot().Organizations["org"].MembershipToken.RefreshToken != "organization-refresh" { + t.Fatal("save failure retried a consumed refresh token or changed stored credentials") + } +} + +func TestMissingExplicitStackDoesNotRefreshTwice(t *testing.T) { + for _, expired := range []bool{false, true} { + t.Run(map[bool]string{false: "current root", true: "expired root"}[expired], func(t *testing.T) { + t.Parallel() + f := newIdentityFixture(t) + f.accesses[0].Stacks = nil + root := f.root(t) + if expired { + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + } + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Stack: "missing"}, io.Discard, nil, store.coordinate) + if err == nil || f.rootRefresh.Load() != 1 || f.childRefresh.Load() != 0 || len(f.forms) != 1 || len(store.snapshot().Targets) != 0 { + t.Fatal("unavailable explicit stack triggered repeated refresh or authorization") + } + }) + } +} + +func TestAmbiguousStackDoesNotRefreshRoot(t *testing.T) { + f := newIdentityFixture(t) + f.accesses = append(f.accesses, organizationAccess{ID: "other", Stacks: []stackAccess{{ID: "stack", URI: f.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}}}) + root := f.root(t) + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Stack: "stack"}, io.Discard, nil, store.coordinate) + if err == nil || !strings.Contains(err.Error(), "--organization") || strings.Contains(err.Error(), "org/stack") || strings.Contains(err.Error(), "other/stack") || f.rootRefresh.Load() != 0 || len(f.forms) != 0 { + t.Fatal("ambiguous stack refreshed or failed to give concise selection instructions") + } +} + +func TestExistingStackWrongOrganizationDoesNotRefreshRoot(t *testing.T) { + for _, org := range []string{"missing", "other"} { + t.Run(org, func(t *testing.T) { + t.Parallel() + f := newIdentityFixture(t) + f.accesses = append(f.accesses, organizationAccess{ID: "other"}) + root := f.root(t) + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Organization: org, Stack: "stack"}, io.Discard, nil, store.coordinate) + if err == nil || f.rootRefresh.Load() != 0 || len(f.forms) != 0 || store.saves != 0 { + t.Fatal("existing stack with incorrect organization refreshed identity or authorized a target") + } + }) + } +} diff --git a/internal/cloud/organization_scopes.go b/internal/cloud/organization_scopes.go new file mode 100644 index 00000000..fdbeab56 --- /dev/null +++ b/internal/cloud/organization_scopes.go @@ -0,0 +1,67 @@ +package cloud + +// Supported native Membership organization scopes, including legacy CLI grants. +var knownOrganizationScopes = []string{ + "organization:Read", + "organization:Create", + "organization:Update", + "organization:Delete", + "organization:ListUsers", + "organization:ReadUser", + "organization:CreateUser", + "organization:UpdateUser", + "organization:DeleteUser", + "organization:ListPolicies", + "organization:ReadPolicy", + "organization:CreatePolicy", + "organization:UpdatePolicy", + "organization:DeletePolicy", + "organization:ListInvitations", + "organization:ReadInvitation", + "organization:CreateInvitation", + "organization:UpdateInvitation", + "organization:AcceptInvitation", + "organization:RejectInvitation", + "organization:DeleteInvitation", + "organization:ListRegions", + "organization:ReadRegion", + "organization:CreateRegion", + "organization:UpdateRegion", + "organization:DeleteRegion", + "organization:ListStacks", + "organization:ReadStack", + "organization:CreateStack", + "organization:UpdateStack", + "organization:DeleteStack", + "organization:EnableStack", + "organization:DisableStack", + "organization:RestoreStack", + "organization:UpgradeStack", + "organization:ListStackUsers", + "organization:ReadStackUser", + "organization:CreateStackUser", + "organization:UpdateStackUser", + "organization:DeleteStackUser", + "organization:ListStackModules", + "organization:EnableStackModule", + "organization:DisableStackModule", + "organization:ListClients", + "organization:ReadClient", + "organization:CreateClient", + "organization:UpdateClient", + "organization:DeleteClient", + "organization:ReadAuthProvider", + "organization:UpdateAuthProvider", + "organization:DeleteAuthProvider", + "organization:ReadLogs", + "organization:ListFeatures", + "organization:ReadFeature", + "organization:ListGroups", + "organization:ReadGroup", + "organization:CreateGroup", + "organization:UpdateGroup", + "organization:DeleteGroup", + "organization:ListGroupMembers", + "organization:AddGroupMember", + "organization:RemoveGroupMember", +} diff --git a/internal/cloud/session_test.go b/internal/cloud/session_test.go new file mode 100644 index 00000000..e1df5a68 --- /dev/null +++ b/internal/cloud/session_test.go @@ -0,0 +1,47 @@ +package cloud + +import ( + "encoding/json" + "slices" + "testing" + + "golang.org/x/oauth2" +) + +func TestApplicationSessionCloneAndJSON(t *testing.T) { + child := &Session{Application: "deploy", Options: Options{Organization: "org"}, IDToken: "identity", + MembershipToken: &oauth2.Token{AccessToken: "access", RefreshToken: "refresh"}, + StackToken: &oauth2.Token{AccessToken: "stack"}, allowedScopes: []string{"apps:Read"}, + Applications: map[string]*Session{"nested": {}}, Targets: map[string]*Session{"nested": {}}, Organizations: map[string]*Session{"nested": {}}} + root := &Session{Applications: map[string]*Session{"org/deploy": child, "empty": nil}} + cloned := copyRoot(root) + copyChild := cloned.Applications["org/deploy"] + copyChild.MembershipToken.RefreshToken = "rotated" + copyChild.StackToken.AccessToken = "changed" + copyChild.allowedScopes[0] = "apps:Write" + delete(cloned.Applications, "empty") + if child.MembershipToken.RefreshToken != "refresh" || child.StackToken.AccessToken != "stack" || child.allowedScopes[0] != "apps:Read" || len(root.Applications) != 2 { + t.Fatal("application clone mutated original credentials, scopes or map") + } + if copyChild.Applications != nil || copyChild.Targets != nil || copyChild.Organizations != nil { + t.Fatal("leaf session retained nested authentication caches") + } + restored := roundTripSession(t, cloned) + if restored.Applications["org/deploy"].Application != "deploy" || restored.Applications["org/deploy"].MembershipToken.RefreshToken != "rotated" || restored.Applications["org/deploy"].allowedScopes != nil { + t.Fatal("application serialization lost alias/rotation or persisted private guard") + } +} + +func TestApplicationClaimsDecode(t *testing.T) { + var claims identityClaims + if err := json.Unmarshal([]byte(`{"org":[{"id":"org","applications":[{"id":"app-id","name":"Apps deploy","alias":"deploy","scopes":["apps:Read","apps:Write"]}]}]}`), &claims); err != nil { + t.Fatal(err) + } + if len(claims.Organizations) != 1 || len(claims.Organizations[0].Applications) != 1 { + t.Fatal("application claim was not decoded") + } + app := claims.Organizations[0].Applications[0] + if app.ID != "app-id" || app.Name != "Apps deploy" || app.Alias != "deploy" || !slices.Equal(app.Scopes, []string{"apps:Read", "apps:Write"}) { + t.Fatal("application claim fields or scopes were lost") + } +} diff --git a/internal/cloud/stack_proof.go b/internal/cloud/stack_proof.go new file mode 100644 index 00000000..c3ded079 --- /dev/null +++ b/internal/cloud/stack_proof.go @@ -0,0 +1,121 @@ +package cloud + +import ( + "context" + "slices" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// Historical providers put stack accesses in the signed ID token. Membership +// now supplies an ordinary ID token and a resource-bound, signed access JWT. +// Both contracts require verified identity; no URL comes from unsigned claims. +func verifyTargetSession(ctx context.Context, provider *oidc.Provider, target targetAccess, child *Session, storedID, storedAccess bool) (bool, error) { + if child.MembershipToken == nil { + return false, authenticationFailure("missing scoped Membership token") + } + options, err := child.Options.normalized() + if err != nil || options != target.Options || child.StackURL != target.URI { + return false, authenticationFailure("scoped session target differs from verified target") + } + if err := validateEndpoint(target.URI); err != nil { + return false, authenticationFailure("invalid scoped stack URL") + } + claims, id, err := verifyIdentity(ctx, provider, target.Options, child.IDToken, storedID) + if err != nil { + return false, err + } + if target.Subject != "" && id.Subject != target.Subject { + return false, authenticationFailure("scoped identity subject differs from root identity") + } + if err := verifyScopedTokenHash(id, child.MembershipToken.AccessToken, storedID); err != nil { + return false, err + } + return verifyTargetAccess(ctx, provider, target, child, claims, id.Subject, storedAccess) +} + +func verifyTargetAccess(ctx context.Context, provider *oidc.Provider, target targetAccess, child *Session, claims identityClaims, subject string, storedAccess bool) (bool, error) { + historicalMatches := true + if len(claims.Organizations) != 0 { + var err error + historicalMatches, err = historicalStackClaimsMatch(target, claims) + if err != nil { + return false, err + } + if strings.Count(child.MembershipToken.AccessToken, ".") != 2 { + return historicalMatches, nil + } + } + matches, err := verifyStackAccess(ctx, provider, target, child, subject, storedAccess) + return matches && historicalMatches, err +} + +func verifyScopedTokenHash(id *oidc.IDToken, access string, storedID bool) error { + // A historical ID token's hash binds the original access token, not a new + // refresh token response which deliberately omits an ID token. + if !storedID && id.AccessTokenHash != "" { + if err := id.VerifyAccessToken(access); err != nil { + return authenticationFailure("scoped identity does not match its access token") + } + } + return nil +} + +func historicalStackClaimsMatch(target targetAccess, claims identityClaims) (bool, error) { + signed, err := resolveTarget(target.Options, claims, target.Options) + if err != nil { + return false, err + } + if signed.URI != target.URI { + return false, authenticationFailure("session stack URL differs from verified claims; log in again") + } + return target.Scopes == nil || slices.Equal(signed.Scopes, target.Scopes), nil +} + +func verifyStackAccess(ctx context.Context, provider *oidc.Provider, target targetAccess, child *Session, subject string, stored bool) (bool, error) { + access, err := provider.Verifier(&oidc.Config{ClientID: target.URI + "/api/auth", SkipExpiryCheck: stored}).Verify(ctx, child.MembershipToken.AccessToken) + if err != nil { + return false, safeError(ctx, "verify scoped stack access", err) + } + var claims struct { + Organization string `json:"organization_id"` + Stack string `json:"stack_id"` + Scope string `json:"scope"` + } + if err := access.Claims(&claims); err != nil { + return false, authenticationFailure("invalid scoped stack access claims") + } + if subject == "" || access.Subject != subject || claims.Organization != target.Options.Organization || claims.Stack != target.Options.Stack { + return false, authenticationFailure("scoped access does not authorize the requested identity, organization and stack") + } + child.MembershipToken.Expiry = minExpiry(child.MembershipToken.Expiry, access.Expiry) + granted := stackPermissions(strings.Fields(claims.Scope)) + return len(granted) != 0 && (target.Scopes == nil || slices.Equal(granted, target.Scopes)), nil +} + +func stackPermissions(scopes []string) []string { + read, write := false, false + for _, scope := range scopes { + service, permission, ok := strings.Cut(scope, ":") + if !ok || !validIdentifier(service) { + return nil + } + switch permission { + case "read": + read = true + case "write": + write = true + default: + return nil + } + } + var result []string + if read { + result = append(result, "stack:Read") + } + if write { + result = append(result, "stack:Write") + } + return result +} diff --git a/internal/cloud/stack_proof_test.go b/internal/cloud/stack_proof_test.go new file mode 100644 index 00000000..cdd735f9 --- /dev/null +++ b/internal/cloud/stack_proof_test.go @@ -0,0 +1,139 @@ +package cloud + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func TestScopedStackProofUsesAccessJWT(t *testing.T) { + for _, kind := range []string{"valid", "signature", "audience", "organization", "stack", "elevated", "subject", "expired"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); checkScopedStackProof(t, kind) }) + } +} + +func TestCachedStackScopeChangeRejectsInvalidAccessProof(t *testing.T) { + for _, kind := range []string{"signature", "audience", "organization", "stack", "subject"} { + t.Run(kind, func(t *testing.T) { checkChangedScopeInvalidAccess(t, kind) }) + } +} + +func checkChangedScopeInvalidAccess(t *testing.T, kind string) { + t.Helper() + f := newIdentityFixture(t) + f.accesses[0].Stacks[0].Scopes = []string{"stack:Read", "stack:Write"} + options := f.f.options + options.ClientID = "fctl" + uri := f.f.server.URL + "/stack" + claims := map[string]any{"iss": options.Issuer, "aud": uri + "/api/auth", "sub": "user", "exp": time.Now().Add(time.Hour).Unix(), "organization_id": "org", "stack_id": "stack", "scope": "ledger:read ledger:write"} + alterStackAccessClaims(claims, kind) + access := signMembershipClaims(t, f, claims) + if kind == "signature" { + access += "invalid" + } + child := &Session{Options: options, StackURL: uri, IDToken: f.signedID(t), MembershipToken: &oauth2.Token{AccessToken: access, Expiry: time.Now().Add(time.Hour)}} + f.accesses[0].Stacks[0].Scopes = []string{"stack:Read"} + root := f.root(t) + root.Targets = map[string]*Session{"org/stack": child} + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err == nil || len(f.forms) != 0 || f.rootRefresh.Load() != 0 || f.f.exchanges.Load() != 0 { + t.Fatal("invalid cached access proof became a scope cache miss and triggered authentication") + } +} + +func alterStackAccessClaims(claims map[string]any, kind string) { + switch kind { + case "audience": + claims["aud"] = "other" + case "organization": + claims["organization_id"] = "other" + case "stack": + claims["stack_id"] = "other" + case "subject": + claims["sub"] = "other" + } +} + +func checkScopedStackProof(t *testing.T, kind string) { + t.Helper() + f := newIdentityFixture(t) + provider, _, err := membership(t.Context(), f.client, Options{Issuer: f.f.options.Issuer, ClientID: "fctl"}) + if err != nil { + t.Fatal(err) + } + target := targetAccess{Options: f.f.options, URI: f.f.server.URL + "/stack", Scopes: []string{"stack:Read"}, Subject: "user"} + target.Options.ClientID = "fctl" + claims := map[string]any{"iss": target.Options.Issuer, "aud": target.URI + "/api/auth", "sub": "user", "exp": time.Now().Add(time.Hour).Unix(), "organization_id": "org", "stack_id": "stack", "scope": "ledger:read auth:read"} + switch kind { + case "audience": + claims["aud"] = "other" + case "organization": + claims["organization_id"] = "other" + case "stack": + claims["stack_id"] = "other" + case "elevated": + claims["scope"] = "ledger:read auth:read ledger:write" + case "subject": + claims["sub"] = "other" + case "expired": + claims["exp"] = time.Now().Add(-time.Hour).Unix() + } + access := signMembershipClaims(t, f, claims) + if kind == "signature" { + access += "invalid" + } + id := signMembershipClaims(t, f, map[string]any{"iss": target.Options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(time.Hour).Unix()}) + child := &Session{Options: target.Options, StackURL: target.URI, IDToken: id, MembershipToken: &oauth2.Token{AccessToken: access, TokenType: "Bearer", RefreshToken: "refresh", Expiry: time.Now().Add(time.Hour)}} + matches, err := verifyTargetSession(t.Context(), provider, target, child, false, false) + if (err == nil && matches) != (kind == "valid") { + t.Fatal("scoped proof did not enforce signature, audience, target, subject, scopes and expiration") + } +} + +func TestOrganizationRefreshOmittedIDAcceptsOnlyFreshAccessProof(t *testing.T) { + f := newMembershipFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + expiredID := signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(-time.Hour).Unix()}) + store.mu.Lock() + store.session.Organizations["org"].IDToken = expiredID + store.session.Organizations["org"].MembershipToken.Expiry = time.Now().Add(-time.Hour) + store.revision++ + store.mu.Unlock() + base := f.client.Transport + f.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + resp, err := base.RoundTrip(r) + if err != nil || r.URL.Path != "/membership/token" || r.Form.Get("grant_type") != "refresh_token" { + return resp, err + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + closeResponse(resp.Body) + return nil, err + } + closeResponse(resp.Body) + delete(body, "id_token") + w := httptest.NewRecorder() + writeJSON(t, w, body) + return w.Result(), nil + }) + if err := membershipRequest(t, client, issuer+"/organizations/org"); err != nil { + t.Fatal(err) + } + if store.snapshot().Organizations["org"].MembershipToken.RefreshToken != "rotated-organization-refresh" || f.refreshes.Load() != 1 { + t.Fatal("omitted ID token lost organization rotation") + } +} diff --git a/internal/cloud/stack_token.go b/internal/cloud/stack_token.go new file mode 100644 index 00000000..7b262f2d --- /dev/null +++ b/internal/cloud/stack_token.go @@ -0,0 +1,36 @@ +package cloud + +import ( + "context" + "errors" + "net/http" +) + +// StackAccessToken returns only a Cloud data-plane token. The source validates +// and refreshes its coordinated session exactly as it does for HTTP requests. +// Direct and Membership clients cannot expose a token through this helper. +func StackAccessToken(ctx context.Context, client *http.Client) (string, error) { + if client == nil { + return "", errors.New("stack token requires an authenticated Cloud stack client") + } + source, ok := client.Transport.(*sessionTransport) + if !ok || source == nil { + return "", errors.New("stack token requires an authenticated Cloud stack client") + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + //nolint:contextcheck // Client lifetime also cancels independent token callers. + stop := context.AfterFunc(source.ctx, cancel) + defer stop() + if err := source.ctx.Err(); err != nil { + return "", err + } + token, err := source.token(ctx) + if err != nil { + return "", err + } + if !usable(token) { + return "", errors.New("cloud stack returned an invalid token") + } + return token.AccessToken, nil +} diff --git a/internal/cloud/stack_token_test.go b/internal/cloud/stack_token_test.go new file mode 100644 index 00000000..965e14b3 --- /dev/null +++ b/internal/cloud/stack_token_test.go @@ -0,0 +1,55 @@ +package cloud + +import ( + "context" + "errors" + "io" + "net/http" + "testing" +) + +func TestStackAccessTokenRejectsOtherClients(t *testing.T) { + for _, client := range []*http.Client{nil, {}, {Transport: &membershipTransport{}}} { + if token, err := StackAccessToken(t.Context(), client); err == nil || token != "" { + t.Fatal("a non-stack client exposed a token") + } + } +} + +func TestStackAccessTokenUsesCoordinatedStackSession(t *testing.T) { + f := newIdentityFixture(t) + root := f.root(t) + store := newCoordinatedStore(root) + client, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + before := store.callbacks + token, err := StackAccessToken(t.Context(), client) + if err != nil || token != "stack-secret" || store.callbacks != before+1 { + t.Fatal("stack token bypassed coordinator or returned Membership credentials") + } + store.mu.Lock() + store.session.Targets["org/stack"].MembershipToken.Expiry = root.MembershipToken.Expiry.Add(-maxTokenLifetime) + store.session.Targets["org/stack"].StackToken = nil + store.revision++ + store.mu.Unlock() + if token, err := StackAccessToken(t.Context(), client); err != nil || token != "stack-secret" { + t.Fatal("token helper failed to refresh the stack session") + } + if f.childRefresh.Load() != 1 || store.snapshot().Targets["org/stack"].MembershipToken.RefreshToken != "rotated-secret" || f.f.writes.Load() != 0 { + t.Fatal("token refresh lost rotation or sent a service request") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if token, err := StackAccessToken(ctx, client); !errors.Is(err, context.Canceled) || token != "" { + t.Fatal("token helper ignored cancellation") + } + store.mu.Lock() + store.session = nil + store.revision++ + store.mu.Unlock() + if token, err := StackAccessToken(t.Context(), client); err == nil || token != "" { + t.Fatal("token helper accepted a logged-out session") + } +} diff --git a/internal/cloud/userinfo.go b/internal/cloud/userinfo.go new file mode 100644 index 00000000..377931ef --- /dev/null +++ b/internal/cloud/userinfo.go @@ -0,0 +1,77 @@ +package cloud + +import ( + "context" + "errors" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Membership refresh grants return no ID token. A historical signed ID token +// binds the login subject; a current signed access JWT and authenticated, +// same-issuer UserInfo response establish current access. Never persist these +// unsigned claims: retrieve them again while the historical ID token is expired. +func (m *targetManager) userInfoAndSave(ctx context.Context, root *Session, save func(*Session) error) (*Session, identityClaims, error) { + previousID, required := root.IDToken, root.RequireUserInfo + root.RequireUserInfo = true + claims, err := m.currentUserInfo(ctx, root) + if !required || previousID != root.IDToken { + if saveErr := m.saveRoot(root, save); saveErr != nil { + return nil, identityClaims{}, saveErr + } + } + return root, claims, err +} + +func (m *targetManager) currentUserInfo(ctx context.Context, root *Session) (identityClaims, error) { + _, historical, err := verifyIdentity(ctx, m.provider, m.options, root.IDToken, true) + if err != nil { + return identityClaims{}, err + } + token := cleanToken(root.MembershipToken) + if err := boundToken(token, time.Now()); err != nil { + root.IDToken = "" + return identityClaims{}, err + } + access, err := m.provider.Verifier(&oidc.Config{ClientID: m.options.ClientID}).Verify(ctx, token.AccessToken) + if err != nil { + if ctx.Err() == nil { + root.IDToken = "" + } + return identityClaims{}, identityUserInfoError(ctx) + } + if access.Subject == "" || access.Subject != historical.Subject { + root.IDToken = "" + return identityClaims{}, errors.New("root access subject differs from verified login identity; log in again") + } + token.Expiry = minExpiry(token.Expiry, access.Expiry) + authCtx := oidc.ClientContext(ctx, authClient(ctx, m.base, m.options.Issuer)) + info, err := m.provider.UserInfo(authCtx, oauth2.StaticTokenSource(token)) + if err != nil { + return identityClaims{}, identityUserInfoError(ctx) + } + if info.Subject != access.Subject { + root.IDToken = "" + return identityClaims{}, errors.New("membership userinfo subject differs from verified access token; log in again") + } + var claims identityClaims + if err := info.Claims(&claims); err != nil { + root.IDToken = "" + return identityClaims{}, errors.New("invalid Membership userinfo claims") + } + if _, err := verifiedIdentityInfo(m.options, claims); err != nil { + root.IDToken = "" + return identityClaims{}, err + } + root.MembershipToken = cleanToken(token) + return claims, nil +} + +func identityUserInfoError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return errors.New("cannot renew cloud identity from Membership userinfo; log in again") +} diff --git a/internal/cloud/userinfo_test.go b/internal/cloud/userinfo_test.go new file mode 100644 index 00000000..c8ff89f1 --- /dev/null +++ b/internal/cloud/userinfo_test.go @@ -0,0 +1,311 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +type userInfoFixture struct { + identity *identityFixture + client *http.Client + fallback http.RoundTripper + access, endpoint string + wrongSubject, wait bool + status int + calls atomic.Int32 +} + +func newUserInfoFixture(t *testing.T) *userInfoFixture { + t.Helper() + f := &userInfoFixture{identity: newIdentityFixture(t)} + f.identity.omitRefreshID = true + f.endpoint = f.identity.f.server.URL + "/membership/userinfo" + f.access = signMembershipClaims(t, f.identity, map[string]any{"iss": f.identity.f.options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(time.Hour).Unix()}) + f.client = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { return f.roundTrip(t, r) })} + return f +} + +func (f *userInfoFixture) roundTrip(t *testing.T, r *http.Request) (*http.Response, error) { + t.Helper() + w := httptest.NewRecorder() + switch r.URL.Path { + case "/membership/.well-known/openid-configuration": + writeJSON(t, w, map[string]any{"issuer": f.identity.f.options.Issuer, "token_endpoint": f.identity.f.server.URL + "/membership/token", "device_authorization_endpoint": f.identity.f.server.URL + "/membership/device", "jwks_uri": f.identity.f.server.URL + "/membership/keys", "userinfo_endpoint": f.endpoint, "id_token_signing_alg_values_supported": []string{"RS256"}}) + return w.Result(), nil + case "/membership/userinfo": + f.calls.Add(1) + if f.status != 0 { + w.WriteHeader(f.status) + writeJSON(t, w, map[string]string{"error": "userinfo-secret"}) + return w.Result(), nil + } + if r.Header.Get("Authorization") != "Bearer "+f.access { + t.Error("userinfo did not use current root credentials") + } + if f.wait { + <-r.Context().Done() + return nil, r.Context().Err() + } + subject := "user" + if f.wrongSubject { + subject = "other" + } + writeJSON(t, w, map[string]any{"sub": subject, "org": f.identity.accesses}) + return w.Result(), nil + case "/membership/token": + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.Form.Get("refresh_token") == "root-refresh" { + return f.rootRefresh(t, r) + } + } + if f.fallback != nil { + return f.fallback.RoundTrip(r) + } + return f.identity.client.Transport.RoundTrip(r) +} + +func (f *userInfoFixture) rootRefresh(t *testing.T, r *http.Request) (*http.Response, error) { + t.Helper() + resp, err := f.identity.client.Transport.RoundTrip(r) + if err != nil || resp.StatusCode != http.StatusOK { + return resp, err + } + var body map[string]any + err = json.NewDecoder(resp.Body).Decode(&body) + closeErr := resp.Body.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + body["access_token"] = f.access + w := httptest.NewRecorder() + writeJSON(t, w, body) + return w.Result(), nil +} + +func TestRootUserInfoRenewsExpiredIdentityWithoutNewLogin(t *testing.T) { + f := newUserInfoFixture(t) + f.identity.idExpiry = time.Now().Add(-time.Hour) + f.identity.accesses[0].Stacks = nil + root := f.identity.root(t) + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + f.identity.idExpiry = time.Time{} + f.identity.accesses[0].Stacks = []stackAccess{{ID: "new", URI: f.identity.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}} + store := newCoordinatedStore(root) + _, _, info, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(info.Stacks["org"], []string{"new"}) || f.identity.rootRefresh.Load() != 1 || f.calls.Load() != 1 { + t.Fatal("expired identity did not use current authenticated userinfo after one refresh") + } + restored := roundTripSession(t, store.snapshot()) + if restored.IDToken != root.IDToken || restored.MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("userinfo replaced signed proof or lost rotation") + } + if _, _, _, err := MembershipClient(t.Context(), f.client, restored, io.Discard, nil, store.coordinate); err != nil { + t.Fatal(err) + } + if f.identity.rootRefresh.Load() != 1 || f.calls.Load() != 2 || len(f.identity.forms) != 1 { + t.Fatal("reloaded client reused unsigned claims or repeated root refresh/device login") + } +} + +func TestNewStackRefreshWithoutIDTokenUsesCurrentUserInfo(t *testing.T) { + f := newUserInfoFixture(t) + f.identity.accesses[0].Stacks = nil + root := f.identity.root(t) + f.identity.accesses[0].Stacks = []stackAccess{{ID: "new", URI: f.identity.f.server.URL + "/stack", Scopes: []string{"stack:Read"}}} + store := newCoordinatedStore(root) + client, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Organization: "org", Stack: "new"}, io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + if token, err := StackAccessToken(t.Context(), client); err != nil || token != "stack-secret" { + t.Fatal("second coordinated prepare could not use cached new-stack authorization") + } + if store.snapshot().Targets["org/new"] == nil || f.identity.rootRefresh.Load() != 1 || f.calls.Load() < 3 || f.identity.f.exchanges.Load() != 1 || len(f.identity.forms) != 3 { + t.Fatal("new stack used stale login claims or required a new root login") + } +} + +func organizationUserInfoFixture(t *testing.T) (*userInfoFixture, *membershipFixture) { + t.Helper() + f := newUserInfoFixture(t) + membership := &membershipFixture{identity: f.identity, grants: make(map[string][]string), used: make(map[string]bool)} + f.fallback = roundTripFunc(func(r *http.Request) (*http.Response, error) { return membership.roundTrip(t, r) }) + return f, membership +} + +func TestNewOrganizationUsesFreshUserInfoAcrossRequests(t *testing.T) { + t.Run("valid root", func(t *testing.T) { checkNewOrganizationRequests(t, false) }) + t.Run("root already refreshed", func(t *testing.T) { checkNewOrganizationRequests(t, true) }) +} + +func checkNewOrganizationRequests(t *testing.T, expiredRoot bool) { + t.Helper() + f, membership := organizationUserInfoFixture(t) + root := f.identity.root(t) + if expiredRoot { + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + } + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + f.identity.accesses = append(f.identity.accesses, organizationAccess{ID: "new", Scopes: []string{"organization:Read"}}) + for range 2 { + if err := membershipRequest(t, client, issuer+"/organizations/new?expand=true"); err != nil { + t.Fatal(err) + } + } + saved := roundTripSession(t, store.snapshot()) + if saved.Organizations["new"] == nil || saved.IDToken != root.IDToken || saved.MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("new organization authorization lost historical proof, scoped cache or root rotation") + } + if f.identity.rootRefresh.Load() != 1 || f.calls.Load() != 2 || membership.devices.Load() != 1 || membership.orgCalls.Load() != 2 || f.identity.f.exchanges.Load() != 0 { + t.Fatal("new organization repeated root rotation, consent or used stale claims") + } + assertNewOrganizationForms(t, membership.forms) +} + +func assertNewOrganizationForms(t *testing.T, forms []url.Values) { + t.Helper() + if len(forms) != 2 || forms[0].Get("organization_id") != "new" || forms[0].Get("scope") != "openid offline_access organization:Read" || forms[0].Has("resource") || forms[1].Has("scope") { + t.Fatal("new organization did not use an organization-scoped device grant") + } +} + +func TestMissingOrganizationRefreshIsBoundedAndFailClosed(t *testing.T) { + f, membership := organizationUserInfoFixture(t) + root := f.identity.root(t) + store := newCoordinatedStore(root) + client, issuer, _, err := MembershipClient(t.Context(), f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err != nil { + t.Fatal(err) + } + for range 2 { + if err := membershipRequest(t, client, issuer+"/organizations/missing"); err == nil { + t.Fatal("unavailable organization was authorized") + } + } + if f.identity.rootRefresh.Load() != 1 || f.calls.Load() != 2 || membership.devices.Load() != 0 || membership.orgCalls.Load() != 0 || len(store.snapshot().Organizations) != 0 { + t.Fatal("missing organization repeated rotation or reached authorization/service") + } +} + +func TestRootUserInfoRejectsUnverifiedOrMismatchedIdentity(t *testing.T) { + for _, kind := range []string{"signature", "audience", "access subject", "userinfo subject", "expired access", "cross origin", "cancellation"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); checkRootUserInfoFailure(t, kind) }) + } +} + +func TestForcedRootRefreshFailureRemainsBlockedAfterReload(t *testing.T) { + for _, kind := range []string{"signature", "userinfo subject"} { + t.Run(kind, func(t *testing.T) { checkForcedRootRefreshFailure(t, kind) }) + } +} + +func checkForcedRootRefreshFailure(t *testing.T, kind string) { + t.Helper() + f := newUserInfoFixture(t) + root := f.identity.root(t) + if kind == "signature" { + f.access += "invalid" + } else { + f.wrongSubject = true + } + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Stack: "new"}, io.Discard, nil, store.coordinate) + if err == nil { + t.Fatal("rejected fresh root proof was accepted") + } + restored := roundTripSession(t, store.snapshot()) + if restored.IDToken != "" || restored.MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("failed proof retained usable historical claims or lost rotation") + } + _, _, _, err = MembershipClient(t.Context(), f.client, restored, io.Discard, nil, store.coordinate) + if err == nil || f.identity.rootRefresh.Load() != 1 || len(store.snapshot().Targets) != 0 { + t.Fatal("reloaded client accepted rejected proof or consumed another refresh") + } +} + +func TestForcedRootRefreshTransientFailureRequiresUserInfoAfterReload(t *testing.T) { + f := newUserInfoFixture(t) + root := f.identity.root(t) + f.status = http.StatusServiceUnavailable + store := newCoordinatedStore(root) + _, _, err := ClientForTarget(t.Context(), f.client, copyRoot(root), Options{Stack: "new"}, io.Discard, nil, store.coordinate) + if err == nil || strings.Contains(err.Error(), "userinfo-secret") { + t.Fatal("transient userinfo failure was accepted or disclosed its body") + } + restored := roundTripSession(t, store.snapshot()) + if !restored.RequireUserInfo || restored.IDToken != root.IDToken || restored.MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("transient failure lost durable verification requirement or credentials") + } + if _, _, _, err := MembershipClient(t.Context(), f.client, restored, io.Discard, nil, store.coordinate); err == nil { + t.Fatal("reload bypassed current userinfo after transient failure") + } + f.status = 0 + if _, _, _, err := MembershipClient(t.Context(), f.client, restored, io.Discard, nil, store.coordinate); err != nil { + t.Fatal(err) + } + if f.identity.rootRefresh.Load() != 1 || f.calls.Load() != 3 { + t.Fatal("userinfo recovery rotated root again or used stale claims") + } +} + +func checkRootUserInfoFailure(t *testing.T, kind string) { + t.Helper() + f := newUserInfoFixture(t) + f.identity.idExpiry = time.Now().Add(-time.Hour) + root := f.identity.root(t) + root.MembershipToken.Expiry = time.Now().Add(-time.Hour) + switch kind { + case "signature": + f.access += "invalid" + case "audience": + f.access = signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "other", "sub": "user", "exp": time.Now().Add(time.Hour).Unix()}) + case "access subject": + f.access = signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "fctl", "sub": "other", "exp": time.Now().Add(time.Hour).Unix()}) + case "userinfo subject": + f.wrongSubject = true + case "expired access": + f.access = signMembershipClaims(t, f.identity, map[string]any{"iss": root.Options.Issuer, "aud": "fctl", "sub": "user", "exp": time.Now().Add(-time.Hour).Unix()}) + case "cross origin": + f.endpoint = "https://other.invalid/userinfo" + case "cancellation": + f.wait = true + } + ctx, cancel := context.WithTimeout(t.Context(), 150*time.Millisecond) + defer cancel() + store := newCoordinatedStore(root) + _, _, _, err := MembershipClient(ctx, f.client, copyRoot(root), io.Discard, nil, store.coordinate) + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unverified userinfo accepted or leaked credentials: %v", err) + } + if kind == "cancellation" && !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("userinfo ignored cancellation") + } + if kind != "cross origin" && store.snapshot().MembershipToken.RefreshToken != "rotated-root-refresh" { + t.Fatal("userinfo failure lost consumed refresh rotation") + } + if len(store.snapshot().Targets) != 0 { + t.Fatal("userinfo failure persisted a target") + } +} diff --git a/internal/command/output.go b/internal/command/output.go new file mode 100644 index 00000000..61048341 --- /dev/null +++ b/internal/command/output.go @@ -0,0 +1,104 @@ +package command + +import ( + "encoding/json" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/presentation" +) + +type outputWriter struct { + io.Writer + options presentation.Options +} + +func (w *outputWriter) RenderJSON(value json.RawMessage) error { + return presentation.Render(w.Writer, value, w.options) +} + +// RawOutput exposes the underlying destination for byte streams. It preserves +// io.Closer and bypasses presentation while ordinary results keep their renderer. +func RawOutput(cmd *cobra.Command) io.Writer { + out := cmd.OutOrStdout() + if wrapped, ok := out.(*outputWriter); ok { + return wrapped.Writer + } + return out +} + +// ConfigureOutput is called after flags are parsed, before any command work. +func ConfigureOutput(cmd *cobra.Command, format, color string) error { + if err := ValidateOutputOptions(format, color); err != nil { + return err + } + out := cmd.OutOrStdout() + if wrapped, ok := out.(*outputWriter); ok { + out = wrapped.Writer + } + terminal, width := terminalInfo(out) + if format == "auto" { + format = "json" + if terminal { + format = "table" + } + } + useColor := color == "always" || (color == "auto" && terminal && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb") + cmd.SetOut(&outputWriter{Writer: out, options: presentation.Options{Format: format, Color: useColor, Width: width}}) + return nil +} + +// InstallHelp preserves Cobra's full help while emphasizing its section titles. +func InstallHelp(root *cobra.Command, color *string) { + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + out := cmd.OutOrStdout() + terminal, _ := terminalInfo(out) + colored := *color == "always" || (*color == "auto" && terminal && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb") + if !colored { + defaultHelp(cmd, args) + return + } + var help strings.Builder + cmd.SetOut(&help) + defaultHelp(cmd, args) + cmd.SetOut(out) + labels := []string{"Usage:", "Examples:", "Available Commands:", "Additional Commands:", "Flags:", "Global Flags:"} + for _, group := range cmd.Groups() { + labels = append(labels, group.Title) + } + lines := strings.Split(help.String(), "\n") + for i, line := range lines { + if line != "" && slices.Contains(labels, line) { + lines[i] = "\x1b[1;36m" + line + "\x1b[0m" + } + } + if _, err := io.WriteString(out, strings.Join(lines, "\n")); err != nil { + return + } + }) +} + +func terminalInfo(out io.Writer) (bool, int) { + file, ok := out.(*os.File) + if !ok { + return false, 80 + } + return consoleInfo(file.Fd()) +} + +// ValidateOutputOptions runs before preparation can perform any network or file work. +func ValidateOutputOptions(format, color string) error { + if err := presentation.ValidateFormat(format); err != nil { + return err + } + if color != "auto" && color != "always" && color != "never" { + return fmt.Errorf("color must be auto, always or never") + } + return nil +} diff --git a/internal/command/output_test.go b/internal/command/output_test.go new file mode 100644 index 00000000..2962abb1 --- /dev/null +++ b/internal/command/output_test.go @@ -0,0 +1,144 @@ +package command_test + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + "testing" + "unicode/utf8" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/command" +) + +const outputEnvelope = `{"data":[{"name":"books","amount":90071992547409930001}],"next":"cursor/+=","hasMore":true}` + +func configuredOutput(t *testing.T, format, color string) (*cobra.Command, *bytes.Buffer) { + t.Helper() + cmd := &cobra.Command{Use: "fixture"} + cmd.Flags().String("output", format, "Output format") + cmd.Flags().String("color", color, "Color policy") + var out bytes.Buffer + cmd.SetOut(&out) + if err := command.ConfigureOutput(cmd, format, color); err != nil { + t.Fatal(err) + } + return cmd, &out +} + +func TestConfigureOutputMachineJSONNeverHasANSI(t *testing.T) { + t.Parallel() + var want bytes.Buffer + if err := json.Indent(&want, []byte(outputEnvelope), "", " "); err != nil { + t.Fatal(err) + } + for _, format := range []string{"auto", "json"} { + for _, color := range []string{"auto", "always", "never"} { + t.Run(format+"/"+color, func(t *testing.T) { + cmd, out := configuredOutput(t, format, color) + if err := command.WriteJSON(cmd.OutOrStdout(), json.RawMessage(outputEnvelope)); err != nil { + t.Fatal(err) + } + if out.String() != want.String()+"\n" || strings.Contains(out.String(), "\x1b") { + t.Fatalf("machine JSON changed: %q", out.String()) + } + }) + } + } +} + +func TestConfigureOutputTableUsesBufferWidthAndExplicitColor(t *testing.T) { + t.Parallel() + for _, color := range []string{"auto", "never", "always"} { + t.Run(color, func(t *testing.T) { + cmd, out := configuredOutput(t, "table", color) + data := json.RawMessage(`{"data":[{"name":"` + strings.Repeat("long", 35) + `","amount":90071992547409930001}]}`) + if err := command.WriteJSON(cmd.OutOrStdout(), data); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "\x1b[") != (color == "always") { + t.Fatal("buffer table color ignored explicit policy") + } + plain := stripOutputANSI(out.String()) + if !strings.Contains(plain, "90071992547409930001") { + t.Fatal("table changed a large integer") + } + assertOutputWidth(t, plain, 80) + }) + } +} + +func assertOutputWidth(t *testing.T, out string, width int) { + t.Helper() + for line := range strings.SplitSeq(out, "\n") { + if utf8.RuneCountInString(line) > width { + t.Errorf("buffer output exceeded %d columns: %q", width, line) + } + } +} + +func TestConfigureOutputCanBeReconfiguredWithoutNestedWriters(t *testing.T) { + t.Parallel() + cmd, out := configuredOutput(t, "table", "always") + if err := command.ConfigureOutput(cmd, "json", "never"); err != nil { + t.Fatal(err) + } + if err := command.WriteJSON(cmd.OutOrStdout(), json.RawMessage(outputEnvelope)); err != nil { + t.Fatal(err) + } + if !json.Valid(out.Bytes()) || strings.Contains(out.String(), "\x1b") || !strings.Contains(out.String(), "90071992547409930001") { + t.Fatal("reconfiguration retained colored table output") + } +} + +func TestConfigureOutputRejectsInvalidOptionsWithoutChangingWriter(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ format, color string }{{"yaml", "auto"}, {"", "auto"}, {"json", "invalid"}, {"table", ""}} { + t.Run(tc.format+"/"+tc.color, func(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + if err := command.ConfigureOutput(cmd, tc.format, tc.color); err == nil { + t.Fatal("invalid output configuration accepted") + } + if cmd.OutOrStdout() != &out || out.Len() != 0 { + t.Fatal("invalid configuration wrote output or replaced the writer") + } + }) + } +} + +func TestOutputNoColorAutoAndExplicitHelpStyles(t *testing.T) { + t.Setenv("NO_COLOR", "1") + for _, color := range []string{"auto", "never", "always"} { + t.Run(color, func(t *testing.T) { + cmd, table := configuredOutput(t, "table", color) + if err := command.WriteJSON(cmd.OutOrStdout(), json.RawMessage(outputEnvelope)); err != nil { + t.Fatal(err) + } + root := &cobra.Command{Use: "fixture"} + root.AddGroup(&cobra.Group{ID: "cloud", Title: "Cloud:"}) + root.AddCommand(&cobra.Command{Use: "cloud", Short: "Cloud commands", GroupID: "cloud"}) + root.PersistentFlags().StringVar(&color, "color", color, "Color policy") + command.InstallHelp(root, &color) + var help bytes.Buffer + root.SetOut(&help) + root.SetArgs([]string{"--help"}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if strings.Contains(table.String(), "\x1b[") != (color == "always") || strings.Contains(help.String(), "\x1b[") != (color == "always") { + t.Fatal("NO_COLOR/explicit color policy changed table or help behavior") + } + if !strings.Contains(stripOutputANSI(help.String()), "Cloud:") || !strings.Contains(stripOutputANSI(help.String()), "Usage:") { + t.Fatal("help styling removed command groups or usage") + } + }) + } +} + +func stripOutputANSI(out string) string { + return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(out, "") +} diff --git a/internal/command/runtime.go b/internal/command/runtime.go new file mode 100644 index 00000000..f7fd40fa --- /dev/null +++ b/internal/command/runtime.go @@ -0,0 +1,79 @@ +// Package command contains the small boundary shared by embedded modules. +package command + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +func WriteJSON(writer io.Writer, value json.RawMessage) error { + if renderer, ok := writer.(interface{ RenderJSON(json.RawMessage) error }); ok { + return renderer.RenderJSON(value) + } + var out bytes.Buffer + if err := json.Indent(&out, value, "", " "); err != nil { + return err + } + if err := out.WriteByte('\n'); err != nil { + return err + } + _, err := writer.Write(out.Bytes()) + return err +} + +// ReadBody accepts inline JSON, @file, or - for stdin, with a bounded size. +func ReadBody(cmd *cobra.Command, value string) (result json.RawMessage, err error) { + var reader io.Reader + switch { + case value == "-": + reader = cmd.InOrStdin() + case len(value) > 0 && value[0] == '@': + file, openErr := os.Open(value[1:]) + if openErr != nil { + return nil, fmt.Errorf("open request body: %w", openErr) + } + defer func() { err = errors.Join(err, file.Close()) }() + reader = file + default: + reader = bytes.NewBufferString(value) + } + data, err := readCancelable(cmd.Context(), reader) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + if len(data) > 4<<20 { + return nil, fmt.Errorf("request body exceeds 4 MiB") + } + if !json.Valid(data) { + return nil, fmt.Errorf("request body must be one valid JSON document") + } + return data, nil +} + +func readCancelable(ctx context.Context, reader io.Reader) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + type result struct { + data []byte + err error + } + finished := make(chan result, 1) + go func() { + data, err := io.ReadAll(io.LimitReader(reader, 4<<20+1)) + finished <- result{data, err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case read := <-finished: + return read.data, read.err + } +} diff --git a/internal/command/runtime_test.go b/internal/command/runtime_test.go new file mode 100644 index 00000000..ad19098e --- /dev/null +++ b/internal/command/runtime_test.go @@ -0,0 +1,96 @@ +package command_test + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/command" +) + +func TestReadBody(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + cmd.SetIn(strings.NewReader(`{"amount":9007199254740993}`)) + body, err := command.ReadBody(cmd, "-") + if err != nil { + t.Fatal(err) + } + if string(body) != `{"amount":9007199254740993}` { + t.Fatalf("body=%s", body) + } + for _, bad := range []string{"", `{} {}`, `{"invalid":`, strings.Repeat(" ", 4<<20+1)} { + if _, err := command.ReadBody(cmd, bad); err == nil { + t.Error("invalid/oversized input accepted") + } + } +} + +func TestWriteJSONPreservesNumbers(t *testing.T) { + t.Parallel() + var out bytes.Buffer + if err := command.WriteJSON(&out, []byte(`{"amount":9007199254740993}`)); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "9007199254740993") { + t.Fatal(out.String()) + } + if !strings.HasSuffix(out.String(), "\n") { + t.Fatal("missing newline") + } + want := errors.New("writer failed") + if err := command.WriteJSON(failingWriter{want}, []byte(`{}`)); !errors.Is(err, want) { + t.Fatalf("error=%v", err) + } +} + +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestStdinReadCancels(t *testing.T) { + t.Parallel() + reader, writer := io.Pipe() + t.Cleanup(func() { + if err := writer.Close(); err != nil { + t.Error(err) + } + }) + t.Cleanup(func() { + if err := reader.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan struct{}) + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.SetIn(startReader{reader, started}) + finished := make(chan error, 1) + go func() { _, err := command.ReadBody(cmd, "-"); finished <- err }() + <-started + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("read error=%v", err) + } + case <-time.After(time.Second): + t.Fatal("stdin read did not cancel") + } +} + +type startReader struct { + reader io.Reader + started chan struct{} +} + +func (r startReader) Read(buf []byte) (int, error) { close(r.started); return r.reader.Read(buf) } diff --git a/internal/command/terminal_darwin.go b/internal/command/terminal_darwin.go new file mode 100644 index 00000000..be9a39c8 --- /dev/null +++ b/internal/command/terminal_darwin.go @@ -0,0 +1,20 @@ +package command + +import ( + "math" + + "golang.org/x/sys/unix" +) + +func consoleInfo(fd uintptr) (bool, int) { + if fd > math.MaxInt { + return false, 80 + } + if _, err := unix.IoctlGetTermios(int(fd), unix.TIOCGETA); err != nil { + return false, 80 + } + if size, err := unix.IoctlGetWinsize(int(fd), unix.TIOCGWINSZ); err == nil && size.Col > 0 { + return true, int(size.Col) + } + return true, 80 +} diff --git a/internal/command/terminal_linux.go b/internal/command/terminal_linux.go new file mode 100644 index 00000000..9d84d3b9 --- /dev/null +++ b/internal/command/terminal_linux.go @@ -0,0 +1,20 @@ +package command + +import ( + "math" + + "golang.org/x/sys/unix" +) + +func consoleInfo(fd uintptr) (bool, int) { + if fd > math.MaxInt { + return false, 80 + } + if _, err := unix.IoctlGetTermios(int(fd), unix.TCGETS); err != nil { + return false, 80 + } + if size, err := unix.IoctlGetWinsize(int(fd), unix.TIOCGWINSZ); err == nil && size.Col > 0 { + return true, int(size.Col) + } + return true, 80 +} diff --git a/internal/command/terminal_other.go b/internal/command/terminal_other.go new file mode 100644 index 00000000..96c517b4 --- /dev/null +++ b/internal/command/terminal_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !linux && !windows + +package command + +func consoleInfo(_ uintptr) (bool, int) { return false, 80 } diff --git a/internal/command/terminal_windows.go b/internal/command/terminal_windows.go new file mode 100644 index 00000000..d63179d8 --- /dev/null +++ b/internal/command/terminal_windows.go @@ -0,0 +1,18 @@ +package command + +import "golang.org/x/sys/windows" + +func consoleInfo(fd uintptr) (bool, int) { + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &mode); err != nil { + return false, 80 + } + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info); err == nil { + width := int(info.Window.Right-info.Window.Left) + 1 + if width > 0 { + return true, width + } + } + return true, 80 +} diff --git a/internal/connection/cloud.go b/internal/connection/cloud.go new file mode 100644 index 00000000..e46c71ce --- /dev/null +++ b/internal/connection/cloud.go @@ -0,0 +1,125 @@ +package connection + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/presentation" +) + +func cloudCoordinator(dir, name string, entry Entry) cloud.Coordinator { + return func(ctx context.Context, work func(*cloud.Session, func(*cloud.Session) error) (*oauth2.Token, error)) (*oauth2.Token, error) { + var token *oauth2.Token + err := withLock(ctx, dir, "auth-"+name+".lock", func() error { + store, err := Load(dir) + if err != nil { + return err + } + current, exists := store.Connections[name] + if !exists || current.Session == nil || current.Options != entry.Options { + return fmt.Errorf("cloud profile changed or logged out during authentication") + } + expected := current.Revision + token, err = work(current.Session, func(session *cloud.Session) error { + saveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + return SaveSession(saveCtx, dir, name, &expected, session) + }) + return err + }) + return token, err + } +} + +func (s *Settings) membershipClient(ctx context.Context, cmd *cobra.Command, client *http.Client, options Options, entry Entry, name, dir string) (*api.Client, error) { + if options.AuthMode != "cloud" { + return nil, fmt.Errorf("cloud commands require a Cloud profile; run fctl login") + } + if cloudIdentity(options) != cloudIdentity(entry.Options) { + return nil, fmt.Errorf("cloud identity settings changed; log in again with the chosen issuer and client") + } + if entry.Session == nil { + return nil, fmt.Errorf("profile is not logged in; run fctl login") + } + if entry.Session.Options.Stack != "" { + return nil, fmt.Errorf("this older session targets a single stack; run fctl login to use Cloud management") + } + authenticated, issuer, identity, err := cloud.MembershipClient(ctx, client, entry.Session, cmd.ErrOrStderr(), s.BrowserOpener(), cloudCoordinator(dir, name, entry)) + if err != nil { + return nil, err + } + resolved, err := api.New(issuer, authenticated) + if err != nil { + return nil, err + } + organization := options.Organization + if organization == "" && len(identity.Organizations) == 1 { + organization = identity.Organizations[0] + } + stack := options.Stack + if stack == "" && len(identity.Stacks[organization]) == 1 { + stack = identity.Stacks[organization][0] + } + organizations, err := json.Marshal(identity.Organizations) + if err != nil { + return nil, err + } + stacks, err := json.Marshal(identity.Stacks) + if err != nil { + return nil, err + } + return resolved.WithContext(map[string]string{"organization": organization, "stack": stack, "organizations": string(organizations), "stacks": string(stacks)}), nil +} + +// ApplicationClient resolves an application grant independently of stack Auth. +func (s *Settings) ApplicationClient(ctx context.Context, cmd *cobra.Command, alias string) (*api.Client, error) { + if err := presentation.ValidateFormat(s.Output); err != nil { + return nil, err + } + if s.Timeout <= 0 { + return nil, fmt.Errorf("timeout must be positive") + } + options, entry, name, dir, err := s.Resolve(cmd) + if err != nil { + return nil, err + } + if err := Validate(options); err != nil { + return nil, err + } + if options.AuthMode != "cloud" || entry.Session == nil { + return nil, fmt.Errorf("cloud apps require a logged-in Cloud profile; run fctl login") + } + if cloudIdentity(options) != cloudIdentity(entry.Options) || entry.Session.Options.Stack != "" { + return nil, fmt.Errorf("cloud identity settings changed or use an older session; run fctl login") + } + base := s.HTTPClient(cmd.ErrOrStderr()) + coordinator := cloudCoordinator(dir, name, entry) + organizationClient, err := s.cloudOrganizationClient(ctx, cmd, base, options, entry, name, dir) + if err != nil { + return nil, err + } + org := organizationClient.Context()["organization"] + if org == "" { + return nil, fmt.Errorf("select an organization with --organization; list choices with fctl cloud organizations list") + } + if alias == "" { + alias = "deploy" + } + client, endpoint, err := cloud.ApplicationClient(ctx, base, entry.Session, org, alias, cmd.ErrOrStderr(), s.BrowserOpener(), coordinator) + if err != nil { + return nil, err + } + resolved, err := api.New(endpoint, client) + if err != nil { + return nil, err + } + return resolved.WithContext(map[string]string{"organization": org, "application": alias}), nil +} diff --git a/internal/connection/cloud_selection.go b/internal/connection/cloud_selection.go new file mode 100644 index 00000000..94deaa36 --- /dev/null +++ b/internal/connection/cloud_selection.go @@ -0,0 +1,235 @@ +package connection + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/interactive" +) + +// cloudCommandTarget follows the manifest's inherited target metadata. Cloud +// identity commands do not need a selected organization or stack. +func cloudCommandTarget(cmd *cobra.Command) string { + for current := cmd; current != nil; current = current.Parent() { + if target := current.Annotations["fctl.target"]; target != "" { + return target + } + } + return "" +} + +// rootMembershipClient uses the existing coordinated authentication path. The +// context contains only IDs obtained from verified current identity claims. +func (s *Settings) rootMembershipClient(ctx context.Context, cmd *cobra.Command, base *http.Client, options Options, entry Entry, name, dir string) (*api.Client, cloud.IdentityInfo, error) { + client, err := s.membershipClient(ctx, cmd, base, options, entry, name, dir) + if err != nil { + return nil, cloud.IdentityInfo{}, err + } + metadata := client.Context() + var identity cloud.IdentityInfo + if err := json.Unmarshal([]byte(metadata["organizations"]), &identity.Organizations); err != nil { + return nil, cloud.IdentityInfo{}, fmt.Errorf("read verified Cloud organizations: %w", err) + } + if err := json.Unmarshal([]byte(metadata["stacks"]), &identity.Stacks); err != nil { + return nil, cloud.IdentityInfo{}, fmt.Errorf("read verified Cloud stacks: %w", err) + } + return client, identity, nil +} + +// cloudOrganizationClient also serves application grants, which require an +// organization but do not require a stack selection. +func (s *Settings) cloudOrganizationClient(ctx context.Context, cmd *cobra.Command, base *http.Client, options Options, entry Entry, name, dir string) (*api.Client, error) { + client, identity, err := s.rootMembershipClient(ctx, cmd, base, options, entry, name, dir) + if err != nil { + return nil, err + } + metadata := client.Context() + if metadata["organization"] != "" || !interactive.Enabled(cmd) { + return client, nil + } + organization, err := chooseCloudOrganization(ctx, cmd, client, identity.Organizations) + if err != nil { + return nil, err + } + if organization == "" { + return client, nil // The command retains its existing missing-target error. + } + if err := setCloudTargetFlag(cmd, "organization", organization); err != nil { + return nil, err + } + metadata["organization"] = organization + return client.WithContext(metadata), nil +} + +func (s *Settings) selectCloudTarget(ctx context.Context, cmd *cobra.Command, base *http.Client, options Options, entry Entry, name, dir string) (Options, error) { + if !interactive.Enabled(cmd) || (options.Organization != "" && options.Stack != "") || entry.Session == nil || entry.Session.Options.Stack != "" { + return options, nil + } + client, identity, err := s.rootMembershipClient(ctx, cmd, base, options, entry, name, dir) + if err != nil { + return options, err + } + organization := options.Organization + if organization == "" { + organizations := organizationsForStack(identity, options.Stack) + organization, err = chooseCloudOrganization(ctx, cmd, client, organizations) + if err != nil || organization == "" { + return options, err + } + } + stack := options.Stack + if stack == "" { + stack, err = chooseCloudStack(ctx, cmd, client, organization, identity.Stacks[organization]) + if err != nil { + return options, err + } + } + return applyCloudTarget(cmd, options, organization, stack) +} + +func applyCloudTarget(cmd *cobra.Command, options Options, organization, stack string) (Options, error) { + if options.Organization == "" { + if err := setCloudTargetFlag(cmd, "organization", organization); err != nil { + return options, err + } + options.Organization = organization + } + if options.Stack == "" { + if err := setCloudTargetFlag(cmd, "stack", stack); err != nil { + return options, err + } + options.Stack = stack + } + return options, nil +} + +func chooseCloudStack(ctx context.Context, cmd *cobra.Command, client *api.Client, organization string, allowed []string) (string, error) { + resources, err := readCloudChoices(ctx, client, api.Path("organizations", organization, "stacks")) + if err != nil { + return "", err + } + choices := readyStackChoices(organization, allowed, resources) + if len(choices) == 0 { + return "", fmt.Errorf("no ready Cloud stacks are available; inspect fctl cloud stack list or set --stack STACK_ID") + } + return chooseCloudOption(ctx, cmd, "Choose a Cloud stack", choices) +} + +func organizationsForStack(identity cloud.IdentityInfo, stack string) []string { + var organizations []string + for _, organization := range identity.Organizations { + stacks := identity.Stacks[organization] + if (stack == "" && len(stacks) > 0) || slices.Contains(stacks, stack) { + organizations = append(organizations, organization) + } + } + return organizations +} + +func chooseCloudOrganization(ctx context.Context, cmd *cobra.Command, client *api.Client, organizations []string) (string, error) { + if len(organizations) < 2 { + return chooseCloudOption(ctx, cmd, "Choose a Cloud organization", organizationChoices(organizations, nil)) + } + resources, err := readCloudChoices(ctx, client, "/organizations") + if err != nil { + return "", err + } + return chooseCloudOption(ctx, cmd, "Choose a Cloud organization", organizationChoices(organizations, resources)) +} + +type cloudChoiceResource struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + State string `json:"state"` + OrganizationID string `json:"organizationId"` +} + +func readCloudChoices(ctx context.Context, client *api.Client, path string) ([]cloudChoiceResource, error) { + data, err := client.Do(ctx, http.MethodGet, path, nil, nil, nil) + if err != nil { + return nil, err + } + var response struct { + Data []cloudChoiceResource `json:"data"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("read Cloud choices: %w", err) + } + return response.Data, nil +} + +func organizationChoices(organizations []string, resources []cloudChoiceResource) []interactive.Option { + names := make(map[string]string, len(resources)) + for _, resource := range resources { + names[resource.ID] = resource.Name + } + choices := make([]interactive.Option, 0, len(organizations)) + for _, id := range organizations { + label := id + if name := names[id]; name != "" { + label = fmt.Sprintf("%s (%s)", name, id) + } + choices = append(choices, interactive.Option{Label: label, Value: id}) + } + sortCloudChoices(choices) + return choices +} + +func readyStackChoices(organization string, allowed []string, resources []cloudChoiceResource) []interactive.Option { + var choices []interactive.Option + seen := make(map[string]bool) + for _, resource := range resources { + if resource.Status != "READY" || (resource.State != "" && resource.State != "ACTIVE") || + (resource.OrganizationID != "" && resource.OrganizationID != organization) || !slices.Contains(allowed, resource.ID) || seen[resource.ID] { + continue + } + seen[resource.ID] = true + choices = append(choices, interactive.Option{ + Label: fmt.Sprintf("%s (%s) · %s", cmp.Or(resource.Name, resource.ID), resource.ID, resource.Status), + Value: resource.ID, + }) + } + sortCloudChoices(choices) + return choices +} + +func sortCloudChoices(choices []interactive.Option) { + slices.SortFunc(choices, func(a, b interactive.Option) int { + return cmp.Or(cmp.Compare(a.Label, b.Label), cmp.Compare(a.Value, b.Value)) + }) +} + +func chooseCloudOption(ctx context.Context, cmd *cobra.Command, title string, choices []interactive.Option) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + switch len(choices) { + case 0: + return "", nil + case 1: + return choices[0].Value, nil + default: + return interactive.Select(ctx, cmd, title, choices) + } +} + +func setCloudTargetFlag(cmd *cobra.Command, name, value string) error { + for current := cmd; current != nil; current = current.Parent() { + if current.Flags().Lookup(name) != nil { + return current.Flags().Set(name, value) + } + if current.PersistentFlags().Lookup(name) != nil { + return current.PersistentFlags().Set(name, value) + } + } + return fmt.Errorf("cloud target flag --%s is unavailable", name) +} diff --git a/internal/connection/cloud_selection_test.go b/internal/connection/cloud_selection_test.go new file mode 100644 index 00000000..9d15e9a8 --- /dev/null +++ b/internal/connection/cloud_selection_test.go @@ -0,0 +1,682 @@ +package connection + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/interactive" +) + +func TestOrganizationChoicesUseVerifiedIDsAndNames(t *testing.T) { + t.Parallel() + choices := organizationChoices([]string{"org-b", "org-a", "org-c"}, []cloudChoiceResource{ + {ID: "org-a", Name: "Books"}, {ID: "org-b", Name: "Accounts"}, {ID: "ungranted", Name: "Other"}, + }) + want := []interactive.Option{ + {Label: "Accounts (org-b)", Value: "org-b"}, {Label: "Books (org-a)", Value: "org-a"}, {Label: "org-c", Value: "org-c"}, + } + if !reflect.DeepEqual(choices, want) { + t.Fatalf("choices = %+v, want %+v", choices, want) + } +} + +func TestReadyStackChoicesFilterAvailabilityAndAuthorization(t *testing.T) { + t.Parallel() + allowed := []string{"a", "b", "disabled", "deleted", "progressing", "wrong-org", "unknown-state"} + resources := []cloudChoiceResource{ + {ID: "b", Name: "Books", Status: "READY", State: "ACTIVE", OrganizationID: "org"}, + {ID: "a", Name: "Books", Status: "READY"}, + {ID: "disabled", Status: "READY", State: "DISABLED"}, + {ID: "deleted", Status: "READY", State: "DELETED"}, + {ID: "progressing", Status: "PROGRESSING", State: "ACTIVE"}, + {ID: "wrong-org", Status: "READY", OrganizationID: "other"}, + {ID: "unknown-state", Status: "READY", State: "RESTORING"}, + {ID: "ungranted", Name: "Unverified", Status: "READY", State: "ACTIVE"}, + {ID: "a", Name: "Duplicate", Status: "READY"}, + } + want := []interactive.Option{ + {Label: "Books (a) · READY", Value: "a"}, {Label: "Books (b) · READY", Value: "b"}, + } + if choices := readyStackChoices("org", allowed, resources); !reflect.DeepEqual(choices, want) { + t.Fatalf("choices = %+v, want %+v", choices, want) + } +} + +func TestOrganizationsForStackPreservesExplicitStack(t *testing.T) { + t.Parallel() + identity := cloud.IdentityInfo{ + Organizations: []string{"empty", "first", "second"}, + Stacks: map[string][]string{"empty": {}, "first": {"one", "shared"}, "second": {"two", "shared"}}, + } + for _, tc := range []struct { + stack string + want []string + }{ + {"", []string{"first", "second"}}, {"one", []string{"first"}}, + {"shared", []string{"first", "second"}}, {"unknown", nil}, + } { + t.Run(tc.stack, func(t *testing.T) { + if got := organizationsForStack(identity, tc.stack); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("organizations = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCloudCommandTargetUsesInheritedAnnotation(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "arbitrary", Annotations: map[string]string{"fctl.target": "organization"}} + child := &cobra.Command{Use: "renamed"} + leaf := &cobra.Command{Use: "read", Annotations: map[string]string{"fctl.target": "identity"}} + root.AddCommand(child) + child.AddCommand(leaf) + if cloudCommandTarget(child) != "organization" || cloudCommandTarget(leaf) != "identity" { + t.Fatal("target metadata was not inherited or overridden") + } + if cloudCommandTarget(&cobra.Command{Use: "cloud stack list"}) != "" { + t.Fatal("command name inferred a target without metadata") + } +} + +func selectionCommand(t *testing.T, settings *Settings) (*cobra.Command, *cobra.Command) { + t.Helper() + root := &cobra.Command{Use: "test", SilenceErrors: true, SilenceUsage: true} + settings.Bind(root) + root.PersistentFlags().Bool("no-input", false, "Disable prompts") + root.SetIn(strings.NewReader("")) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetContext(t.Context()) + child := &cobra.Command{Use: "command"} + child.SetContext(t.Context()) + root.AddCommand(child) + if err := child.ParseFlags(nil); err != nil { + t.Fatal(err) + } + return root, child +} + +func TestApplyCloudTargetSetsInheritedFlagsAndPreservesStore(t *testing.T) { + t.Parallel() + dir := t.TempDir() + entry := NewEntry(Options{AuthMode: "cloud"}) + if err := Save(dir, Store{Active: "work", Connections: map[string]Entry{"work": entry}}); err != nil { + t.Fatal(err) + } + s := &Settings{} + root, child := selectionCommand(t, s) + if err := root.PersistentFlags().Set("config-dir", dir); err != nil { + t.Fatal(err) + } + options, err := applyCloudTarget(child, entry.Options, "org", "stack") + if err != nil { + t.Fatal(err) + } + if options.Organization != "org" || options.Stack != "stack" || !root.PersistentFlags().Changed("organization") || !root.PersistentFlags().Changed("stack") { + t.Fatal("selected target did not update inherited flags") + } + resolved, original, _, _, err := s.Resolve(child) + if err != nil { + t.Fatal(err) + } + if resolved != options || !reflect.DeepEqual(original, entry) { + t.Fatal("selection did not resolve through the same profile or changed saved defaults") + } + store, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(store.Connections["work"], entry) { + t.Fatal("selection persisted a target or changed the profile revision") + } +} + +func TestApplyCloudTargetNeverOverridesExplicitIDs(t *testing.T) { + t.Parallel() + s := &Settings{} + root, child := selectionCommand(t, s) + options := Options{AuthMode: "cloud", Organization: "explicit-org", Stack: "explicit-stack"} + selected, err := applyCloudTarget(child, options, "another-org", "another-stack") + if err != nil { + t.Fatal(err) + } + if selected != options || root.PersistentFlags().Changed("organization") || root.PersistentFlags().Changed("stack") { + t.Fatal("picker overrode explicit target IDs") + } +} + +func TestSelectCloudTargetWithoutInputDoesNotListResources(t *testing.T) { + t.Parallel() + for _, noInput := range []bool{false, true} { + t.Run(map[bool]string{false: "nonTTY", true: "no-input"}[noInput], func(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + s := &Settings{} + root, child := selectionCommand(t, s) + if noInput { + if err := root.PersistentFlags().Set("no-input", "true"); err != nil { + t.Fatal(err) + } + } + options := Options{AuthMode: "cloud", Issuer: server.URL} + entry := NewEntry(options) + entry.Session = &cloud.Session{IDToken: "not-a-token"} + got, err := s.selectCloudTarget(t.Context(), child, server.Client(), options, entry, "work", t.TempDir()) + if err != nil || got != options || requests.Load() != 0 { + t.Fatalf("noninteractive selection changed options or made requests: err=%v requests=%d", err, requests.Load()) + } + }) + } +} + +func TestChooseCloudStackAutomaticallyUsesOnlyReadyAuthorizedChoice(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/organizations/org/stacks" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if _, err := io.WriteString(w, `{"data":[{"id":"ready","name":"Books","status":"READY","state":"ACTIVE"},{"id":"disabled","status":"READY","state":"DISABLED"},{"id":"unsigned","status":"READY"}]}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + _, cmd := selectionCommand(t, &Settings{}) + stack, err := chooseCloudStack(t.Context(), cmd, client, "org", []string{"ready", "disabled"}) + if err != nil || stack != "ready" || requests.Load() != 1 { + t.Fatalf("selected stack=%q err=%v requests=%d", stack, err, requests.Load()) + } +} + +func TestChooseCloudStackDoesNotFallBackToUnavailableSignedTarget(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if _, err := io.WriteString(w, `{"data":[{"id":"only","status":"PROGRESSING"}]}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + _, cmd := selectionCommand(t, &Settings{}) + if stack, err := chooseCloudStack(t.Context(), cmd, client, "org", []string{"only"}); err == nil || stack != "" { + t.Fatal("selected an unavailable signed target") + } +} + +func TestChooseCloudOrganizationSingleAndCanceledNeedNoListing(t *testing.T) { + t.Parallel() + _, cmd := selectionCommand(t, &Settings{}) + organization, err := chooseCloudOrganization(t.Context(), cmd, nil, []string{"only"}) + if err != nil || organization != "only" { + t.Fatalf("single organization=%q err=%v", organization, err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if organization, err := chooseCloudOrganization(ctx, cmd, nil, []string{"only"}); !errors.Is(err, context.Canceled) || organization != "" { + t.Fatalf("canceled selection organization=%q err=%v", organization, err) + } +} + +func TestReadCloudChoicesRejectsMalformedData(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if _, err := io.WriteString(w, `{"data":[{"id":42}]}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL, &http.Client{Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + if _, err := readCloudChoices(t.Context(), client, "/organizations"); err == nil { + t.Fatal("accepted malformed resource IDs") + } +} + +type selectionFixture struct { + server *httptest.Server + dir string + entry Entry + organizations []cloudChoiceResource + stacks map[string][]cloudChoiceResource + organizationTokens map[string]string + listOrganizations atomic.Int32 + listStacks atomic.Int32 + posts atomic.Int32 +} + +func newSelectionFixture(t *testing.T) *selectionFixture { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &selectionFixture{ + dir: t.TempDir(), organizationTokens: make(map[string]string), + organizations: []cloudChoiceResource{{ID: "first", Name: "Accounts"}, {ID: "second", Name: "Books"}}, + stacks: map[string][]cloudChoiceResource{ + "first": {{ID: "one", Name: "Sandbox", Status: "READY", State: "ACTIVE"}, {ID: "two", Name: "Production", Status: "READY", State: "ACTIVE"}}, + "second": {{ID: "three", Name: "Books", Status: "READY", State: "ACTIVE"}}, + }, + } + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.serve(t, key, w, r) + })) + t.Cleanup(f.server.Close) + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithHeader("kid", "key")) + if err != nil { + t.Fatal(err) + } + issuer := f.server.URL + "/api" + scopes := []string{"organization:Read", "organization:ListStacks"} + accesses := []any{ + map[string]any{"id": "first", "scopes": scopes, "stacks": []any{ + map[string]any{"id": "one", "uri": f.server.URL + "/one", "scopes": []string{"stack:Read"}}, + map[string]any{"id": "two", "uri": f.server.URL + "/two", "scopes": []string{"stack:Read"}}, + }}, + map[string]any{"id": "second", "scopes": scopes, "stacks": []any{ + map[string]any{"id": "three", "uri": f.server.URL + "/three", "scopes": []string{"stack:Read"}}, + }}, + } + id := signSelectionClaims(t, signer, issuer, map[string]any{"org": accesses}) + options := cloud.Options{Issuer: issuer, ClientID: "fctl"} + root := &cloud.Session{Options: options, IDToken: id, MembershipToken: selectionToken("root-access"), Organizations: make(map[string]*cloud.Session)} + for _, organization := range []string{"first", "second"} { + access := signSelectionClaims(t, signer, issuer, map[string]any{"organization_id": organization, "scope": strings.Join(scopes, " ")}) + f.organizationTokens[organization] = access + childOptions := options + childOptions.Organization = organization + root.Organizations[organization] = &cloud.Session{Options: childOptions, IDToken: id, MembershipToken: selectionToken(access)} + } + f.entry = NewEntry(Options{AuthMode: "cloud", Issuer: issuer, ClientID: "fctl"}) + f.entry.Session = root + if err := Save(f.dir, Store{Active: "work", Connections: map[string]Entry{"work": f.entry}}); err != nil { + t.Fatal(err) + } + return f +} + +func selectionToken(access string) *oauth2.Token { + return &oauth2.Token{AccessToken: access, TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} +} + +func signSelectionClaims(t *testing.T, signer jose.Signer, issuer string, values map[string]any) string { + t.Helper() + values["iss"], values["aud"], values["sub"], values["exp"] = issuer, "fctl", "user", time.Now().Add(time.Hour).Unix() + raw, err := jwt.Signed(signer).Claims(values).Serialize() + if err != nil { + t.Fatal(err) + } + return raw +} + +func (f *selectionFixture) serve(t *testing.T, key *rsa.PrivateKey, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.Method != http.MethodGet { + f.posts.Add(1) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + switch r.URL.Path { + case "/api/.well-known/openid-configuration": + writeSelectionJSON(t, w, map[string]any{ + "issuer": f.server.URL + "/api", "authorization_endpoint": f.server.URL + "/api/authorize", "device_authorization_endpoint": f.server.URL + "/api/device", + "token_endpoint": f.server.URL + "/api/token", "jwks_uri": f.server.URL + "/api/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + case "/api/jwks": + writeSelectionJSON(t, w, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "key", Algorithm: "RS256", Use: "sig"}}}) + case "/api/organizations": + f.listOrganizations.Add(1) + if r.Header.Get("Authorization") != "Bearer root-access" { + t.Error("organization listing did not use the root Membership client") + } + writeSelectionJSON(t, w, map[string]any{"data": f.organizations}) + case "/api/organizations/first/stacks", "/api/organizations/second/stacks": + f.listStacks.Add(1) + organization := strings.Split(r.URL.Path, "/")[3] + if r.Header.Get("Authorization") != "Bearer "+f.organizationTokens[organization] { + t.Error("stack listing did not use its scoped Membership grant") + } + writeSelectionJSON(t, w, map[string]any{"data": f.stacks[organization]}) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func writeSelectionJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Error(err) + } +} + +func (f *selectionFixture) settings(t *testing.T) (*Settings, *cobra.Command) { + t.Helper() + s := &Settings{NoBrowser: true} + root, cmd := selectionCommand(t, s) + if err := root.PersistentFlags().Set("config-dir", f.dir); err != nil { + t.Fatal(err) + } + if err := root.PersistentFlags().Set("no-browser", "true"); err != nil { + t.Fatal(err) + } + return s, cmd +} + +func TestCloudSelectionNoninteractivePreservesExistingErrorsAndRootSession(t *testing.T) { + t.Parallel() + f := newSelectionFixture(t) + before, err := os.ReadFile(filepath.Join(f.dir, "connections.json")) + if err != nil { + t.Fatal(err) + } + s, cmd := f.settings(t) + cmd.Annotations = map[string]string{"fctl.target": "organization"} + client, err := s.Client(t.Context(), cmd, "cloud") + if err != nil { + t.Fatal(err) + } + if client.Context()["organization"] != "" || client.Context()["stack"] != "" { + t.Fatal("selected an ambiguous Cloud management target without input") + } + if _, err := s.Client(t.Context(), cmd, "ledger"); err == nil || !strings.HasPrefix(err.Error(), "choose a Cloud stack with --organization") { + t.Fatalf("old missing-target error changed: %v", err) + } + after, err := os.ReadFile(filepath.Join(f.dir, "connections.json")) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || f.listOrganizations.Load() != 0 || f.listStacks.Load() != 0 || f.posts.Load() != 0 { + t.Fatal("noninteractive resolution mutated the profile or listed resources") + } +} + +type selectionRunner func(context.Context, *cobra.Command, []interactive.Field) ([]string, error) + +func (run selectionRunner) Run(ctx context.Context, cmd *cobra.Command, fields []interactive.Field) ([]string, error) { + return run(ctx, cmd, fields) +} + +func selectionContext(t *testing.T, cmd *cobra.Command, run selectionRunner) context.Context { + t.Helper() + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + ctx := interactive.WithRunner(t.Context(), run) + cmd.SetContext(ctx) + return ctx +} + +func (f *selectionFixture) storeBytes(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(f.dir, "connections.json")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestCloudManagementSelectsOnlyAnnotatedOrganization(t *testing.T) { + for _, target := range []string{"", "identity", "organization"} { + t.Run(target, func(t *testing.T) { + checkCloudManagementTarget(t, target) + }) + } +} + +func checkCloudManagementTarget(t *testing.T, target string) { + t.Helper() + f := newSelectionFixture(t) + before := f.storeBytes(t) + s, cmd := f.settings(t) + cmd.Annotations = map[string]string{"fctl.target": target} + calls := 0 + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + calls++ + want := []interactive.Option{{Label: "Accounts (first)", Value: "first"}, {Label: "Books (second)", Value: "second"}} + if target != "organization" || len(fields) != 1 || !reflect.DeepEqual(fields[0].Options, want) { + t.Fatalf("unexpected picker fields: %+v", fields) + } + return []string{"second"}, nil + }) + client, err := s.Client(ctx, cmd, "cloud") + if err != nil { + t.Fatal(err) + } + wantOrg, wantCalls := "", 0 + if target == "organization" { + wantOrg, wantCalls = "second", 1 + } + if client.Context()["organization"] != wantOrg || client.Context()["stack"] != "" || calls != wantCalls || f.listOrganizations.Load() != int32(wantCalls) || f.listStacks.Load() != 0 || f.posts.Load() != 0 { + t.Fatal("Cloud command selected an unnecessary target or did not apply the selected organization") + } + if f.storeBytes(t) != before { + t.Fatal("management picker changed the saved defaults or sessions") + } +} + +type stackPickerCase struct { + name, organization, stack, chosenOrganization, chosenStack string + answers []string + organizationLists, stackLists int32 +} + +func TestCloudStackPickerUsesCoordinatedMembershipAndUniqueChoices(t *testing.T) { + for _, tc := range []stackPickerCase{ + {name: "organization and stack", chosenOrganization: "first", chosenStack: "two", answers: []string{"first", "two"}, organizationLists: 1, stackLists: 1}, + {name: "unique ready stack", chosenOrganization: "second", chosenStack: "three", answers: []string{"second"}, organizationLists: 1, stackLists: 1}, + {name: "explicit organization", organization: "first", chosenOrganization: "first", chosenStack: "one", answers: []string{"one"}, stackLists: 1}, + {name: "explicit stack identifies organization", stack: "three", chosenOrganization: "second", chosenStack: "three"}, + } { + t.Run(tc.name, func(t *testing.T) { + checkCloudStackPickerCase(t, tc) + }) + } +} + +func checkCloudStackPickerCase(t *testing.T, tc stackPickerCase) { + t.Helper() + f := newSelectionFixture(t) + before := f.storeBytes(t) + s, cmd := f.settings(t) + setSelectionFlags(t, cmd, tc.organization, tc.stack) + calls := 0 + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + if len(fields) != 1 || calls >= len(tc.answers) { + t.Fatal("unexpected picker call") + } + if fields[0].Title == "Choose a Cloud stack" { + want := []interactive.Option{{Label: "Production (two) · READY", Value: "two"}, {Label: "Sandbox (one) · READY", Value: "one"}} + if !reflect.DeepEqual(fields[0].Options, want) { + t.Fatalf("stack picker options = %+v", fields[0].Options) + } + } + answer := tc.answers[calls] + calls++ + return []string{answer}, nil + }) + options, entry, name, dir, err := s.Resolve(cmd) + if err != nil { + t.Fatal(err) + } + selected, err := s.selectCloudTarget(ctx, cmd, f.server.Client(), options, entry, name, dir) + if err != nil { + t.Fatal(err) + } + if selected.Organization != tc.chosenOrganization || selected.Stack != tc.chosenStack || calls != len(tc.answers) || f.listOrganizations.Load() != tc.organizationLists || f.listStacks.Load() != tc.stackLists || f.posts.Load() != 0 { + t.Fatal("selected target or Membership requests differ from expected resolution") + } + resolved, _, _, _, err := s.Resolve(cmd) + if err != nil || resolved != selected || f.storeBytes(t) != before { + t.Fatal("choice lost its command flags or changed persisted defaults/session") + } +} + +func setSelectionFlags(t *testing.T, cmd *cobra.Command, organization, stack string) { + t.Helper() + for name, value := range map[string]string{"organization": organization, "stack": stack} { + if value != "" { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatal(err) + } + } + } +} + +func TestCloudTargetPickerCancellationStopsBeforeServiceAuthorization(t *testing.T) { + for _, atStack := range []bool{false, true} { + t.Run(map[bool]string{false: "organization", true: "stack"}[atStack], func(t *testing.T) { + checkCloudPickerCancellation(t, atStack) + }) + } +} + +func checkCloudPickerCancellation(t *testing.T, atStack bool) { + t.Helper() + f := newSelectionFixture(t) + before := f.storeBytes(t) + s, cmd := f.settings(t) + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + if atStack && fields[0].Title == "Choose a Cloud organization" { + return []string{"first"}, nil + } + return nil, interactive.ErrCanceled + }) + if _, err := s.Client(ctx, cmd, "ledger"); !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("cancellation error = %v", err) + } + wantStacks := int32(0) + if atStack { + wantStacks = 1 + } + if f.listOrganizations.Load() != 1 || f.listStacks.Load() != wantStacks || f.posts.Load() != 0 || f.storeBytes(t) != before || cmd.Flags().Changed("organization") || cmd.Flags().Changed("stack") { + t.Fatal("canceled picker changed the target/session or authorized a service") + } +} + +func TestResolvedCloudTargetsRespectFlagEnvironmentAndSavedDefaults(t *testing.T) { + for _, source := range []string{"saved", "environment", "flags"} { + t.Run(source, func(t *testing.T) { + checkResolvedCloudTargetSource(t, source) + }) + } +} + +func checkResolvedCloudTargetSource(t *testing.T, source string) { + t.Helper() + f := newSelectionFixture(t) + entry := f.entry + entry.Options.Organization, entry.Options.Stack = "first", "one" + if err := Save(f.dir, Store{Active: "work", Connections: map[string]Entry{"work": entry}}); err != nil { + t.Fatal(err) + } + before := f.storeBytes(t) + s, cmd := f.settings(t) + wantOrg, wantStack := "first", "one" + t.Setenv("FCTL_ORGANIZATION", "first") + t.Setenv("FCTL_STACK", "one") + if source == "saved" { + if err := os.Unsetenv("FCTL_ORGANIZATION"); err != nil { + t.Fatal(err) + } + if err := os.Unsetenv("FCTL_STACK"); err != nil { + t.Fatal(err) + } + } else { + t.Setenv("FCTL_ORGANIZATION", "second") + t.Setenv("FCTL_STACK", "three") + wantOrg, wantStack = "second", "three" + } + if source == "flags" { + setSelectionFlags(t, cmd, "first", "two") + wantOrg, wantStack = "first", "two" + } + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, _ []interactive.Field) ([]string, error) { + t.Fatal("complete resolved target prompted for a choice") + return nil, interactive.ErrCanceled + }) + options, original, name, dir, err := s.Resolve(cmd) + if err != nil { + t.Fatal(err) + } + selected, err := s.selectCloudTarget(ctx, cmd, f.server.Client(), options, original, name, dir) + if err != nil || selected.Organization != wantOrg || selected.Stack != wantStack || f.listOrganizations.Load() != 0 || f.listStacks.Load() != 0 || f.posts.Load() != 0 || f.storeBytes(t) != before { + t.Fatal("target precedence changed or a complete target required discovery") + } +} + +func TestCloudPickerHonorsDisabledInputWithInjectedRunner(t *testing.T) { + for _, disabled := range []string{"no-input", "CI", "FCTL_NO_INPUT"} { + t.Run(disabled, func(t *testing.T) { + s := &Settings{} + _, cmd := selectionCommand(t, s) + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, _ []interactive.Field) ([]string, error) { + t.Fatal("disabled input invoked a picker") + return nil, interactive.ErrCanceled + }) + if disabled == "no-input" { + if err := cmd.Flags().Set("no-input", "true"); err != nil { + t.Fatal(err) + } + } else { + t.Setenv(disabled, "true") + } + options := Options{AuthMode: "cloud"} + entry := Entry{Options: options, Session: &cloud.Session{}} + selected, err := s.selectCloudTarget(ctx, cmd, nil, options, entry, "work", t.TempDir()) + if err != nil || selected != options { + t.Fatalf("disabled input changed resolution: %v", err) + } + }) + } +} + +func TestCloudPickerLeavesLegacySessionBoundaryIntact(t *testing.T) { + s := &Settings{} + _, cmd := selectionCommand(t, s) + ctx := selectionContext(t, cmd, func(_ context.Context, _ *cobra.Command, _ []interactive.Field) ([]string, error) { + t.Fatal("legacy single-stack session invoked a picker") + return nil, interactive.ErrCanceled + }) + options := Options{AuthMode: "cloud"} + entry := Entry{Options: options, Session: &cloud.Session{Options: cloud.Options{Stack: "legacy"}}} + selected, err := s.selectCloudTarget(ctx, cmd, nil, options, entry, "work", t.TempDir()) + if err != nil || selected != options { + t.Fatalf("legacy session changed resolution: %v", err) + } +} diff --git a/internal/connection/connectivity_cloud_test.go b/internal/connection/connectivity_cloud_test.go new file mode 100644 index 00000000..abbbcd21 --- /dev/null +++ b/internal/connection/connectivity_cloud_test.go @@ -0,0 +1,57 @@ +package connection + +import ( + "io" + "net/http" + "testing" + + "github.com/formancehq/fctl/v4/internal/cloud" +) + +//nolint:gocognit // Verifies a signed cached grant, route resolution and use of the scoped Stack token rather than root identity. +func TestConnectivityCloudUsesScopedStackClient(t *testing.T) { + t.Parallel() + f := newSelectionFixture(t) + options := cloud.Options{Issuer: f.entry.Options.Issuer, ClientID: "fctl", Organization: "first", Stack: "one"} + f.entry.Session.Targets = map[string]*cloud.Session{"first/one": { + Options: options, IDToken: f.entry.Session.IDToken, StackURL: f.server.URL + "/one", + MembershipToken: selectionToken("scoped-membership"), StackToken: selectionToken("connectivity-stack"), + }} + if err := Save(f.dir, Store{Active: "work", Connections: map[string]Entry{"work": f.entry}}); err != nil { + t.Fatal(err) + } + previous := f.server.Config.Handler + reads := 0 + f.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/one/api/connectivity/_info" { + reads++ + if r.Header.Get("Authorization") != "Bearer connectivity-stack" { + t.Error("Connectivity received the root Membership token") + } + if _, err := io.WriteString(w, `{"version":"fixture"}`); err != nil { + t.Error(err) + } + return + } + previous.ServeHTTP(w, r) + }) + s, cmd := f.settings(t) + for flag, value := range map[string]string{"organization": "first", "stack": "one"} { + if err := cmd.Root().PersistentFlags().Set(flag, value); err != nil { + t.Fatal(err) + } + } + client, err := s.Client(t.Context(), cmd, "connectivity") + if err != nil { + t.Fatal(err) + } + if client.Endpoint() != f.server.URL+"/one/api/connectivity" { + t.Fatalf("Cloud endpoint=%s", client.Endpoint()) + } + if _, err := client.Do(t.Context(), http.MethodGet, "/_info", nil, nil, nil); err != nil { + t.Fatal(err) + } + if reads != 1 || f.posts.Load() != 0 { + t.Fatalf("reads=%d extra grant/exchange requests=%d", reads, f.posts.Load()) + } +} diff --git a/internal/connection/connectivity_test.go b/internal/connection/connectivity_test.go new file mode 100644 index 00000000..d9f79de8 --- /dev/null +++ b/internal/connection/connectivity_test.go @@ -0,0 +1,120 @@ +package connection_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" +) + +//nolint:gocognit // The matrix verifies HTTP paths and token acquisition for each direct/gateway authentication boundary. +func TestConnectivityDirectAndGatewayAuthentication(t *testing.T) { + t.Setenv("FCTL_CLIENT_SECRET", "fixture-secret") + for _, mode := range []string{"none", "client-credentials"} { + for _, flag := range []string{"--connectivity-url", "--stack-url"} { + t.Run(mode+flag, func(t *testing.T) { + path := "/prefix/connectorinstances" + if flag == "--stack-url" { + path = "/prefix/api/connectivity/connectorinstances" + } + tokens, reads := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/token" { + tokens++ + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("grant_type") != "client_credentials" || r.Form.Get("scope") != "connectivity:read" { + t.Error("wrong credential grant") + } + if _, err := io.WriteString(w, `{"access_token":"connectivity-access","token_type":"Bearer","expires_in":3600}`); err != nil { + t.Error(err) + } + return + } + reads++ + wantAuth := "" + if mode == "client-credentials" { + wantAuth = "Bearer connectivity-access" + } + if r.Method != http.MethodGet || r.URL.Path != path || r.Header.Get("Authorization") != wantAuth { + t.Errorf("request %s %s authentication=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) + } + if _, err := io.WriteString(w, `{"cursor":{"hasMore":false,"data":[]}}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + s := &connection.Settings{} + args := []string{"--config-dir", t.TempDir(), "--auth-mode", mode, flag, server.URL + "/prefix"} + if mode == "client-credentials" { + args = append(args, "--client-id", "automation", "--token-url", server.URL+"/token", "--scopes", "connectivity:read") + } + root := commandWith(s, args...) + root.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := s.Client(cmd.Context(), cmd, "connectivity") + if err != nil { + return err + } + _, err = client.Do(cmd.Context(), http.MethodGet, "/connectorinstances", nil, nil, nil) + return err + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + wantTokens := 0 + if mode == "client-credentials" { + wantTokens = 1 + } + if reads != 1 || tokens != wantTokens { + t.Fatalf("reads=%d tokens=%d", reads, tokens) + } + }) + } + } +} + +func TestConnectivityURLPersistenceAndOverrides(t *testing.T) { + dir := t.TempDir() + entry := connection.NewEntry(connection.Options{AuthMode: "none", ConnectivityURL: "http://localhost:8080/saved"}) + if err := connection.Save(dir, connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": entry}}); err != nil { + t.Fatal(err) + } + t.Setenv("FCTL_CONNECTIVITY_URL", "http://localhost:8080/env") + s := &connection.Settings{} + root := commandWith(s, "--config-dir", dir, "--connectivity-url", "http://localhost:8080/flag") + root.RunE = func(cmd *cobra.Command, _ []string) error { + options, _, _, _, err := s.Resolve(cmd) + if err != nil { + return err + } + if options.ConnectivityURL != "http://localhost:8080/flag" { + t.Fatal("flag did not override saved and env endpoints") + } + return connection.Validate(options) + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + store, err := connection.Load(dir) + if err != nil || store.Connections["local"].Options.ConnectivityURL != entry.Options.ConnectivityURL { + t.Fatal("one-off flag changed saved endpoint") + } + //nolint:gosec // These are rejection fixtures, not credentials used by the CLI. + for _, options := range []connection.Options{ + {AuthMode: "cloud", ConnectivityURL: "https://service.example"}, + {AuthMode: "client-credentials", ClientID: "client", TokenURL: "https://auth.example/token", ConnectivityURL: "http://service.example"}, + {AuthMode: "none", ConnectivityURL: "https://user:secret@service.example"}, + } { + if err := connection.Validate(options); err == nil || strings.TrimSpace(err.Error()) == "" { + t.Fatal("accepted invalid endpoint/auth combination") + } + } +} diff --git a/internal/connection/login.go b/internal/connection/login.go new file mode 100644 index 00000000..f5fe7755 --- /dev/null +++ b/internal/connection/login.go @@ -0,0 +1,73 @@ +package connection + +import ( + "cmp" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/cloud" +) + +// LoginProfile resolves Cloud settings without writing the store or requiring +// a target. Explicit +// local profiles are rejected; an implicit active local profile remains intact. +func (s *Settings) LoginProfile(ctx context.Context, cmd *cobra.Command) (Options, Entry, string, string, error) { + dir, err := s.DirectoryPath() + if err != nil { + return Options{}, Entry{}, "", "", err + } + requested := cmp.Or(s.Name, os.Getenv("FCTL_PROFILE")) + store, err := Load(dir) + if err != nil { + return Options{}, Entry{}, "", "", err + } + name := loginName(requested, store) + if err := ValidateName(name); err != nil { + return Options{}, Entry{}, "", "", err + } + entry, exists := store.Connections[name] + if exists && entry.Options.AuthMode != "cloud" { + return Options{}, Entry{}, "", "", fmt.Errorf("login requires a Cloud profile; %q uses %s", name, entry.Options.AuthMode) + } + options := s.loginOptions(cmd, entry.Options) + if options.AuthMode != "cloud" { + return Options{}, Entry{}, "", "", fmt.Errorf("login requires auth-mode=cloud") + } + if err := Validate(options); err != nil { + return Options{}, Entry{}, "", "", err + } + if err := ctx.Err(); err != nil { + return Options{}, Entry{}, "", "", err + } + return options, entry, name, dir, nil +} + +func loginName(requested string, store Store) string { + if requested != "" { + return requested + } + if current, exists := store.Connections[store.Active]; exists && current.Options.AuthMode == "cloud" { + return store.Active + } + return "cloud" +} + +func (s *Settings) loginOptions(cmd *cobra.Command, saved Options) Options { + resolved := Settings{Options: saved} + fields := resolved.fields() + for i, setting := range s.fields() { + if cmd.Root().PersistentFlags().Changed(setting.name) { + *fields[i].value = *setting.value + } else if value, exists := os.LookupEnv("FCTL_" + strings.ToUpper(strings.ReplaceAll(setting.name, "-", "_"))); exists { + *fields[i].value = value + } + } + resolved.Options.AuthMode = cmp.Or(resolved.Options.AuthMode, "cloud") + resolved.Options.Issuer = cmp.Or(resolved.Options.Issuer, cloud.DefaultIssuer) + resolved.Options.ClientID = cmp.Or(resolved.Options.ClientID, "fctl") + return resolved.Options +} diff --git a/internal/connection/login_test.go b/internal/connection/login_test.go new file mode 100644 index 00000000..090f5883 --- /dev/null +++ b/internal/connection/login_test.go @@ -0,0 +1,422 @@ +package connection_test + +import ( + "os" + "reflect" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" +) + +func loginSettings(t *testing.T, dir string, args ...string) (*connection.Settings, *cobra.Command) { + t.Helper() + s := &connection.Settings{} + args = append([]string{"--config-dir", dir}, args...) + root := commandWith(s, args...) + if err := root.ParseFlags(args); err != nil { + t.Fatal(err) + } + return s, root +} + +func isolateLoginEnvironment(t *testing.T) { + t.Helper() + for _, key := range []string{"CONNECTION", "CONFIG_DIR", "STACK_URL", "LEDGER_URL", "AUTH_URL", "AUTH_MODE", "TOKEN_URL", "CLIENT_ID", "SCOPES", "ISSUER", "ORGANIZATION", "STACK"} { + t.Setenv("FCTL_"+key, "") + // LookupEnv distinguishes absent settings from explicit empty overrides. + // Setenv above registers restoration of the original environment. + if err := os.Unsetenv("FCTL_" + key); err != nil { + t.Fatal(err) + } + } +} + +func saveLoginStore(t *testing.T, dir string, store connection.Store) { + t.Helper() + if err := connection.Save(dir, store); err != nil { + t.Fatal(err) + } +} + +func loadLoginStore(t *testing.T, dir string) connection.Store { + t.Helper() + store, err := connection.Load(dir) + if err != nil { + t.Fatal(err) + } + return store +} + +func loginStoreBytes(t *testing.T, dir string) []byte { + t.Helper() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + data, err := root.ReadFile("connections.json") + if err != nil { + t.Fatal(err) + } + return data +} + +func TestLoginProfilePreparesCloudWithoutCreatingProfile(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {"cloud", nil}, + {"new-cloud", []string{"--profile", "new-cloud"}}, + {"short-cloud", []string{"-p", "short-cloud"}}, + } { + t.Run(tc.name, func(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + s, root := loginSettings(t, dir, tc.args...) + options, entry, gotName, gotDir, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + want := connection.Options{AuthMode: "cloud", Issuer: cloud.DefaultIssuer, ClientID: "fctl"} + if options != want || !reflect.DeepEqual(entry, connection.Entry{}) || gotName != tc.name || gotDir != dir { + t.Fatalf("prepared options=%+v entry=%+v name=%q dir=%q", options, entry, gotName, gotDir) + } + assertLoginDirectoryEmpty(t, dir) + }) + } +} + +func assertLoginDirectoryEmpty(t *testing.T, dir string) { + t.Helper() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + if _, err := root.Stat("connections.json"); !os.IsNotExist(err) { + t.Fatalf("preparation created a configuration file: %v", err) + } + if _, err := root.Stat("connections.lock"); !os.IsNotExist(err) { + t.Fatalf("read-only preparation created a lock file: %v", err) + } +} + +func TestLoginProfileReusesActiveCloud(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + entry := connection.NewEntry(connection.Options{AuthMode: "cloud", Issuer: "https://saved.example", ClientID: "saved-client", Organization: "org", Stack: "stack"}) + entry.Session = &cloud.Session{IDToken: "existing-session"} + before := connection.Store{Active: "work", Connections: map[string]connection.Entry{"work": entry}} + saveLoginStore(t, dir, before) + s, root := loginSettings(t, dir) + options, prepared, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if name != "work" || options != entry.Options || !reflect.DeepEqual(prepared, entry) { + t.Fatalf("active Cloud profile was replaced: name=%q entry=%+v", name, prepared) + } + if after := loadLoginStore(t, dir); !reflect.DeepEqual(after, before) { + t.Fatalf("unchanged identity mutated store: %+v", after) + } +} + +func TestLoginProfileImplicitLocalFallsBackUntilSessionSaved(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + local := connection.NewEntry(connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}) + saveLoginStore(t, dir, connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": local}}) + s, root := loginSettings(t, dir) + options, entry, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + before := loadLoginStore(t, dir) + if name != "cloud" || before.Active != "local" || !reflect.DeepEqual(before.Connections["local"], local) { + t.Fatalf("login preparation changed local profile: %+v", before) + } + if len(before.Connections) != 1 || entry.Revision != "" { + t.Fatal("preparation created the fallback Cloud profile") + } + expected := entry.Revision + session := &cloud.Session{Options: cloud.Options{Issuer: cloud.DefaultIssuer, ClientID: "fctl"}, IDToken: "new-session"} + if err := connection.SaveLoginSession(t.Context(), dir, name, &expected, options, session); err != nil { + t.Fatal(err) + } + after := loadLoginStore(t, dir) + if after.Active != "cloud" || after.Connections["cloud"].Options != options || !reflect.DeepEqual(after.Connections["cloud"].Session, session) || !reflect.DeepEqual(after.Connections["local"], local) { + t.Fatalf("successful login did not persist session and selection together: %+v", after) + } + if expected == entry.Revision || after.Connections["cloud"].Revision != expected { + t.Fatal("successful login did not advance the caller's revision") + } + assertStaleLoginPreservesStore(t, dir, name, entry.Revision, options) +} + +func TestLoginProfileRejectsLocalAndNonCloudModesWithoutMutation(t *testing.T) { + for _, tc := range []struct { + name string + args []string + env string + }{ + {"explicit local", []string{"--profile", "local"}, ""}, + {"environment local", nil, "local"}, + {"none", []string{"--profile", "new", "--auth-mode", "none"}, ""}, + {"client credentials", []string{"--profile", "new", "--auth-mode", "client-credentials"}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + isolateLoginEnvironment(t) + t.Setenv("FCTL_PROFILE", tc.env) + dir := t.TempDir() + local := connection.NewEntry(connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}) + saveLoginStore(t, dir, connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": local}}) + before := string(loginStoreBytes(t, dir)) + s, root := loginSettings(t, dir, tc.args...) + if _, _, _, _, err := s.LoginProfile(t.Context(), root); err == nil { + t.Fatal("accepted non-Cloud login") + } + if string(loginStoreBytes(t, dir)) != before { + t.Fatal("rejected login changed persisted settings") + } + }) + } +} + +func TestLoginProfileIdentityOverridePreservesTarget(t *testing.T) { + for _, tc := range []struct { + flag, value, issuer, client string + }{ + {"--issuer", "https://new.example", "https://new.example", "old-client"}, + {"--client-id", "new-client", "https://old.example", "new-client"}, + } { + t.Run(tc.flag, func(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + entry := connection.NewEntry(connection.Options{AuthMode: "cloud", Issuer: "https://old.example", ClientID: "old-client", Organization: "org", Stack: "stack"}) + entry.Session = &cloud.Session{IDToken: "old-session"} + saveLoginStore(t, dir, connection.Store{Active: "work", Connections: map[string]connection.Entry{"work": entry}}) + before := string(loginStoreBytes(t, dir)) + want := entry.Options + want.Issuer, want.ClientID = tc.issuer, tc.client + s, root := loginSettings(t, dir, tc.flag, tc.value) + options, prepared, _, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if options != want || !reflect.DeepEqual(prepared, entry) { + t.Fatalf("identity preparation changed original entry or lost target: %+v", prepared) + } + if string(loginStoreBytes(t, dir)) != before { + t.Fatal("identity preparation changed persisted session, options or revision") + } + assertSuccessfulLoginCommit(t, dir, "work", entry.Revision, options) + }) + } +} + +func TestLoginProfileFlagsOverrideEnvironment(t *testing.T) { + for _, flags := range []bool{false, true} { + t.Run(map[bool]string{false: "environment", true: "flags"}[flags], func(t *testing.T) { + isolateLoginEnvironment(t) + t.Setenv("FCTL_PROFILE", "environment") + t.Setenv("FCTL_ISSUER", "https://environment.example") + t.Setenv("FCTL_CLIENT_ID", "environment-client") + t.Setenv("FCTL_AUTH_MODE", "cloud") + wantName, wantIssuer, wantClient := "environment", "https://environment.example", "environment-client" + var args []string + if flags { + t.Setenv("FCTL_AUTH_MODE", "none") + args = []string{"--profile", "flag", "--issuer", "https://flag.example", "--client-id", "flag-client", "--auth-mode", "cloud"} + wantName, wantIssuer, wantClient = "flag", "https://flag.example", "flag-client" + } + s, root := loginSettings(t, t.TempDir(), args...) + options, _, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if name != wantName || options.Issuer != wantIssuer || options.ClientID != wantClient || options.AuthMode != "cloud" { + t.Fatalf("wrong flag/environment precedence: name=%q options=%+v", name, options) + } + }) + } +} + +func TestLoginProfilePreservesIdentityWhenTargetOrDefaultsChange(t *testing.T) { + for _, tc := range []struct { + name string + saved connection.Options + args []string + want connection.Options + }{ + { + name: "default target", + saved: connection.Options{AuthMode: "cloud", Issuer: cloud.DefaultIssuer, ClientID: "fctl", Organization: "old-org", Stack: "old-stack"}, + args: []string{"--organization", "new-org", "--stack", "new-stack"}, + want: connection.Options{AuthMode: "cloud", Issuer: cloud.DefaultIssuer, ClientID: "fctl", Organization: "new-org", Stack: "new-stack"}, + }, + { + name: "implicit identity defaults", + saved: connection.Options{AuthMode: "cloud", Organization: "org", Stack: "stack"}, + want: connection.Options{AuthMode: "cloud", Issuer: cloud.DefaultIssuer, ClientID: "fctl", Organization: "org", Stack: "stack"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + entry := connection.NewEntry(tc.saved) + entry.Session = &cloud.Session{IDToken: "existing-identity", Targets: map[string]*cloud.Session{"old-org/old-stack": {IDToken: "cached-target"}}} + saveLoginStore(t, dir, connection.Store{Active: "work", Connections: map[string]connection.Entry{"work": entry}}) + before := string(loginStoreBytes(t, dir)) + s, root := loginSettings(t, dir, tc.args...) + options, prepared, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if options != tc.want || !reflect.DeepEqual(prepared, entry) { + t.Fatalf("target/default preparation changed original entry: %+v", prepared) + } + if string(loginStoreBytes(t, dir)) != before { + t.Fatal("target/default preparation changed persisted settings or session") + } + assertSuccessfulLoginCommit(t, dir, name, entry.Revision, options) + }) + } +} + +func assertSuccessfulLoginCommit(t *testing.T, dir, name, revision string, options connection.Options) { + t.Helper() + expected := revision + session := &cloud.Session{IDToken: "successfully-authenticated"} + if err := connection.SaveLoginSession(t.Context(), dir, name, &expected, options, session); err != nil { + t.Fatal(err) + } + store := loadLoginStore(t, dir) + entry := store.Connections[name] + if store.Active != name || entry.Options != options || !reflect.DeepEqual(entry.Session, session) { + t.Fatal("successful login did not commit options, session and selection together") + } + if expected == "" || expected == revision || entry.Revision != expected { + t.Fatal("successful login did not advance the persisted and caller revisions") + } + assertStaleLoginPreservesStore(t, dir, name, revision, options) +} + +func assertStaleLoginPreservesStore(t *testing.T, dir, name, revision string, options connection.Options) { + t.Helper() + before := loadLoginStore(t, dir) + expected := revision + if err := connection.SaveLoginSession(t.Context(), dir, name, &expected, options, &cloud.Session{IDToken: "stale-login"}); err == nil { + t.Fatal("settings change accepted login started with previous revision") + } + if after := loadLoginStore(t, dir); expected != revision || !reflect.DeepEqual(after, before) { + t.Fatal("failed reauthentication discarded existing identity or changed settings") + } +} + +func TestSaveLoginSessionRejectsLogoutDeleteAndReplacement(t *testing.T) { + for _, operation := range []string{"logout", "delete", "replace"} { + t.Run(operation, func(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + local := connection.NewEntry(connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}) + cloudEntry := connection.NewEntry(connection.Options{AuthMode: "cloud", Issuer: cloud.DefaultIssuer, ClientID: "fctl"}) + cloudEntry.Session = &cloud.Session{IDToken: "existing-identity"} + saveLoginStore(t, dir, connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": local, "cloud": cloudEntry}}) + s, root := loginSettings(t, dir) + options, entry, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + expected := entry.Revision + invalidateLoginEntry(t, dir, name, entry, operation) + before := string(loginStoreBytes(t, dir)) + if err := connection.SaveLoginSession(t.Context(), dir, name, &expected, options, &cloud.Session{IDToken: "stale-session"}); err == nil { + t.Fatal("accepted stale login result") + } + if expected != entry.Revision || string(loginStoreBytes(t, dir)) != before { + t.Fatal("failed CAS mutated revision, session or active profile") + } + }) + } +} + +func invalidateLoginEntry(t *testing.T, dir, name string, entry connection.Entry, operation string) { + t.Helper() + if operation == "logout" { + expected := entry.Revision + if err := connection.SaveSession(t.Context(), dir, name, &expected, nil); err != nil { + t.Fatal(err) + } + return + } + if err := connection.Update(t.Context(), dir, func(store *connection.Store) error { + if operation == "delete" { + delete(store.Connections, name) + } else { + store.Connections[name] = connection.NewEntry(entry.Options) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestSaveLoginSessionRejectsConcurrentCreationOfAbsentProfile(t *testing.T) { + isolateLoginEnvironment(t) + dir := t.TempDir() + s, root := loginSettings(t, dir, "--profile", "new-cloud") + options, entry, name, _, err := s.LoginProfile(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if entry.Revision != "" { + t.Fatal("absent profile returned an existing revision") + } + assertLoginDirectoryEmpty(t, dir) + concurrent := connection.NewEntry(options) + concurrent.Session = &cloud.Session{IDToken: "other-command-identity"} + saveLoginStore(t, dir, connection.Store{Active: name, Connections: map[string]connection.Entry{name: concurrent}}) + assertStaleLoginPreservesStore(t, dir, name, entry.Revision, options) +} + +func TestValidateCloudAllowsIdentityWithoutTarget(t *testing.T) { + for _, options := range []connection.Options{ + {AuthMode: "cloud"}, + {AuthMode: "cloud", Issuer: "https://issuer.example"}, + {AuthMode: "cloud", Organization: "org"}, + {AuthMode: "cloud", Stack: "stack"}, + {AuthMode: "cloud", Organization: "org", Stack: "stack"}, + } { + if err := connection.Validate(options); err != nil { + t.Errorf("Cloud identity with optional issuer/target rejected: %+v: %v", options, err) + } + } +} + +func TestNoBrowserIsGlobalLoginSetting(t *testing.T) { + isolateLoginEnvironment(t) + s, root := loginSettings(t, t.TempDir()) + if s.NoBrowser || s.BrowserOpener() == nil { + t.Fatal("default browser opening is disabled") + } + child := &cobra.Command{Use: "login"} + root.AddCommand(child) + if err := child.ParseFlags([]string{"--no-browser"}); err != nil { + t.Fatal(err) + } + if !s.NoBrowser || s.BrowserOpener() != nil || !root.PersistentFlags().Changed("no-browser") { + t.Fatal("login does not inherit global --no-browser setting") + } +} diff --git a/internal/connection/settings.go b/internal/connection/settings.go new file mode 100644 index 00000000..18da8329 --- /dev/null +++ b/internal/connection/settings.go @@ -0,0 +1,361 @@ +// Package connection resolves service endpoints independently of the Cloud. +package connection + +import ( + "cmp" + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/browser" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/httpdebug" + "github.com/formancehq/fctl/v4/internal/presentation" +) + +type Settings struct { + Name string + Directory string + Options Options + Timeout time.Duration + Output string + NoBrowser bool + Debug bool + Color string +} + +func (s *Settings) Bind(root *cobra.Command) { + f := root.PersistentFlags() + f.StringVarP(&s.Name, "profile", "p", "", "Saved profile name (FCTL_PROFILE)") + f.StringVar(&s.Directory, "config-dir", "", "Private v4 configuration directory (FCTL_CONFIG_DIR)") + f.DurationVar(&s.Timeout, "timeout", 30*time.Second, "Timeout for each HTTP request") + f.StringVarP(&s.Output, "output", "o", "auto", "Output format: auto, table or json (auto uses tables in a terminal)") + f.StringVar(&s.Color, "color", "auto", "Color: auto, always or never (NO_COLOR disables automatic color)") + f.BoolVarP(&s.Debug, "debug", "d", false, "Trace HTTP requests and responses on stderr (credentials redacted)") + f.BoolVar(&s.NoBrowser, "no-browser", false, "Display login instructions without opening a browser") + for _, setting := range s.fields() { + f.StringVar(setting.value, setting.name, "", setting.help) + } +} + +type field struct { + name string + value *string + help string +} + +func (s *Settings) fields() []field { + return []field{ + {"stack-url", &s.Options.StackURL, "Gateway base URL"}, + {"ledger-url", &s.Options.LedgerURL, "Standalone Ledger endpoint"}, + {"auth-url", &s.Options.AuthURL, "Standalone Auth endpoint"}, + {"connectivity-url", &s.Options.ConnectivityURL, "Standalone Connectivity endpoint"}, + {"auth-mode", &s.Options.AuthMode, "Authentication: none, client-credentials, cloud"}, + {"token-url", &s.Options.TokenURL, "OAuth2 token endpoint"}, + {"client-id", &s.Options.ClientID, "OAuth2 client ID; defaults to fctl for Cloud"}, + {"scopes", &s.Options.Scopes, "Space-separated OAuth2 scopes"}, + {"issuer", &s.Options.Issuer, "Membership OIDC issuer for Cloud login"}, + {"organization", &s.Options.Organization, "Cloud organization ID"}, + {"stack", &s.Options.Stack, "Cloud stack ID"}, + } +} + +func (s *Settings) DirectoryPath() (string, error) { + if dir := cmp.Or(s.Directory, os.Getenv("FCTL_CONFIG_DIR")); dir != "" { + return dir, nil + } + return DefaultDirectory() +} + +func (s *Settings) Resolve(cmd *cobra.Command) (Options, Entry, string, string, error) { + dir, err := s.DirectoryPath() + if err != nil { + return Options{}, Entry{}, "", "", err + } + store, err := Load(dir) + if err != nil { + return Options{}, Entry{}, "", "", err + } + name := cmp.Or(s.Name, os.Getenv("FCTL_PROFILE"), store.Active) + entry := store.Connections[name] + if name != "" { + if err := ValidateName(name); err != nil { + return Options{}, Entry{}, "", "", err + } + if _, ok := store.Connections[name]; !ok { + return Options{}, Entry{}, "", "", fmt.Errorf("profile %q does not exist", name) + } + } + resolved := Settings{Options: entry.Options} + mode := os.Getenv("FCTL_AUTH_MODE") + if cmd.Root().PersistentFlags().Changed("auth-mode") { + mode = s.Options.AuthMode + } + if mode != "" && mode != entry.Options.AuthMode { + // An explicit mode switch starts a fresh connection boundary. In + // particular, local one-off commands must not inherit Cloud identity. + resolved.Options = Options{} + } + saved := resolved.fields() + for i, setting := range s.fields() { + if cmd.Root().PersistentFlags().Changed(setting.name) { + *saved[i].value = *setting.value + } else if value, ok := os.LookupEnv("FCTL_" + strings.ToUpper(strings.ReplaceAll(setting.name, "-", "_"))); ok { + *saved[i].value = value + } + } + return resolved.Options, entry, name, dir, nil +} + +func (s *Settings) Input(cmd *cobra.Command) Options { + resolved := Settings{} + fields := resolved.fields() + for i, setting := range s.fields() { + if cmd.Root().PersistentFlags().Changed(setting.name) { + *fields[i].value = *setting.value + } else { + *fields[i].value = os.Getenv("FCTL_" + strings.ToUpper(strings.ReplaceAll(setting.name, "-", "_"))) + } + } + return resolved.Options +} + +func (s *Settings) Client(ctx context.Context, cmd *cobra.Command, service string) (*api.Client, error) { + if err := presentation.ValidateFormat(s.Output); err != nil { + return nil, err + } + if s.Timeout <= 0 { + return nil, fmt.Errorf("timeout must be positive") + } + options, entry, name, dir, err := s.Resolve(cmd) + if err != nil { + return nil, err + } + if err := Validate(options); err != nil { + return nil, err + } + client := s.HTTPClient(cmd.ErrOrStderr()) + if service == "cloud" { + if cloudCommandTarget(cmd) == "organization" { + return s.cloudOrganizationClient(ctx, cmd, client, options, entry, name, dir) + } + return s.membershipClient(ctx, cmd, client, options, entry, name, dir) + } + if options.AuthMode == "cloud" { + return s.cloudServiceClient(ctx, cmd, service, client, options, entry, name, dir) + } + base, err := endpoint(options, service) + if err != nil { + return nil, err + } + switch options.AuthMode { + case "none": + case "client-credentials": + client, err = credentialClient(ctx, client, options) + default: + return nil, fmt.Errorf("choose --auth-mode=none, client-credentials or cloud") + } + if err != nil { + return nil, err + } + return api.New(base, client) +} + +func (s *Settings) cloudServiceClient(ctx context.Context, cmd *cobra.Command, service string, client *http.Client, options Options, entry Entry, name, dir string) (*api.Client, error) { + options, err := s.selectCloudTarget(ctx, cmd, client, options, entry, name, dir) + if err != nil { + return nil, err + } + authenticated, base, target, err := savedCloudClient(ctx, client, options, entry, name, dir, cmd.ErrOrStderr(), s.BrowserOpener()) + if err != nil { + return nil, err + } + options, err = applyCloudTarget(cmd, options, target.Organization, target.Stack) + if err != nil { + return nil, err + } + if service != "stack" { + base = strings.TrimRight(base, "/") + "/api/" + service + } + resolved, err := api.New(base, authenticated) + if err != nil { + return nil, err + } + return resolved.WithContext(map[string]string{"organization": options.Organization, "stack": options.Stack}), nil +} + +func savedCloudClient(ctx context.Context, client *http.Client, options Options, entry Entry, name, dir string, out io.Writer, open func(context.Context, string) error) (*http.Client, string, cloud.Options, error) { + if cloudIdentity(options) != cloudIdentity(entry.Options) { + return nil, "", cloud.Options{}, fmt.Errorf("cloud identity settings changed; log in again with the chosen issuer and client") + } + if entry.Session == nil { + return nil, "", cloud.Options{}, fmt.Errorf("profile is not logged in; run fctl login --profile %s", name) + } + coordinator := cloudCoordinator(dir, name, entry) + if entry.Session.Options.Stack != "" { + if options != entry.Options { + return nil, "", cloud.Options{}, fmt.Errorf("this older session targets a single stack; run fctl login before changing its target") + } + authenticated, endpoint, err := cloud.Client(ctx, client, entry.Session, nil, coordinator) + return authenticated, endpoint, entry.Session.Options, err + } + return cloud.ClientForResolvedTarget(ctx, client, entry.Session, cloud.Options{Issuer: options.Issuer, ClientID: options.ClientID, Organization: options.Organization, Stack: options.Stack}, out, open, coordinator) +} + +func (s *Settings) BrowserOpener() func(context.Context, string) error { + if s.NoBrowser { + return nil + } + return browser.Open +} + +func cloudIdentity(options Options) Options { + options.Organization, options.Stack = "", "" + if options.Issuer == "" { + options.Issuer = cloud.DefaultIssuer + } + if options.ClientID == "" { + options.ClientID = "fctl" + } + return options +} + +func Validate(o Options) error { + for _, value := range []string{o.StackURL, o.LedgerURL, o.AuthURL, o.ConnectivityURL, o.TokenURL, o.Issuer} { + if value == "" { + continue + } + if err := api.ValidateURL(value); err != nil { + return err + } + } + switch o.AuthMode { + case "none": + if err := validateAnonymous(o); err != nil { + return err + } + case "client-credentials": + if err := validateCredentials(o); err != nil { + return err + } + case "cloud": + return validateCloud(o) + default: + return fmt.Errorf("choose auth-mode none, client-credentials or cloud") + } + if o.AuthMode != "cloud" && o.StackURL == "" && o.LedgerURL == "" && o.AuthURL == "" && o.ConnectivityURL == "" { + return fmt.Errorf("configure stack-url, ledger-url, auth-url or connectivity-url") + } + return nil +} + +func validateAnonymous(o Options) error { + if o.ClientID != "" || o.TokenURL != "" || o.Scopes != "" || o.Issuer != "" || o.Organization != "" || o.Stack != "" { + return fmt.Errorf("auth-mode=none cannot include OAuth2 or Cloud settings") + } + return nil +} + +func validateCredentials(o Options) error { + if o.ClientID == "" || o.TokenURL == "" { + return fmt.Errorf("client-credentials requires client-id and token-url") + } + if o.Organization != "" || o.Stack != "" || o.Issuer != "" { + return fmt.Errorf("client-credentials cannot include Cloud settings") + } + for _, endpoint := range []string{o.TokenURL, o.StackURL, o.LedgerURL, o.AuthURL, o.ConnectivityURL} { + if endpoint == "" { + continue + } + if err := api.ValidateSecureURL(endpoint); err != nil { + return err + } + } + return nil +} + +func validateCloud(o Options) error { + if o.StackURL != "" || o.LedgerURL != "" || o.AuthURL != "" || o.ConnectivityURL != "" || o.TokenURL != "" || o.Scopes != "" { + return fmt.Errorf("cloud service endpoints and scopes are supplied by Membership") + } + issuer := cmp.Or(o.Issuer, cloud.DefaultIssuer) + return api.ValidateSecureURL(issuer) +} + +func endpoint(o Options, service string) (string, error) { + if o.AuthMode == "cloud" { + return "", nil + } + if service == "stack" { + if o.StackURL == "" { + return "", fmt.Errorf("configure --stack-url for stack utilities") + } + return o.StackURL, nil + } + var direct string + switch service { + case "ledger": + direct = o.LedgerURL + case "auth": + direct = o.AuthURL + case "connectivity": + direct = o.ConnectivityURL + case "payments", "orchestration", "reconciliation", "wallets", "webhooks": + default: + return "", fmt.Errorf("unsupported service %q", service) + } + if direct != "" { + return direct, nil + } + if o.StackURL != "" { + return strings.TrimRight(o.StackURL, "/") + "/api/" + service, nil + } + return "", fmt.Errorf("configure --%s-url or --stack-url", service) +} + +func credentialClient(ctx context.Context, base *http.Client, o Options) (*http.Client, error) { + secret := os.Getenv("FCTL_CLIENT_SECRET") + if o.ClientID == "" || secret == "" { + return nil, fmt.Errorf("client-credentials requires --client-id and FCTL_CLIENT_SECRET") + } + if o.TokenURL == "" { + return nil, fmt.Errorf("client-credentials requires --token-url") + } + if err := api.ValidateURL(o.TokenURL); err != nil { + return nil, err + } + config := clientcredentials.Config{ClientID: o.ClientID, ClientSecret: secret, TokenURL: o.TokenURL, Scopes: strings.Fields(o.Scopes)} + ctx = context.WithValue(ctx, oauth2.HTTPClient, base) + client := oauth2.NewClient(ctx, safeSource{config.TokenSource(ctx)}) + client.Timeout = base.Timeout + client.CheckRedirect = base.CheckRedirect + return client, nil +} + +type safeSource struct{ source oauth2.TokenSource } + +func (s safeSource) Token() (*oauth2.Token, error) { + token, err := s.source.Token() + if err != nil { + return nil, fmt.Errorf("OAuth2 authentication failed; check client credentials and token endpoint") + } + return token, nil +} + +// HTTPClient supplies the shared transport for services and authentication. +func (s *Settings) HTTPClient(out io.Writer) *http.Client { + client := &http.Client{Timeout: s.Timeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + if s.Debug { + client.Transport = httpdebug.New(nil, out) + } + return client +} diff --git a/internal/connection/settings_test.go b/internal/connection/settings_test.go new file mode 100644 index 00000000..52cf25f8 --- /dev/null +++ b/internal/connection/settings_test.go @@ -0,0 +1,272 @@ +package connection_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" +) + +func commandWith(s *connection.Settings, args ...string) *cobra.Command { + root := &cobra.Command{Use: "test"} + s.Bind(root) + root.SetArgs(args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + return root +} + +func TestResolvePrecedence(t *testing.T) { + dir := t.TempDir() + store := connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": {Options: connection.Options{AuthMode: "none", LedgerURL: "http://saved:9000"}}}} + if err := connection.Save(dir, store); err != nil { + t.Fatal(err) + } + t.Setenv("FCTL_LEDGER_URL", "http://environment:9000") + s := &connection.Settings{} + root := commandWith(s, "--config-dir", dir, "--ledger-url", "http://flag:9000") + root.RunE = func(cmd *cobra.Command, _ []string) error { + opts, _, _, _, err := s.Resolve(cmd) + if err != nil { + return err + } + if opts.LedgerURL != "http://flag:9000" { + t.Fatalf("resolved %s", opts.LedgerURL) + } + if opts.AuthMode != "none" { + t.Fatal("lost saved auth mode") + } + return nil + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestDirectOverrideDoesNotInheritCloudIdentity(t *testing.T) { + t.Parallel() + dir := t.TempDir() + entry := connection.NewEntry(connection.Options{AuthMode: "cloud", Issuer: "https://membership.example.com", Organization: "org", Stack: "stack"}) + if err := connection.Save(dir, connection.Store{Active: "cloud", Connections: map[string]connection.Entry{"cloud": entry}}); err != nil { + t.Fatal(err) + } + s := &connection.Settings{} + root := commandWith(s, "--config-dir", dir, "--auth-mode", "none", "--ledger-url", "http://localhost:9000") + root.RunE = func(cmd *cobra.Command, _ []string) error { + opts, _, _, _, err := s.Resolve(cmd) + if err != nil { + return err + } + if opts.Issuer != "" || opts.Organization != "" || opts.Stack != "" { + t.Fatal("direct command inherited Cloud identity") + } + return connection.Validate(opts) + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } +} + +func TestUnauthenticatedGatewayAndDirect(t *testing.T) { + for _, tc := range []struct{ flag, path string }{{"--stack-url", "/gateway/api/ledger/v3/"}, {"--ledger-url", "/gateway/v3/"}} { + t.Run(tc.flag, func(t *testing.T) { + server := httptest.NewServer(anonymousHandler(t, tc.path)) + t.Cleanup(server.Close) + s := &connection.Settings{} + root := commandWith(s, "--config-dir", t.TempDir(), tc.flag, server.URL+"/gateway", "--auth-mode", "none") + root.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := s.Client(cmd.Context(), cmd, "ledger") + if err != nil { + return err + } + _, err = client.Do(cmd.Context(), http.MethodGet, "/v3/", nil, nil, nil) + return err + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestClientCredentialsAndRedirectIsolation(t *testing.T) { + t.Setenv("FCTL_CLIENT_SECRET", "private-secret") + stolen := false + other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { stolen = true })) + t.Cleanup(other.Close) + server := httptest.NewServer(credentialsHandler(t, other.URL)) + t.Cleanup(server.Close) + s := &connection.Settings{} + root := commandWith(s, "--config-dir", t.TempDir(), "--ledger-url", server.URL, "--auth-mode", "client-credentials", "--client-id", "service-client", "--token-url", server.URL+"/token") + root.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := s.Client(cmd.Context(), cmd, "ledger") + if err != nil { + return err + } + _, err = client.Do(cmd.Context(), http.MethodGet, "/v3/", nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "302") { + return fmt.Errorf("unexpected redirect result: %w", err) + } + return nil + } + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if stolen { + t.Fatal("followed redirect with credentials") + } +} + +func TestTokenErrorsHideSecrets(t *testing.T) { + t.Setenv("FCTL_CLIENT_SECRET", "private-secret") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + if _, err := w.Write([]byte(`{"error":"invalid_client","error_description":"private-secret"}`)); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + s := &connection.Settings{} + root := commandWith(s, "--config-dir", t.TempDir(), "--ledger-url", server.URL, "--auth-mode", "client-credentials", "--client-id", "client", "--token-url", server.URL+"/token") + root.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := s.Client(cmd.Context(), cmd, "ledger") + if err != nil { + return err + } + _, err = client.Do(cmd.Context(), http.MethodGet, "/v3/", nil, nil, nil) + return err + } + err := root.ExecuteContext(t.Context()) + if err == nil || strings.Contains(err.Error(), "private-secret") { + t.Fatalf("unsafe error: %v", err) + } +} + +func TestStoreIsPrivateAndRejectsEscapingSymlink(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "config") + store := connection.Store{Active: "local", Connections: map[string]connection.Entry{"local": {Options: connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}}}} + if err := connection.Save(dir, store); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "connections.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("file mode=%v", info.Mode()) + } + loaded, err := connection.Load(dir) + if err != nil { + t.Fatal(err) + } + if loaded.Active != "local" { + t.Fatal("store failed roundtrip") + } + outside := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(outside, []byte(`{"active":"stolen"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(dir, "connections.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "connections.json")); err != nil { + t.Fatal(err) + } + if _, err := connection.Load(dir); err == nil { + t.Fatal("followed escaping configuration symlink") + } +} + +func TestValidateConfiguration(t *testing.T) { + t.Parallel() + for _, opts := range []connection.Options{ + {}, + {AuthMode: "none", LedgerURL: "http://local", ClientID: "client"}, + {AuthMode: "client-credentials", LedgerURL: "http://local"}, + {AuthMode: "cloud", Issuer: "http://issuer"}, + {AuthMode: "cloud", Issuer: "https://issuer", Organization: "org", Stack: "stack", LedgerURL: "https://other"}, + {AuthMode: "none", LedgerURL: "https://user:secret@host"}, //nolint:gosec // Invalid synthetic URL is a rejection fixture, not a credential. + } { + if connection.Validate(opts) == nil { + t.Error("accepted invalid configuration") + } + } +} + +func TestCanceledDirectRequest(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + s := &connection.Settings{} + root := commandWith(s, "--config-dir", t.TempDir(), "--ledger-url", "http://127.0.0.1:1", "--auth-mode", "none") + var out bytes.Buffer + root.SetOut(&out) + root.RunE = func(cmd *cobra.Command, _ []string) error { + client, err := s.Client(cmd.Context(), cmd, "ledger") + if err != nil { + return err + } + _, err = client.Do(cmd.Context(), http.MethodGet, "/v3/", nil, nil, nil) + return err + } + if err := root.ExecuteContext(ctx); err == nil { + t.Fatal("canceled command succeeded") + } +} + +func anonymousHandler(t *testing.T, path string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + t.Errorf("path=%s", r.URL.Path) + } + if r.Header.Get("Authorization") != "" { + t.Error("unexpected authorization") + } + if _, err := w.Write([]byte(`{"data":[]}`)); err != nil { + t.Error(err) + } + }) +} +func credentialsHandler(t *testing.T, redirect string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + serveCredentialToken(t, w, r) + return + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("missing bearer") + } + http.Redirect(w, r, redirect, http.StatusFound) + }) +} +func serveCredentialToken(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + id, secret, _ := r.BasicAuth() + if id != "service-client" || secret != "private-secret" { + t.Error("credentials missing") + } + if r.PostForm.Get("grant_type") != "client_credentials" { + t.Error("wrong grant") + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"access_token":"test-token","token_type":"Bearer","expires_in":3600}`)); err != nil { + t.Error(err) + } +} diff --git a/internal/connection/stack_version.go b/internal/connection/stack_version.go new file mode 100644 index 00000000..957712d6 --- /dev/null +++ b/internal/connection/stack_version.go @@ -0,0 +1,49 @@ +package connection + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/api" +) + +// StackVersion reads the Cloud compatibility line from Membership. Direct +// service connections have no stack line and use their service API version. +func (s *Settings) StackVersion(ctx context.Context, cmd *cobra.Command) (string, error) { + options, _, _, _, err := s.Resolve(cmd) + if err != nil { + return "", err + } + if options.AuthMode != "cloud" { + return "", nil + } + if options.Organization == "" || options.Stack == "" { + return "", fmt.Errorf("choose an organization and stack before selecting a service provider") + } + client, err := s.Client(ctx, cmd, "cloud") + if err != nil { + return "", err + } + data, err := client.Do(ctx, http.MethodGet, api.Path("organizations", options.Organization, "stacks", options.Stack), nil, nil, nil) + if err != nil { + return "", fmt.Errorf("discover stack compatibility line: %w", err) + } + var result struct { + Data struct { + Version string `json:"version"` + ID string `json:"id"` + Organization string `json:"organizationId"` + } `json:"data"` + } + if err = json.Unmarshal(data, &result); err != nil { + return "", err + } + if result.Data.ID != options.Stack || result.Data.Organization != options.Organization || result.Data.Version == "" { + return "", fmt.Errorf("membership returned an incomplete or mismatched stack identity") + } + return result.Data.Version, nil +} diff --git a/internal/connection/store.go b/internal/connection/store.go new file mode 100644 index 00000000..dca158c4 --- /dev/null +++ b/internal/connection/store.go @@ -0,0 +1,217 @@ +package connection + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/gofrs/flock" + + "github.com/formancehq/fctl/v4/internal/cloud" +) + +// Options is persisted configuration. Client secrets are read only from the environment. +type Options struct { + StackURL string `json:"stackURL,omitzero"` + LedgerURL string `json:"ledgerURL,omitzero"` + AuthURL string `json:"authURL,omitzero"` + ConnectivityURL string `json:"connectivityURL,omitzero"` + AuthMode string `json:"authMode,omitzero"` + TokenURL string `json:"tokenURL,omitzero"` + ClientID string `json:"clientID,omitzero"` + Scopes string `json:"scopes,omitzero"` + Issuer string `json:"issuer,omitzero"` + Organization string `json:"organization,omitzero"` + Stack string `json:"stack,omitzero"` +} + +type Entry struct { + Revision string `json:"revision"` + Options Options `json:"options"` + Session *cloud.Session `json:"session,omitzero"` +} + +// Update serializes read/modify/write across CLI processes. The lock is held +// only for local persistence, never while waiting for an OAuth2 response. +func Update(ctx context.Context, directory string, change func(*Store) error) error { + return withLock(ctx, directory, "connections.lock", func() error { + store, err := Load(directory) + if err != nil { + return err + } + if err := change(&store); err != nil { + return err + } + return Save(directory, store) + }) +} +func withLock(ctx context.Context, directory, name string, work func() error) (err error) { + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + root, err := os.OpenRoot(directory) + if err != nil { + return err + } + defer func() { err = errors.Join(err, root.Close()) }() + // Check containment before the portable lock library opens its fixed file. + file, err := openLockFile(root, name) + if err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + lock := flock.New(filepath.Join(directory, name)) + locked, err := lock.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return err + } + if !locked { + return fmt.Errorf("profile store lock unavailable") + } + defer func() { err = errors.Join(err, lock.Close()) }() + return work() +} + +func openLockFile(root *os.Root, name string) (*os.File, error) { + file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if err == nil { + return file, nil + } + if errors.Is(err, os.ErrExist) || errors.Is(err, os.ErrNotExist) { + // Another creator may have won between Root's path checks and openat. + return root.OpenFile(name, os.O_RDWR, 0o600) + } + return nil, err +} + +func NewEntry(options Options) Entry { return Entry{Options: options, Revision: rand.Text()} } + +// SaveSession refuses results from commands whose connection was changed or +// logged out while their authentication request was in flight. +func SaveSession(ctx context.Context, directory, name string, expected *string, session *cloud.Session) error { + var revision string + err := Update(ctx, directory, func(store *Store) error { + entry, exists := store.Connections[name] + if !exists || entry.Revision != *expected { + return fmt.Errorf("profile changed during authentication; retry with its current settings") + } + entry.Session = session + entry.Revision = rand.Text() + revision = entry.Revision + store.Connections[name] = entry + return nil + }) + if err == nil { + *expected = revision + } + return err +} + +// SaveLoginSession commits profile settings, identity and active selection only +// after successful authentication. An empty revision means the name was absent. +func SaveLoginSession(ctx context.Context, directory, name string, expected *string, options Options, session *cloud.Session) error { + var revision string + err := Update(ctx, directory, func(store *Store) error { + current, exists := store.Connections[name] + if (*expected == "" && exists) || (*expected != "" && (!exists || current.Revision != *expected)) { + return fmt.Errorf("profile changed during login; retry with its current settings") + } + entry := NewEntry(options) + entry.Session = session + revision = entry.Revision + store.Connections[name] = entry + store.Active = name + return nil + }) + if err == nil { + *expected = revision + } + return err +} + +type Store struct { + Active string `json:"active"` + Connections map[string]Entry `json:"connections"` +} + +func DefaultDirectory() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "formance", "fctl", "v4"), nil +} + +func ValidateName(name string) error { + if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`).MatchString(name) { + return fmt.Errorf("profile name must contain 1-64 letters, digits, underscores or hyphens") + } + return nil +} + +func Load(directory string) (result Store, err error) { + store := Store{Connections: make(map[string]Entry)} + root, err := os.OpenRoot(directory) + if errors.Is(err, os.ErrNotExist) { + return store, nil + } + if err != nil { + return store, fmt.Errorf("open profile directory: %w", err) + } + defer func() { err = errors.Join(err, root.Close()) }() + data, err := root.ReadFile("connections.json") + if errors.Is(err, os.ErrNotExist) { + return store, nil + } + if err != nil { + return store, fmt.Errorf("read profiles: %w", err) + } + if err := json.Unmarshal(data, &store); err != nil { + return store, fmt.Errorf("decode profiles: %w", err) + } + if store.Connections == nil { + store.Connections = make(map[string]Entry) + } + return store, nil +} + +// Save atomically replaces private configuration, including Cloud refresh tokens. +func Save(directory string, store Store) (err error) { + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + root, err := os.OpenRoot(directory) + if err != nil { + return err + } + defer func() { err = errors.Join(err, root.Close()) }() + name := ".connections-" + rand.Text() + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { + if cleanupErr := root.Remove(name); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) { + err = errors.Join(err, cleanupErr) + } + }() + _, writeErr := file.Write(data) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return root.Rename(name, "connections.json") +} diff --git a/internal/connection/store_test.go b/internal/connection/store_test.go new file mode 100644 index 00000000..804e44bb --- /dev/null +++ b/internal/connection/store_test.go @@ -0,0 +1,74 @@ +package connection_test + +import ( + "fmt" + "sync" + "testing" + + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" +) + +func TestUpdateSerializesWriters(t *testing.T) { + t.Parallel() + dir := t.TempDir() + var workers sync.WaitGroup + for i := range 20 { + workers.Go(func() { + if err := connection.Update(t.Context(), dir, func(store *connection.Store) error { + store.Connections[fmt.Sprintf("profile-%d", i)] = connection.NewEntry(connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}) + return nil + }); err != nil { + t.Error(err) + } + }) + } + workers.Wait() + store, err := connection.Load(dir) + if err != nil { + t.Fatal(err) + } + if len(store.Connections) != 20 { + t.Fatalf("lost concurrent updates: %d profiles", len(store.Connections)) + } +} + +func TestStaleSessionCannotUndoLogoutOrReplacement(t *testing.T) { + t.Parallel() + for _, operation := range []string{"logout", "replace", "delete"} { + t.Run(operation, func(t *testing.T) { + assertStaleSessionRejected(t, operation) + }) + } +} + +func assertStaleSessionRejected(t *testing.T, operation string) { + t.Helper() + dir := t.TempDir() + entry := connection.NewEntry(connection.Options{AuthMode: "cloud"}) + entry.Session = &cloud.Session{} + if err := connection.Save(dir, connection.Store{Connections: map[string]connection.Entry{"cloud": entry}}); err != nil { + t.Fatal(err) + } + expected := entry.Revision + if err := connection.Update(t.Context(), dir, func(store *connection.Store) error { + if operation == "delete" { + delete(store.Connections, "cloud") + return nil + } + store.Connections["cloud"] = connection.NewEntry(entry.Options) + return nil + }); err != nil { + t.Fatal(err) + } + if err := connection.SaveSession(t.Context(), dir, "cloud", &expected, &cloud.Session{}); err == nil { + t.Fatal("accepted stale authentication result") + } + store, err := connection.Load(dir) + if err != nil { + t.Fatal(err) + } + if store.Connections["cloud"].Session != nil { + t.Fatal("resurrected logged-out session") + } +} diff --git a/internal/deployserverclient/.devcontainer/README.md b/internal/deployserverclient/.devcontainer/README.md deleted file mode 100644 index a1810600..00000000 --- a/internal/deployserverclient/.devcontainer/README.md +++ /dev/null @@ -1,30 +0,0 @@ - -> **Remember to shutdown a GitHub Codespace when it is not in use!** - -# Dev Containers Quick Start - -The default location for usage snippets is the `samples` directory. - -## Running a Usage Sample - -A sample usage example has been provided in a `root.go` file. As you work with the SDK, it's expected that you will modify these samples to fit your needs. To execute this particular snippet, use the command below. - -``` -go run root.go -``` - -## Generating Additional Usage Samples - -The speakeasy CLI allows you to generate more usage snippets. Here's how: - -- To generate a sample for a specific operation by providing an operation ID, use: - -``` -speakeasy generate usage -s ../../openapi/deployserver.yaml -l go -i {INPUT_OPERATION_ID} -o ./samples -``` - -- To generate samples for an entire namespace (like a tag or group name), use: - -``` -speakeasy generate usage -s ../../openapi/deployserver.yaml -l go -n {INPUT_TAG_NAME} -o ./samples -``` diff --git a/internal/deployserverclient/.devcontainer/devcontainer.json b/internal/deployserverclient/.devcontainer/devcontainer.json deleted file mode 100644 index 92f5ba5b..00000000 --- a/internal/deployserverclient/.devcontainer/devcontainer.json +++ /dev/null @@ -1,59 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/go -{ - "name": "Go", - "image": "mcr.microsoft.com/devcontainers/go:1-1.22-bullseye", - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "sudo chmod +x ./.devcontainer/setup.sh && ./.devcontainer/setup.sh", - "customizations": { - "vscode": { - "extensions": [ - "golang.go", - "github.vscode-pull-request-github" // Github interaction - ], - "settings": { - "files.eol": "\n", - "editor.formatOnSave": true, - "go.buildTags": "", - "go.toolsEnvVars": { - "CGO_ENABLED": "0" - }, - "go.useLanguageServer": true, - "go.testEnvVars": { - "CGO_ENABLED": "1" - }, - "go.testFlags": [ - "-v", - "-race" - ], - "go.testTimeout": "60s", - "go.coverOnSingleTest": true, - "go.coverOnSingleTestFile": true, - "go.coverOnTestPackage": true, - "go.lintTool": "golangci-lint", - "go.lintOnSave": "package", - "[go]": { - "editor.codeActionsOnSave": { - "source.organizeImports": true - } - }, - "gopls": { - "usePlaceholders": false, - "staticcheck": true, - "vulncheck": "Imports" - } - } - }, - "codespaces": { - "openFiles": [ - ".devcontainer/README.md" - ] - } - } - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} \ No newline at end of file diff --git a/internal/deployserverclient/.devcontainer/setup.sh b/internal/deployserverclient/.devcontainer/setup.sh deleted file mode 100644 index 0082d5d8..00000000 --- a/internal/deployserverclient/.devcontainer/setup.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -# Install the speakeasy CLI -curl -fsSL https://raw.githubusercontent.com/speakeasy-api/speakeasy/main/install.sh | sh - -# Setup samples directory -rmdir samples || true -mkdir samples - -# Go module commands -go mod download -go mod tidy - -# Generate starter usage sample with speakeasy -speakeasy generate usage -s ../../openapi/deployserver.yaml -l go -o samples/root.go \ No newline at end of file diff --git a/internal/deployserverclient/.gitattributes b/internal/deployserverclient/.gitattributes deleted file mode 100644 index e6a99441..00000000 --- a/internal/deployserverclient/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# This allows generated code to be indexed correctly -*.go linguist-generated=false \ No newline at end of file diff --git a/internal/deployserverclient/.gitignore b/internal/deployserverclient/.gitignore deleted file mode 100644 index 04c9c850..00000000 --- a/internal/deployserverclient/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.DS_Store -**/.speakeasy/temp/ -**/.speakeasy/logs/ -.env -.env.local -# .gitignore -.speakeasy/reports diff --git a/internal/deployserverclient/.speakeasy/gen.lock b/internal/deployserverclient/.speakeasy/gen.lock deleted file mode 100644 index 59f8436a..00000000 --- a/internal/deployserverclient/.speakeasy/gen.lock +++ /dev/null @@ -1,1134 +0,0 @@ -lockVersion: 2.0.0 -id: 12df0dd4-09cf-49f5-8116-53c77b28ba01 -management: - docChecksum: f5ce490c03f43321db414fea57886595 - docVersion: 0.1.0 - speakeasyVersion: 1.759.2 - generationVersion: 2.869.23 - releaseVersion: 0.1.1 - configChecksum: 65bf761ed2e45c2edc12016a208c0bb8 -persistentEdits: - generation_id: 8bb554ab-93f0-40eb-9efe-f19356a5b00c - pristine_commit_hash: af4da278bcd978adddbc4dde4909a36db89f32e1 - pristine_tree_hash: aa970b4f0d11f7d728a389b23d75bf56007c544d -features: - go: - acceptHeaders: 2.81.2 - additionalDependencies: 0.1.0 - constsAndDefaults: 0.1.14 - core: 3.13.40 - defaultEnabledRetries: 0.2.0 - devContainers: 2.90.0 - downloadStreams: 0.1.2 - envVarSecurityUsage: 0.3.2 - flattening: 2.81.2 - globalSecurity: 2.82.13 - globalSecurityCallbacks: 0.1.0 - globalSecurityFlattening: 0.1.0 - globalServerURLs: 2.83.1 - intellisenseMarkdownSupport: 0.1.0 - responseFormat: 0.1.2 - retries: 2.84.2 - sdkHooks: 0.3.0 - uploadStreams: 0.1.0 -trackedFiles: - .devcontainer/README.md: - id: b170c0f184ac - last_write_checksum: sha1:55485c0c6f488af7b6d15229fef6b69c113c2105 - pristine_git_object: a1810600850c8957c76ebb640e0e9f125b36864d - .devcontainer/devcontainer.json: - id: b34062a34eb1 - last_write_checksum: sha1:a518b2fc2f92e998593a1dcb935db4ca1eb2e2f1 - pristine_git_object: 92f5ba5b54156225322fb4b09d2acf4abde9abc9 - .devcontainer/setup.sh: - id: 5f1dfbfeb8eb - last_write_checksum: sha1:84cfba8802230186e48c55b5b99b4697215e0b96 - pristine_git_object: 0082d5d8a10b1ebe1df5711ccd334aae52d994ad - .gitattributes: - id: 24139dae6567 - last_write_checksum: sha1:84b0115789fc3ea52e31df701d8a27f76077e29f - pristine_git_object: e6a994416d0f527912d2d272e2583458f4fc9bcb - USAGE.md: - id: 3aed33ce6e6f - last_write_checksum: sha1:e64f0c797a0a08e9f7d1cf3de0b5462343d21394 - pristine_git_object: 5e9db16f4c1c967289b654d79c909291e1e52e8f - deployserver.go: - id: 4e88cb1c88d3 - last_write_checksum: sha1:6a002f47279489f88f6276eee8ef47bb1e92a12c - pristine_git_object: 9212799b25b650560a7385357fbe56fdd6b8a05f - docs/models/apierrors/error.md: - id: 7c6e7719d689 - last_write_checksum: sha1:e3115a5a7de0780066dc4a2256c42f7c0976447b - pristine_git_object: 5450343d4870b85f53cb47ab9190928cf72fb86f - docs/models/components/app.md: - id: a31b0d7743a7 - last_write_checksum: sha1:002bd53acd9a8208b9aedb5e6ae368fbc8c8851f - pristine_git_object: c77eac6a3543a43397427958811562bf754f65fc - docs/models/components/appcurrentmanifest.md: - id: 2dd2ef03f47d - last_write_checksum: sha1:59effaf340c7928d5de891115eed2d5fd9a292af - pristine_git_object: 8d2c8375985e46c50c132fad56f699f663f60736 - docs/models/components/appmanifestdivergence.md: - id: a0580427f5bb - last_write_checksum: sha1:afe31b7e636c6293e67149addf9062b140e6d7c2 - pristine_git_object: 92ae72e8ef09024eb6d1175e07fb8107a563602a - docs/models/components/appresponse.md: - id: d91756763bd8 - last_write_checksum: sha1:bc1cb4578672a35de623e2c851eb3cd7ac1ef282 - pristine_git_object: 4b41a7e872fa8e853f9b1121498b03f2c9ff7484 - docs/models/components/attachmanifestrequest.md: - id: cb3dfde05df4 - last_write_checksum: sha1:2a6c296c5ebb62b3ceee718540389ff4e0229ac4 - pristine_git_object: b1ecb225ac6534877f822476b25b56d539234523 - docs/models/components/createapprequest.md: - id: f64ccea218c9 - last_write_checksum: sha1:8281f54cb47b92d3af4f645c6029dd2941ea7572 - pristine_git_object: 8d2ae919f0a7e732719ebf8ed5b29c959c68a54b - docs/models/components/createdeploymentrequest.md: - id: f4fbf8bda1d8 - last_write_checksum: sha1:676927d3710e088975778a88a91cb14d534e2453 - pristine_git_object: 03f669ebc8fbefa0a3d544e4ec3d4daca2c828ad - docs/models/components/createmanifestresponse.md: - id: 2a7b75671c99 - last_write_checksum: sha1:1d8e04b9cf3d114a7663311c9f167ff750a130bb - pristine_git_object: c686febb6d115bc4c25f8727ed39b6d75eb18fb6 - docs/models/components/createvariablerequest.md: - id: 1656d1204270 - last_write_checksum: sha1:650be824e43882247b0beb008425e05b8182f946 - pristine_git_object: a18fbe1a7c9214e01ab88c294a26b33e5bf336e7 - docs/models/components/createvariableresponse.md: - id: 77cc5c4876b1 - last_write_checksum: sha1:6f884cdcec2003eaa5bf016cc521b269869a0628 - pristine_git_object: 82ba7c122ddd49378c9e1880cde26477645bb5ae - docs/models/components/data.md: - id: 3f89c52c2b17 - last_write_checksum: sha1:e578e08fe6a5e194a0292de6f85a28c328774c52 - pristine_git_object: f17677259850f82838a1bd8112b6c42f6fec5deb - docs/models/components/deleteappresponse.md: - id: f57e1423f086 - last_write_checksum: sha1:7e55b75abfc7d75c181f5712f19696bba389dbbc - pristine_git_object: 0d8b766a83b8979e46cd756ef04bb06821f92e2f - docs/models/components/deploymentresource.md: - id: cdd39d4f1853 - last_write_checksum: sha1:a41756777d1668bcfa7059800ae31b9b1972ecec - pristine_git_object: 4bf3d77b919ee372715c9a8ad9ed93be1eeade72 - docs/models/components/deploymentresponse.md: - id: 30bd20db7b83 - last_write_checksum: sha1:5a7aab41fc5ad3264cc5ecc0a7a4e32d5fd3b2e3 - pristine_git_object: e5a1bb0f3398f1826e9712997c145579c6151784 - docs/models/components/diagnostic.md: - id: 201e27a8a975 - last_write_checksum: sha1:317229cc87d1448ed825e407aca88c0adb7442b6 - pristine_git_object: 0d90d8be9c298a731d3f1fa6e5866ce0430960a3 - docs/models/components/error.md: - id: 8ccbea40d025 - last_write_checksum: sha1:e3115a5a7de0780066dc4a2256c42f7c0976447b - pristine_git_object: 5450343d4870b85f53cb47ab9190928cf72fb86f - docs/models/components/httpmetadata.md: - id: 728948c70168 - last_write_checksum: sha1:7c40945dac218ce5e5e11e7c907d27a3ea14c417 - pristine_git_object: df1fdd59f3c85e4ba70573ff7954b12ac1d33d08 - docs/models/components/kind.md: - id: 72cd1832e8b4 - last_write_checksum: sha1:e6a4325929adae5d5be88ccaa35a2fdb6b50bb38 - pristine_git_object: 983f988a4d90a4549a108b3f8852e39af4447cfe - docs/models/components/listappsresponse.md: - id: e0c61f1197fc - last_write_checksum: sha1:6a0d42593e1c1ecf470c3ed54dca3273f4a210b6 - pristine_git_object: 99147c1aed8ae0711c2c68230aa21934460e2888 - docs/models/components/listappsresponsecursor.md: - id: 6bc6b6706743 - last_write_checksum: sha1:ce39f99729035c93417553c89025f974d69cac26 - pristine_git_object: 93de14540768e09f9bee9a114f80b5ff78f9ef7b - docs/models/components/listdeploymentsresponse.md: - id: bd1553c8e405 - last_write_checksum: sha1:fe988a5676822ca0550ec89afdc02c32ab867252 - pristine_git_object: 2ce25a3965f00243b4af688c24a3c4acf21b68a9 - docs/models/components/listdeploymentsresponsecursor.md: - id: 72a23d339d69 - last_write_checksum: sha1:fbc76ed2adafb8ae4f140f558f0ed1d9365c3f89 - pristine_git_object: 2baeeaa9e59e018cae19f3e683c5a938c1eaddab - docs/models/components/listmanifestsresponse.md: - id: e34a1d20941d - last_write_checksum: sha1:9b080300659d61a4927c24d10824c823f3c32e25 - pristine_git_object: 74d925c603f701c322d13f61da8bac323fbfda61 - docs/models/components/listmanifestsresponsecursor.md: - id: 1c38804b29ff - last_write_checksum: sha1:9c5ccbc84020bab1fa2bdb5243d88a218bfb142e - pristine_git_object: 1c70dee747ef15947f1e33ff8acf6089470f5060 - docs/models/components/listmanifestversionsresponse.md: - id: b038781da9cd - last_write_checksum: sha1:b3307e20a493086cdee8fa6c93f02c1182bc96dd - pristine_git_object: cdfc28ea12871d16b7795ce8c9ec6ea1f7aa81d5 - docs/models/components/listmanifestversionsresponsecursor.md: - id: fb3a70cef30c - last_write_checksum: sha1:2fb9f2d85a37294b80c7fdeefaf42f16cb0f99b4 - pristine_git_object: 3f829a1ddada5fe4626b51a0f0996ba0affe69ce - docs/models/components/log.md: - id: e1037d748e03 - last_write_checksum: sha1:dad2f73e07553382b0ee972eebdb04229160e357 - pristine_git_object: 79e08b9c5265af867fa20da18fcdfdf66565238a - docs/models/components/manifest.md: - id: ab82543dfdb2 - last_write_checksum: sha1:4d67f726b6ade8e4aa1882b7881076d1dc7b063d - pristine_git_object: 407cb57380e88a7dc6eda8458d3227847bfaa880 - docs/models/components/manifestresponse.md: - id: 7599449e468c - last_write_checksum: sha1:80cd053266a1bfea9c64302b2bc5aafdbd2b7ea2 - pristine_git_object: 30a0ca2dbd06afc370502ab1d5d4b59d04fbe884 - docs/models/components/manifestversion.md: - id: 2e7a2a66eda5 - last_write_checksum: sha1:642d679209158616f7ddac2023dcddfbea3b9533 - pristine_git_object: 608ea35c773d2b382929c84bd41e199b18c73624 - docs/models/components/manifestversionresponse.md: - id: eb5b04b5ffcd - last_write_checksum: sha1:ab09b1be64e1f96be13d52b60d95cd13c306199d - pristine_git_object: fe14fcbdf85fca4009fceff944c4a6e8899ecbf8 - docs/models/components/readlogsresponse.md: - id: ec725d9b453a - last_write_checksum: sha1:e057941f9e846b32c1f3b7292f0d7ac7f2584d3f - pristine_git_object: 01c385db354d9b90ea38b029551a3bbe27ba6415 - docs/models/components/readvariablesresponse.md: - id: 9f63356b6b86 - last_write_checksum: sha1:026fa015644e4bd39562870e963228d7d107da4e - pristine_git_object: 56f1ad49a3904e5588b917c40103f53cb67a0f7e - docs/models/components/readvariablesresponsecursor.md: - id: c616d7701ed2 - last_write_checksum: sha1:d2e6762d758029e501287c6c1b5bbacea874faa3 - pristine_git_object: cc317df08105c5c792ff8f3cd67b5bb1f485bf51 - docs/models/components/security.md: - id: 54906b49ea28 - last_write_checksum: sha1:3d20d8f28e0ecfee6550ce09129b5ec06e25c24d - pristine_git_object: c15c11e5000d4802bfc26a199e249e734ad2b549 - docs/models/components/state.md: - id: 441de3ea6229 - last_write_checksum: sha1:2ee342b694271c882a14006f561ea81fc4c7ff3a - pristine_git_object: 4e5096c4a66891a9695900b59c559baeb773d4c1 - docs/models/components/updateapprequest.md: - id: 405074ca5d37 - last_write_checksum: sha1:d184f58db2351ccc0fa24f7a7606d63ec53984d4 - pristine_git_object: 61637f46a88b8bfa9bea421bbfab25668bfa47ea - docs/models/components/updatemanifestrequest.md: - id: 0ba0a64236c1 - last_write_checksum: sha1:ade50162ea19a7b5b5f609cf35b9abe6ccde6823 - pristine_git_object: 2df4cefaf6f3c0196b30afce2d6e1b54f58ea3c8 - docs/models/components/variable.md: - id: 106f069148a0 - last_write_checksum: sha1:de4bd21c2646d76e8b1038a16f0d9f6bfa5d6173 - pristine_git_object: 3f697c8284a53ec8c399d5523bf6ccabf68d40c8 - docs/models/components/variabledata.md: - id: 41fd2f11ae57 - last_write_checksum: sha1:3bee0384908c2d2164659d7f4a4b803c0c65cf43 - pristine_git_object: 42c414ca2c7efc9262ce82ee3bc8950191300beb - docs/models/operations/attachappmanifestrequest.md: - id: accaf996506a - last_write_checksum: sha1:e0fa8acf1f13cca121733c69619da7ecf6e00611 - pristine_git_object: 9a803bad8e5ea48b6f6ab281ff6ab182cd44f3cd - docs/models/operations/attachappmanifestresponse.md: - id: 60fe275da864 - last_write_checksum: sha1:545bf3c1ccba5638f97fc59967750e54ae3d85a5 - pristine_git_object: 58ad5c5b9c63d7332f56b467e26199517bccefce - docs/models/operations/createappresponse.md: - id: 06821c964f8b - last_write_checksum: sha1:357317e66e4b7703f26de3777fc0e3438b520cf6 - pristine_git_object: 7a6e7de9907d2e50a8828b1ebcf8961724f2532e - docs/models/operations/createappvariablerequest.md: - id: b6f7c3436795 - last_write_checksum: sha1:3cb0bfbabf7e4fcadc98532e8affad9e1dbe257b - pristine_git_object: 349840f195bbe4311368cf342f3a36510b107325 - docs/models/operations/createappvariableresponse.md: - id: c22ab459449d - last_write_checksum: sha1:2997e3902e9b95ad72de1a228b189e7e4256801c - pristine_git_object: 07452bcd2cc549c56e03ecc0d7cb3331351df8f9 - docs/models/operations/createdeploymentresponse.md: - id: 0ca0d30ebb83 - last_write_checksum: sha1:02db410d2fb68c69402305bc027eab8144a2652a - pristine_git_object: d6be343012c94546e5b4c47fe8a3820c0b71a52a - docs/models/operations/createmanifestrawrequest.md: - id: 9c0f7a9c45fc - last_write_checksum: sha1:732932b84bc2d28c88611a5d7ed9381cebeaa2f2 - pristine_git_object: dc7c3123dab32b7844747a39751fa55d43ed89fc - docs/models/operations/createmanifestrawresponse.md: - id: 9f74a7d48f44 - last_write_checksum: sha1:f19aecd730aa1dd6c522ef982d39767ee80014d0 - pristine_git_object: 3e8b398ef51b79f60250ec70fea3fcd418fa90fd - docs/models/operations/createmanifestrequest.md: - id: 00ac20b869e1 - last_write_checksum: sha1:2e580919ecea521706c39ee6ce9aae20adb7f5f9 - pristine_git_object: 319e78b7389b1fe271cf69793eb1c49ef82cfabf - docs/models/operations/createmanifestrequestbody.md: - id: be4690911b9f - last_write_checksum: sha1:a28556106cd526daf14d0784468bd8deefca9c40 - pristine_git_object: 6089e69cc8f0bacf3e67cc1de9cd7227122bb7fa - docs/models/operations/createmanifestresponse.md: - id: a7e3bf8362e6 - last_write_checksum: sha1:9de86374ce0bfe849f090c0ed0c309554ce7ce3f - pristine_git_object: ea064c638fde2204a9e737ad9cb60f93390fe312 - docs/models/operations/deleteapprequest.md: - id: 2514a7504692 - last_write_checksum: sha1:67156c64d8b1f95a5d59d8cf8bcab03ea82a423c - pristine_git_object: 63b4b8ba5e61e302f3b4a340752b7eb529257a65 - docs/models/operations/deleteappresponse.md: - id: e16e7da4c1e7 - last_write_checksum: sha1:bc431ecccceaf41b362bbaccda94309ddce19a49 - pristine_git_object: 41ecd5c7e2d96668e50cd25dbcb3bbd45bbf58b3 - docs/models/operations/deleteappvariablerequest.md: - id: 43f868ba1805 - last_write_checksum: sha1:1cdd03262fe383836802771ed64728f8a2992a0a - pristine_git_object: c44e4fd42fd75addf01d213cf3dab7d60479fb67 - docs/models/operations/deleteappvariableresponse.md: - id: dcb2ef841938 - last_write_checksum: sha1:d0801eb3f8fb163d540d5bffb917a9942ce707e2 - pristine_git_object: 6a2eb11056337dcb97c23a79ae38149d6e85fab8 - docs/models/operations/deletemanifestrequest.md: - id: b942f2d5560f - last_write_checksum: sha1:183ec89c4a6143c58cf43fe71043102c4d379a21 - pristine_git_object: 26341ed15996027d533febb5984e8a67263a4379 - docs/models/operations/deletemanifestresponse.md: - id: 7b9e8dfc4e3e - last_write_checksum: sha1:aec0c2e4f2c0be4ebedd4a65d1ae968b9fb4f753 - pristine_git_object: e8a81fae4aab84de1a75fff7223f70da5d88c2d9 - docs/models/operations/detachappmanifestrequest.md: - id: da916e1cfe7c - last_write_checksum: sha1:87897e24e0e18511163a5fb3594b795f16e54d57 - pristine_git_object: d11d0dba181909c1e06c00f4e1b2e32a5b592ba9 - docs/models/operations/detachappmanifestresponse.md: - id: eb6aafbf031b - last_write_checksum: sha1:6c93f0e8f124c89d0d547e20bc157302362f0a78 - pristine_git_object: fbbd1f5af2d20d35ab0340f38e562d06e7535de5 - docs/models/operations/listappsrequest.md: - id: 5efe8d3cde07 - last_write_checksum: sha1:49ea7178bb417c38ef42fff76eadaf3b7c6196af - pristine_git_object: 11c97674a4850750238e4289aa0123be54e62e41 - docs/models/operations/listappsresponse.md: - id: 2c4aaa805a52 - last_write_checksum: sha1:54f047b67d567b402899316ba8dc2bae26889df4 - pristine_git_object: 4da0499356a0ebed5078ddfcfb4d5541fb92b223 - docs/models/operations/listdeploymentsrequest.md: - id: 854523666c28 - last_write_checksum: sha1:2c10dfce2cef861a1f47529ecfcfef2a0f6705c7 - pristine_git_object: 2ee05d5a47d284e7b92a7f5461535d6443667497 - docs/models/operations/listdeploymentsresponse.md: - id: b3b1e4f8a3b8 - last_write_checksum: sha1:cfcd3928517093a37efd9602f83e8f7020a69599 - pristine_git_object: cc0444002ab9aa126d0cb3dc0eba3c4484128915 - docs/models/operations/listmanifestsrequest.md: - id: 62d8ae59d357 - last_write_checksum: sha1:3b0bfaa2615048d562d456c5a68b1e8421a97126 - pristine_git_object: 115e9ccbd785dd20b080b0de6c4d50f5b8452c08 - docs/models/operations/listmanifestsresponse.md: - id: 44929c2f905b - last_write_checksum: sha1:c6302e89d1e353c091cde1af1e9c2dabb23565c1 - pristine_git_object: 7bb7f793707b0d54e26d1747901606391fb28594 - docs/models/operations/listmanifestversionsrequest.md: - id: 533cb7d27434 - last_write_checksum: sha1:c204e7d33a6d5a69ea2a02fe0a3ffac4c49cb8fb - pristine_git_object: 7200d26c456ccd8efdb814af8f6c4853281d26c8 - docs/models/operations/listmanifestversionsresponse.md: - id: dc6be6e3c23d - last_write_checksum: sha1:d6a291ff40460b4d23cf3a7f68f5c2fb564db1ca - pristine_git_object: 0349721934edc32307e5285695b66d17c340872f - docs/models/operations/option.md: - id: 2ac1bd52a5fb - last_write_checksum: sha1:793ef6714c6322eff196c44264b5308c9964083a - pristine_git_object: c511959bfd8c5137c45a023323a0bb6774d6dc5e - docs/models/operations/pushmanifestversionrawrequest.md: - id: fc1c2faac987 - last_write_checksum: sha1:f404defcc70c8b6dcb819dfbb63cadaf5dbc4809 - pristine_git_object: a21204cf28b22c88657a4fdabd6d2c1849a7550b - docs/models/operations/pushmanifestversionrawresponse.md: - id: 8e19d2bbdebe - last_write_checksum: sha1:42c73736637c6253ea33c10626d81a9d8f19868c - pristine_git_object: 0985bb7c163ae85312c2176af9982e25729649fc - docs/models/operations/pushmanifestversionrequest.md: - id: 0e437a001784 - last_write_checksum: sha1:10b1465859520d840ff12e567f335fae3d33568f - pristine_git_object: dd1aab9d8253114ab284118c8357803585af63a0 - docs/models/operations/pushmanifestversionrequestbody.md: - id: 529bf30dc215 - last_write_checksum: sha1:4098dfb2722b817a742dc8333b495b98314b9248 - pristine_git_object: 520019e88698de4b25bd9771b104c24bb6e550bb - docs/models/operations/pushmanifestversionresponse.md: - id: 6d1d879fcf12 - last_write_checksum: sha1:27da301023e0c55b1316194a4d94dfbe23be4e61 - pristine_git_object: f1e2e08b597fa4fd9248907d8f63e4fd0bcee1ec - docs/models/operations/readappinclude.md: - id: 666467dbec9f - last_write_checksum: sha1:73d9cf6affda33099b60084d2bb8fde31e13360e - pristine_git_object: 5f20740f220a8cd1fef84fb55deffc75635e0028 - docs/models/operations/readapprequest.md: - id: 87dd15e3966c - last_write_checksum: sha1:f06192b275e2605eed7e8a9cfc5a4936cb3e9a57 - pristine_git_object: c7041dc275a848564191df13f13b66dab150df88 - docs/models/operations/readappresponse.md: - id: 516dad5c8317 - last_write_checksum: sha1:40f059afc4ded4284b1318ea86c9d6ed9d281d25 - pristine_git_object: b82b6c789db1b0f67fd3770e7d9c8c8b0ff4d41f - docs/models/operations/readappvariablesrequest.md: - id: da8db5c99ce3 - last_write_checksum: sha1:766d739b4937a12f7a7000a5642fc70bc56ca823 - pristine_git_object: 369da6d9afadac57f1464c55735b4bc37ece2644 - docs/models/operations/readappvariablesresponse.md: - id: cd4942c5e28a - last_write_checksum: sha1:3a9135c3914273ff65e2771e62c72eb0d2702d4a - pristine_git_object: 4a2ae584cc7fc046ea3eead91bf6bcfcb2bed3e0 - docs/models/operations/readdeploymentinclude.md: - id: 39b2bf052792 - last_write_checksum: sha1:ea03ce02fe35923c87f3253b0136137259b437b2 - pristine_git_object: 9f5f178921aeaadbe2f04e95347bf6ab8db0ca3e - docs/models/operations/readdeploymentlogsrequest.md: - id: 5ee7d0e5cf67 - last_write_checksum: sha1:d1a47b6ad4adf2c026faa535d14c024d67a29da1 - pristine_git_object: f028eaba2b067fac8e59bf80366fc37d0b94494e - docs/models/operations/readdeploymentlogsresponse.md: - id: 12268f3fcdce - last_write_checksum: sha1:968b5d645086cd919249dcc7400e82fa673d2447 - pristine_git_object: 1388ac812646ffadd823d1bb11cfc4c2228b4499 - docs/models/operations/readdeploymentrequest.md: - id: 5b96618f6491 - last_write_checksum: sha1:f932ac8efa6ab8c6e94b7c7259db7f2e6bc21ccb - pristine_git_object: 52c58dbb080c6b57f9f934b3b9f1b5246dae8a12 - docs/models/operations/readdeploymentresponse.md: - id: 9d554c8b4f05 - last_write_checksum: sha1:f8d04a1190c995c546fdb31768af93a57352cb8a - pristine_git_object: d63d2c3b071efcc7abec7d59dc28efb0e872e16c - docs/models/operations/readmanifestrequest.md: - id: 91ea98d58795 - last_write_checksum: sha1:16551ee11c5cc18db61ef5bace565840261c9273 - pristine_git_object: d785ed1deea75440082ca954b41e068c5ceaa1d8 - docs/models/operations/readmanifestresponse.md: - id: e1d36c0ae15b - last_write_checksum: sha1:b064540fc256982901c12ef85b9ed8f31e16a7ff - pristine_git_object: f6626bd62e5da14e7dcb72b103e323163fa701f0 - docs/models/operations/readmanifestversionrequest.md: - id: fc96d48d85b9 - last_write_checksum: sha1:00bbd804dbee924382ff97c7f7322961cc51e081 - pristine_git_object: f917413f9384e49e31c840f4921d7ca1b7febe6c - docs/models/operations/readmanifestversionresponse.md: - id: ef7853e4d9d6 - last_write_checksum: sha1:8d353448be94ec6c8c03eb021d351ef4d7dffcca - pristine_git_object: 616ab664638b0561a50a445ac7e012b863a8bf93 - docs/models/operations/updateapprequest.md: - id: 8a75849387c1 - last_write_checksum: sha1:7783b8e18a82e7c2ff9fb2a6ee76b3d125cbab59 - pristine_git_object: 059831c358360bed849bd710f0c0393cffa3afc7 - docs/models/operations/updateappresponse.md: - id: 7b8b6323a779 - last_write_checksum: sha1:7d2496e58922053f42abccddb3059ab263e527cc - pristine_git_object: bd285862f57bdb86d2b5b56fc222a50516ae07cf - docs/models/operations/updatemanifestrequest.md: - id: f6bf88efcf70 - last_write_checksum: sha1:ab86b7f6b4de5139ec8c707cab821f7c5b56ae4a - pristine_git_object: 0d35f2859440fe6172137d10e6f98833e47ee089 - docs/models/operations/updatemanifestresponse.md: - id: f12a75675f76 - last_write_checksum: sha1:5effe289f872924e6ad0b6c32651c1fdd28acd31 - pristine_git_object: f8b0e9ef7ee64c5c8ade8601fac272aa7909171e - docs/sdks/deployserver/README.md: - id: 7bbb48696892 - last_write_checksum: sha1:4962abdafa3760f7c2297262c42de8a81b81df50 - pristine_git_object: 38b6bb51ec82a6ebea63740d05b607e4e0a166d1 - go.mod: - id: c47645c391ad - last_write_checksum: sha1:ead89da45ae7455f74b3a2b3ffdc417f239ea0df - pristine_git_object: 1e4907d36c775a495e8b2c427341fa64e0543cb9 - internal/config/sdkconfiguration.go: - id: 33b8bfac5265 - last_write_checksum: sha1:5148d0658d626b9af95c22141725e94da07f0f47 - pristine_git_object: dd5ad42c3fbdc6e1e0178a0ab23baccf18594caf - internal/hooks/hooks.go: - id: 336be70ed5c0 - last_write_checksum: sha1:93d4659d008e4af51582e5407bae6307cdfa6cc5 - pristine_git_object: 1c56d5ce358bced3f581f40e7153ce52ea0ceb93 - internal/utils/contenttype.go: - id: 928a56fe6755 - last_write_checksum: sha1:6dbe211b4ac5c1fbdbb8c7c424b53235e475ee8b - pristine_git_object: 3b9cb4f30d5462abd9f4731c56329705007b48ce - internal/utils/env.go: - id: ac8bab30c73d - last_write_checksum: sha1:d2b3f4f53b89ba26a1e72b900c3b6698cb506f0f - pristine_git_object: dbdbd7efa2e81c689903863518f919fe53724c55 - internal/utils/form.go: - id: 8ea133d76535 - last_write_checksum: sha1:9f7482eb202bcb215a4b05434a055e7f961b8bc5 - pristine_git_object: 10d0d48c5b66efbc628794313a2ff142607f80e0 - internal/utils/headers.go: - id: 90d4f18d4adf - last_write_checksum: sha1:c871b832292cdd14b084d1776d152d97d88c0be9 - pristine_git_object: 2984ad8006036daa31fce2150aa2bf474ccaf03c - internal/utils/json.go: - id: ad03e3c502d8 - last_write_checksum: sha1:47f52874aaab4c131525670ac0d31aa920086c2a - pristine_git_object: 475c377c784f552d7e87de1b4b34c76cb8877eec - internal/utils/pathparams.go: - id: 1f3e0c30fd90 - last_write_checksum: sha1:5d04a2c089dc39a1d30f7979e2b01f491d0f3ab8 - pristine_git_object: d0d7e090f1337cc4226af2d64690200064200d66 - internal/utils/queryparams.go: - id: e7f604e65a58 - last_write_checksum: sha1:c02123a916948bb574dcea3435c4d1a460837938 - pristine_git_object: 007c562439e9be1a1e3466f5a17bc88d17cd89b8 - internal/utils/requestbody.go: - id: 22445ec1c044 - last_write_checksum: sha1:41fa8b7dee442da9974cf72f079ff7259a963502 - pristine_git_object: 18d55197c498ec347fb8d0944a97c348b590a2fa - internal/utils/retries.go: - id: b5e011d15c2d - last_write_checksum: sha1:b7e456ba26fb5c7fca1e2f0de5d0e81c545c9a6e - pristine_git_object: 9d899d17a817ca495c087060986eb94a82ebd845 - internal/utils/security.go: - id: eeb54c55241b - last_write_checksum: sha1:e2164e6a9dc13323d8025092c56d03bbddf146ac - pristine_git_object: a90d91a34aa8b1f8452ac053014b2042c74d034f - internal/utils/union.go: - id: db86c85746b6 - last_write_checksum: sha1:ae0360910936ce3fb4a2bd4aeb690c4185bbb80d - pristine_git_object: 52acb35dfc06cb972dc92c538e51c774cf717ea8 - internal/utils/union_test.go: - id: 4b56247171bd - last_write_checksum: sha1:43870b26efe508d444a348b12aa0198b86d69e17 - pristine_git_object: 81fb52e861333f8bcf79519e6fa819998cf2798d - internal/utils/utils.go: - id: 1133b2a27019 - last_write_checksum: sha1:78cf8a70f75ceb1c6a0155381c79d1661172b19f - pristine_git_object: a3c2bcdf7edbd5daa9ae2e3af480f0861f8474b3 - models/apierrors/apierror.go: - id: 4cecf27a5a54 - last_write_checksum: sha1:381f21ea747ea015453c234c9489aca812e139f2 - pristine_git_object: 7974d2a957da853ba871dec5f8b55062e1b6debf - models/apierrors/error.go: - id: 4b4731adf85f - last_write_checksum: sha1:7c904184238189b67c0dc9d852485fa69c993179 - pristine_git_object: 3216263c45c4b5e21766a1f164f0c0a94e1ca931 - models/components/app.go: - id: 9c69c824fb8f - last_write_checksum: sha1:19773356001a79743a8aaa9c42fcfc9e283763cb - pristine_git_object: c56bdf01391c69b0faeac68369ed73192c7c2ff6 - models/components/appcurrentmanifest.go: - id: 8052b72ab148 - last_write_checksum: sha1:886ddb12506c04280fe6a959e701be6dbd0190d9 - pristine_git_object: e7de2cd99ecf7a2c3451a510da437df6284366bc - models/components/appmanifestdivergence.go: - id: f3524b0e65be - last_write_checksum: sha1:6820083fcaa959ea751921818eb136fb78401bc6 - pristine_git_object: c9e0e72241330906811efd220382e376424ab2ce - models/components/appresponse.go: - id: b25e95988f98 - last_write_checksum: sha1:1bfd097001d33ab7ee59f46af273f580d59d1c58 - pristine_git_object: 33edf7fbf66a033955dd4f47651babc06bb5426e - models/components/attachmanifestrequest.go: - id: 2175c8772b17 - last_write_checksum: sha1:f32d443b94b064b6ffe062b31e15175b9e88039b - pristine_git_object: 5cab118b3eaae9365d6aa54a3c59795298d1af6f - models/components/createapprequest.go: - id: 0af34a35e29f - last_write_checksum: sha1:40d84c521b00da93908a60a88a094512e2e41518 - pristine_git_object: f8efe94841634e24768fea038a35454ac212c0b9 - models/components/createdeploymentrequest.go: - id: 22e94762a36e - last_write_checksum: sha1:0ee39822c8c00037ef6b5dc67a942cd021b667c1 - pristine_git_object: 9b4eaf86f0894bae0c59ec8ab1e0e5a649704373 - models/components/createmanifestresponse.go: - id: 8a09822d66c1 - last_write_checksum: sha1:8e149a11b813c92ff2bba317811620710212b73c - pristine_git_object: 4b428503db65fec8cab6373f85846d706290af18 - models/components/createvariablerequest.go: - id: 488ed9ed73b4 - last_write_checksum: sha1:6dad11fecda3f9df54a90dc5bda33496c7ee5904 - pristine_git_object: 9a299f3795b76ddc687ab5cd39b629bae92ecc56 - models/components/createvariableresponse.go: - id: 00d4e6ccb333 - last_write_checksum: sha1:5ebf70f308832820ee08a1e7085a783eab30424b - pristine_git_object: 8486f09e0ef6802ab6c10d0d0e2d0f2642166964 - models/components/deleteappresponse.go: - id: 0868463bc266 - last_write_checksum: sha1:2ad3379899c1656abc7825ce3f4c5f9b036577b4 - pristine_git_object: 4d77c78bc89cf176ec99686845a78ddc8fb5ca09 - models/components/deploymentresource.go: - id: 07bccccda991 - last_write_checksum: sha1:136b0d400ed8449c2f2262aae284c64cff09dec1 - pristine_git_object: eae7f8194e8ba50afe273f2887f7d7ea65c32c7b - models/components/deploymentresponse.go: - id: cec4476a3fd8 - last_write_checksum: sha1:3873e3a69107cbb82efbb1ea5f8a0fa5d3f6ac53 - pristine_git_object: e3b7e32d22573b892fcaf72e3512de9b7b0633b5 - models/components/error.go: - id: 101a721ad2e7 - last_write_checksum: sha1:8a2878902488471c0f5886173385bd1762127780 - pristine_git_object: 3d663dcc392be5baeb5b416eaf52b8271b7303df - models/components/httpmetadata.go: - id: 790bad4a7abb - last_write_checksum: sha1:0198fe52c644528e0ca265e95166b296b70632b4 - pristine_git_object: 49b58c940ad027f8d35deb3d19a3f3ba60176296 - models/components/listappsresponse.go: - id: be17d7d20579 - last_write_checksum: sha1:c8bb7e6297ff5f98a24d11a4c9d6f6e675f761b1 - pristine_git_object: b8eb4b25a70be893ef001bab31edbcea8f76a0b9 - models/components/listdeploymentsresponse.go: - id: c81969fa6fd8 - last_write_checksum: sha1:7f06de75b63c0bf64f6b8f3ae2a8fc5bc7377a7f - pristine_git_object: e6a145155b7c09fe9bc7673c7442ca5958803f0b - models/components/listmanifestsresponse.go: - id: 8ed2ba8f477d - last_write_checksum: sha1:cdedc173f1a1e1b78fd80bfaa543972fd92acd4e - pristine_git_object: 2911e50d8636088d2ec723eefe60d3e6b0d15a3b - models/components/listmanifestversionsresponse.go: - id: a74974d4f433 - last_write_checksum: sha1:3ca1a53dd15f4a29113cfb40ea3e4172dcb03547 - pristine_git_object: 6d02f172080fe8850c0ee12fb742387e54c84838 - models/components/log.go: - id: "076958063552" - last_write_checksum: sha1:08f93eab2190052f9affb791dbc491700485388a - pristine_git_object: 014faaf92105efcd6b6f480bbeeaa978cd0f7523 - models/components/manifest.go: - id: dc929f032587 - last_write_checksum: sha1:71633d7a4eeb86321447026ea3c0d19c81cbdbcb - pristine_git_object: 5057e76370454b97d069e8facc3c5ae2c10a7cdf - models/components/manifestresponse.go: - id: 826d316fa365 - last_write_checksum: sha1:3772dc6ac0335e105bb5c2c3cb798e7aec4e5869 - pristine_git_object: 9e92054721ca35d11dc54993d7e0282fb5e24d7e - models/components/manifestversion.go: - id: ebe526a37dd8 - last_write_checksum: sha1:d2cfcd1b2bf002488b3e38e3f12fa6bfc872c5e7 - pristine_git_object: 0344b172778559a0c1161794d0923b6ef9c955e7 - models/components/manifestversionresponse.go: - id: 30f8a7e13cc2 - last_write_checksum: sha1:6241b4f37a61256bda363111b331e36afc1e0d8f - pristine_git_object: 80e366ca2043ac7158c75269942f9da54943f81c - models/components/readlogsresponse.go: - id: 90e15a603a44 - last_write_checksum: sha1:3cdcbe5ac1e537507ccdc81ec71bef53b0384af0 - pristine_git_object: f2d84592e4c2946e92abf391eb06ac2b96015e88 - models/components/readvariablesresponse.go: - id: 4b628607f38f - last_write_checksum: sha1:6cc3d29087b54bfc47bd5646b81892b0fee333ec - pristine_git_object: c707e31ef6e66e4dc88bc89d753d4bb528ae90d3 - models/components/security.go: - id: 3a5eba5fe9e0 - last_write_checksum: sha1:9a146f6b93f07556ac125224448fd06ecdb5cea6 - pristine_git_object: 5f41eea61eac2527fff133f9516135b8ff70fda8 - models/components/state.go: - id: cb569493a6e4 - last_write_checksum: sha1:c332a3b170a8c5876f68a10dc91a0f973077e5a6 - pristine_git_object: 4a14aefd93ad5cea5b2179c40422db51de1dc8d5 - models/components/updateapprequest.go: - id: 02e323c36bcb - last_write_checksum: sha1:ef3cc70fe08244f4ebb38742e2086444ac1a67ba - pristine_git_object: 7045a1fba0e3d8531d9773aab4ed0f4689360ee7 - models/components/updatemanifestrequest.go: - id: 2528e271ad88 - last_write_checksum: sha1:86c356f8e623c2092f1788a28e6e963199e5dd08 - pristine_git_object: 8cd2a3858debf89dd5f196c2e7b9750f612f743f - models/components/variable.go: - id: 37684cf48fa4 - last_write_checksum: sha1:9ce7b2c7c01aca7fd5c98eca565bac207df545dc - pristine_git_object: b0a97d2aff52f8d3699740708cac0ca873d2884b - models/components/variabledata.go: - id: c47e6d6b22e2 - last_write_checksum: sha1:0fb69cbceb89a1528bf9ad2168bb6045ebcc8f58 - pristine_git_object: b1cfac06d51033bfc683bf8f720d08bd6a8418e2 - models/operations/attachappmanifest.go: - id: 1b3eab4170c4 - last_write_checksum: sha1:b608954727c46e4af9e438e30b82a3b17733dda6 - pristine_git_object: 5833ba6a813271ecc395ee3fd5b76d353b122df2 - models/operations/createapp.go: - id: 5854dfd707b5 - last_write_checksum: sha1:662f38efed6fcd5a485f124d983815d71c5b0aae - pristine_git_object: 3c06d4a1c5a035c3a395a7164e1d7a64f2bfe206 - models/operations/createappvariable.go: - id: 23dce5a300a7 - last_write_checksum: sha1:7f18af8d0b93a268b6a831856e01c8a892825da3 - pristine_git_object: 6e6b587c8a8a6f6381df8bd34c06a9fd11d7578f - models/operations/createdeployment.go: - id: fc879f2c4bd0 - last_write_checksum: sha1:c8fdc9478563ada2a8a538ffcd1a4b7cd1b50272 - pristine_git_object: 03c8cee8787f64dccddca03e2ca5c161e3129ab7 - models/operations/createmanifest.go: - id: a6767be7cb3c - last_write_checksum: sha1:c67f6b38302c9aff1cfa32bbbb57d20114399365 - pristine_git_object: 8640b8525cf083f6d0415389ba5eb195ca989229 - models/operations/createmanifestraw.go: - id: 1cb46a50dbc7 - last_write_checksum: sha1:28f1ad690efe6b573e63a5bc6a8ba08c8f04e6cb - pristine_git_object: 8aad3fd7619ce9d7cc93f75cc6eb022be9416426 - models/operations/deleteapp.go: - id: 16ffc0588ffa - last_write_checksum: sha1:fc3736ab52cf7e9c24a38f157288d76665d6b897 - pristine_git_object: c6a72d8bc198551bb8e709d2c703ae4e9b943559 - models/operations/deleteappvariable.go: - id: a1a35df253e4 - last_write_checksum: sha1:e7adcc4d853d2866a7559bf5f0c08d533e0debd8 - pristine_git_object: ad5019fede49c81723ca34e1a2bf474752aea945 - models/operations/deletemanifest.go: - id: a9e05f283389 - last_write_checksum: sha1:d9b5b22243bad0bb985c474d9e666147ec8554e2 - pristine_git_object: 06b43a2ff341f3eef670d9e1981915fab88b3613 - models/operations/detachappmanifest.go: - id: 83007b2db814 - last_write_checksum: sha1:26a49783675474a6f4de06ce9b8dddea7c480099 - pristine_git_object: dfa94ca5125eeae7e5aed22535f33ff0359e694a - models/operations/listapps.go: - id: 73fbcbeab28f - last_write_checksum: sha1:2affae7cd897b025cf1865449c9d6abe32e3c452 - pristine_git_object: 35573e662a5a59a07076f0fe7929a76bec02c17a - models/operations/listdeployments.go: - id: 0241618e0c69 - last_write_checksum: sha1:de2fc1094e438ec56519207846856c4c9cba8d09 - pristine_git_object: 6c51c65b2209681869a451c5260a7a29385d75e7 - models/operations/listmanifests.go: - id: c8df7087082a - last_write_checksum: sha1:05da46939ab67ddb81b5ce1ef87ae6836a2ab15e - pristine_git_object: fb5f4953f26ec4aeb74839bcb8674b88f93c0ef4 - models/operations/listmanifestversions.go: - id: c3d6a0006b82 - last_write_checksum: sha1:fb9346db18b793a25baf81b45a2abb26b2a9de88 - pristine_git_object: 383a02ecd2db96c47e7691af304ca63386b551b9 - models/operations/options.go: - id: d20dd4db9865 - last_write_checksum: sha1:63bf566497cff1c15f022c5ef1e088614b458825 - pristine_git_object: 6c56bd901e7e6cdd8f3053cddca0b7bb577bc090 - models/operations/pushmanifestversion.go: - id: 103d3cd7154c - last_write_checksum: sha1:1e79c961001d02886374474081b2f444092bad78 - pristine_git_object: d8c0578c883f8c5b9099bf95a10aa8689760954e - models/operations/pushmanifestversionraw.go: - id: 4407558a1706 - last_write_checksum: sha1:9ce5959e88ad074e86ae993b66b9057fd6e25ca8 - pristine_git_object: 03411e7c0ab6606e466a50ee95861384dab8e0d1 - models/operations/readapp.go: - id: 5ffcd917de05 - last_write_checksum: sha1:5997868fdbb48083cfae50f1e990eafc952834a4 - pristine_git_object: fdaf235b8e0f5f4355a3b7f25152bc95267648f1 - models/operations/readappvariables.go: - id: 3c029298a72b - last_write_checksum: sha1:2ebe235e100ef10034b5f8579dea0126c4e9e9a9 - pristine_git_object: b273ef6b05ce4df4d48877c6546b07b202d019a3 - models/operations/readdeployment.go: - id: 91ac6c44d1bd - last_write_checksum: sha1:835b9dedfd9742db8e350b3ced4a99e5c75acd3f - pristine_git_object: 43788bebcf2233fca353e13d67c6453f0f172d25 - models/operations/readdeploymentlogs.go: - id: 33138b5f3516 - last_write_checksum: sha1:50ac57766820913a5b2874e706f060d7960f2126 - pristine_git_object: 0ac0a7172e2154a2bf949f88dc8cf506b961d86e - models/operations/readmanifest.go: - id: 9724390accda - last_write_checksum: sha1:4230961090fe0df4302b5dcaed96f39fe666ae0d - pristine_git_object: da0a84d40b5eb3e16bca40e650c9e58671007222 - models/operations/readmanifestversion.go: - id: 6d717792910c - last_write_checksum: sha1:8c5b0e35b09d98b466e65664f39f4490ebd8ee5e - pristine_git_object: 586511fc2e9efab59cef96a965f108748bb81297 - models/operations/updateapp.go: - id: d3f1951d5c54 - last_write_checksum: sha1:719d31225d99a895e93220742ac70812869b204b - pristine_git_object: e0aa500334cedfc25ef02b5d46a340c376904c0d - models/operations/updatemanifest.go: - id: b0676a872835 - last_write_checksum: sha1:38ed2a5a9906f5127af079bd489c45a674456162 - pristine_git_object: 3b64059f09e9cae116efb9f9d8d58ef8bf0a21e2 - optionalnullable/optionalnullable.go: - id: ce60f259ead3 - last_write_checksum: sha1:d65954ba9be9e724bae40a5d2d09b052ed65628e - pristine_git_object: b0b69dcd7c4f2fff71f5910906c00a01e371b640 - optionalnullable/optionalnullable_test.go: - id: ad67101ebca3 - last_write_checksum: sha1:623e7949676be2d6b074dbf918fd33c48c73d237 - pristine_git_object: 6b7cc5f07542d1672a056219fc6e47d5acc64f44 - retry/config.go: - id: 82d673cb0389 - last_write_checksum: sha1:cc1a5573c4b4d8c50a26a5f15ab34f26ad44505f - pristine_git_object: 2447d253bd0de537447838e007209837c0615624 - types/bigint.go: - id: 6f911e1a03c3 - last_write_checksum: sha1:6e5229a7ce0f5c23a87ed384abcb758fbfa1ca8f - pristine_git_object: a6e1e46e61e4a8de364507760578901fef0cc6c1 - types/date.go: - id: 231a623ce1b7 - last_write_checksum: sha1:189f3dfb1a4ecf06552bb2b7eb1877171f74ca8f - pristine_git_object: b4bdc8ecd930d94acd0546c014b311a718b9e78b - types/datetime.go: - id: 5023aac30a47 - last_write_checksum: sha1:856bfc5abef63a32cd7141c423d62d1132514496 - pristine_git_object: 7739aadb178118aacc9993d1be4ca225a9333ffd - types/pointers.go: - id: 6d0d0a6cb6f3 - last_write_checksum: sha1:d24c0150589efefa932d9ad9a1cc18fb530e3206 - pristine_git_object: 8c0b30bedb7a08a394ae78937999e46f111c6a5b -examples: - listApps: - speakeasy-default-list-apps: - parameters: - query: - organizationId: "" - responses: - "200": - application/json: {"cursor": {"hasMore": true, "data": [{"id": "", "name": ""}]}} - default: - application/json: {"errorCode": ""} - createApp: - speakeasy-default-create-app: - requestBody: - application/json: {"name": ""} - responses: - "201": - application/json: {"data": {"id": "", "name": ""}} - default: - application/json: {"errorCode": ""} - updateApp: - speakeasy-default-update-app: - parameters: - path: - id: "" - requestBody: - application/json: {"name": ""} - responses: - default: - application/json: {"errorCode": ""} - readApp: - speakeasy-default-read-app: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"id": "", "name": ""}} - default: - application/json: {"errorCode": ""} - deleteApp: - speakeasy-default-delete-app: - parameters: - path: - id: "" - query: - wait: false - responses: - default: - application/json: {"errorCode": ""} - "202": - application/json: {} - readAppCurrentStateVersion: - speakeasy-default-read-app-current-state-version: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"stack": {"key": "", "key1": "", "key2": ""}}} - default: - application/json: {"errorCode": ""} - readAppVariables: - speakeasy-default-read-app-variables: - parameters: - path: - id: "" - responses: - "200": - application/json: {"cursor": {"hasMore": true, "data": []}} - default: - application/json: {"errorCode": ""} - createAppVariable: - speakeasy-default-create-app-variable: - parameters: - path: - id: "" - requestBody: - application/json: {"variable": {"key": "", "value": ""}} - responses: - "201": - application/json: {"data": {"key": "", "value": "", "id": ""}} - default: - application/json: {"errorCode": ""} - deleteAppVariable: - speakeasy-default-delete-app-variable: - parameters: - path: - id: "" - variableId: "" - responses: - default: - application/json: {"errorCode": ""} - readAppRuns: - speakeasy-default-read-app-runs: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"items": [{"id": "", "autoApply": true, "createdAt": "2025-03-02T05:46:18.656Z", "isDestroy": false, "hasChanges": true, "message": "", "planOnly": true, "refresh": false, "refreshOnly": true, "savePlan": true, "status": ""}]}} - default: - application/json: {"errorCode": ""} - readAppVersions: - speakeasy-default-read-app-versions: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"items": []}} - default: - application/json: {"errorCode": ""} - deployAppConfiguration: - speakeasy-default-deploy-app-configuration-raw: - parameters: - path: - id: "" - requestBody: - application/yaml: "x-file: example.file" - responses: - "202": - application/json: {"data": {"id": "", "autoApply": true, "createdAt": "2025-06-20T11:04:58.624Z", "isDestroy": false, "hasChanges": false, "message": "", "planOnly": true, "refresh": true, "refreshOnly": true, "savePlan": false, "status": ""}} - default: - application/json: {"errorCode": ""} - speakeasy-default-deploy-app-configuration: - parameters: - path: - id: "" - requestBody: - application/json: {"stack": {"name": "", "region": {}}} - responses: - "202": - application/json: {"data": {"id": "", "autoApply": true, "createdAt": "2025-06-20T11:04:58.624Z", "isDestroy": false, "hasChanges": false, "message": "", "planOnly": true, "refresh": true, "refreshOnly": true, "savePlan": false, "status": ""}} - default: - application/json: {"errorCode": ""} - readCurrentRun: - speakeasy-default-read-current-run: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"id": "", "autoApply": true, "createdAt": "2025-08-19T01:34:09.674Z", "isDestroy": true, "hasChanges": true, "message": "", "planOnly": false, "refresh": false, "refreshOnly": false, "savePlan": true, "status": ""}} - default: - application/json: {"errorCode": ""} - readVersion: - speakeasy-default-read-version: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"id": "", "autoQueueRuns": false, "error": "", "errorMessage": "", "status": "uploaded"}} - application/gzip: "x-file: example.file" - application/yaml: "x-file: example.file" - default: - application/json: {"errorCode": ""} - readRun: - speakeasy-default-read-run: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"id": "", "autoApply": false, "createdAt": "2024-05-09T05:04:30.620Z", "isDestroy": false, "hasChanges": false, "message": "", "planOnly": false, "refresh": false, "refreshOnly": true, "savePlan": true, "status": ""}} - default: - application/json: {"errorCode": ""} - readRunLogs: - speakeasy-default-read-run-logs: - parameters: - path: - id: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - readCurrentRunLogs: - speakeasy-default-read-current-run-logs: - parameters: - path: - id: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - readCurrentAppVersion: - speakeasy-default-read-current-app-version: - parameters: - path: - id: "" - responses: - "200": - application/json: {"data": {"id": "", "autoQueueRuns": false, "error": "", "errorMessage": "", "status": "errored"}} - application/gzip: "x-file: example.file" - application/yaml: "x-file: example.file" - default: - application/json: {"errorCode": ""} - createManifest: - speakeasy-default-create-manifest-raw: - parameters: - query: - name: "" - requestBody: - application/yaml: "x-file: example.file" - responses: - "201": - application/json: {"data": {"id": "", "version": 342622, "name": "", "createdAt": "2026-06-07T23:43:24.174Z"}} - default: - application/json: {"errorCode": ""} - speakeasy-default-create-manifest: - parameters: - query: - name: "" - requestBody: - application/json: {} - responses: - "201": - application/json: {"data": {"id": "", "version": 342622, "name": "", "createdAt": "2026-06-07T23:43:24.174Z"}} - default: - application/json: {"errorCode": ""} - listManifests: - speakeasy-default-list-manifests: - responses: - "200": - application/json: {"cursor": {"hasMore": true, "data": []}} - default: - application/json: {"errorCode": ""} - readManifest: - speakeasy-default-read-manifest: - parameters: - path: - manifestId: "" - responses: - "200": - application/json: {"data": {"id": "", "name": "", "latestVersion": 449356, "createdAt": "2025-01-13T21:01:06.775Z", "updatedAt": "2026-12-07T01:47:24.857Z"}} - application/gzip: "x-file: example.file" - default: - application/json: {"errorCode": ""} - updateManifest: - speakeasy-default-update-manifest: - parameters: - path: - manifestId: "" - requestBody: - application/json: {"name": ""} - responses: - "200": - application/json: {"data": {"id": "", "name": "", "latestVersion": 797309, "createdAt": "2026-04-09T21:16:18.941Z", "updatedAt": "2026-09-02T19:43:33.178Z"}} - default: - application/json: {"errorCode": ""} - deleteManifest: - speakeasy-default-delete-manifest: - parameters: - path: - manifestId: "" - responses: - "409": - application/json: {"errorCode": ""} - default: - application/json: {"errorCode": ""} - pushManifestVersion: - speakeasy-default-push-manifest-version-raw: - parameters: - path: - manifestId: "" - requestBody: - application/yaml: "x-file: example.file" - responses: - "201": - application/json: {"data": {"manifestId": "", "version": 836923, "createdAt": "2024-10-29T19:28:36.110Z"}} - default: - application/json: {"errorCode": ""} - speakeasy-default-push-manifest-version: - parameters: - path: - manifestId: "" - requestBody: - application/json: {} - responses: - "201": - application/json: {"data": {"manifestId": "", "version": 836923, "createdAt": "2024-10-29T19:28:36.110Z"}} - default: - application/json: {"errorCode": ""} - listManifestVersions: - speakeasy-default-list-manifest-versions: - parameters: - path: - manifestId: "" - responses: - "200": - application/json: {"cursor": {"hasMore": false, "data": []}} - default: - application/json: {"errorCode": ""} - readManifestVersion: - speakeasy-default-read-manifest-version: - parameters: - path: - manifestId: "" - version: "" - responses: - "200": - application/json: {"data": {"manifestId": "", "version": 291553, "createdAt": "2024-12-09T16:22:04.214Z"}} - application/x-yaml: "x-file: example.file" - default: - application/json: {"errorCode": ""} - createDeployment: - speakeasy-default-create-deployment: - requestBody: - application/json: {"appId": "", "manifestId": "", "manifestVersion": 5649} - responses: - "201": - application/json: {"data": {"id": "", "appId": "", "status": "", "createdAt": "2024-01-07T04:35:31.471Z", "updatedAt": "2025-12-16T01:27:14.812Z"}} - default: - application/json: {"errorCode": ""} - speakeasy-default-create-deployment-raw: - requestBody: - application/yaml: "x-file: example.file" - responses: - "201": - application/json: {"data": {"id": "", "appId": "", "status": "", "createdAt": "2024-01-07T04:35:31.471Z", "updatedAt": "2025-12-16T01:27:14.812Z"}} - default: - application/json: {"errorCode": ""} - listDeployments: - speakeasy-default-list-deployments: - responses: - "200": - application/json: {"cursor": {"hasMore": false, "data": [{"id": "", "appId": "", "status": "", "createdAt": "2024-05-13T18:10:51.662Z", "updatedAt": "2025-08-14T19:55:10.264Z"}]}} - default: - application/json: {"errorCode": ""} - readDeployment: - speakeasy-default-read-deployment: - parameters: - path: - deploymentId: "" - responses: - "200": - application/json: {"data": {"id": "", "appId": "", "status": "", "createdAt": "2025-06-20T10:16:58.267Z", "updatedAt": "2025-04-30T01:36:03.827Z"}} - application/gzip: "x-file: example.file" - application/yaml: "x-file: example.file" - default: - application/json: {"errorCode": ""} - readDeploymentLogs: - speakeasy-default-read-deployment-logs: - parameters: - path: - deploymentId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - attachAppManifest: - speakeasy-default-attach-app-manifest: - parameters: - path: - id: "" - requestBody: - application/json: {"manifestId": ""} - responses: - "400": - application/json: {"errorCode": ""} - default: - application/json: {"errorCode": ""} - detachAppManifest: - speakeasy-default-detach-app-manifest: - parameters: - path: - id: "" - responses: - default: - application/json: {"errorCode": ""} -examplesVersion: 1.0.2 -generatedTests: {} diff --git a/internal/deployserverclient/.speakeasy/gen.yaml b/internal/deployserverclient/.speakeasy/gen.yaml deleted file mode 100644 index 9fb731ae..00000000 --- a/internal/deployserverclient/.speakeasy/gen.yaml +++ /dev/null @@ -1,73 +0,0 @@ -configVersion: 2.0.0 -generation: - devContainers: - enabled: true - schemaPath: ../../openapi/deployserver.yaml - sdkClassName: DeployServer - maintainOpenAPIOrder: true - usageSnippets: - optionalPropertyRendering: withExample - sdkInitStyle: constructor - useClassNamesForArrayFields: true - fixes: - nameResolutionDec2023: true - nameResolutionFeb2025: true - parameterOrderingFeb2024: true - requestResponseComponentNamesFeb2024: true - securityFeb2025: true - sharedErrorComponentsApr2025: false - sharedNestedComponentsJan2026: false - nameOverrideFeb2026: false - auth: - oAuth2ClientCredentialsEnabled: true - oAuth2PasswordEnabled: true - hoistGlobalSecurity: true - inferSSEOverload: true - sdkHooksConfigAccess: true - schemas: - allOfMergeStrategy: shallowMerge - requestBodyFieldName: "" - versioningStrategy: automatic - persistentEdits: - enabled: "true" - tests: - generateTests: true - generateNewTests: false - skipResponseBodyAssertions: false -go: - version: 0.1.1 - additionalDependencies: {} - allowUnknownFieldsInWeakUnions: false - baseErrorName: DeployServerError - clientServerStatusCodesAsErrors: true - codeSamples: - output: false - defaultErrorName: APIError - enableCustomCodeRegions: false - enableSkipDeserialization: false - envVarPrefix: DEPLOYSERVER - flattenGlobalSecurity: true - forwardCompatibleEnumsByDefault: false - forwardCompatibleUnionsByDefault: "false" - imports: - option: openapi - paths: - callbacks: models/callbacks - errors: models/apierrors - operations: models/operations - shared: models/components - webhooks: models/webhooks - includeEmptyObjects: false - inferUnionDiscriminators: false - inputModelSuffix: input - maxMethodParams: 4 - methodArguments: require-security-and-request - modulePath: github.com/formancehq/fctl/internal/deployserverclient/v3 - multipartArrayFormat: legacy - nullableOptionalWrapper: false - outputModelSuffix: output - respectRequiredFields: false - respectTitlesForPrimitiveUnionMembers: false - responseFormat: envelope-http - sdkPackageName: deployserverclient - unionStrategy: left-to-right diff --git a/internal/deployserverclient/.speakeasy/workflow.lock b/internal/deployserverclient/.speakeasy/workflow.lock deleted file mode 100644 index e9a3235a..00000000 --- a/internal/deployserverclient/.speakeasy/workflow.lock +++ /dev/null @@ -1,34 +0,0 @@ -speakeasyVersion: 1.759.2 -sources: - Terraform HCP Proxy API: - sourceNamespace: terraform-hcp-proxy-api - sourceRevisionDigest: sha256:d54ede8b0d8f74daea2fc161cf13846dd4a7084f96941f68356d8307bfa92186 - sourceBlobDigest: sha256:8564cde40781c471d4f17f1781a0c92fcf0ddc26ca20fbb3a2cc2a39d0c22f70 - tags: - - latest - - 0.1.0 -targets: - deploy-server: - source: Terraform HCP Proxy API - sourceNamespace: terraform-hcp-proxy-api - sourceRevisionDigest: sha256:d54ede8b0d8f74daea2fc161cf13846dd4a7084f96941f68356d8307bfa92186 - sourceBlobDigest: sha256:8564cde40781c471d4f17f1781a0c92fcf0ddc26ca20fbb3a2cc2a39d0c22f70 -workflow: - workflowVersion: 1.0.0 - speakeasyVersion: 1.759.2 - sources: - Terraform HCP Proxy API: - inputs: - - location: ../../openapi/deployserver.yaml - registry: - location: registry.speakeasyapi.dev/formance/formance/terraform-hcp-proxy-api - targets: - deploy-server: - target: go - source: Terraform HCP Proxy API - codeSamples: - registry: - location: registry.speakeasyapi.dev/formance/formance/terraform-hcp-proxy-api-go-code-samples - labelOverride: - fixedValue: Go (SDK) - blocking: false diff --git a/internal/deployserverclient/.speakeasy/workflow.yaml b/internal/deployserverclient/.speakeasy/workflow.yaml deleted file mode 100644 index 63f3e934..00000000 --- a/internal/deployserverclient/.speakeasy/workflow.yaml +++ /dev/null @@ -1,18 +0,0 @@ -workflowVersion: 1.0.0 -speakeasyVersion: 1.759.2 # Should be synced with flake.nix speakeasy version. -sources: - Terraform HCP Proxy API: - inputs: - - location: ../../openapi/deployserver.yaml - registry: - location: registry.speakeasyapi.dev/formance/formance/terraform-hcp-proxy-api -targets: - deploy-server: - target: go - source: Terraform HCP Proxy API - codeSamples: - registry: - location: registry.speakeasyapi.dev/formance/formance/terraform-hcp-proxy-api-go-code-samples - labelOverride: - fixedValue: Go (SDK) - blocking: false diff --git a/internal/deployserverclient/CONTRIBUTING.md b/internal/deployserverclient/CONTRIBUTING.md deleted file mode 100644 index d585717f..00000000 --- a/internal/deployserverclient/CONTRIBUTING.md +++ /dev/null @@ -1,26 +0,0 @@ -# Contributing to This Repository - -Thank you for your interest in contributing to this repository. Please note that this repository contains generated code. As such, we do not accept direct changes or pull requests. Instead, we encourage you to follow the guidelines below to report issues and suggest improvements. - -## How to Report Issues - -If you encounter any bugs or have suggestions for improvements, please open an issue on GitHub. When reporting an issue, please provide as much detail as possible to help us reproduce the problem. This includes: - -- A clear and descriptive title -- Steps to reproduce the issue -- Expected and actual behavior -- Any relevant logs, screenshots, or error messages -- Information about your environment (e.g., operating system, software versions) - - For example can be collected using the `npx envinfo` command from your terminal if you have Node.js installed - -## Issue Triage and Upstream Fixes - -We will review and triage issues as quickly as possible. Our goal is to address bugs and incorporate improvements in the upstream source code. Fixes will be included in the next generation of the generated code. - -## Contact - -If you have any questions or need further assistance, please feel free to reach out by opening an issue. - -Thank you for your understanding and cooperation! - -The Maintainers diff --git a/internal/deployserverclient/README.md b/internal/deployserverclient/README.md deleted file mode 100644 index 55a8f66a..00000000 --- a/internal/deployserverclient/README.md +++ /dev/null @@ -1,429 +0,0 @@ -# github.com/formancehq/fctl/internal/deployserverclient - -Developer-friendly & type-safe Go SDK specifically catered to leverage *github.com/formancehq/fctl/internal/deployserverclient* API. - -
- - - - -
- - -

-> [!IMPORTANT] -> This SDK is not yet ready for production use. To complete setup please follow the steps outlined in your [workspace](https://app.speakeasy.com/org/formance/formance). Delete this section before > publishing to a package manager. - - -## Summary - - - - - -## Table of Contents - -* [github.com/formancehq/fctl/internal/deployserverclient](#githubcomformancehqfctlinternaldeployserverclient) - * [SDK Installation](#sdk-installation) - * [SDK Example Usage](#sdk-example-usage) - * [Authentication](#authentication) - * [Available Resources and Operations](#available-resources-and-operations) - * [Retries](#retries) - * [Error Handling](#error-handling) - * [Server Selection](#server-selection) - * [Custom HTTP Client](#custom-http-client) -* [Development](#development) - * [Maturity](#maturity) - * [Contributions](#contributions) - - - - -## SDK Installation - -To add the SDK as a dependency to your project: -```bash -go get github.com/formancehq/fctl/internal/deployserverclient/v3 -``` - - - -## SDK Example Usage - -### Example - -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - - - -## Authentication - -### Per-Client Security Schemes - -This SDK supports the following security scheme globally: - -| Name | Type | Scheme | Environment Variable | -| ------------ | ---- | ----------- | -------------------------- | -| `BearerAuth` | http | HTTP Bearer | `DEPLOYSERVER_BEARER_AUTH` | - -You can configure it using the `WithSecurity` option when initializing the SDK client instance. For example: -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - - - -## Available Resources and Operations - -
-Available methods - -### [DeployServer SDK](docs/sdks/deployserver/README.md) - -* [ListApps](docs/sdks/deployserver/README.md#listapps) - List organization apps -* [CreateApp](docs/sdks/deployserver/README.md#createapp) - Create a new app -* [UpdateApp](docs/sdks/deployserver/README.md#updateapp) - Update an app -* [ReadApp](docs/sdks/deployserver/README.md#readapp) - read app details -* [DeleteApp](docs/sdks/deployserver/README.md#deleteapp) - Delete an app -* [AttachAppManifest](docs/sdks/deployserver/README.md#attachappmanifest) - Bind an app to a manifest -* [DetachAppManifest](docs/sdks/deployserver/README.md#detachappmanifest) - Unbind an app from its manifest -* [ReadAppVariables](docs/sdks/deployserver/README.md#readappvariables) - Get all variables of an app -* [CreateAppVariable](docs/sdks/deployserver/README.md#createappvariable) - Create variable for an app -* [DeleteAppVariable](docs/sdks/deployserver/README.md#deleteappvariable) - Delete a variable from an app -* [CreateManifestRaw](docs/sdks/deployserver/README.md#createmanifestraw) - Create a new manifest -* [CreateManifest](docs/sdks/deployserver/README.md#createmanifest) - Create a new manifest -* [ListManifests](docs/sdks/deployserver/README.md#listmanifests) - List manifests in the organization -* [ReadManifest](docs/sdks/deployserver/README.md#readmanifest) - Read a manifest -* [UpdateManifest](docs/sdks/deployserver/README.md#updatemanifest) - Update manifest metadata -* [DeleteManifest](docs/sdks/deployserver/README.md#deletemanifest) - Delete a manifest and all its versions -* [PushManifestVersionRaw](docs/sdks/deployserver/README.md#pushmanifestversionraw) - Push a new version of a manifest -* [PushManifestVersion](docs/sdks/deployserver/README.md#pushmanifestversion) - Push a new version of a manifest -* [ListManifestVersions](docs/sdks/deployserver/README.md#listmanifestversions) - List versions of a manifest -* [ReadManifestVersion](docs/sdks/deployserver/README.md#readmanifestversion) - Get a specific manifest version with content -* [CreateDeployment](docs/sdks/deployserver/README.md#createdeployment) - Create a deployment (triggers a run) -* [ListDeployments](docs/sdks/deployserver/README.md#listdeployments) - List deployments -* [ReadDeployment](docs/sdks/deployserver/README.md#readdeployment) - Get a single deployment -* [ReadDeploymentLogs](docs/sdks/deployserver/README.md#readdeploymentlogs) - Get run logs for a deployment - -
- - - -## Retries - -Some of the endpoints in this SDK support retries. If you use the SDK without any configuration, it will fall back to the default retry strategy provided by the API. However, the default retry strategy can be overridden on a per-operation basis, or across the entire SDK. - -To change the default retry strategy for a single API call, simply provide a `retry.Config` object to the call by using the `WithRetries` option: -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "log" - "models/operations" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil, operations.WithRetries( - retry.Config{ - Strategy: "backoff", - Backoff: &retry.BackoffStrategy{ - InitialInterval: 1, - MaxInterval: 50, - Exponent: 1.1, - MaxElapsedTime: 100, - }, - RetryConnectionErrors: false, - })) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - -If you'd like to override the default retry strategy for all operations that support retries, you can use the `WithRetryConfig` option at SDK initialization: -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithRetryConfig( - retry.Config{ - Strategy: "backoff", - Backoff: &retry.BackoffStrategy{ - InitialInterval: 1, - MaxInterval: 50, - Exponent: 1.1, - MaxElapsedTime: 100, - }, - RetryConnectionErrors: false, - }), - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - - - -## Error Handling - -Handling errors in this SDK should largely match your expectations. All operations return a response object or an error, they will never return both. - -By Default, an API error will return `apierrors.APIError`. When custom error responses are specified for an operation, the SDK may also return their associated error. You can refer to respective *Errors* tables in SDK docs for more details on possible error types for each operation. - -For example, the `AttachAppManifest` function may return the following errors: - -| Error Type | Status Code | Content Type | -| ------------------ | ----------- | ---------------- | -| apierrors.Error | 400, 404 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -### Example - -```go -package main - -import ( - "context" - "errors" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/apierrors" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.AttachAppManifest(ctx, "", components.AttachManifestRequest{ - ManifestID: "", - }) - if err != nil { - - var e *apierrors.Error - if errors.As(err, &e) { - // handle error - log.Fatal(e.Error()) - } - - var e *apierrors.APIError - if errors.As(err, &e) { - // handle error - log.Fatal(e.Error()) - } - } -} - -``` - - - -## Server Selection - -### Select Server by Index - -You can override the default server globally using the `WithServerIndex(serverIndex int)` option when initializing the SDK client instance. The selected server will then be used as the default on the operations that use it. This table lists the indexes associated with the available servers: - -| # | Server | Description | -| --- | ---------------------------------------------- | ----------------- | -| 0 | `https://deploy.formance.cloud` | Production server | -| 1 | `https://deploy-server.staging.formance.cloud` | Staging server | -| 2 | `http://localhost:8080` | Local server | - -#### Example - -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithServerIndex(0), - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - -### Override Server URL Per-Client - -The default server can also be overridden globally using the `WithServerURL(serverURL string)` option when initializing the SDK client instance. For example: -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithServerURL("http://localhost:8080"), - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - - - -## Custom HTTP Client - -The Go SDK makes API calls that wrap an internal HTTP client. The requirements for the HTTP client are very simple. It must match this interface: - -```go -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} -``` - -The built-in `net/http` client satisfies this interface and a default client based on the built-in is provided by default. To replace this default with a client of your own, you can implement this interface yourself or provide your own client configured as desired. Here's a simple example, which adds a client with a 30 second timeout. - -```go -import ( - "net/http" - "time" - - "github.com/formancehq/fctl/internal/deployserverclient/v3" -) - -var ( - httpClient = &http.Client{Timeout: 30 * time.Second} - sdkClient = deployserverclient.New(deployserverclient.WithClient(httpClient)) -) -``` - -This can be a convenient way to configure timeouts, cookies, proxies, custom headers, and other low-level configuration. - - - - -# Development - -## Maturity - -This SDK is in beta, and there may be breaking changes between versions without a major version update. Therefore, we recommend pinning usage -to a specific package version. This way, you can install the same version each time without breaking changes unless you are intentionally -looking for the latest version. - -## Contributions - -While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation. -We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release. - -### SDK Created by [Speakeasy](https://www.speakeasy.com/?utm_source=github-com/formancehq/fctl/internal/deployserverclient&utm_campaign=go) diff --git a/internal/deployserverclient/USAGE.md b/internal/deployserverclient/USAGE.md deleted file mode 100644 index 5e9db16f..00000000 --- a/internal/deployserverclient/USAGE.md +++ /dev/null @@ -1,29 +0,0 @@ - -```go -package main - -import ( - "context" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" - "os" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} - -``` - \ No newline at end of file diff --git a/internal/deployserverclient/deployserver.go b/internal/deployserverclient/deployserver.go deleted file mode 100644 index 9212799b..00000000 --- a/internal/deployserverclient/deployserver.go +++ /dev/null @@ -1,5652 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: e914b2f992f9 - -package deployserverclient - -// Generated from OpenAPI doc version 0.1.0 and generator version 2.869.23 - -import ( - "bytes" - "context" - "fmt" - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/config" - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/hooks" - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/apierrors" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "net/http" - "net/url" - "time" -) - -// ServerList contains the list of servers available to the SDK -var ServerList = []string{ - // Production server - "https://deploy.formance.cloud", - // Staging server - "https://deploy-server.staging.formance.cloud", - // Local server - "http://localhost:8080", -} - -// HTTPClient provides an interface for supplying the SDK with a custom HTTP client -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -// String provides a helper function to return a pointer to a string -func String(s string) *string { return &s } - -// Bool provides a helper function to return a pointer to a bool -func Bool(b bool) *bool { return &b } - -// Int provides a helper function to return a pointer to an int -func Int(i int) *int { return &i } - -// Int64 provides a helper function to return a pointer to an int64 -func Int64(i int64) *int64 { return &i } - -// Float32 provides a helper function to return a pointer to a float32 -func Float32(f float32) *float32 { return &f } - -// Float64 provides a helper function to return a pointer to a float64 -func Float64(f float64) *float64 { return &f } - -// Pointer provides a helper function to return a pointer to a type -func Pointer[T any](v T) *T { return &v } - -type DeployServer struct { - SDKVersion string - - sdkConfiguration config.SDKConfiguration - hooks *hooks.Hooks -} - -type SDKOption func(*DeployServer) - -// WithServerURL allows providing an alternative server URL -func WithServerURL(serverURL string) SDKOption { - return func(sdk *DeployServer) { - sdk.sdkConfiguration.ServerURL = serverURL - } -} - -// WithTemplatedServerURL allows the overriding of the default server URL with a templated URL populated with the provided parameters -func WithTemplatedServerURL(serverURL string, params map[string]string) SDKOption { - return func(sdk *DeployServer) { - if params != nil { - serverURL = utils.ReplaceParameters(serverURL, params) - } - - sdk.sdkConfiguration.ServerURL = serverURL - } -} - -// WithServerIndex allows the overriding of the default server by index -func WithServerIndex(serverIndex int) SDKOption { - return func(sdk *DeployServer) { - if serverIndex < 0 || serverIndex >= len(ServerList) { - panic(fmt.Errorf("server index %d out of range", serverIndex)) - } - - sdk.sdkConfiguration.ServerIndex = serverIndex - } -} - -// WithClient allows the overriding of the default HTTP client used by the SDK -func WithClient(client HTTPClient) SDKOption { - return func(sdk *DeployServer) { - sdk.sdkConfiguration.Client = client - } -} - -// WithSecurity configures the SDK to use the provided security details -func WithSecurity(bearerAuth string) SDKOption { - return func(sdk *DeployServer) { - security := components.Security{BearerAuth: &bearerAuth} - sdk.sdkConfiguration.Security = utils.AsSecuritySource(&security) - } -} - -// WithSecuritySource configures the SDK to invoke the Security Source function on each method call to determine authentication -func WithSecuritySource(security func(context.Context) (components.Security, error)) SDKOption { - return func(sdk *DeployServer) { - sdk.sdkConfiguration.Security = func(ctx context.Context) (interface{}, error) { - return security(ctx) - } - } -} - -func WithRetryConfig(retryConfig retry.Config) SDKOption { - return func(sdk *DeployServer) { - sdk.sdkConfiguration.RetryConfig = &retryConfig - } -} - -// WithTimeout Optional request timeout applied to each operation -func WithTimeout(timeout time.Duration) SDKOption { - return func(sdk *DeployServer) { - sdk.sdkConfiguration.Timeout = &timeout - } -} - -// New creates a new instance of the SDK with the provided options -func New(opts ...SDKOption) *DeployServer { - sdk := &DeployServer{ - SDKVersion: "0.1.1", - sdkConfiguration: config.SDKConfiguration{ - UserAgent: "speakeasy-sdk/go 0.1.1 2.869.23 0.1.0 github.com/formancehq/fctl/internal/deployserverclient/v3", - ServerList: ServerList, - }, - hooks: hooks.New(), - } - for _, opt := range opts { - opt(sdk) - } - - if sdk.sdkConfiguration.Security == nil { - var envVarSecurity components.Security - if utils.PopulateSecurityFromEnv(&envVarSecurity) { - sdk.sdkConfiguration.Security = utils.AsSecuritySource(envVarSecurity) - } - } - - // Use WithClient to override the default client if you would like to customize the timeout - if sdk.sdkConfiguration.Client == nil { - sdk.sdkConfiguration.Client = &http.Client{Timeout: 60 * time.Second} - } - - sdk.sdkConfiguration = sdk.hooks.SDKInit(sdk.sdkConfiguration) - - return sdk -} - -// ListApps - List organization apps -func (s *DeployServer) ListApps(ctx context.Context, pageSize *int64, cursor *string, opts ...operations.Option) (*operations.ListAppsResponse, error) { - request := operations.ListAppsRequest{ - PageSize: pageSize, - Cursor: cursor, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/apps") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listApps", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListAppsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListAppsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListAppsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateApp - Create a new app -func (s *DeployServer) CreateApp(ctx context.Context, request components.CreateAppRequest, opts ...operations.Option) (*operations.CreateAppResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/apps") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createApp", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "Request", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateAppResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.AppResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.AppResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdateApp - Update an app -func (s *DeployServer) UpdateApp(ctx context.Context, id string, updateAppRequest components.UpdateAppRequest, opts ...operations.Option) (*operations.UpdateAppResponse, error) { - request := operations.UpdateAppRequest{ - ID: id, - UpdateAppRequest: updateAppRequest, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updateApp", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "UpdateAppRequest", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdateAppResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadApp - read app details -func (s *DeployServer) ReadApp(ctx context.Context, id string, include []operations.ReadAppInclude, opts ...operations.Option) (*operations.ReadAppResponse, error) { - request := operations.ReadAppRequest{ - ID: id, - Include: include, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readApp", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadAppResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.AppResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.AppResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteApp - Delete an app -// Soft-deletes the app immediately and enqueues a destroy deployment to -// clean up the app's resources on Formance Cloud via the openapi -// reconciler's tear-down path. The app becomes invisible to all -// subsequent reads. The destroy runs through the normal worker -// pipeline; once it reaches a successful terminal status the worker -// hard-deletes the app row. -// -// By default this returns 202 Accepted as soon as the destroy is -// enqueued (or 202 with no body if no destroy was needed). Pass -// `?wait=true` to block until the destroy reaches a terminal status. -func (s *DeployServer) DeleteApp(ctx context.Context, id string, wait *bool, opts ...operations.Option) (*operations.DeleteAppResponse, error) { - request := operations.DeleteAppRequest{ - ID: id, - Wait: wait, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteApp", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteAppResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.DeleteAppResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.DeleteAppResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// AttachAppManifest - Bind an app to a manifest -// Updates the app's `manifest_id` pointer. Idempotent — re-binding to the -// same id is a no-op; rebinding to a different id moves the pointer. -func (s *DeployServer) AttachAppManifest(ctx context.Context, id string, attachManifestRequest components.AttachManifestRequest, opts ...operations.Option) (*operations.AttachAppManifestResponse, error) { - request := operations.AttachAppManifestRequest{ - ID: id, - AttachManifestRequest: attachManifestRequest, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}/manifest", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "attachAppManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "AttachManifestRequest", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"400", "404", "4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.AttachAppManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode == 400: - fallthrough - case httpRes.StatusCode == 404: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DetachAppManifest - Unbind an app from its manifest -// Clears the app's `manifest_id` pointer. Idempotent. -func (s *DeployServer) DetachAppManifest(ctx context.Context, id string, opts ...operations.Option) (*operations.DetachAppManifestResponse, error) { - request := operations.DetachAppManifestRequest{ - ID: id, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}/manifest", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "detachAppManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DetachAppManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadAppVariables - Get all variables of an app -func (s *DeployServer) ReadAppVariables(ctx context.Context, id string, pageSize *int64, cursor *string, opts ...operations.Option) (*operations.ReadAppVariablesResponse, error) { - request := operations.ReadAppVariablesRequest{ - ID: id, - PageSize: pageSize, - Cursor: cursor, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}/variables", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readAppVariables", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadAppVariablesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadVariablesResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadVariablesResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateAppVariable - Create variable for an app -func (s *DeployServer) CreateAppVariable(ctx context.Context, id string, createVariableRequest components.CreateVariableRequest, opts ...operations.Option) (*operations.CreateAppVariableResponse, error) { - request := operations.CreateAppVariableRequest{ - ID: id, - CreateVariableRequest: createVariableRequest, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}/variables", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createAppVariable", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "CreateVariableRequest", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateAppVariableResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateVariableResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateVariableResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteAppVariable - Delete a variable from an app -func (s *DeployServer) DeleteAppVariable(ctx context.Context, id string, variableID string, opts ...operations.Option) (*operations.DeleteAppVariableResponse, error) { - request := operations.DeleteAppVariableRequest{ - ID: id, - VariableID: variableID, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/apps/{id}/variables/{variableId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteAppVariable", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteAppVariableResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateManifestRaw - Create a new manifest -func (s *DeployServer) CreateManifestRaw(ctx context.Context, name string, requestBody any, opts ...operations.Option) (*operations.CreateManifestRawResponse, error) { - request := operations.CreateManifestRawRequest{ - Name: name, - RequestBody: requestBody, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/manifests") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createManifest_raw", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "RequestBody", "raw", `request:"mediaType=application/yaml"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateManifestRawResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateManifestResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateManifestResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateManifest - Create a new manifest -func (s *DeployServer) CreateManifest(ctx context.Context, name string, requestBody operations.CreateManifestRequestBody, opts ...operations.Option) (*operations.CreateManifestResponse, error) { - request := operations.CreateManifestRequest{ - Name: name, - RequestBody: requestBody, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/manifests") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "RequestBody", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateManifestResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateManifestResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListManifests - List manifests in the organization -func (s *DeployServer) ListManifests(ctx context.Context, pageSize *int64, cursor *string, opts ...operations.Option) (*operations.ListManifestsResponse, error) { - request := operations.ListManifestsRequest{ - PageSize: pageSize, - Cursor: cursor, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/manifests") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listManifests", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListManifestsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListManifestsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListManifestsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadManifest - Read a manifest -func (s *DeployServer) ReadManifest(ctx context.Context, manifestID string, include *string, opts ...operations.Option) (*operations.ReadManifestResponse, error) { - request := operations.ReadManifestRequest{ - ManifestID: manifestID, - Include: include, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ManifestResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ManifestResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdateManifest - Update manifest metadata -func (s *DeployServer) UpdateManifest(ctx context.Context, manifestID string, updateManifestRequest components.UpdateManifestRequest, opts ...operations.Option) (*operations.UpdateManifestResponse, error) { - request := operations.UpdateManifestRequest{ - ManifestID: manifestID, - UpdateManifestRequest: updateManifestRequest, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updateManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "UpdateManifestRequest", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PATCH", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdateManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ManifestResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ManifestResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteManifest - Delete a manifest and all its versions -func (s *DeployServer) DeleteManifest(ctx context.Context, manifestID string, opts ...operations.Option) (*operations.DeleteManifestResponse, error) { - request := operations.DeleteManifestRequest{ - ManifestID: manifestID, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteManifest", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"409", "4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteManifestResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode == 409: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// PushManifestVersionRaw - Push a new version of a manifest -func (s *DeployServer) PushManifestVersionRaw(ctx context.Context, manifestID string, requestBody any, opts ...operations.Option) (*operations.PushManifestVersionRawResponse, error) { - request := operations.PushManifestVersionRawRequest{ - ManifestID: manifestID, - RequestBody: requestBody, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}/versions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "pushManifestVersion_raw", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "RequestBody", "raw", `request:"mediaType=application/yaml"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.PushManifestVersionRawResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ManifestVersionResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ManifestVersionResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// PushManifestVersion - Push a new version of a manifest -func (s *DeployServer) PushManifestVersion(ctx context.Context, manifestID string, requestBody operations.PushManifestVersionRequestBody, opts ...operations.Option) (*operations.PushManifestVersionResponse, error) { - request := operations.PushManifestVersionRequest{ - ManifestID: manifestID, - RequestBody: requestBody, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}/versions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "pushManifestVersion", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "RequestBody", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.PushManifestVersionResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ManifestVersionResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ManifestVersionResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListManifestVersions - List versions of a manifest -func (s *DeployServer) ListManifestVersions(ctx context.Context, manifestID string, pageSize *int64, cursor *string, opts ...operations.Option) (*operations.ListManifestVersionsResponse, error) { - request := operations.ListManifestVersionsRequest{ - ManifestID: manifestID, - PageSize: pageSize, - Cursor: cursor, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}/versions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listManifestVersions", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListManifestVersionsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListManifestVersionsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListManifestVersionsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadManifestVersion - Get a specific manifest version with content -func (s *DeployServer) ReadManifestVersion(ctx context.Context, manifestID string, version string, opts ...operations.Option) (*operations.ReadManifestVersionResponse, error) { - request := operations.ReadManifestVersionRequest{ - ManifestID: manifestID, - Version: version, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - operations.SupportedOptionAcceptHeaderOverride, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/manifests/{manifestId}/versions/{version}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readManifestVersion", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - if o.AcceptHeaderOverride != nil { - req.Header.Set("Accept", string(*o.AcceptHeaderOverride)) - } else { - req.Header.Set("Accept", "application/json;q=1, application/x-yaml;q=0") - } - - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadManifestVersionResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ManifestVersionResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ManifestVersionResponse = &out - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/x-yaml`): - res.ResponseStream = httpRes.Body - - return res, nil - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateDeployment - Create a deployment (triggers a run) -func (s *DeployServer) CreateDeployment(ctx context.Context, request components.CreateDeploymentRequest, opts ...operations.Option) (*operations.CreateDeploymentResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/deployments") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createDeployment", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "Request", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateDeploymentResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.DeploymentResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.DeploymentResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListDeployments - List deployments -func (s *DeployServer) ListDeployments(ctx context.Context, appID *string, pageSize *int64, cursor *string, opts ...operations.Option) (*operations.ListDeploymentsResponse, error) { - request := operations.ListDeploymentsRequest{ - AppID: appID, - PageSize: pageSize, - Cursor: cursor, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/deployments") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listDeployments", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListDeploymentsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListDeploymentsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListDeploymentsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadDeployment - Get a single deployment -func (s *DeployServer) ReadDeployment(ctx context.Context, deploymentID string, include []operations.ReadDeploymentInclude, opts ...operations.Option) (*operations.ReadDeploymentResponse, error) { - request := operations.ReadDeploymentRequest{ - DeploymentID: deploymentID, - Include: include, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - operations.SupportedOptionAcceptHeaderOverride, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/deployments/{deploymentId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readDeployment", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - if o.AcceptHeaderOverride != nil { - req.Header.Set("Accept", string(*o.AcceptHeaderOverride)) - } else { - req.Header.Set("Accept", "application/json;q=1, application/yaml;q=0") - } - - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadDeploymentResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.DeploymentResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.DeploymentResponse = &out - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/yaml`): - res.ResponseStream = httpRes.Body - - return res, nil - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadDeploymentLogs - Get run logs for a deployment -func (s *DeployServer) ReadDeploymentLogs(ctx context.Context, deploymentID string, opts ...operations.Option) (*operations.ReadDeploymentLogsResponse, error) { - request := operations.ReadDeploymentLogsRequest{ - DeploymentID: deploymentID, - } - - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/deployments/{deploymentId}/logs", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readDeploymentLogs", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadDeploymentLogsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadLogsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadLogsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} diff --git a/internal/deployserverclient/docs/models/apierrors/error.md b/internal/deployserverclient/docs/models/apierrors/error.md deleted file mode 100644 index 5450343d..00000000 --- a/internal/deployserverclient/docs/models/apierrors/error.md +++ /dev/null @@ -1,9 +0,0 @@ -# Error - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ErrorCode` | `string` | :heavy_check_mark: | N/A | -| `ErrorMessage` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/app.md b/internal/deployserverclient/docs/models/components/app.md deleted file mode 100644 index c77eac6a..00000000 --- a/internal/deployserverclient/docs/models/components/app.md +++ /dev/null @@ -1,13 +0,0 @@ -# App - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | Unique identifier for the app | -| `Name` | `string` | :heavy_check_mark: | Name of the app | -| `StackID` | `*string` | :heavy_minus_sign: | Optional existing stack ID claimed by this app | -| `StackRegionID` | `*string` | :heavy_minus_sign: | Stack region ID (set when the app's stack lives in a specific region) | -| `State` | [*components.State](../../models/components/state.md) | :heavy_minus_sign: | N/A | -| `CurrentManifest` | [*components.AppCurrentManifest](../../models/components/appcurrentmanifest.md) | :heavy_minus_sign: | The manifest the app is currently bound to (apps.manifest_id), with
catalog metadata and divergence vs the most recent applied deployment.
Populated by GET /apps/{id} when a manifest is bound.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/appcurrentmanifest.md b/internal/deployserverclient/docs/models/components/appcurrentmanifest.md deleted file mode 100644 index 8d2c8375..00000000 --- a/internal/deployserverclient/docs/models/components/appcurrentmanifest.md +++ /dev/null @@ -1,16 +0,0 @@ -# AppCurrentManifest - -The manifest the app is currently bound to (apps.manifest_id), with -catalog metadata and divergence vs the most recent applied deployment. -Populated by GET /apps/{id} when a manifest is bound. - - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | ID of the manifest the app is bound to | -| `Name` | `string` | :heavy_check_mark: | Name of the bound manifest | -| `LatestVersion` | `int64` | :heavy_check_mark: | Latest version available in the manifest's lineage | -| `Divergence` | [*components.AppManifestDivergence](../../models/components/appmanifestdivergence.md) | :heavy_minus_sign: | Relationship between the app's bound manifest and what's currently
deployed (last `applied` deployment, ignoring destroys).
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/appmanifestdivergence.md b/internal/deployserverclient/docs/models/components/appmanifestdivergence.md deleted file mode 100644 index 92ae72e8..00000000 --- a/internal/deployserverclient/docs/models/components/appmanifestdivergence.md +++ /dev/null @@ -1,15 +0,0 @@ -# AppManifestDivergence - -Relationship between the app's bound manifest and what's currently -deployed (last `applied` deployment, ignoring destroys). - - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Kind` | [components.Kind](../../models/components/kind.md) | :heavy_check_mark: | - `undeployed`: the app has a manifest bound but no successful deployment yet.
- `synced`: deployed (manifestId, version) matches the bound manifest's latest version.
- `behind`: deployed manifest matches the bound manifest but on an older version.
- `rebound`: the app was rebound; deployed manifest is a different manifest.
| -| `DeployedManifestID` | `*string` | :heavy_minus_sign: | ID of the manifest reflected in the most recent applied deployment | -| `DeployedVersion` | `*int64` | :heavy_minus_sign: | Version reflected in the most recent applied deployment | -| `LatestVersion` | `int64` | :heavy_check_mark: | Latest version available in the bound manifest's lineage | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/appresponse.md b/internal/deployserverclient/docs/models/components/appresponse.md deleted file mode 100644 index 4b41a7e8..00000000 --- a/internal/deployserverclient/docs/models/components/appresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# AppResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------ | -| `Data` | [components.App](../../models/components/app.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/attachmanifestrequest.md b/internal/deployserverclient/docs/models/components/attachmanifestrequest.md deleted file mode 100644 index b1ecb225..00000000 --- a/internal/deployserverclient/docs/models/components/attachmanifestrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# AttachManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------- | ------------------------------------- | ------------------------------------- | ------------------------------------- | -| `ManifestID` | `string` | :heavy_check_mark: | ID of the manifest to bind to the app | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/createapprequest.md b/internal/deployserverclient/docs/models/components/createapprequest.md deleted file mode 100644 index 8d2ae919..00000000 --- a/internal/deployserverclient/docs/models/components/createapprequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateAppRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Name of the app | -| `StackID` | `*string` | :heavy_minus_sign: | Optional existing stack ID to claim | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/createdeploymentrequest.md b/internal/deployserverclient/docs/models/components/createdeploymentrequest.md deleted file mode 100644 index 03f669eb..00000000 --- a/internal/deployserverclient/docs/models/components/createdeploymentrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateDeploymentRequest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------- | -------------------------- | -------------------------- | -------------------------- | -| `AppID` | `string` | :heavy_check_mark: | ID of the app to deploy to | -| `ManifestID` | `string` | :heavy_check_mark: | Manifest catalog ID | -| `ManifestVersion` | `int64` | :heavy_check_mark: | Manifest version | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/createmanifestresponse.md b/internal/deployserverclient/docs/models/components/createmanifestresponse.md deleted file mode 100644 index c686febb..00000000 --- a/internal/deployserverclient/docs/models/components/createmanifestresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -| `Data` | [components.Data](../../models/components/data.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/createvariablerequest.md b/internal/deployserverclient/docs/models/components/createvariablerequest.md deleted file mode 100644 index a18fbe1a..00000000 --- a/internal/deployserverclient/docs/models/components/createvariablerequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateVariableRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `Variable` | [components.VariableData](../../models/components/variabledata.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/createvariableresponse.md b/internal/deployserverclient/docs/models/components/createvariableresponse.md deleted file mode 100644 index 82ba7c12..00000000 --- a/internal/deployserverclient/docs/models/components/createvariableresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateVariableResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | -| `Data` | [components.Variable](../../models/components/variable.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/data.md b/internal/deployserverclient/docs/models/components/data.md deleted file mode 100644 index f1767725..00000000 --- a/internal/deployserverclient/docs/models/components/data.md +++ /dev/null @@ -1,11 +0,0 @@ -# Data - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `Version` | `int64` | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/deleteappresponse.md b/internal/deployserverclient/docs/models/components/deleteappresponse.md deleted file mode 100644 index 0d8b766a..00000000 --- a/internal/deployserverclient/docs/models/components/deleteappresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteAppResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DestroyDeploymentID` | `*string` | :heavy_minus_sign: | ID of the destroy deployment enqueued to clean up the app's
resources. Empty when no destroy was needed (e.g. no prior
applied deployment). Use `GET /deployments/{id}` to poll its
status.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/deploymentresource.md b/internal/deployserverclient/docs/models/components/deploymentresource.md deleted file mode 100644 index 4bf3d77b..00000000 --- a/internal/deployserverclient/docs/models/components/deploymentresource.md +++ /dev/null @@ -1,15 +0,0 @@ -# DeploymentResource - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `AppID` | `string` | :heavy_check_mark: | N/A | -| `ManifestID` | `*string` | :heavy_minus_sign: | N/A | -| `ManifestVersion` | `*int64` | :heavy_minus_sign: | N/A | -| `Status` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `State` | [*components.State](../../models/components/state.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/deploymentresponse.md b/internal/deployserverclient/docs/models/components/deploymentresponse.md deleted file mode 100644 index e5a1bb0f..00000000 --- a/internal/deployserverclient/docs/models/components/deploymentresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeploymentResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `Data` | [components.DeploymentResource](../../models/components/deploymentresource.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/diagnostic.md b/internal/deployserverclient/docs/models/components/diagnostic.md deleted file mode 100644 index 0d90d8be..00000000 --- a/internal/deployserverclient/docs/models/components/diagnostic.md +++ /dev/null @@ -1,12 +0,0 @@ -# Diagnostic - -Detailed log message - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------- | -------------------------------- | -------------------------------- | -------------------------------- | -| `Severity` | `string` | :heavy_check_mark: | Severity of the log message | -| `Detail` | `string` | :heavy_check_mark: | N/A | -| `Summary` | `string` | :heavy_check_mark: | Brief summary of the log message | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/error.md b/internal/deployserverclient/docs/models/components/error.md deleted file mode 100644 index 5450343d..00000000 --- a/internal/deployserverclient/docs/models/components/error.md +++ /dev/null @@ -1,9 +0,0 @@ -# Error - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ErrorCode` | `string` | :heavy_check_mark: | N/A | -| `ErrorMessage` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/httpmetadata.md b/internal/deployserverclient/docs/models/components/httpmetadata.md deleted file mode 100644 index df1fdd59..00000000 --- a/internal/deployserverclient/docs/models/components/httpmetadata.md +++ /dev/null @@ -1,9 +0,0 @@ -# HTTPMetadata - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Response` | [*http.Response](https://pkg.go.dev/net/http#Response) | :heavy_check_mark: | Raw HTTP response; suitable for custom response parsing | -| `Request` | [*http.Request](https://pkg.go.dev/net/http#Request) | :heavy_check_mark: | Raw HTTP request; suitable for debugging | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/kind.md b/internal/deployserverclient/docs/models/components/kind.md deleted file mode 100644 index 983f988a..00000000 --- a/internal/deployserverclient/docs/models/components/kind.md +++ /dev/null @@ -1,27 +0,0 @@ -# Kind - -- `undeployed`: the app has a manifest bound but no successful deployment yet. -- `synced`: deployed (manifestId, version) matches the bound manifest's latest version. -- `behind`: deployed manifest matches the bound manifest but on an older version. -- `rebound`: the app was rebound; deployed manifest is a different manifest. - - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -value := components.KindUndeployed -``` - - -## Values - -| Name | Value | -| ---------------- | ---------------- | -| `KindUndeployed` | undeployed | -| `KindSynced` | synced | -| `KindBehind` | behind | -| `KindRebound` | rebound | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listappsresponse.md b/internal/deployserverclient/docs/models/components/listappsresponse.md deleted file mode 100644 index 99147c1a..00000000 --- a/internal/deployserverclient/docs/models/components/listappsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListAppsResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `Cursor` | [components.ListAppsResponseCursor](../../models/components/listappsresponsecursor.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listappsresponsecursor.md b/internal/deployserverclient/docs/models/components/listappsresponsecursor.md deleted file mode 100644 index 93de1454..00000000 --- a/internal/deployserverclient/docs/models/components/listappsresponsecursor.md +++ /dev/null @@ -1,18 +0,0 @@ -# ListAppsResponseCursor - -Cursor pagination envelope. `next` and `previous` are opaque tokens -produced by the server; pass them back as `?cursor=` to fetch the -adjacent page. `hasMore` is `true` when more results exist after the -current page. `pageSize` echoes the page size used for this page. - - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | -| `PageSize` | `*int64` | :heavy_minus_sign: | Number of items requested for this page | -| `HasMore` | `bool` | :heavy_check_mark: | True when more results exist after this page | -| `Previous` | `*string` | :heavy_minus_sign: | Opaque cursor token for the previous page (empty when none) | -| `Next` | `*string` | :heavy_minus_sign: | Opaque cursor token for the next page (empty when none) | -| `Data` | [][components.App](../../models/components/app.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listdeploymentsresponse.md b/internal/deployserverclient/docs/models/components/listdeploymentsresponse.md deleted file mode 100644 index 2ce25a39..00000000 --- a/internal/deployserverclient/docs/models/components/listdeploymentsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListDeploymentsResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `Cursor` | [components.ListDeploymentsResponseCursor](../../models/components/listdeploymentsresponsecursor.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listdeploymentsresponsecursor.md b/internal/deployserverclient/docs/models/components/listdeploymentsresponsecursor.md deleted file mode 100644 index 2baeeaa9..00000000 --- a/internal/deployserverclient/docs/models/components/listdeploymentsresponsecursor.md +++ /dev/null @@ -1,18 +0,0 @@ -# ListDeploymentsResponseCursor - -Cursor pagination envelope. `next` and `previous` are opaque tokens -produced by the server; pass them back as `?cursor=` to fetch the -adjacent page. `hasMore` is `true` when more results exist after the -current page. `pageSize` echoes the page size used for this page. - - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `PageSize` | `*int64` | :heavy_minus_sign: | Number of items requested for this page | -| `HasMore` | `bool` | :heavy_check_mark: | True when more results exist after this page | -| `Previous` | `*string` | :heavy_minus_sign: | Opaque cursor token for the previous page (empty when none) | -| `Next` | `*string` | :heavy_minus_sign: | Opaque cursor token for the next page (empty when none) | -| `Data` | [][components.DeploymentResource](../../models/components/deploymentresource.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listmanifestsresponse.md b/internal/deployserverclient/docs/models/components/listmanifestsresponse.md deleted file mode 100644 index 74d925c6..00000000 --- a/internal/deployserverclient/docs/models/components/listmanifestsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListManifestsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| `Cursor` | [components.ListManifestsResponseCursor](../../models/components/listmanifestsresponsecursor.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listmanifestsresponsecursor.md b/internal/deployserverclient/docs/models/components/listmanifestsresponsecursor.md deleted file mode 100644 index 1c70dee7..00000000 --- a/internal/deployserverclient/docs/models/components/listmanifestsresponsecursor.md +++ /dev/null @@ -1,18 +0,0 @@ -# ListManifestsResponseCursor - -Cursor pagination envelope. `next` and `previous` are opaque tokens -produced by the server; pass them back as `?cursor=` to fetch the -adjacent page. `hasMore` is `true` when more results exist after the -current page. `pageSize` echoes the page size used for this page. - - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | -| `PageSize` | `*int64` | :heavy_minus_sign: | Number of items requested for this page | -| `HasMore` | `bool` | :heavy_check_mark: | True when more results exist after this page | -| `Previous` | `*string` | :heavy_minus_sign: | Opaque cursor token for the previous page (empty when none) | -| `Next` | `*string` | :heavy_minus_sign: | Opaque cursor token for the next page (empty when none) | -| `Data` | [][components.Manifest](../../models/components/manifest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listmanifestversionsresponse.md b/internal/deployserverclient/docs/models/components/listmanifestversionsresponse.md deleted file mode 100644 index cdfc28ea..00000000 --- a/internal/deployserverclient/docs/models/components/listmanifestversionsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListManifestVersionsResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `Cursor` | [components.ListManifestVersionsResponseCursor](../../models/components/listmanifestversionsresponsecursor.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/listmanifestversionsresponsecursor.md b/internal/deployserverclient/docs/models/components/listmanifestversionsresponsecursor.md deleted file mode 100644 index 3f829a1d..00000000 --- a/internal/deployserverclient/docs/models/components/listmanifestversionsresponsecursor.md +++ /dev/null @@ -1,18 +0,0 @@ -# ListManifestVersionsResponseCursor - -Cursor pagination envelope. `next` and `previous` are opaque tokens -produced by the server; pass them back as `?cursor=` to fetch the -adjacent page. `hasMore` is `true` when more results exist after the -current page. `pageSize` echoes the page size used for this page. - - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `PageSize` | `*int64` | :heavy_minus_sign: | Number of items requested for this page | -| `HasMore` | `bool` | :heavy_check_mark: | True when more results exist after this page | -| `Previous` | `*string` | :heavy_minus_sign: | Opaque cursor token for the previous page (empty when none) | -| `Next` | `*string` | :heavy_minus_sign: | Opaque cursor token for the next page (empty when none) | -| `Data` | [][components.ManifestVersion](../../models/components/manifestversion.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/log.md b/internal/deployserverclient/docs/models/components/log.md deleted file mode 100644 index 79e08b9c..00000000 --- a/internal/deployserverclient/docs/models/components/log.md +++ /dev/null @@ -1,11 +0,0 @@ -# Log - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | -| `Message` | `string` | :heavy_check_mark: | Log message | -| `Timestamp` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Timestamp when the log was created | -| `Module` | `string` | :heavy_check_mark: | Module or component that generated the log | -| `Diagnostic` | [*components.Diagnostic](../../models/components/diagnostic.md) | :heavy_minus_sign: | Detailed log message | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/manifest.md b/internal/deployserverclient/docs/models/components/manifest.md deleted file mode 100644 index 407cb573..00000000 --- a/internal/deployserverclient/docs/models/components/manifest.md +++ /dev/null @@ -1,12 +0,0 @@ -# Manifest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | Unique identifier for the manifest | -| `Name` | `string` | :heavy_check_mark: | Name of the manifest | -| `LatestVersion` | `int64` | :heavy_check_mark: | Latest version number | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Timestamp when the manifest was created | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Timestamp when the manifest was last updated | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/manifestresponse.md b/internal/deployserverclient/docs/models/components/manifestresponse.md deleted file mode 100644 index 30a0ca2d..00000000 --- a/internal/deployserverclient/docs/models/components/manifestresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | -| `Data` | [components.Manifest](../../models/components/manifest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/manifestversion.md b/internal/deployserverclient/docs/models/components/manifestversion.md deleted file mode 100644 index 608ea35c..00000000 --- a/internal/deployserverclient/docs/models/components/manifestversion.md +++ /dev/null @@ -1,11 +0,0 @@ -# ManifestVersion - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `ManifestID` | `string` | :heavy_check_mark: | ID of the manifest this version belongs to | -| `Version` | `int64` | :heavy_check_mark: | Version number | -| `Content` | `*string` | :heavy_minus_sign: | Manifest content (base64-encoded). Null when listing versions, present when reading a specific version. | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Timestamp when the version was created | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/manifestversionresponse.md b/internal/deployserverclient/docs/models/components/manifestversionresponse.md deleted file mode 100644 index fe14fcbd..00000000 --- a/internal/deployserverclient/docs/models/components/manifestversionresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ManifestVersionResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `Data` | [components.ManifestVersion](../../models/components/manifestversion.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/readlogsresponse.md b/internal/deployserverclient/docs/models/components/readlogsresponse.md deleted file mode 100644 index 01c385db..00000000 --- a/internal/deployserverclient/docs/models/components/readlogsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadLogsResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -| `Data` | [][components.Log](../../models/components/log.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/readvariablesresponse.md b/internal/deployserverclient/docs/models/components/readvariablesresponse.md deleted file mode 100644 index 56f1ad49..00000000 --- a/internal/deployserverclient/docs/models/components/readvariablesresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadVariablesResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| `Cursor` | [components.ReadVariablesResponseCursor](../../models/components/readvariablesresponsecursor.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/readvariablesresponsecursor.md b/internal/deployserverclient/docs/models/components/readvariablesresponsecursor.md deleted file mode 100644 index cc317df0..00000000 --- a/internal/deployserverclient/docs/models/components/readvariablesresponsecursor.md +++ /dev/null @@ -1,18 +0,0 @@ -# ReadVariablesResponseCursor - -Cursor pagination envelope. `next` and `previous` are opaque tokens -produced by the server; pass them back as `?cursor=` to fetch the -adjacent page. `hasMore` is `true` when more results exist after the -current page. `pageSize` echoes the page size used for this page. - - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | -| `PageSize` | `*int64` | :heavy_minus_sign: | Number of items requested for this page | -| `HasMore` | `bool` | :heavy_check_mark: | True when more results exist after this page | -| `Previous` | `*string` | :heavy_minus_sign: | Opaque cursor token for the previous page (empty when none) | -| `Next` | `*string` | :heavy_minus_sign: | Opaque cursor token for the next page (empty when none) | -| `Data` | [][components.Variable](../../models/components/variable.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/schema.md b/internal/deployserverclient/docs/models/components/schema.md deleted file mode 100644 index 9027af16..00000000 --- a/internal/deployserverclient/docs/models/components/schema.md +++ /dev/null @@ -1,9 +0,0 @@ -# Schema - - -## Fields - -| Field | Type | Required | Description | -| ------------------- | ------------------- | ------------------- | ------------------- | -| `Metadata` | map[string]*string* | :heavy_minus_sign: | N/A | -| `Name` | **string* | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/security.md b/internal/deployserverclient/docs/models/components/security.md deleted file mode 100644 index c15c11e5..00000000 --- a/internal/deployserverclient/docs/models/components/security.md +++ /dev/null @@ -1,8 +0,0 @@ -# Security - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `BearerAuth` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/state.md b/internal/deployserverclient/docs/models/components/state.md deleted file mode 100644 index 4e5096c4..00000000 --- a/internal/deployserverclient/docs/models/components/state.md +++ /dev/null @@ -1,8 +0,0 @@ -# State - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Stack` | map[string]`any` | :heavy_check_mark: | The live stack details reported by Formance Cloud (a projection of
the bound stack from Membership). Shape is a free-form object
mirroring the upstream stack resource.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/updateapprequest.md b/internal/deployserverclient/docs/models/components/updateapprequest.md deleted file mode 100644 index 61637f46..00000000 --- a/internal/deployserverclient/docs/models/components/updateapprequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdateAppRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------ | ------------------------ | ------------------------ | ------------------------ | -| `Name` | `string` | :heavy_check_mark: | Updated name for the app | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/updatemanifestrequest.md b/internal/deployserverclient/docs/models/components/updatemanifestrequest.md deleted file mode 100644 index 2df4cefa..00000000 --- a/internal/deployserverclient/docs/models/components/updatemanifestrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdateManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | -| `Name` | `string` | :heavy_check_mark: | Updated name for the manifest | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/variable.md b/internal/deployserverclient/docs/models/components/variable.md deleted file mode 100644 index 3f697c82..00000000 --- a/internal/deployserverclient/docs/models/components/variable.md +++ /dev/null @@ -1,11 +0,0 @@ -# Variable - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Key` | `string` | :heavy_check_mark: | Snake-case identifier (case-insensitive at the API boundary,
normalized to lowercase on storage to match `var.` in the
generated HCL). Either `MONEYCORP_API_KEY` or
`moneycorp_api_key` is accepted.
| -| `Value` | `string` | :heavy_check_mark: | Value of the variable. Always treated as sensitive: `value` is
redacted (`REDACTED`) on every read endpoint.
| -| `Description` | `*string` | :heavy_minus_sign: | Description of the variable | -| `ID` | `string` | :heavy_check_mark: | Unique identifier for the variable | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/components/variabledata.md b/internal/deployserverclient/docs/models/components/variabledata.md deleted file mode 100644 index 42c414ca..00000000 --- a/internal/deployserverclient/docs/models/components/variabledata.md +++ /dev/null @@ -1,10 +0,0 @@ -# VariableData - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Key` | `string` | :heavy_check_mark: | Snake-case identifier (case-insensitive at the API boundary,
normalized to lowercase on storage to match `var.` in the
generated HCL). Either `MONEYCORP_API_KEY` or
`moneycorp_api_key` is accepted.
| -| `Value` | `string` | :heavy_check_mark: | Value of the variable. Always treated as sensitive: `value` is
redacted (`REDACTED`) on every read endpoint.
| -| `Description` | `*string` | :heavy_minus_sign: | Description of the variable | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/attachappmanifestrequest.md b/internal/deployserverclient/docs/models/operations/attachappmanifestrequest.md deleted file mode 100644 index 9a803bad..00000000 --- a/internal/deployserverclient/docs/models/operations/attachappmanifestrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# AttachAppManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `AttachManifestRequest` | [components.AttachManifestRequest](../../models/components/attachmanifestrequest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/attachappmanifestresponse.md b/internal/deployserverclient/docs/models/operations/attachappmanifestresponse.md deleted file mode 100644 index 58ad5c5b..00000000 --- a/internal/deployserverclient/docs/models/operations/attachappmanifestresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# AttachAppManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | manifestId missing or empty | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createappresponse.md b/internal/deployserverclient/docs/models/operations/createappresponse.md deleted file mode 100644 index 7a6e7de9..00000000 --- a/internal/deployserverclient/docs/models/operations/createappresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateAppResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `AppResponse` | [*components.AppResponse](../../models/components/appresponse.md) | :heavy_minus_sign: | App created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createappvariablerequest.md b/internal/deployserverclient/docs/models/operations/createappvariablerequest.md deleted file mode 100644 index 349840f1..00000000 --- a/internal/deployserverclient/docs/models/operations/createappvariablerequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateAppVariableRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `CreateVariableRequest` | [components.CreateVariableRequest](../../models/components/createvariablerequest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createappvariableresponse.md b/internal/deployserverclient/docs/models/operations/createappvariableresponse.md deleted file mode 100644 index 07452bcd..00000000 --- a/internal/deployserverclient/docs/models/operations/createappvariableresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateAppVariableResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateVariableResponse` | [*components.CreateVariableResponse](../../models/components/createvariableresponse.md) | :heavy_minus_sign: | Variable created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createdeploymentresponse.md b/internal/deployserverclient/docs/models/operations/createdeploymentresponse.md deleted file mode 100644 index d6be3430..00000000 --- a/internal/deployserverclient/docs/models/operations/createdeploymentresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateDeploymentResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `DeploymentResponse` | [*components.DeploymentResponse](../../models/components/deploymentresponse.md) | :heavy_minus_sign: | Deployment created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createmanifestrawrequest.md b/internal/deployserverclient/docs/models/operations/createmanifestrawrequest.md deleted file mode 100644 index dc7c3123..00000000 --- a/internal/deployserverclient/docs/models/operations/createmanifestrawrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateManifestRawRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------- | --------------------- | --------------------- | --------------------- | -| `Name` | `string` | :heavy_check_mark: | Name for the manifest | -| `RequestBody` | `any` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createmanifestrawresponse.md b/internal/deployserverclient/docs/models/operations/createmanifestrawresponse.md deleted file mode 100644 index 3e8b398e..00000000 --- a/internal/deployserverclient/docs/models/operations/createmanifestrawresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateManifestRawResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateManifestResponse` | [*components.CreateManifestResponse](../../models/components/createmanifestresponse.md) | :heavy_minus_sign: | Manifest created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createmanifestrequest.md b/internal/deployserverclient/docs/models/operations/createmanifestrequest.md deleted file mode 100644 index 319e78b7..00000000 --- a/internal/deployserverclient/docs/models/operations/createmanifestrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Name for the manifest | -| `RequestBody` | [operations.CreateManifestRequestBody](../../models/operations/createmanifestrequestbody.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createmanifestrequestbody.md b/internal/deployserverclient/docs/models/operations/createmanifestrequestbody.md deleted file mode 100644 index 6089e69c..00000000 --- a/internal/deployserverclient/docs/models/operations/createmanifestrequestbody.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateManifestRequestBody - -JSON manifest content - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/createmanifestresponse.md b/internal/deployserverclient/docs/models/operations/createmanifestresponse.md deleted file mode 100644 index ea064c63..00000000 --- a/internal/deployserverclient/docs/models/operations/createmanifestresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateManifestResponse` | [*components.CreateManifestResponse](../../models/components/createmanifestresponse.md) | :heavy_minus_sign: | Manifest created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deleteapprequest.md b/internal/deployserverclient/docs/models/operations/deleteapprequest.md deleted file mode 100644 index 63b4b8ba..00000000 --- a/internal/deployserverclient/docs/models/operations/deleteapprequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteAppRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `Wait` | `*bool` | :heavy_minus_sign: | When `true`, the call blocks until the destroy deployment reaches a
terminal status. Default `false` (returns 202 Accepted).
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deleteappresponse.md b/internal/deployserverclient/docs/models/operations/deleteappresponse.md deleted file mode 100644 index 41ecd5c7..00000000 --- a/internal/deployserverclient/docs/models/operations/deleteappresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# DeleteAppResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `DeleteAppResponse` | [*components.DeleteAppResponse](../../models/components/deleteappresponse.md) | :heavy_minus_sign: | Soft-delete accepted. Cleanup runs asynchronously. The
`Location` header points at the destroy deployment if one was
enqueued.
| -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deleteappvariablerequest.md b/internal/deployserverclient/docs/models/operations/deleteappvariablerequest.md deleted file mode 100644 index c44e4fd4..00000000 --- a/internal/deployserverclient/docs/models/operations/deleteappvariablerequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteAppVariableRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `VariableID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deleteappvariableresponse.md b/internal/deployserverclient/docs/models/operations/deleteappvariableresponse.md deleted file mode 100644 index 6a2eb110..00000000 --- a/internal/deployserverclient/docs/models/operations/deleteappvariableresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteAppVariableResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deletemanifestrequest.md b/internal/deployserverclient/docs/models/operations/deletemanifestrequest.md deleted file mode 100644 index 26341ed1..00000000 --- a/internal/deployserverclient/docs/models/operations/deletemanifestrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/deletemanifestresponse.md b/internal/deployserverclient/docs/models/operations/deletemanifestresponse.md deleted file mode 100644 index e8a81fae..00000000 --- a/internal/deployserverclient/docs/models/operations/deletemanifestresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Manifest is still referenced by deployments | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/detachappmanifestrequest.md b/internal/deployserverclient/docs/models/operations/detachappmanifestrequest.md deleted file mode 100644 index d11d0dba..00000000 --- a/internal/deployserverclient/docs/models/operations/detachappmanifestrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DetachAppManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/detachappmanifestresponse.md b/internal/deployserverclient/docs/models/operations/detachappmanifestresponse.md deleted file mode 100644 index fbbd1f5a..00000000 --- a/internal/deployserverclient/docs/models/operations/detachappmanifestresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DetachAppManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listappsrequest.md b/internal/deployserverclient/docs/models/operations/listappsrequest.md deleted file mode 100644 index 11c97674..00000000 --- a/internal/deployserverclient/docs/models/operations/listappsrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListAppsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `Cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listappsresponse.md b/internal/deployserverclient/docs/models/operations/listappsresponse.md deleted file mode 100644 index 4da04993..00000000 --- a/internal/deployserverclient/docs/models/operations/listappsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListAppsResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListAppsResponse` | [*components.ListAppsResponse](../../models/components/listappsresponse.md) | :heavy_minus_sign: | Apps retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listdeploymentsrequest.md b/internal/deployserverclient/docs/models/operations/listdeploymentsrequest.md deleted file mode 100644 index 2ee05d5a..00000000 --- a/internal/deployserverclient/docs/models/operations/listdeploymentsrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListDeploymentsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AppID` | `*string` | :heavy_minus_sign: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `Cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listdeploymentsresponse.md b/internal/deployserverclient/docs/models/operations/listdeploymentsresponse.md deleted file mode 100644 index cc044400..00000000 --- a/internal/deployserverclient/docs/models/operations/listdeploymentsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListDeploymentsResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListDeploymentsResponse` | [*components.ListDeploymentsResponse](../../models/components/listdeploymentsresponse.md) | :heavy_minus_sign: | Deployments retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listmanifestsrequest.md b/internal/deployserverclient/docs/models/operations/listmanifestsrequest.md deleted file mode 100644 index 115e9ccb..00000000 --- a/internal/deployserverclient/docs/models/operations/listmanifestsrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListManifestsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `Cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listmanifestsresponse.md b/internal/deployserverclient/docs/models/operations/listmanifestsresponse.md deleted file mode 100644 index 7bb7f793..00000000 --- a/internal/deployserverclient/docs/models/operations/listmanifestsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListManifestsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListManifestsResponse` | [*components.ListManifestsResponse](../../models/components/listmanifestsresponse.md) | :heavy_minus_sign: | Manifests retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listmanifestversionsrequest.md b/internal/deployserverclient/docs/models/operations/listmanifestversionsrequest.md deleted file mode 100644 index 7200d26c..00000000 --- a/internal/deployserverclient/docs/models/operations/listmanifestversionsrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListManifestVersionsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `Cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/listmanifestversionsresponse.md b/internal/deployserverclient/docs/models/operations/listmanifestversionsresponse.md deleted file mode 100644 index 03497219..00000000 --- a/internal/deployserverclient/docs/models/operations/listmanifestversionsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListManifestVersionsResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListManifestVersionsResponse` | [*components.ListManifestVersionsResponse](../../models/components/listmanifestversionsresponse.md) | :heavy_minus_sign: | Manifest versions retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/option.md b/internal/deployserverclient/docs/models/operations/option.md deleted file mode 100644 index c511959b..00000000 --- a/internal/deployserverclient/docs/models/operations/option.md +++ /dev/null @@ -1,155 +0,0 @@ -# Options - -## Global Options - -Global options are passed when initializing the SDK client and apply to all operations. - -### WithServerURL - -WithServerURL allows providing an alternative server URL. - -```go -deployserverclient.WithServerURL("https://api.example.com") -``` - -### WithTemplatedServerURL - -WithTemplatedServerURL allows providing an alternative server URL with templated parameters. - -```go -deployserverclient.WithTemplatedServerURL("https://{host}:{port}", map[string]string{ - "host": "api.example.com", - "port": "8080", -}) -``` - -### WithServerIndex - -WithServerIndex allows the overriding of the default server by index. - -```go -deployserverclient.WithServerIndex(1) -``` - -### WithClient - -WithClient allows the overriding of the default HTTP client used by the SDK. - -```go -deployserverclient.WithClient(httpClient) -``` - -### WithSecurity - -WithSecurity configures the SDK to use the provided security details. - -```go -deployserverclient.WithSecurity(/* ... */) -``` - -### WithSecuritySource - -WithSecuritySource configures the SDK to invoke the provided function on each method call to determine authentication. - -```go -deployserverclient.WithSecuritySource(/* ... */) -``` - -### WithRetryConfig - -WithRetryConfig allows setting the default retry configuration used by the SDK for all supported operations. - -```go -deployserverclient.WithRetryConfig(retry.Config{ - Strategy: "backoff", - Backoff: retry.BackoffStrategy{ - InitialInterval: 500 * time.Millisecond, - MaxInterval: 60 * time.Second, - Exponent: 1.5, - MaxElapsedTime: 5 * time.Minute, - }, - RetryConnectionErrors: true, -}) -``` - -### WithTimeout - -WithTimeout sets the default request timeout for all operations. - -```go -deployserverclient.WithTimeout(30 * time.Second) -``` - -## Per-Method Options - -Per-method options are passed as the last argument to individual methods and override any global settings for that request. - -### WithServerURL - -WithServerURL allows providing an alternative server URL for a single request. - -```go -operations.WithServerURL("http://api.example.com") -``` - -### WithTemplatedServerURL - -WithTemplatedServerURL allows providing an alternative server URL with templated parameters for a single request. - -```go -operations.WithTemplatedServerURL("http://{host}:{port}", map[string]string{ - "host": "api.example.com", - "port": "8080", -}) -``` - -### WithRetries - -WithRetries allows customizing the default retry configuration for a single request. - -```go -operations.WithRetries(retry.Config{ - Strategy: "backoff", - Backoff: retry.BackoffStrategy{ - InitialInterval: 500 * time.Millisecond, - MaxInterval: 60 * time.Second, - Exponent: 1.5, - MaxElapsedTime: 5 * time.Minute, - }, - RetryConnectionErrors: true, -}) -``` - -### WithOperationTimeout - -WithOperationTimeout allows setting the request timeout for a single request. - -```go -operations.WithOperationTimeout(30 * time.Second) -``` - -### WithSetHeaders - -WithSetHeaders allows setting custom headers on a per-request basis. If the request already contains headers matching the provided keys, they will be overwritten. - -```go -operations.WithSetHeaders(map[string]string{ - "X-Cache-TTL": "60", -}) -``` - -### WithURLOverride - -WithURLOverride allows overriding the default URL for an operation. - -```go -operations.WithURLOverride("/custom/path") -``` - -### WithAcceptHeaderOverride - -WithAcceptHeaderOverride allows overriding the `Accept` header for operations that support multiple response content types. - -```go -operations.WithAcceptHeaderOverride(operations.AcceptHeaderEnumApplicationJson) -``` \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/pushmanifestversionrawrequest.md b/internal/deployserverclient/docs/models/operations/pushmanifestversionrawrequest.md deleted file mode 100644 index a21204cf..00000000 --- a/internal/deployserverclient/docs/models/operations/pushmanifestversionrawrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# PushManifestVersionRawRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `RequestBody` | `any` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/pushmanifestversionrawresponse.md b/internal/deployserverclient/docs/models/operations/pushmanifestversionrawresponse.md deleted file mode 100644 index 0985bb7c..00000000 --- a/internal/deployserverclient/docs/models/operations/pushmanifestversionrawresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# PushManifestVersionRawResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ManifestVersionResponse` | [*components.ManifestVersionResponse](../../models/components/manifestversionresponse.md) | :heavy_minus_sign: | Manifest version created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/pushmanifestversionrequest.md b/internal/deployserverclient/docs/models/operations/pushmanifestversionrequest.md deleted file mode 100644 index dd1aab9d..00000000 --- a/internal/deployserverclient/docs/models/operations/pushmanifestversionrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# PushManifestVersionRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `RequestBody` | [operations.PushManifestVersionRequestBody](../../models/operations/pushmanifestversionrequestbody.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/pushmanifestversionrequestbody.md b/internal/deployserverclient/docs/models/operations/pushmanifestversionrequestbody.md deleted file mode 100644 index 520019e8..00000000 --- a/internal/deployserverclient/docs/models/operations/pushmanifestversionrequestbody.md +++ /dev/null @@ -1,9 +0,0 @@ -# PushManifestVersionRequestBody - -JSON manifest content - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/pushmanifestversionresponse.md b/internal/deployserverclient/docs/models/operations/pushmanifestversionresponse.md deleted file mode 100644 index f1e2e08b..00000000 --- a/internal/deployserverclient/docs/models/operations/pushmanifestversionresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# PushManifestVersionResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ManifestVersionResponse` | [*components.ManifestVersionResponse](../../models/components/manifestversionresponse.md) | :heavy_minus_sign: | Manifest version created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readappinclude.md b/internal/deployserverclient/docs/models/operations/readappinclude.md deleted file mode 100644 index 5f20740f..00000000 --- a/internal/deployserverclient/docs/models/operations/readappinclude.md +++ /dev/null @@ -1,18 +0,0 @@ -# ReadAppInclude - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" -) - -value := operations.ReadAppIncludeState -``` - - -## Values - -| Name | Value | -| --------------------- | --------------------- | -| `ReadAppIncludeState` | state | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readapprequest.md b/internal/deployserverclient/docs/models/operations/readapprequest.md deleted file mode 100644 index c7041dc2..00000000 --- a/internal/deployserverclient/docs/models/operations/readapprequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadAppRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `Include` | [][operations.ReadAppInclude](../../models/operations/readappinclude.md) | :heavy_minus_sign: | Comma-separated list of related resources to include.
- `state`: Include the live stack state reported by Formance Cloud
(a projection of the bound stack from Membership, not a snapshot
persisted by the proxy).
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readappresponse.md b/internal/deployserverclient/docs/models/operations/readappresponse.md deleted file mode 100644 index b82b6c78..00000000 --- a/internal/deployserverclient/docs/models/operations/readappresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadAppResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `AppResponse` | [*components.AppResponse](../../models/components/appresponse.md) | :heavy_minus_sign: | App details retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readappvariablesrequest.md b/internal/deployserverclient/docs/models/operations/readappvariablesrequest.md deleted file mode 100644 index 369da6d9..00000000 --- a/internal/deployserverclient/docs/models/operations/readappvariablesrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadAppVariablesRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `Cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readappvariablesresponse.md b/internal/deployserverclient/docs/models/operations/readappvariablesresponse.md deleted file mode 100644 index 4a2ae584..00000000 --- a/internal/deployserverclient/docs/models/operations/readappvariablesresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadAppVariablesResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadVariablesResponse` | [*components.ReadVariablesResponse](../../models/components/readvariablesresponse.md) | :heavy_minus_sign: | App variables retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readdeploymentinclude.md b/internal/deployserverclient/docs/models/operations/readdeploymentinclude.md deleted file mode 100644 index 9f5f1789..00000000 --- a/internal/deployserverclient/docs/models/operations/readdeploymentinclude.md +++ /dev/null @@ -1,18 +0,0 @@ -# ReadDeploymentInclude - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" -) - -value := operations.ReadDeploymentIncludeState -``` - - -## Values - -| Name | Value | -| ---------------------------- | ---------------------------- | -| `ReadDeploymentIncludeState` | state | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readdeploymentlogsrequest.md b/internal/deployserverclient/docs/models/operations/readdeploymentlogsrequest.md deleted file mode 100644 index f028eaba..00000000 --- a/internal/deployserverclient/docs/models/operations/readdeploymentlogsrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadDeploymentLogsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `DeploymentID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readdeploymentlogsresponse.md b/internal/deployserverclient/docs/models/operations/readdeploymentlogsresponse.md deleted file mode 100644 index 1388ac81..00000000 --- a/internal/deployserverclient/docs/models/operations/readdeploymentlogsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadDeploymentLogsResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadLogsResponse` | [*components.ReadLogsResponse](../../models/components/readlogsresponse.md) | :heavy_minus_sign: | Deployment logs retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readdeploymentrequest.md b/internal/deployserverclient/docs/models/operations/readdeploymentrequest.md deleted file mode 100644 index 52c58dbb..00000000 --- a/internal/deployserverclient/docs/models/operations/readdeploymentrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadDeploymentRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DeploymentID` | `string` | :heavy_check_mark: | N/A | -| `Include` | [][operations.ReadDeploymentInclude](../../models/operations/readdeploymentinclude.md) | :heavy_minus_sign: | Comma-separated list of related resources to include.
- `state`: Include the live stack state reported by Formance Cloud
for this deployment's bound stack. Note: this is the **live**
projection from Membership, not a frozen per-deployment snapshot.
| \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readdeploymentresponse.md b/internal/deployserverclient/docs/models/operations/readdeploymentresponse.md deleted file mode 100644 index d63d2c3b..00000000 --- a/internal/deployserverclient/docs/models/operations/readdeploymentresponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# ReadDeploymentResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `DeploymentResponse` | [*components.DeploymentResponse](../../models/components/deploymentresponse.md) | :heavy_minus_sign: | Deployment retrieved successfully | -| `ResponseStream` | `io.ReadCloser` | :heavy_minus_sign: | Deployment retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readmanifestrequest.md b/internal/deployserverclient/docs/models/operations/readmanifestrequest.md deleted file mode 100644 index d785ed1d..00000000 --- a/internal/deployserverclient/docs/models/operations/readmanifestrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `Include` | `*string` | :heavy_minus_sign: | Comma-separated includes (e.g. "latest" to embed latest version content) | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readmanifestresponse.md b/internal/deployserverclient/docs/models/operations/readmanifestresponse.md deleted file mode 100644 index f6626bd6..00000000 --- a/internal/deployserverclient/docs/models/operations/readmanifestresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ManifestResponse` | [*components.ManifestResponse](../../models/components/manifestresponse.md) | :heavy_minus_sign: | Manifest retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readmanifestversionrequest.md b/internal/deployserverclient/docs/models/operations/readmanifestversionrequest.md deleted file mode 100644 index f917413f..00000000 --- a/internal/deployserverclient/docs/models/operations/readmanifestversionrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadManifestVersionRequest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------- | -------------------------- | -------------------------- | -------------------------- | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `Version` | `string` | :heavy_check_mark: | Version number or "latest" | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/readmanifestversionresponse.md b/internal/deployserverclient/docs/models/operations/readmanifestversionresponse.md deleted file mode 100644 index 616ab664..00000000 --- a/internal/deployserverclient/docs/models/operations/readmanifestversionresponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# ReadManifestVersionResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ManifestVersionResponse` | [*components.ManifestVersionResponse](../../models/components/manifestversionresponse.md) | :heavy_minus_sign: | Manifest version retrieved successfully | -| `ResponseStream` | `io.ReadCloser` | :heavy_minus_sign: | Manifest version retrieved successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/updateapprequest.md b/internal/deployserverclient/docs/models/operations/updateapprequest.md deleted file mode 100644 index 059831c3..00000000 --- a/internal/deployserverclient/docs/models/operations/updateapprequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateAppRequest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `UpdateAppRequest` | [components.UpdateAppRequest](../../models/components/updateapprequest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/updateappresponse.md b/internal/deployserverclient/docs/models/operations/updateappresponse.md deleted file mode 100644 index bd285862..00000000 --- a/internal/deployserverclient/docs/models/operations/updateappresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateAppResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/updatemanifestrequest.md b/internal/deployserverclient/docs/models/operations/updatemanifestrequest.md deleted file mode 100644 index 0d35f285..00000000 --- a/internal/deployserverclient/docs/models/operations/updatemanifestrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateManifestRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ManifestID` | `string` | :heavy_check_mark: | N/A | -| `UpdateManifestRequest` | [components.UpdateManifestRequest](../../models/components/updatemanifestrequest.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/deployserverclient/docs/models/operations/updatemanifestresponse.md b/internal/deployserverclient/docs/models/operations/updatemanifestresponse.md deleted file mode 100644 index f8b0e9ef..00000000 --- a/internal/deployserverclient/docs/models/operations/updatemanifestresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdateManifestResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ManifestResponse` | [*components.ManifestResponse](../../models/components/manifestresponse.md) | :heavy_minus_sign: | Manifest updated successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/deployserverclient/docs/sdks/deployserver/README.md b/internal/deployserverclient/docs/sdks/deployserver/README.md deleted file mode 100644 index 38b6bb51..00000000 --- a/internal/deployserverclient/docs/sdks/deployserver/README.md +++ /dev/null @@ -1,1349 +0,0 @@ -# DeployServer SDK - -## Overview - -### Available Operations - -* [ListApps](#listapps) - List organization apps -* [CreateApp](#createapp) - Create a new app -* [UpdateApp](#updateapp) - Update an app -* [ReadApp](#readapp) - read app details -* [DeleteApp](#deleteapp) - Delete an app -* [AttachAppManifest](#attachappmanifest) - Bind an app to a manifest -* [DetachAppManifest](#detachappmanifest) - Unbind an app from its manifest -* [ReadAppVariables](#readappvariables) - Get all variables of an app -* [CreateAppVariable](#createappvariable) - Create variable for an app -* [DeleteAppVariable](#deleteappvariable) - Delete a variable from an app -* [CreateManifestRaw](#createmanifestraw) - Create a new manifest -* [CreateManifest](#createmanifest) - Create a new manifest -* [ListManifests](#listmanifests) - List manifests in the organization -* [ReadManifest](#readmanifest) - Read a manifest -* [UpdateManifest](#updatemanifest) - Update manifest metadata -* [DeleteManifest](#deletemanifest) - Delete a manifest and all its versions -* [PushManifestVersionRaw](#pushmanifestversionraw) - Push a new version of a manifest -* [PushManifestVersion](#pushmanifestversion) - Push a new version of a manifest -* [ListManifestVersions](#listmanifestversions) - List versions of a manifest -* [ReadManifestVersion](#readmanifestversion) - Get a specific manifest version with content -* [CreateDeployment](#createdeployment) - Create a deployment (triggers a run) -* [ListDeployments](#listdeployments) - List deployments -* [ReadDeployment](#readdeployment) - Get a single deployment -* [ReadDeploymentLogs](#readdeploymentlogs) - Get run logs for a deployment - -## ListApps - -List organization apps - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListApps(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListAppsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `pageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListAppsResponse](../../models/operations/listappsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateApp - -Create a new app - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.CreateApp(ctx, components.CreateAppRequest{ - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.AppResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [components.CreateAppRequest](../../models/components/createapprequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateAppResponse](../../models/operations/createappresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdateApp - -Update an app - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.UpdateApp(ctx, "", components.UpdateAppRequest{ - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `updateAppRequest` | [components.UpdateAppRequest](../../models/components/updateapprequest.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdateAppResponse](../../models/operations/updateappresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadApp - -read app details - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadApp(ctx, "", nil) - if err != nil { - log.Fatal(err) - } - if res.AppResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `include` | [][operations.ReadAppInclude](../../models/operations/readappinclude.md) | :heavy_minus_sign: | Comma-separated list of related resources to include.
- `state`: Include the live stack state reported by Formance Cloud
(a projection of the bound stack from Membership, not a snapshot
persisted by the proxy).
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadAppResponse](../../models/operations/readappresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteApp - -Soft-deletes the app immediately and enqueues a destroy deployment to -clean up the app's resources on Formance Cloud via the openapi -reconciler's tear-down path. The app becomes invisible to all -subsequent reads. The destroy runs through the normal worker -pipeline; once it reaches a successful terminal status the worker -hard-deletes the app row. - -By default this returns 202 Accepted as soon as the destroy is -enqueued (or 202 with no body if no destroy was needed). Pass -`?wait=true` to block until the destroy reaches a terminal status. - - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.DeleteApp(ctx, "", deployserverclient.Pointer(false)) - if err != nil { - log.Fatal(err) - } - if res.DeleteAppResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `wait` | `*bool` | :heavy_minus_sign: | When `true`, the call blocks until the destroy deployment reaches a
terminal status. Default `false` (returns 202 Accepted).
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteAppResponse](../../models/operations/deleteappresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## AttachAppManifest - -Updates the app's `manifest_id` pointer. Idempotent — re-binding to the -same id is a no-op; rebinding to a different id moves the pointer. - - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.AttachAppManifest(ctx, "", components.AttachManifestRequest{ - ManifestID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `attachManifestRequest` | [components.AttachManifestRequest](../../models/components/attachmanifestrequest.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.AttachAppManifestResponse](../../models/operations/attachappmanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.Error | 400, 404 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DetachAppManifest - -Clears the app's `manifest_id` pointer. Idempotent. - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.DetachAppManifest(ctx, "") - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DetachAppManifestResponse](../../models/operations/detachappmanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadAppVariables - -Get all variables of an app - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadAppVariables(ctx, "", nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ReadVariablesResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `pageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadAppVariablesResponse](../../models/operations/readappvariablesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateAppVariable - -Create variable for an app - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.CreateAppVariable(ctx, "", components.CreateVariableRequest{ - Variable: components.VariableData{ - Key: "", - Value: "", - }, - }) - if err != nil { - log.Fatal(err) - } - if res.CreateVariableResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `createVariableRequest` | [components.CreateVariableRequest](../../models/components/createvariablerequest.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateAppVariableResponse](../../models/operations/createappvariableresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteAppVariable - -Delete a variable from an app - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.DeleteAppVariable(ctx, "", "") - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `id` | `string` | :heavy_check_mark: | N/A | -| `variableID` | `string` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteAppVariableResponse](../../models/operations/deleteappvariableresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateManifestRaw - -Create a new manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - example, fileErr := os.Open("example.file") - if fileErr != nil { - panic(fileErr) - } - - res, err := s.CreateManifestRaw(ctx, "", example) - if err != nil { - log.Fatal(err) - } - if res.CreateManifestResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `name` | `string` | :heavy_check_mark: | Name for the manifest | -| `requestBody` | `any` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateManifestRawResponse](../../models/operations/createmanifestrawresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateManifest - -Create a new manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.CreateManifest(ctx, "", operations.CreateManifestRequestBody{}) - if err != nil { - log.Fatal(err) - } - if res.CreateManifestResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `name` | `string` | :heavy_check_mark: | Name for the manifest | -| `requestBody` | [operations.CreateManifestRequestBody](../../models/operations/createmanifestrequestbody.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateManifestResponse](../../models/operations/createmanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListManifests - -List manifests in the organization - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListManifests(ctx, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListManifestsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `pageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListManifestsResponse](../../models/operations/listmanifestsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadManifest - -Read a manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadManifest(ctx, "", nil) - if err != nil { - log.Fatal(err) - } - if res.ManifestResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `include` | `*string` | :heavy_minus_sign: | Comma-separated includes (e.g. "latest" to embed latest version content) | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadManifestResponse](../../models/operations/readmanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdateManifest - -Update manifest metadata - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.UpdateManifest(ctx, "", components.UpdateManifestRequest{ - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ManifestResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `updateManifestRequest` | [components.UpdateManifestRequest](../../models/components/updatemanifestrequest.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdateManifestResponse](../../models/operations/updatemanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteManifest - -Delete a manifest and all its versions - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.DeleteManifest(ctx, "") - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteManifestResponse](../../models/operations/deletemanifestresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.Error | 409 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## PushManifestVersionRaw - -Push a new version of a manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - example, fileErr := os.Open("example.file") - if fileErr != nil { - panic(fileErr) - } - - res, err := s.PushManifestVersionRaw(ctx, "", example) - if err != nil { - log.Fatal(err) - } - if res.ManifestVersionResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `requestBody` | `any` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.PushManifestVersionRawResponse](../../models/operations/pushmanifestversionrawresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## PushManifestVersion - -Push a new version of a manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.PushManifestVersion(ctx, "", operations.PushManifestVersionRequestBody{}) - if err != nil { - log.Fatal(err) - } - if res.ManifestVersionResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `requestBody` | [operations.PushManifestVersionRequestBody](../../models/operations/pushmanifestversionrequestbody.md) | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.PushManifestVersionResponse](../../models/operations/pushmanifestversionresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListManifestVersions - -List versions of a manifest - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListManifestVersions(ctx, "", nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListManifestVersionsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `pageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListManifestVersionsResponse](../../models/operations/listmanifestversionsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadManifestVersion - -Get a specific manifest version with content - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadManifestVersion(ctx, "", "") - if err != nil { - log.Fatal(err) - } - if res.ManifestVersionResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `manifestID` | `string` | :heavy_check_mark: | N/A | -| `version` | `string` | :heavy_check_mark: | Version number or "latest" | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadManifestVersionResponse](../../models/operations/readmanifestversionresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateDeployment - -Create a deployment (triggers a run) - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.CreateDeployment(ctx, components.CreateDeploymentRequest{ - AppID: "", - ManifestID: "", - ManifestVersion: 5649, - }) - if err != nil { - log.Fatal(err) - } - if res.DeploymentResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [components.CreateDeploymentRequest](../../models/components/createdeploymentrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateDeploymentResponse](../../models/operations/createdeploymentresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListDeployments - -List deployments - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ListDeployments(ctx, nil, nil, nil) - if err != nil { - log.Fatal(err) - } - if res.ListDeploymentsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `appID` | `*string` | :heavy_minus_sign: | N/A | -| `pageSize` | `*int64` | :heavy_minus_sign: | Maximum number of items to return on the first page. Capped at 100;
ignored when `cursor` is supplied (subsequent pages reuse the page
size baked into the cursor token). Defaults to 100.
| -| `cursor` | `*string` | :heavy_minus_sign: | Opaque pagination token returned in `cursor.next` / `cursor.previous`
on the previous page. Pass it back unchanged to fetch the adjacent
page. Mutually exclusive with `pageSize` after the first request.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListDeploymentsResponse](../../models/operations/listdeploymentsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadDeployment - -Get a single deployment - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadDeployment(ctx, "", nil) - if err != nil { - log.Fatal(err) - } - if res.DeploymentResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `deploymentID` | `string` | :heavy_check_mark: | N/A | -| `include` | [][operations.ReadDeploymentInclude](../../models/operations/readdeploymentinclude.md) | :heavy_minus_sign: | Comma-separated list of related resources to include.
- `state`: Include the live stack state reported by Formance Cloud
for this deployment's bound stack. Note: this is the **live**
projection from Membership, not a frozen per-deployment snapshot.
| -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadDeploymentResponse](../../models/operations/readdeploymentresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadDeploymentLogs - -Get run logs for a deployment - -### Example Usage - - -```go -package main - -import( - "context" - "os" - deployserverclient "github.com/formancehq/fctl/internal/deployserverclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := deployserverclient.New( - deployserverclient.WithSecurity(os.Getenv("DEPLOYSERVER_BEARER_AUTH")), - ) - - res, err := s.ReadDeploymentLogs(ctx, "") - if err != nil { - log.Fatal(err) - } - if res.ReadLogsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `deploymentID` | `string` | :heavy_check_mark: | N/A | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadDeploymentLogsResponse](../../models/operations/readdeploymentlogsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | \ No newline at end of file diff --git a/internal/deployserverclient/go.mod b/internal/deployserverclient/go.mod deleted file mode 100644 index 1e4907d3..00000000 --- a/internal/deployserverclient/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module github.com/formancehq/fctl/internal/deployserverclient/v3 - -go 1.22 - -require github.com/stretchr/testify v1.12.1 - -require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/internal/deployserverclient/go.sum b/internal/deployserverclient/go.sum deleted file mode 100644 index c2336837..00000000 --- a/internal/deployserverclient/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -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= diff --git a/internal/deployserverclient/internal/config/sdkconfiguration.go b/internal/deployserverclient/internal/config/sdkconfiguration.go deleted file mode 100644 index dd5ad42c..00000000 --- a/internal/deployserverclient/internal/config/sdkconfiguration.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: cf859f0b990a - -package config - -import ( - "context" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "net/http" - "time" -) - -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -type SDKConfiguration struct { - Client HTTPClient - Security func(context.Context) (interface{}, error) - ServerURL string - ServerIndex int - ServerList []string - UserAgent string - RetryConfig *retry.Config - Timeout *time.Duration -} - -func (c *SDKConfiguration) GetServerDetails() (string, map[string]string) { - if c.ServerURL != "" { - return c.ServerURL, nil - } - - return c.ServerList[c.ServerIndex], nil -} diff --git a/internal/deployserverclient/internal/hooks/hooks.go b/internal/deployserverclient/internal/hooks/hooks.go deleted file mode 100644 index 1c56d5ce..00000000 --- a/internal/deployserverclient/internal/hooks/hooks.go +++ /dev/null @@ -1,154 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 78181624ca02 - -package hooks - -import ( - "context" - "errors" - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/config" - "net/http" -) - -type FailEarly struct { - Cause error -} - -var _ error = (*FailEarly)(nil) - -func (f *FailEarly) Error() string { - return f.Cause.Error() -} - -// HTTPClient provides an interface for supplying the SDK with a custom HTTP client -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -type HookContext struct { - SDK any - SDKConfiguration config.SDKConfiguration - BaseURL string - Context context.Context - OperationID string - OAuth2Scopes []string - SecuritySource func(context.Context) (interface{}, error) -} - -type BeforeRequestContext struct { - HookContext -} - -type AfterSuccessContext struct { - HookContext -} - -type AfterErrorContext struct { - HookContext -} - -// sdkInitHook is called when the SDK is initializing. The hook can modify and return a new baseURL and HTTP client to be used by the SDK. -type sdkInitHook interface { - SDKInit(config config.SDKConfiguration) config.SDKConfiguration -} - -// beforeRequestHook is called before the SDK sends a request. The hook can modify the request before it is sent or return an error to stop the request from being sent. -type beforeRequestHook interface { - BeforeRequest(hookCtx BeforeRequestContext, req *http.Request) (*http.Request, error) -} - -// afterSuccessHook is called after the SDK receives a response. The hook can modify the response before it is handled or return an error to stop the response from being handled. -type afterSuccessHook interface { - AfterSuccess(hookCtx AfterSuccessContext, res *http.Response) (*http.Response, error) -} - -// afterErrorHook is called after the SDK encounters an error, or a non-successful response. The hook can modify the response if available otherwise modify the error. -// All afterErrorHook hooks are called and returning an error won't stop the other hooks from being called. But if you want to stop the other hooks from being called, you can return a FailEarly error wrapping your error. -type afterErrorHook interface { - AfterError(hookCtx AfterErrorContext, res *http.Response, err error) (*http.Response, error) -} - -type Hooks struct { - sdkInitHooks []sdkInitHook - beforeRequestHook []beforeRequestHook - afterSuccessHook []afterSuccessHook - afterErrorHook []afterErrorHook -} - -var _ sdkInitHook = (*Hooks)(nil) -var _ beforeRequestHook = (*Hooks)(nil) -var _ afterSuccessHook = (*Hooks)(nil) -var _ afterErrorHook = (*Hooks)(nil) - -func New() *Hooks { - h := &Hooks{ - sdkInitHooks: []sdkInitHook{}, - beforeRequestHook: []beforeRequestHook{}, - afterSuccessHook: []afterSuccessHook{}, - afterErrorHook: []afterErrorHook{}, - } - - initHooks(h) - - return h -} - -// registerSDKInitHook registers a hook to be used by the SDK for the initialization event. -func (h *Hooks) registerSDKInitHook(hook sdkInitHook) { - h.sdkInitHooks = append(h.sdkInitHooks, hook) -} - -// registerBeforeRequestHook registers a hook to be used by the SDK for the before request event. -func (h *Hooks) registerBeforeRequestHook(hook beforeRequestHook) { - h.beforeRequestHook = append(h.beforeRequestHook, hook) -} - -// registerAfterSuccessHook registers a hook to be used by the SDK for the after success event. -func (h *Hooks) registerAfterSuccessHook(hook afterSuccessHook) { - h.afterSuccessHook = append(h.afterSuccessHook, hook) -} - -// registerAfterErrorHook registers a hook to be used by the SDK for the after error event. -func (h *Hooks) registerAfterErrorHook(hook afterErrorHook) { - h.afterErrorHook = append(h.afterErrorHook, hook) -} - -func (h *Hooks) SDKInit(config config.SDKConfiguration) config.SDKConfiguration { - for _, hook := range h.sdkInitHooks { - config = hook.SDKInit(config) - } - return config -} - -func (h *Hooks) BeforeRequest(hookCtx BeforeRequestContext, req *http.Request) (*http.Request, error) { - for _, hook := range h.beforeRequestHook { - var err error - req, err = hook.BeforeRequest(hookCtx, req) - if err != nil { - return req, err - } - } - return req, nil -} - -func (h *Hooks) AfterSuccess(hookCtx AfterSuccessContext, res *http.Response) (*http.Response, error) { - for _, hook := range h.afterSuccessHook { - var err error - res, err = hook.AfterSuccess(hookCtx, res) - if err != nil { - return res, err - } - } - return res, nil -} - -func (h *Hooks) AfterError(hookCtx AfterErrorContext, res *http.Response, err error) (*http.Response, error) { - for _, hook := range h.afterErrorHook { - res, err = hook.AfterError(hookCtx, res, err) - var fe *FailEarly - if errors.As(err, &fe) { - return nil, fe.Cause - } - } - return res, err -} diff --git a/internal/deployserverclient/internal/hooks/registration.go b/internal/deployserverclient/internal/hooks/registration.go deleted file mode 100644 index fa131be8..00000000 --- a/internal/deployserverclient/internal/hooks/registration.go +++ /dev/null @@ -1,18 +0,0 @@ -package hooks - -/* - * This file is only ever generated once on the first generation and then is free to be modified. - * Any hooks you wish to add should be registered in the initHooks function. Feel free to define - * your hooks in this file or in separate files in the hooks package. - * - * Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance. - */ - -func initHooks(h *Hooks) { - // exampleHook := &ExampleHook{} - - // h.registerSDKInitHook(exampleHook) - // h.registerBeforeRequestHook(exampleHook) - // h.registerAfterErrorHook(exampleHook) - // h.registerAfterSuccessHook(exampleHook) -} diff --git a/internal/deployserverclient/internal/utils/contenttype.go b/internal/deployserverclient/internal/utils/contenttype.go deleted file mode 100644 index 3b9cb4f3..00000000 --- a/internal/deployserverclient/internal/utils/contenttype.go +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 54cc35e84d32 - -package utils - -import ( - "fmt" - "mime" - "strings" -) - -func MatchContentType(contentType string, pattern string) bool { - if contentType == "" { - contentType = "application/octet-stream" - } - - if contentType == pattern || pattern == "*" || pattern == "*/*" { - return true - } - - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - - if mediaType == pattern { - return true - } - - parts := strings.Split(mediaType, "/") - if len(parts) == 2 { - if fmt.Sprintf("%s/*", parts[0]) == pattern || fmt.Sprintf("*/%s", parts[1]) == pattern { - return true - } - } - - return false -} diff --git a/internal/deployserverclient/internal/utils/env.go b/internal/deployserverclient/internal/utils/env.go deleted file mode 100644 index dbdbd7ef..00000000 --- a/internal/deployserverclient/internal/utils/env.go +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a4b2dca3c3dd - -package utils - -import ( - "os" -) - -// GetEnv returns the value of the environment variable named by the key or the defaultValue if the environment variable is not set. -func GetEnv(name, defaultValue string) string { - value := os.Getenv(name) - if value == "" { - return defaultValue - } - return value -} diff --git a/internal/deployserverclient/internal/utils/form.go b/internal/deployserverclient/internal/utils/form.go deleted file mode 100644 index 10d0d48c..00000000 --- a/internal/deployserverclient/internal/utils/form.go +++ /dev/null @@ -1,156 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 65838d758173 - -package utils - -import ( - "fmt" - "math/big" - "net/url" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/deployserverclient/v3/types" -) - -func populateForm(paramName string, explode bool, objType reflect.Type, objValue reflect.Value, delimiter string, defaultValue *string, allowEmptyValue map[string]struct{}, getFieldName func(reflect.StructField) string) url.Values { - - formValues := url.Values{} - - if isNil(objType, objValue) { - if defaultValue != nil { - formValues.Add(paramName, *defaultValue) - } else if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - - return formValues - } - - if objType.Kind() == reflect.Pointer { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Struct: - switch objValue.Interface().(type) { - case time.Time: - formValues.Add(paramName, valToString(objValue.Interface())) - case types.Date: - formValues.Add(paramName, valToString(objValue.Interface())) - case big.Int: - formValues.Add(paramName, valToString(objValue.Interface())) - default: - var items []string - - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if valType.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - fieldName := getFieldName(fieldType) - if fieldName == "" { - continue - } - - if explode { - if valType.Kind() == reflect.Slice || valType.Kind() == reflect.Array { - for i := 0; i < valType.Len(); i++ { - formValues.Add(fieldName, valToString(valType.Index(i).Interface())) - } - } else { - formValues.Add(fieldName, valToString(valType.Interface())) - } - } else { - items = append(items, fmt.Sprintf("%s%s%s", fieldName, delimiter, valToString(valType.Interface()))) - } - } - - if len(items) > 0 { - formValues.Add(paramName, strings.Join(items, delimiter)) - } - } - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - formValues.Add(paramName, valToString(value)) - } - // If not set or explicitly null, skip adding to form - return formValues - } - - items := []string{} - - iter := objValue.MapRange() - for iter.Next() { - if explode { - formValues.Add(iter.Key().String(), valToString(iter.Value().Interface())) - } else { - items = append(items, fmt.Sprintf("%s%s%s", iter.Key().String(), delimiter, valToString(iter.Value().Interface()))) - } - } - - if len(items) > 0 { - formValues.Add(paramName, strings.Join(items, delimiter)) - } - case reflect.Slice, reflect.Array: - if objValue.Len() == 0 { - if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - } else { - values := parseDelimitedArray(explode, objValue, delimiter) - for _, v := range values { - formValues.Add(paramName, v) - } - } - default: - // For string types, use the value directly without conversion - if objType.Kind() == reflect.String { - stringValue := objValue.String() - formValues.Add(paramName, stringValue) - } else { - stringValue := valToString(objValue.Interface()) - if stringValue == "" { - if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - } else if stringValue != "" { - formValues.Add(paramName, stringValue) - } - } - } - - return formValues -} - -func parseDelimitedArray(explode bool, objValue reflect.Value, delimiter string) []string { - values := []string{} - items := []string{} - - for i := 0; i < objValue.Len(); i++ { - if explode { - values = append(values, valToString(objValue.Index(i).Interface())) - } else { - items = append(items, valToString(objValue.Index(i).Interface())) - } - } - - if len(items) > 0 { - values = append(values, strings.Join(items, delimiter)) - } - - return values -} diff --git a/internal/deployserverclient/internal/utils/headers.go b/internal/deployserverclient/internal/utils/headers.go deleted file mode 100644 index 2984ad80..00000000 --- a/internal/deployserverclient/internal/utils/headers.go +++ /dev/null @@ -1,168 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 55c7a1dbaa41 - -package utils - -import ( - "context" - "fmt" - "net/http" - "reflect" - "strings" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/optionalnullable" -) - -func PopulateHeaders(_ context.Context, req *http.Request, headers interface{}, globals interface{}) { - globalsAlreadyPopulated := populateHeaders(headers, globals, req.Header, []string{}) - if globals != nil { - _ = populateHeaders(globals, nil, req.Header, globalsAlreadyPopulated) - } -} - -func populateHeaders(headers interface{}, globals interface{}, reqHeaders http.Header, skipFields []string) []string { - headerParamsStructType, headerParamsValType := dereferencePointers(reflect.TypeOf(headers), reflect.ValueOf(headers)) - - globalsAlreadyPopulated := []string{} - - for i := 0; i < headerParamsStructType.NumField(); i++ { - fieldType := headerParamsStructType.Field(i) - valType := headerParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, headerParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - tag := parseParamTag(headerParamTagKey, fieldType, "simple", false) - if tag == nil { - continue - } - - defaultConstValue := handleDefaultConstHeaderValue(valType, fieldType.Tag) - if defaultConstValue != "" { - reqHeaders.Add(tag.ParamName, defaultConstValue) - continue - } - - value := serializeHeader(fieldType.Type, valType, tag.Explode) - if value != "" { - reqHeaders.Add(tag.ParamName, value) - } - } - - return globalsAlreadyPopulated -} - -func handleDefaultConstHeaderValue(v reflect.Value, tag reflect.StructTag) string { - constTag := tag.Get("const") - if constTag != "" { - return constTag - } - - if isNil(v.Type(), v) { - defaultTag := tag.Get("default") - if defaultTag != "" { - return defaultTag - } - } - - return "" -} - -func serializeHeader(objType reflect.Type, objValue reflect.Value, explode bool) string { - if isNil(objType, objValue) { - return "" - } - - if objType.Kind() == reflect.Pointer { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Struct: - items := []string{} - - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - tag := parseParamTag(headerParamTagKey, fieldType, "simple", false) - if tag == nil { - continue - } - - fieldName := tag.ParamName - - if fieldName == "" { - continue - } - - var value string - - defaultConstValue := handleDefaultConstHeaderValue(valType, fieldType.Tag) - if defaultConstValue != "" { - value = defaultConstValue - } else { - value = valToString(valType.Interface()) - } - - if explode { - items = append(items, fmt.Sprintf("%s=%s", fieldName, value)) - } else { - items = append(items, fieldName, value) - } - } - - return strings.Join(items, ",") - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - return valToString(value) - } - // If not set or explicitly null, return empty string - return "" - } - - items := []string{} - - iter := objValue.MapRange() - for iter.Next() { - if explode { - items = append(items, fmt.Sprintf("%s=%s", iter.Key().String(), valToString(iter.Value().Interface()))) - } else { - items = append(items, iter.Key().String(), valToString(iter.Value().Interface())) - } - } - - return strings.Join(items, ",") - case reflect.Slice, reflect.Array: - items := []string{} - - for i := 0; i < objValue.Len(); i++ { - items = append(items, valToString(objValue.Index(i).Interface())) - } - - return strings.Join(items, ",") - default: - return valToString(objValue.Interface()) - } -} diff --git a/internal/deployserverclient/internal/utils/json.go b/internal/deployserverclient/internal/utils/json.go deleted file mode 100644 index 475c377c..00000000 --- a/internal/deployserverclient/internal/utils/json.go +++ /dev/null @@ -1,744 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 38a5583d7e7f - -package utils - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math/big" - "reflect" - "strconv" - "strings" - "time" - "unsafe" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/types" -) - -func MarshalJSON(v interface{}, tag reflect.StructTag, topLevel bool) ([]byte, error) { - // Handle nil interface early - if v == nil { - return []byte("null"), nil - } - - // Check for nil pointer before dereferencing to avoid creating invalid reflect.Value - origVal := reflect.ValueOf(v) - if origVal.Kind() == reflect.Ptr && origVal.IsNil() { - return []byte("null"), nil - } - - typ, val := dereferencePointers(reflect.TypeOf(v), origVal) - - switch { - case isModelType(typ): - // When topLevel=true, only use json.Marshal if the type has a custom MarshalJSON - // to ensure nested structs with custom tags (like integer:"string") are handled correctly - if topLevel && implementsJSONMarshaler(v) { - return json.Marshal(v) - } - - if isNil(typ, val) || !val.IsValid() { - return []byte("null"), nil - } - - out := map[string]json.RawMessage{} - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - fieldName := field.Name - - omitEmpty := false - omitZero := false - jsonTag := field.Tag.Get("json") - if jsonTag != "" { - for _, tag := range strings.Split(jsonTag, ",") { - if tag == "omitempty" { - omitEmpty = true - } else if tag == "omitzero" { - omitZero = true - } else { - fieldName = tag - } - } - } - - if (omitEmpty || omitZero) && field.Tag.Get("const") == "" { - // Both omitempty and omitzero skip zero values (including nil) - if isNil(field.Type, fieldVal) { - continue - } - - if omitZero && fieldVal.IsZero() { - continue - } - - if omitEmpty && fieldVal.Kind() != reflect.Struct && fieldVal.IsZero() { - continue - } - - if omitEmpty && isEmptyContainer(field.Type, fieldVal) { - continue - } - } - - if !field.IsExported() && field.Tag.Get("const") == "" { - continue - } - - additionalProperties := field.Tag.Get("additionalProperties") - if fieldName == "-" && additionalProperties == "" { - continue - } - - if additionalProperties == "true" { - if isNil(field.Type, fieldVal) { - continue - } - fieldVal := trueReflectValue(fieldVal) - if fieldVal.Type().Kind() != reflect.Map { - return nil, fmt.Errorf("additionalProperties must be a map") - } - - for _, key := range fieldVal.MapKeys() { - r, err := marshalValue(fieldVal.MapIndex(key).Interface(), field.Tag) - if err != nil { - return nil, err - } - - out[key.String()] = r - } - - continue - } - - var fv interface{} - - if field.IsExported() { - fv = fieldVal.Interface() - } else { - pt := reflect.New(typ).Elem() - pt.Set(val) - - pf := pt.Field(i) - - fv = reflect.NewAt(pf.Type(), unsafe.Pointer(pf.UnsafeAddr())).Elem().Interface() - } - - r, err := marshalValue(fv, field.Tag) - if err != nil { - return nil, err - } - - out[fieldName] = r - } - - return json.Marshal(out) - default: - return marshalValue(v, tag) - } -} - -func UnmarshalJSON(b []byte, v interface{}, tag reflect.StructTag, topLevel bool, requiredFields []string) error { - if reflect.TypeOf(v).Kind() != reflect.Ptr { - return errors.New("v must be a pointer") - } - - typ, val := dereferencePointers(reflect.TypeOf(v), reflect.ValueOf(v)) - - switch { - case isModelType(typ): - if bytes.Equal(b, []byte("null")) { - return json.Unmarshal(b, v) - } - // When topLevel=true, only use json.Unmarshal if the type has a custom UnmarshalJSON - // to ensure nested structs with custom tags (like integer:"string") are handled correctly - if topLevel && implementsJSONUnmarshaler(reflect.TypeOf(v)) { - return json.Unmarshal(b, v) - } - - var unmarshaled map[string]json.RawMessage - - if err := json.Unmarshal(b, &unmarshaled); err != nil { - return err - } - - missingFields := []string{} - for _, requiredField := range requiredFields { - if _, ok := unmarshaled[requiredField]; !ok { - missingFields = append(missingFields, requiredField) - } - } - if len(missingFields) > 0 { - return fmt.Errorf("missing required fields: %s", strings.Join(missingFields, ", ")) - } - - var additionalPropertiesField *reflect.StructField - var additionalPropertiesValue *reflect.Value - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - fieldName := field.Name - - jsonTag := field.Tag.Get("json") - if jsonTag != "" { - for _, tag := range strings.Split(jsonTag, ",") { - if tag != "omitempty" && tag != "omitzero" { - fieldName = tag - } - } - } - - if field.Tag.Get("additionalProperties") == "true" { - additionalPropertiesField = &field - additionalPropertiesValue = &fieldVal - continue - } - - // If we receive a value for a const field ignore it but mark it as unmarshaled - if field.Tag.Get("const") != "" { - if r, ok := unmarshaled[fieldName]; ok { - val := string(r) - - if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { - var err error - val, err = strconv.Unquote(val) - if err != nil { - return fmt.Errorf("failed to unquote const field `%s` value `%s`: %w", fieldName, val, err) - } - } - constValue := field.Tag.Get("const") - if val != constValue { - return fmt.Errorf("const field `%s` does not match expected value `%s` got `%s`", fieldName, constValue, val) - } - - delete(unmarshaled, fieldName) - } - } else if !field.IsExported() { - continue - } - - value, ok := unmarshaled[fieldName] - if !ok { - defaultTag, defaultOk := field.Tag.Lookup("default") - if defaultOk { - value = handleDefaultConstValue(defaultTag, fieldVal.Interface(), field.Tag) - ok = true - } - } else { - delete(unmarshaled, fieldName) - } - - if ok { - if err := unmarshalValue(value, fieldVal, field.Tag); err != nil { - return err - } - } - } - - keys := make([]string, 0, len(unmarshaled)) - for k := range unmarshaled { - keys = append(keys, k) - } - - if len(keys) > 0 { - if additionalPropertiesField != nil && additionalPropertiesValue != nil { - typeOfMap := additionalPropertiesField.Type - if additionalPropertiesValue.Type().Kind() == reflect.Interface { - typeOfMap = reflect.TypeOf(map[string]interface{}{}) - } else if additionalPropertiesValue.Type().Kind() != reflect.Map { - return fmt.Errorf("additionalProperties must be a map") - } - - mapValue := reflect.MakeMap(typeOfMap) - - for key, value := range unmarshaled { - val := reflect.New(typeOfMap.Elem()) - - if err := unmarshalValue(value, val, additionalPropertiesField.Tag); err != nil { - return err - } - - if val.Elem().Type().String() == typeOfMap.Elem().String() { - mapValue.SetMapIndex(reflect.ValueOf(key), val.Elem()) - } else { - mapValue.SetMapIndex(reflect.ValueOf(key), trueReflectValue(val)) - } - - } - if additionalPropertiesValue.Type().Kind() == reflect.Interface { - additionalPropertiesValue.Set(mapValue) - } else { - additionalPropertiesValue.Set(mapValue) - } - } - } - default: - return unmarshalValue(b, reflect.ValueOf(v), tag) - } - - return nil -} - -func marshalValue(v interface{}, tag reflect.StructTag) (json.RawMessage, error) { - constTag := tag.Get("const") - if constTag != "" { - return handleDefaultConstValue(constTag, v, tag), nil - } - - if isNil(reflect.TypeOf(v), reflect.ValueOf(v)) { - defaultTag, ok := tag.Lookup("default") - if ok { - return handleDefaultConstValue(defaultTag, v, tag), nil - } - - return []byte("null"), nil - } - - typ, val := dereferencePointers(reflect.TypeOf(v), reflect.ValueOf(v)) - switch typ.Kind() { - case reflect.Int64: - format := tag.Get("integer") - if format == "string" { - b := val.Interface().(int64) - return []byte(fmt.Sprintf(`"%d"`, b)), nil - } - case reflect.Float64: - format := tag.Get("number") - if format == "string" { - b := val.Interface().(float64) - return []byte(fmt.Sprintf(`"%g"`, b)), nil - } - case reflect.Map: - if isNil(typ, val) { - return []byte("null"), nil - } - - // Check if the map implements json.Marshaler (like optionalnullable.OptionalNullable[T]) - if marshaler, ok := val.Interface().(json.Marshaler); ok { - return marshaler.MarshalJSON() - } - - out := map[string]json.RawMessage{} - - for _, key := range val.MapKeys() { - itemVal := val.MapIndex(key) - - if isNil(itemVal.Type(), itemVal) { - out[key.String()] = []byte("null") - continue - } - - r, err := marshalValue(itemVal.Interface(), tag) - if err != nil { - return nil, err - } - - out[key.String()] = r - } - - return json.Marshal(out) - case reflect.Slice, reflect.Array: - if isNil(typ, val) { - return []byte("null"), nil - } - - out := []json.RawMessage{} - - for i := 0; i < val.Len(); i++ { - itemVal := val.Index(i) - - if isNil(itemVal.Type(), itemVal) { - out = append(out, []byte("null")) - continue - } - - r, err := marshalValue(itemVal.Interface(), tag) - if err != nil { - return nil, err - } - - out = append(out, r) - } - - return json.Marshal(out) - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - return []byte(fmt.Sprintf(`"%s"`, val.Interface().(time.Time).Format(time.RFC3339Nano))), nil - case reflect.TypeOf(big.Int{}): - format := tag.Get("bigint") - if format == "string" { - b := val.Interface().(big.Int) - return []byte(fmt.Sprintf(`"%s"`, (&b).String())), nil - } - default: - // For model types without custom MarshalJSON, use field processing - // to handle custom tags like integer:"string" - if isModelType(typ) && !implementsJSONMarshaler(v) { - return MarshalJSON(v, "", false) - } - } - } - - return json.Marshal(v) -} - -func implementsJSONMarshaler(v interface{}) bool { - marshalerType := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - vType := reflect.TypeOf(v) - if vType.Implements(marshalerType) { - return true - } - if vType.Kind() == reflect.Ptr { - // For double pointers (e.g., **TypeA), check if the inner pointer type - // implements the interface (e.g., *TypeA) - if vType.Elem().Implements(marshalerType) { - return true - } - // Also check if pointer to element implements it - return reflect.PtrTo(vType.Elem()).Implements(marshalerType) - } - return reflect.PtrTo(vType).Implements(marshalerType) -} - -func handleDefaultConstValue(tagValue string, val interface{}, tag reflect.StructTag) json.RawMessage { - if tagValue == "null" { - return []byte("null") - } - - typ := dereferenceTypePointer(reflect.TypeOf(val)) - switch typ { - case reflect.TypeOf(time.Time{}): - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - case reflect.TypeOf(big.Int{}): - bigIntTag := tag.Get("bigint") - if bigIntTag == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(int64(0)): - format := tag.Get("integer") - if format == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(float64(0)): - format := tag.Get("number") - if format == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(types.Date{}): - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - default: - if typ.Kind() == reflect.String { - return []byte(fmt.Sprintf("%q", tagValue)) - } - } - - return []byte(tagValue) -} - -func unmarshalValue(value json.RawMessage, v reflect.Value, tag reflect.StructTag) error { - if bytes.Equal(value, []byte("null")) { - if v.CanAddr() { - return json.Unmarshal(value, v.Addr().Interface()) - } else { - return json.Unmarshal(value, v.Interface()) - } - } - - typ := dereferenceTypePointer(v.Type()) - - switch typ.Kind() { - case reflect.Int64: - var b int64 - - format := tag.Get("integer") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var err error - b, err = strconv.ParseInt(s, 10, 64) - if err != nil { - return fmt.Errorf("failed to parse string as int64: %w", err) - } - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - } - case reflect.Float64: - var b float64 - - format := tag.Get("number") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var err error - b, err = strconv.ParseFloat(s, 64) - if err != nil { - return fmt.Errorf("failed to parse string as float64: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - } - case reflect.Map: - if bytes.Equal(value, []byte("null")) || !isComplexValueType(dereferenceTypePointer(typ.Elem())) { - if v.CanAddr() { - return json.Unmarshal(value, v.Addr().Interface()) - } else { - return json.Unmarshal(value, v.Interface()) - } - } - - var unmarshaled map[string]json.RawMessage - - if err := json.Unmarshal(value, &unmarshaled); err != nil { - return err - } - - m := reflect.MakeMap(typ) - - for k, value := range unmarshaled { - itemVal := reflect.New(typ.Elem()) - - if err := unmarshalValue(value, itemVal, tag); err != nil { - return err - } - - m.SetMapIndex(reflect.ValueOf(k), itemVal.Elem()) - } - - // Dereference pointer before setting the map value. - // v may be a pointer to a map (e.g., from reflect.ValueOf(&mapVar)). - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - v.Set(m) - return nil - case reflect.Slice, reflect.Array: - var unmarshaled []json.RawMessage - - if err := json.Unmarshal(value, &unmarshaled); err != nil { - return err - } - - arrVal := reflect.MakeSlice(typ, len(unmarshaled), len(unmarshaled)) - - for index, value := range unmarshaled { - itemVal := reflect.New(typ.Elem()) - - if err := unmarshalValue(value, itemVal, tag); err != nil { - return err - } - - arrVal.Index(index).Set(itemVal.Elem()) - } - - if v.Kind() == reflect.Pointer { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(arrVal) - return nil - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - return fmt.Errorf("failed to parse string as time.Time: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() && v.CanSet() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(t)) - return nil - case reflect.TypeOf(big.Int{}): - var b *big.Int - - format := tag.Get("bigint") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var ok bool - b, ok = new(big.Int).SetString(s, 10) - if !ok { - return fmt.Errorf("failed to parse string as big.Int") - } - } else { - if err := json.Unmarshal(value, &b); err != nil { - return err - } - } - - if v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Ptr { - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - case reflect.TypeOf(types.Date{}): - var s string - - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - d, err := types.DateFromString(s) - if err != nil { - return fmt.Errorf("failed to parse string as types.Date: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() && v.CanSet() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(d)) - return nil - default: - // For model types without custom UnmarshalJSON, use field processing - // to handle custom tags like integer:"string" - if isModelType(typ) && !implementsJSONUnmarshaler(v.Type()) { - // If v is already a pointer, we can unmarshal directly into it - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(v.Type().Elem())) - } - // Handle double pointers (e.g., **Struct for nullable array elements) - inner := v.Elem() - if inner.Kind() == reflect.Ptr { - if inner.IsNil() { - inner.Set(reflect.New(typ)) - } - return UnmarshalJSON(value, inner.Interface(), "", false, nil) - } - return UnmarshalJSON(value, v.Interface(), "", false, nil) - } - // For non-pointer struct values that are addressable - if v.CanAddr() { - return UnmarshalJSON(value, v.Addr().Interface(), "", false, nil) - } - // For non-addressable struct values, fall through to json.Unmarshal - } - } - } - - var val interface{} - - if v.CanAddr() { - val = v.Addr().Interface() - } else { - val = v.Interface() - } - - return json.Unmarshal(value, val) -} - -func implementsJSONUnmarshaler(typ reflect.Type) bool { - unmarshalerType := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() - if typ.Implements(unmarshalerType) { - return true - } - if typ.Kind() == reflect.Ptr { - // For double pointers (e.g., **TypeA), check if the inner pointer type - // implements the interface (e.g., *TypeA) - if typ.Elem().Implements(unmarshalerType) { - return true - } - // Also check if pointer to element implements it - return reflect.PtrTo(typ.Elem()).Implements(unmarshalerType) - } - return reflect.PtrTo(typ).Implements(unmarshalerType) -} - -func dereferencePointers(typ reflect.Type, val reflect.Value) (reflect.Type, reflect.Value) { - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - val = val.Elem() - } else { - return typ, val - } - - return dereferencePointers(typ, val) -} - -func dereferenceTypePointer(typ reflect.Type) reflect.Type { - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - } else { - return typ - } - - return dereferenceTypePointer(typ) -} - -func isComplexValueType(typ reflect.Type) bool { - switch typ.Kind() { - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - fallthrough - case reflect.TypeOf(big.Int{}): - fallthrough - case reflect.TypeOf(types.Date{}): - return true - } - } - - return false -} - -func isModelType(typ reflect.Type) bool { - if isComplexValueType(typ) { - return false - } - - if typ.Kind() == reflect.Struct { - return true - } - - return false -} diff --git a/internal/deployserverclient/internal/utils/pathparams.go b/internal/deployserverclient/internal/utils/pathparams.go deleted file mode 100644 index d0d7e090..00000000 --- a/internal/deployserverclient/internal/utils/pathparams.go +++ /dev/null @@ -1,180 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 4117f52ac593 - -package utils - -import ( - "context" - "fmt" - "math/big" - "net/url" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/deployserverclient/v3/types" -) - -func GenerateURL(_ context.Context, serverURL, path string, pathParams interface{}, globals interface{}) (string, error) { - uri := strings.TrimSuffix(serverURL, "/") + path - - parsedParameters := map[string]string{} - - globalsAlreadyPopulated, err := populateParsedParameters(pathParams, globals, parsedParameters, []string{}) - if err != nil { - return "", err - } - - if globals != nil { - _, err = populateParsedParameters(globals, nil, parsedParameters, globalsAlreadyPopulated) - if err != nil { - return "", err - } - } - - // TODO should we handle the case where there are no matching path params? - return ReplaceParameters(uri, parsedParameters), nil -} - -func populateParsedParameters(pathParams interface{}, globals interface{}, parsedParameters map[string]string, skipFields []string) ([]string, error) { - pathParamsStructType, pathParamsValType := dereferencePointers(reflect.TypeOf(pathParams), reflect.ValueOf(pathParams)) - - globalsAlreadyPopulated := []string{} - - for i := 0; i < pathParamsStructType.NumField(); i++ { - fieldType := pathParamsStructType.Field(i) - valType := pathParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - requestTag := getRequestTag(fieldType) - if requestTag != nil { - continue - } - - ppTag := parseParamTag(pathParamTagKey, fieldType, "simple", false) - if ppTag == nil { - continue - } - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, pathParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - if ppTag.Serialization != "" { - vals, err := populateSerializedParams(ppTag, fieldType.Type, valType) - if err != nil { - return nil, err - } - for k, v := range vals { - parsedParameters[k] = url.PathEscape(v) - } - } else { - // TODO: support other styles - switch ppTag.Style { - case "simple": - simpleParams := getSimplePathParams(ppTag.ParamName, fieldType.Type, valType, ppTag.Explode) - for k, v := range simpleParams { - parsedParameters[k] = v - } - } - } - } - - return globalsAlreadyPopulated, nil -} - -func getSimplePathParams(parentName string, objType reflect.Type, objValue reflect.Value, explode bool) map[string]string { - pathParams := make(map[string]string) - - if isNil(objType, objValue) { - return nil - } - - if objType.Kind() == reflect.Ptr { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Array, reflect.Slice: - if objValue.Len() == 0 { - return nil - } - var ppVals []string - for i := 0; i < objValue.Len(); i++ { - ppVals = append(ppVals, valToString(objValue.Index(i).Interface())) - } - pathParams[parentName] = strings.Join(ppVals, ",") - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - pathParams[parentName] = valToString(value) - } - // If not set or explicitly null, return nil (skip parameter) - return pathParams - } - - if objValue.Len() == 0 { - return nil - } - var ppVals []string - objMap := objValue.MapRange() - for objMap.Next() { - if explode { - ppVals = append(ppVals, fmt.Sprintf("%s=%s", objMap.Key().String(), valToString(objMap.Value().Interface()))) - } else { - ppVals = append(ppVals, fmt.Sprintf("%s,%s", objMap.Key().String(), valToString(objMap.Value().Interface()))) - } - } - pathParams[parentName] = strings.Join(ppVals, ",") - case reflect.Struct: - switch objValue.Interface().(type) { - case time.Time: - pathParams[parentName] = valToString(objValue.Interface()) - case types.Date: - pathParams[parentName] = valToString(objValue.Interface()) - case big.Int: - pathParams[parentName] = valToString(objValue.Interface()) - default: - var ppVals []string - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - ppTag := parseParamTag(pathParamTagKey, fieldType, "simple", explode) - if ppTag == nil { - continue - } - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - if explode { - ppVals = append(ppVals, fmt.Sprintf("%s=%s", ppTag.ParamName, valToString(valType.Interface()))) - } else { - ppVals = append(ppVals, fmt.Sprintf("%s,%s", ppTag.ParamName, valToString(valType.Interface()))) - } - } - pathParams[parentName] = strings.Join(ppVals, ",") - } - default: - pathParams[parentName] = valToString(objValue.Interface()) - } - - return pathParams -} diff --git a/internal/deployserverclient/internal/utils/queryparams.go b/internal/deployserverclient/internal/utils/queryparams.go deleted file mode 100644 index 007c5624..00000000 --- a/internal/deployserverclient/internal/utils/queryparams.go +++ /dev/null @@ -1,298 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 39337f13df44 - -package utils - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "net/http" - "net/url" - "reflect" - "time" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/deployserverclient/v3/types" -) - -func PopulateQueryParams(_ context.Context, req *http.Request, queryParams interface{}, globals interface{}, allowEmptyValue map[string]struct{}) error { - // Query parameters may already be present from overriding URL - if req.URL.RawQuery != "" { - return nil - } - - values := url.Values{} - - globalsAlreadyPopulated, err := populateQueryParams(queryParams, globals, values, []string{}, allowEmptyValue) - if err != nil { - return err - } - - if globals != nil { - _, err = populateQueryParams(globals, nil, values, globalsAlreadyPopulated, allowEmptyValue) - if err != nil { - return err - } - } - - req.URL.RawQuery = values.Encode() - - return nil -} - -func populateQueryParams(queryParams interface{}, globals interface{}, values url.Values, skipFields []string, allowEmptyValue map[string]struct{}) ([]string, error) { - queryParamsVal := reflect.ValueOf(queryParams) - if queryParamsVal.Kind() == reflect.Pointer && queryParamsVal.IsNil() { - return nil, nil - } - queryParamsStructType, queryParamsValType := dereferencePointers(reflect.TypeOf(queryParams), queryParamsVal) - - globalsAlreadyPopulated := []string{} - for i := 0; i < queryParamsStructType.NumField(); i++ { - fieldType := queryParamsStructType.Field(i) - valType := queryParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - requestTag := getRequestTag(fieldType) - if requestTag != nil { - continue - } - - qpTag := parseQueryParamTag(fieldType) - if qpTag == nil { - continue - } - - constValue := parseConstTag(fieldType) - if constValue != nil { - values.Add(qpTag.ParamName, *constValue) - continue - } - - defaultValue := parseDefaultTag(fieldType) - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, queryParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - if qpTag.Serialization != "" { - vals, err := populateSerializedParams(qpTag, fieldType.Type, valType) - if err != nil { - return nil, err - } - for k, v := range vals { - values.Add(k, v) - } - } else { - switch qpTag.Style { - case "deepObject": - vals := populateDeepObjectParams(qpTag, fieldType.Type, valType) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - case "form": - vals := populateFormParams(qpTag, fieldType.Type, valType, ",", defaultValue, allowEmptyValue) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - case "pipeDelimited": - vals := populateFormParams(qpTag, fieldType.Type, valType, "|", defaultValue, allowEmptyValue) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - default: - return nil, fmt.Errorf("unsupported style: %s", qpTag.Style) - } - } - } - - return globalsAlreadyPopulated, nil -} - -func populateSerializedParams(tag *paramTag, objType reflect.Type, objValue reflect.Value) (map[string]string, error) { - if isNil(objType, objValue) { - return nil, nil - } - - if objType.Kind() == reflect.Pointer { - objValue = objValue.Elem() - } - - values := map[string]string{} - - switch tag.Serialization { - case "json": - data, err := json.Marshal(objValue.Interface()) - if err != nil { - return nil, fmt.Errorf("error marshaling json: %v", err) - } - values[tag.ParamName] = string(data) - } - - return values, nil -} - -func populateDeepObjectParams(tag *paramTag, objType reflect.Type, objValue reflect.Value) url.Values { - values := url.Values{} - - if isNil(objType, objValue) { - return values - } - - if objValue.Kind() == reflect.Pointer { - objValue = objValue.Elem() - } - - switch objValue.Kind() { - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - values.Add(tag.ParamName, valToString(value)) - } - // If not set or explicitly null, skip adding to values - return values - } - - populateDeepObjectParamsMap(values, tag.ParamName, objValue) - case reflect.Struct: - populateDeepObjectParamsStruct(values, tag.ParamName, objValue) - } - - return values -} - -func populateDeepObjectParamsArray(qsValues url.Values, priorScope string, value reflect.Value) { - if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { - return - } - - for i := 0; i < value.Len(); i++ { - qsValues.Add(priorScope, valToString(value.Index(i).Interface())) - } -} - -func populateDeepObjectParamsMap(qsValues url.Values, priorScope string, mapValue reflect.Value) { - if mapValue.Kind() != reflect.Map { - return - } - - iter := mapValue.MapRange() - - for iter.Next() { - scope := priorScope + "[" + iter.Key().String() + "]" - iterValue := iter.Value() - - switch iterValue.Kind() { - case reflect.Array, reflect.Slice: - populateDeepObjectParamsArray(qsValues, scope, iterValue) - case reflect.Map: - populateDeepObjectParamsMap(qsValues, scope, iterValue) - default: - qsValues.Add(scope, valToString(iterValue.Interface())) - } - } -} - -func populateDeepObjectParamsStruct(qsValues url.Values, priorScope string, structValue reflect.Value) { - if structValue.Kind() != reflect.Struct { - return - } - - structType := structValue.Type() - - for i := 0; i < structType.NumField(); i++ { - field := structType.Field(i) - fieldValue := structValue.Field(i) - - if isNil(field.Type, fieldValue) { - continue - } - - if fieldValue.Kind() == reflect.Pointer { - fieldValue = fieldValue.Elem() - } - - qpTag := parseQueryParamTag(field) - - if qpTag == nil { - continue - } - - scope := priorScope - - if !qpTag.Inline { - scope = priorScope + "[" + qpTag.ParamName + "]" - } - - switch fieldValue.Kind() { - case reflect.Array, reflect.Slice: - populateDeepObjectParamsArray(qsValues, scope, fieldValue) - case reflect.Map: - populateDeepObjectParamsMap(qsValues, scope, fieldValue) - case reflect.Struct: - switch fieldValue.Type() { - case reflect.TypeOf(big.Int{}), reflect.TypeOf(time.Time{}), reflect.TypeOf(types.Date{}): - qsValues.Add(scope, valToString(fieldValue.Interface())) - - continue - } - - populateDeepObjectParamsStruct(qsValues, scope, fieldValue) - default: - qsValues.Add(scope, valToString(fieldValue.Interface())) - } - } -} - -func populateFormParams(tag *paramTag, objType reflect.Type, objValue reflect.Value, delimiter string, defaultValue *string, allowEmptyValue map[string]struct{}) url.Values { - return populateForm(tag.ParamName, tag.Explode, objType, objValue, delimiter, defaultValue, allowEmptyValue, func(fieldType reflect.StructField) string { - qpTag := parseQueryParamTag(fieldType) - if qpTag == nil { - return "" - } - - // When inline is true, use the parent's param name instead of the field's own name. - // This allows union/oneOf wrapper types to serialize their values directly under - // the parent's query parameter name. - if qpTag.Inline { - return tag.ParamName - } - - return qpTag.ParamName - }) -} - -type paramTag struct { - Style string - Explode bool - ParamName string - Serialization string - - // Inline is a special case for union/oneOf. When a wrapper struct type is - // used, each union/oneOf value field should be inlined (e.g. not appended - // in deepObject style with the name) as if the value was directly on the - // parent struct field. Without this annotation, the value would not be - // encoded by downstream logic that requires the struct field tag. - Inline bool -} - -func parseQueryParamTag(field reflect.StructField) *paramTag { - return parseParamTag(queryParamTagKey, field, "form", true) -} diff --git a/internal/deployserverclient/internal/utils/requestbody.go b/internal/deployserverclient/internal/utils/requestbody.go deleted file mode 100644 index 18d55197..00000000 --- a/internal/deployserverclient/internal/utils/requestbody.go +++ /dev/null @@ -1,461 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: b46da641dec2 - -package utils - -import ( - "bytes" - "context" - "fmt" - "io" - "mime" - "mime/multipart" - "net/textproto" - "net/url" - "path/filepath" - "reflect" - "regexp" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/optionalnullable" -) - -const ( - requestTagKey = "request" - multipartFormTagKey = "multipartForm" - formTagKey = "form" -) - -var ( - jsonEncodingRegex = regexp.MustCompile(`^(application|text)\/([^+]+\+)*json.*`) - multipartEncodingRegex = regexp.MustCompile(`^multipart\/.*`) - urlEncodedEncodingRegex = regexp.MustCompile(`^application\/x-www-form-urlencoded.*`) -) - -func SerializeRequestBody(_ context.Context, request interface{}, nullable, optional bool, requestFieldName, serializationMethod, tag string) (io.Reader, string, error) { - bodyReader, contentType, err := serializeRequestBody(request, nullable, optional, requestFieldName, serializationMethod, tag) - if err != nil { - return nil, "", fmt.Errorf("error serializing request body: %w", err) - } - - if bodyReader == nil && !optional { - return nil, "", fmt.Errorf("request body is required") - } - - return bodyReader, contentType, nil -} - -func serializeRequestBody(request interface{}, nullable, optional bool, requestFieldName, serializationMethod, tag string) (io.Reader, string, error) { - requestStructType := reflect.TypeOf(request) - requestValType := reflect.ValueOf(request) - - if isNil(requestStructType, requestValType) { - if !nullable && optional { - return nil, "", nil - } - - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], requestValType, tag) - } - - if requestStructType.Kind() == reflect.Pointer { - requestStructType = requestStructType.Elem() - requestValType = requestValType.Elem() - } - - if requestStructType.Kind() != reflect.Struct { - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], requestValType, tag) - } - - requestField, ok := requestStructType.FieldByName(requestFieldName) - - if ok { - tag := getRequestTag(requestField) - if tag != nil { - // request object (non-flattened) - requestVal := requestValType.FieldByName(requestFieldName) - val := reflect.ValueOf(requestVal.Interface()) - if isNil(requestField.Type, requestVal) { - if !nullable && optional { - return nil, "", nil - } - - return serializeContentType(requestFieldName, tag.MediaType, val, string(requestField.Tag)) - } - - return serializeContentType(requestFieldName, tag.MediaType, val, string(requestField.Tag)) - } - } - - // flattened request object - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], reflect.ValueOf(request), tag) -} - -func serializeContentType(fieldName string, mediaType string, val reflect.Value, tag string) (io.Reader, string, error) { - buf := &bytes.Buffer{} - - if isNil(val.Type(), val) { - // TODO: what does a null mean for other content types? Just returning an empty buffer for now - if jsonEncodingRegex.MatchString(mediaType) { - if _, err := buf.Write([]byte("null")); err != nil { - return nil, "", err - } - } - - return buf, mediaType, nil - } - - switch { - case jsonEncodingRegex.MatchString(mediaType): - data, err := MarshalJSON(val.Interface(), reflect.StructTag(tag), true) - if err != nil { - return nil, "", err - } - - if _, err := buf.Write(data); err != nil { - return nil, "", err - } - case multipartEncodingRegex.MatchString(mediaType): - var err error - mediaType, err = encodeMultipartFormData(buf, val.Interface()) - if err != nil { - return nil, "", err - } - case urlEncodedEncodingRegex.MatchString(mediaType): - if err := encodeFormData(fieldName, buf, val.Interface()); err != nil { - return nil, "", err - } - case val.Type().Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()): - return val.Interface().(io.Reader), mediaType, nil - default: - val = reflect.Indirect(val) - - switch { - case val.Type().Kind() == reflect.String: - if _, err := buf.WriteString(valToString(val.Interface())); err != nil { - return nil, "", err - } - case reflect.TypeOf(val.Interface()) == reflect.TypeOf([]byte(nil)): - if _, err := buf.Write(val.Interface().([]byte)); err != nil { - return nil, "", err - } - default: - return nil, "", fmt.Errorf("invalid request body type %s for mediaType %s", val.Type(), mediaType) - } - } - - return buf, mediaType, nil -} - -func encodeMultipartFormData(w io.Writer, data interface{}) (string, error) { - requestStructType := reflect.TypeOf(data) - requestValType := reflect.ValueOf(data) - - if requestStructType.Kind() == reflect.Pointer { - requestStructType = requestStructType.Elem() - requestValType = requestValType.Elem() - } - - writer := multipart.NewWriter(w) - - for i := 0; i < requestStructType.NumField(); i++ { - field := requestStructType.Field(i) - fieldType := field.Type - valType := requestValType.Field(i) - - if isNil(fieldType, valType) { - continue - } - - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - valType = valType.Elem() - } - - tag := parseMultipartFormTag(field) - if tag.File { - switch fieldType.Kind() { - case reflect.Slice, reflect.Array: - for i := 0; i < valType.Len(); i++ { - arrayVal := valType.Index(i) - - if err := encodeMultipartFormDataFile(writer, tag.Name+"[]", arrayVal.Type(), arrayVal); err != nil { - writer.Close() - return "", err - } - } - default: - if err := encodeMultipartFormDataFile(writer, tag.Name, fieldType, valType); err != nil { - writer.Close() - return "", err - } - } - } else if tag.JSON { - jw, err := writer.CreateFormField(tag.Name) - if err != nil { - writer.Close() - return "", err - } - d, err := MarshalJSON(valType.Interface(), field.Tag, true) - if err != nil { - writer.Close() - return "", err - } - if _, err := jw.Write(d); err != nil { - writer.Close() - return "", err - } - } else { - switch fieldType.Kind() { - case reflect.Slice, reflect.Array: - values := parseDelimitedArray(true, valType, ",") - for _, v := range values { - if err := writer.WriteField(tag.Name, v); err != nil { - writer.Close() - return "", err - } - } - default: - if err := writer.WriteField(tag.Name, valToString(valType.Interface())); err != nil { - writer.Close() - return "", err - } - } - } - } - - if err := writer.Close(); err != nil { - return "", err - } - - return writer.FormDataContentType(), nil -} - -func encodeMultipartFormDataFile(w *multipart.Writer, fieldName string, fieldType reflect.Type, valType reflect.Value) error { - if fieldType.Kind() != reflect.Struct { - return fmt.Errorf("invalid type %s for multipart/form-data file", valType.Type()) - } - - var fileName string - var reader io.Reader - - for i := 0; i < fieldType.NumField(); i++ { - field := fieldType.Field(i) - val := valType.Field(i) - - tag := parseMultipartFormTag(field) - if !tag.Content && tag.Name == "" { - continue - } - - if tag.Content && val.CanInterface() { - if reflect.TypeOf(val.Interface()) == reflect.TypeOf([]byte(nil)) { - reader = bytes.NewReader(val.Interface().([]byte)) - } else if reflect.TypeOf(val.Interface()).Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { - reader = val.Interface().(io.Reader) - } - } else { - fileName = val.String() - } - } - - if fileName == "" || reader == nil { - return fmt.Errorf("invalid multipart/form-data file") - } - - // Detect content type based on file extension - contentType := mime.TypeByExtension(filepath.Ext(fileName)) - if contentType == "" { - contentType = "application/octet-stream" - } - - // Create multipart header with proper content type - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileName)) - h.Set("Content-Type", contentType) - - fw, err := w.CreatePart(h) - if err != nil { - return err - } - if _, err := io.Copy(fw, reader); err != nil { - return err - } - - // Reset seek position to 0 if the reader supports seeking - if seeker, ok := reader.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - return err - } - } - - return nil -} - -func encodeFormData(fieldName string, w io.Writer, data interface{}) error { - requestType := reflect.TypeOf(data) - requestValType := reflect.ValueOf(data) - - if requestType.Kind() == reflect.Pointer { - requestType = requestType.Elem() - requestValType = requestValType.Elem() - } - - dataValues := url.Values{} - - switch requestType.Kind() { - case reflect.Struct: - for i := 0; i < requestType.NumField(); i++ { - field := requestType.Field(i) - fieldType := field.Type - valType := requestValType.Field(i) - - if isNil(fieldType, valType) { - continue - } - - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - valType = valType.Elem() - } - - tag := parseFormTag(field) - if tag.JSON { - data, err := MarshalJSON(valType.Interface(), field.Tag, true) - if err != nil { - return err - } - dataValues.Set(tag.Name, string(data)) - } else { - switch tag.Style { - // TODO: support other styles - case "form": - values := populateForm(tag.Name, tag.Explode, fieldType, valType, ",", nil, nil, func(sf reflect.StructField) string { - tag := parseFormTag(field) - if tag == nil { - return "" - } - - return tag.Name - }) - for k, v := range values { - for _, vv := range v { - dataValues.Add(k, vv) - } - } - } - } - } - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(requestValType); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - dataValues.Set(fieldName, valToString(value)) - } - // If not set or explicitly null, skip adding to form - break - } - - // Handle regular map - for _, k := range requestValType.MapKeys() { - v := requestValType.MapIndex(k) - dataValues.Set(fmt.Sprintf("%v", k.Interface()), valToString(v.Interface())) - } - case reflect.Slice, reflect.Array: - for i := 0; i < requestValType.Len(); i++ { - v := requestValType.Index(i) - dataValues.Set(fieldName, valToString(v.Interface())) - } - } - - if _, err := w.Write([]byte(dataValues.Encode())); err != nil { - return err - } - - return nil -} - -type requestTag struct { - MediaType string -} - -func getRequestTag(field reflect.StructField) *requestTag { - // example `request:"mediaType=multipart/form-data"` - values := parseStructTag(requestTagKey, field) - if values == nil { - return nil - } - - tag := &requestTag{ - MediaType: "application/octet-stream", - } - - for k, v := range values { - switch k { - case "mediaType": - tag.MediaType = v - } - } - - return tag -} - -type multipartFormTag struct { - File bool - Content bool - JSON bool - Name string -} - -func parseMultipartFormTag(field reflect.StructField) *multipartFormTag { - // example `multipartForm:"name=file"` - values := parseStructTag(multipartFormTagKey, field) - - tag := &multipartFormTag{} - - for k, v := range values { - switch k { - case "file": - tag.File = v == "true" - case "content": - tag.Content = v == "true" - case "name": - tag.Name = v - case "json": - tag.JSON = v == "true" - } - } - - return tag -} - -type formTag struct { - Name string - JSON bool - Style string - Explode bool -} - -func parseFormTag(field reflect.StructField) *formTag { - // example `form:"name=propName,style=spaceDelimited,explode"` - values := parseStructTag(formTagKey, field) - - tag := &formTag{ - Style: "form", - Explode: true, - } - - for k, v := range values { - switch k { - case "name": - tag.Name = v - case "json": - tag.JSON = v == "true" - case "style": - tag.Style = v - case "explode": - tag.Explode = v == "true" - } - } - - return tag -} diff --git a/internal/deployserverclient/internal/utils/retries.go b/internal/deployserverclient/internal/utils/retries.go deleted file mode 100644 index 9d899d17..00000000 --- a/internal/deployserverclient/internal/utils/retries.go +++ /dev/null @@ -1,272 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 1be9e7e90ac1 - -package utils - -import ( - "context" - "errors" - "fmt" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "io" - "math" - "math/rand" - "net" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - "syscall" - "time" -) - -// Deprecated: Use retry.BackoffStrategy instead. -type BackoffStrategy = retry.BackoffStrategy - -// Deprecated: Use retry.Config instead. -type RetryConfig = retry.Config - -type Retries struct { - Config *retry.Config - StatusCodes []string -} - -var ( - // IETF RFC 7231 4.2 safe and idempotent HTTP methods for connection retries - idempotentHTTPMethods = []string{ - http.MethodDelete, - http.MethodGet, - http.MethodHead, - http.MethodOptions, - http.MethodPut, - } -) - -func Retry(ctx context.Context, r Retries, operation func() (*http.Response, error)) (*http.Response, error) { - switch r.Config.Strategy { - case "backoff": - if r.Config.Backoff == nil { - return operation() - } - - var resp *http.Response - - err := retryWithBackoff(ctx, r.Config.Backoff, func() error { - if resp != nil { - resp.Body.Close() - } - - select { - case <-ctx.Done(): - return retry.Permanent(ctx.Err()) - default: - } - - res, err := operation() - if err != nil { - if !r.Config.RetryConnectionErrors { - return retry.Permanent(err) - } - - var httpMethod string - - // Use http.Request method if available - if res != nil && res.Request != nil { - httpMethod = res.Request.Method - } - - isIdempotentHTTPMethod := slices.Contains(idempotentHTTPMethods, httpMethod) - urlError := new(url.Error) - - if errors.As(err, &urlError) { - if urlError.Temporary() || urlError.Timeout() { - return err - } - - // In certain error cases, the http.Request may not have - // been populated, so use url.Error.Op which only has its - // first character capitalized from the original request - // HTTP method. - if httpMethod == "" { - httpMethod = strings.ToUpper(urlError.Op) - } - - isIdempotentHTTPMethod = slices.Contains(idempotentHTTPMethods, httpMethod) - - // Connection closed - if errors.Is(urlError.Err, io.EOF) && isIdempotentHTTPMethod { - return err - } - } - - var networkOperationError *net.OpError - isBrokenPipeOrConnectionReset := errors.As(err, &networkOperationError) && - (errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET)) - - if isBrokenPipeOrConnectionReset && isIdempotentHTTPMethod { - return err - } - - return retry.Permanent(err) - } - resp = res - if res == nil { - return fmt.Errorf("no response") - } - - for _, code := range r.StatusCodes { - if strings.Contains(strings.ToUpper(code), "X") { - codeRange, err := strconv.Atoi(code[:1]) - if err != nil { - continue - } - - s := res.StatusCode / 100 - - if s >= codeRange && s < codeRange+1 { - return retry.TemporaryFromResponse("request failed", res) - } - } else { - parsedCode, err := strconv.Atoi(code) - if err != nil { - continue - } - - if res.StatusCode == parsedCode { - return retry.TemporaryFromResponse("request failed", res) - } - } - } - - resp = res - - return nil - }) - - var tempErr *retry.TemporaryError - if err != nil && !errors.As(err, &tempErr) { - return nil, err - } - - return resp, nil - default: - return operation() - } -} - -func retryWithBackoff(ctx context.Context, s *retry.BackoffStrategy, operation func() error) error { - var ( - err error - next time.Duration - attempt int - start = time.Now() - maxElapsedTime = time.Duration(s.MaxElapsedTime) * time.Millisecond - ) - - timer := &defaultTimer{} - defer func() { - timer.Stop() - }() - - for { - err = operation() - if err == nil { - return nil - } - - var permanent *retry.PermanentError - if errors.As(err, &permanent) { - return permanent.Unwrap() - } - - if time.Since(start) >= maxElapsedTime { - return err - } - - var temporary *retry.TemporaryError - if errors.As(err, &temporary) { - next = temporary.RetryAfter() - } - - if next <= 0 { - next = nextInterval(s, attempt) - } - - timer.Start(next) - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C(): - } - - attempt += 1 - } -} - -type Timer interface { - Start(duration time.Duration) - Stop() - C() <-chan time.Time -} - -// defaultTimer implements Timer interface using time.Timer -type defaultTimer struct { - timer *time.Timer -} - -// C returns the timers channel which receives the current time when the timer fires. -func (t *defaultTimer) C() <-chan time.Time { - return t.timer.C -} - -// Start starts the timer to fire after the given duration -func (t *defaultTimer) Start(duration time.Duration) { - if t.timer == nil { - t.timer = time.NewTimer(duration) - return - } - - if !t.timer.Stop() { - select { - case <-t.timer.C: - default: - } - } - - t.timer.Reset(duration) -} - -// Stop is called when the timer is not used anymore and resources may be freed. -func (t *defaultTimer) Stop() { - if t.timer != nil { - t.timer.Stop() - } -} - -func nextInterval(s *retry.BackoffStrategy, attempt int) time.Duration { - initialInterval := float64(time.Duration(s.InitialInterval) * time.Millisecond) - maxInterval := float64(time.Duration(s.MaxInterval) * time.Millisecond) - exponent := s.Exponent - jitterFactor := float64(0.25) - - interval := initialInterval * math.Pow(float64(attempt+1), exponent) - - jitter := rand.Float64() * jitterFactor * interval - if rand.Float64() < 0.5 { - jitter = -1 * jitter - } - - interval = interval + jitter - - if interval <= 0 { - interval = initialInterval - } - - if interval > maxInterval { - interval = maxInterval - } - - return time.Duration(interval) -} diff --git a/internal/deployserverclient/internal/utils/security.go b/internal/deployserverclient/internal/utils/security.go deleted file mode 100644 index a90d91a3..00000000 --- a/internal/deployserverclient/internal/utils/security.go +++ /dev/null @@ -1,378 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 0c861be97d5a - -package utils - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "reflect" - "strings" -) - -const ( - securityTagKey = "security" -) - -type securityTag struct { - Option bool - Scheme bool - Name string - Type string - SubType string - Env string -} - -func PopulateSecurity(ctx context.Context, req *http.Request, securitySource func(context.Context) (interface{}, error)) error { - if securitySource == nil { - return nil - } - - security, err := securitySource(ctx) - if err != nil { - return err - } - - headers := make(map[string]string) - queryParams := make(map[string]string) - - securityValType := trueReflectValue(reflect.ValueOf(security)) - securityStructType := securityValType.Type() - - if isNil(securityStructType, securityValType) { - return nil - } - - if securityStructType.Kind() == reflect.Ptr { - securityStructType = securityStructType.Elem() - securityValType = securityValType.Elem() - } - - for i := 0; i < securityStructType.NumField(); i++ { - fieldType := securityStructType.Field(i) - valType := securityValType.Field(i) - - kind := valType.Kind() - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - kind = valType.Elem().Kind() - } - - secTag := parseSecurityTag(fieldType) - if secTag != nil { - if secTag.Option { - handleSecurityOption(headers, queryParams, valType.Interface()) - } else if secTag.Scheme { - // Special case for basic auth which could be a flattened struct - if secTag.SubType == "basic" && kind != reflect.Struct { - parseSecurityScheme(headers, queryParams, secTag, security) - } else { - parseSecurityScheme(headers, queryParams, secTag, valType.Interface()) - } - } - } - } - - for key, value := range headers { - req.Header.Add(key, value) - } - - query := req.URL.Query() - for key, value := range queryParams { - query.Add(key, value) - } - req.URL.RawQuery = query.Encode() - - return nil -} - -func PopulateSecurityFromEnv(security interface{}) bool { - securityValType := trueReflectValue(reflect.ValueOf(security)) - securityStructType := securityValType.Type() - - if securityStructType.Kind() == reflect.Ptr { - securityStructType = securityStructType.Elem() - securityValType = securityValType.Elem() - } - - return populateStructFromEnv(securityStructType, securityValType) -} - -func populateStructFromEnv(structType reflect.Type, structVal reflect.Value) bool { - fieldPopulated := false - for i := 0; i < structType.NumField(); i++ { - fieldType := structType.Field(i) - secTag := parseSecurityTag(fieldType) - if fieldType.Type.Kind() == reflect.Struct || - (fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct) { - if fieldType.Type.Kind() == reflect.Ptr { - if hasEnvVarsForSecurityStruct(fieldType.Type.Elem(), secTag != nil && secTag.Option) { - fieldVal := reflect.New(fieldType.Type.Elem()) - if populateStructFromEnv(fieldType.Type.Elem(), fieldVal.Elem()) { - structVal.Field(i).Set(fieldVal) - fieldPopulated = true - } - } - } else { - fieldVal := structVal.Field(i) - if populateStructFromEnv(fieldType.Type.Elem(), fieldVal.Elem()) { - fieldPopulated = true - } - } - continue - } - - if secTag == nil || secTag.Env == "" { - continue - } - - envValue := getCaseInsensitiveEnvVar(secTag.Env) - if envValue == "" { - continue - } - - if fieldType.Type.Kind() == reflect.Ptr { - fieldVal := reflect.New(fieldType.Type.Elem()) - if setFieldValue(fieldVal.Elem(), envValue) { - structVal.Field(i).Set(fieldVal) - fieldPopulated = true - } - } else { - fieldVal := structVal.Field(i) - if setFieldValue(fieldVal, envValue) { - fieldPopulated = true - } - } - } - - return fieldPopulated -} - -func hasEnvVarsForSecurityStruct(structType reflect.Type, isSecurityOption bool) bool { - valid := false - for i := 0; i < structType.NumField(); i++ { - fieldType := structType.Field(i) - secTag := parseSecurityTag(fieldType) - if secTag == nil { - continue - } - - if secTag.Env != "" || getCaseInsensitiveEnvVar(secTag.Env) != "" { - valid = true - } else if isSecurityOption { - return false - } - - if fieldType.Type.Kind() == reflect.Struct || - (fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct) { - if hasEnvVarsForSecurityStruct(fieldType.Type, secTag.Option) { - valid = true - } else if isSecurityOption { - return false - } - } - } - - return valid -} - -func handleSecurityOption(headers, queryParams map[string]string, option interface{}) { - optionValType := trueReflectValue(reflect.ValueOf(option)) - optionStructType := optionValType.Type() - - if isNil(optionStructType, optionValType) { - return - } - - for i := 0; i < optionStructType.NumField(); i++ { - fieldType := optionStructType.Field(i) - valType := optionValType.Field(i) - - secTag := parseSecurityTag(fieldType) - if secTag != nil && secTag.Scheme { - parseSecurityScheme(headers, queryParams, secTag, valType.Interface()) - } - } -} - -func parseSecurityScheme(headers, queryParams map[string]string, schemeTag *securityTag, scheme interface{}) { - schemeVal := trueReflectValue(reflect.ValueOf(scheme)) - schemeType := schemeVal.Type() - - if isNil(schemeType, schemeVal) { - return - } - - if schemeType.Kind() == reflect.Struct { - if schemeTag.Type == "http" { - switch schemeTag.SubType { - case "basic": - handleBasicAuthScheme(headers, schemeVal.Interface()) - return - case "custom": - return - } - } - - for i := 0; i < schemeType.NumField(); i++ { - fieldType := schemeType.Field(i) - valType := schemeVal.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Ptr { - valType = valType.Elem() - } - - secTag := parseSecurityTag(fieldType) - if secTag == nil || secTag.Name == "" { - return - } - - parseSecuritySchemeValue(headers, queryParams, schemeTag, secTag, valType.Interface()) - } - } else { - parseSecuritySchemeValue(headers, queryParams, schemeTag, schemeTag, schemeVal.Interface()) - } -} - -func parseSecuritySchemeValue(headers, queryParams map[string]string, schemeTag *securityTag, secTag *securityTag, val interface{}) { - switch schemeTag.Type { - case "apiKey": - switch schemeTag.SubType { - case "header": - headers[secTag.Name] = valToString(val) - case "query": - queryParams[secTag.Name] = valToString(val) - case "cookie": - headers["Cookie"] = fmt.Sprintf("%s=%s", secTag.Name, valToString(val)) - default: - panic("not supported") - } - case "openIdConnect": - headers[secTag.Name] = prefixBearer(valToString(val)) - case "oauth2": - if schemeTag.SubType != "client_credentials" { - headers[secTag.Name] = prefixBearer(valToString(val)) - } - case "http": - switch schemeTag.SubType { - case "bearer": - headers[secTag.Name] = prefixBearer(valToString(val)) - case "basic": - headers[secTag.Name] = valToString(val) - case "custom": - default: - panic("not supported") - } - default: - panic("not supported") - } -} - -func prefixBearer(authHeaderValue string) string { - if strings.HasPrefix(strings.ToLower(authHeaderValue), "bearer ") { - return authHeaderValue - } - - return fmt.Sprintf("Bearer %s", authHeaderValue) -} - -func handleBasicAuthScheme(headers map[string]string, scheme interface{}) { - schemeStructType := reflect.TypeOf(scheme) - schemeValType := reflect.ValueOf(scheme) - - var username, password string - - for i := 0; i < schemeStructType.NumField(); i++ { - fieldType := schemeStructType.Field(i) - valType := schemeValType.Field(i) - - if fieldType.Type.Kind() == reflect.Ptr { - valType = valType.Elem() - } - - secTag := parseSecurityTag(fieldType) - if secTag == nil || secTag.Name == "" { - continue - } - - switch secTag.Name { - case "username": - username = valType.String() - case "password": - password = valType.String() - } - } - - headers["Authorization"] = fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))) -} - -func parseSecurityTag(field reflect.StructField) *securityTag { - tag := field.Tag.Get(securityTagKey) - if tag == "" { - return nil - } - - option := false - scheme := false - name := "" - securityType := "" - securitySubType := "" - env := "" - - options := strings.Split(tag, ",") - for _, optionConf := range options { - parts := strings.Split(optionConf, "=") - if len(parts) < 1 || len(parts) > 2 { - continue - } - - switch parts[0] { - case "name": - name = parts[1] - case "type": - securityType = parts[1] - case "subtype": - securitySubType = parts[1] - case "option": - option = true - case "scheme": - scheme = true - case "env": - env = parts[1] - } - } - - // TODO: validate tag? - - return &securityTag{ - Option: option, - Scheme: scheme, - Name: name, - Type: securityType, - SubType: securitySubType, - Env: env, - } -} - -func trueReflectValue(val reflect.Value) reflect.Value { - kind := val.Type().Kind() - for kind == reflect.Interface || kind == reflect.Ptr { - innerVal := val.Elem() - if !innerVal.IsValid() { - break - } - val = innerVal - kind = val.Type().Kind() - } - return val -} diff --git a/internal/deployserverclient/internal/utils/union.go b/internal/deployserverclient/internal/utils/union.go deleted file mode 100644 index 52acb35d..00000000 --- a/internal/deployserverclient/internal/utils/union.go +++ /dev/null @@ -1,290 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 1d201009c7ad - -package utils - -import ( - "encoding/json" - "math/big" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/deployserverclient/v3/types" -) - -// UnionCandidate represents a candidate type during union deserialization -type UnionCandidate struct { - Matched int // Count of matched fields (includes inexact) - Inexact int // Count of fields with unknown/unrecognized enum values - Unmatched int // Count of struct fields not found in raw JSON - AdditionalProperties int // Count of fields captured by additionalProperties - Type any // The union type enum value - Value any // The unmarshaled value -} - -// PickBestUnionCandidate selects the best union type candidate according to `betterCandidate` -func PickBestUnionCandidate(candidates []UnionCandidate, rawJSON []byte) *UnionCandidate { - if len(candidates) == 0 { - return nil - } - - if len(candidates) == 1 { - return &candidates[0] - } - - var raw any - _ = json.Unmarshal(rawJSON, &raw) - - var best *UnionCandidate - for i := range candidates { - countFields(&candidates[i], raw) - best = betterCandidate(best, &candidates[i]) - } - return best -} - -// betterCandidate returns the better of two candidates based on: -// 1. Matched count (higher is better) -// 2. Inexact count (lower is better) -// 3. Unmatched count (lower is better - fewer zero defaulted values) -// 4. AdditionalProperties count (higher is better - captures more extra fields) -// Returns a if tied (preserving spec order). -func betterCandidate(a, b *UnionCandidate) *UnionCandidate { - if a == nil { - return b - } - if b == nil { - return a - } - if a.Matched != b.Matched { - if a.Matched > b.Matched { - return a - } - return b - } - if a.Inexact != b.Inexact { - if a.Inexact > b.Inexact { - return b - } - return a - } - if a.Unmatched != b.Unmatched { - if a.Unmatched > b.Unmatched { - return b - } - return a - } - if a.AdditionalProperties != b.AdditionalProperties { - if a.AdditionalProperties > b.AdditionalProperties { - return a - } - return b - } - return a -} - -// countFields populates UnionCandidate.Matched, UnionCandidate.Inexact, and UnionCandidate.Unmatched fields -func countFields(candidate *UnionCandidate, raw any) { - typ := reflect.TypeOf(candidate.Value) - val := reflect.ValueOf(candidate.Value) - countFieldsRecursive(candidate, typ, val, raw) -} - -func countFieldsRecursive(candidate *UnionCandidate, typ reflect.Type, val reflect.Value, raw any) { - kind := typ.Kind() - - // Handle interface{}/any types - can hold any JSON value - if kind == reflect.Interface { - candidate.Matched++ - return - } - - if typ.Kind() == reflect.Ptr { - if raw == nil { - // Handle null JSON value match - candidate.Matched++ - return - } - typ, val = dereferencePointers(typ, val) - kind = typ.Kind() - } - - // Handle primitives - if kind == reflect.String || - kind == reflect.Bool || - kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64 || - kind == reflect.Uint || kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 || kind == reflect.Uint64 || - kind == reflect.Float32 || kind == reflect.Float64 || - typ == reflect.TypeOf(time.Time{}) || - typ == reflect.TypeOf(big.Int{}) || - typ == reflect.TypeOf(types.Date{}) || - typ == reflect.TypeOf([]byte{}) { - candidate.Matched++ - if !isExact(val) || raw == nil { - candidate.Inexact++ - } - return - } - - // Handle unions - if isUnion, activeVariant, variantVal := findActiveUnionVariant(typ, val); isUnion { - if activeVariant != nil && !variantVal.IsNil() { - countFieldsRecursive(candidate, activeVariant.Type.Elem(), variantVal.Elem(), raw) - } - return - } - - // Handle regular structs - if kind == reflect.Struct { - rawObj, ok := raw.(map[string]any) - if !ok { - return - } - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - if field.Tag.Get("additionalProperties") == "true" { - if field.Type.Kind() == reflect.Map && !fieldVal.IsNil() { - candidate.AdditionalProperties += fieldVal.Len() - } - continue - } - - jsonName, ok := jsonFieldName(field) - if !ok { - continue - } - - rawField, exists := rawObj[jsonName] - if !exists { - candidate.Unmatched++ - continue - } - - countFieldsRecursive(candidate, field.Type, fieldVal, rawField) - } - return - } - - // Handle slices and arrays - if kind == reflect.Slice || kind == reflect.Array { - if val.IsNil() || val.Len() == 0 { - return - } - - rawArr, ok := raw.([]any) - if !ok { - return - } - - // Count each array/slice element - for i := 0; i < val.Len() && i < len(rawArr); i++ { - itemVal := val.Index(i) - countFieldsRecursive(candidate, itemVal.Type(), itemVal, rawArr[i]) - } - return - } - - // Handle maps - if kind == reflect.Map { - if val.IsNil() || val.Len() == 0 { - return - } - - rawObj, ok := raw.(map[string]any) - if !ok { - return - } - - for _, key := range val.MapKeys() { - keyStr := key.String() - rawVal, exists := rawObj[keyStr] - if exists { - mapVal := val.MapIndex(key) - countFieldsRecursive(candidate, mapVal.Type(), mapVal, rawVal) - } - } - return - } - // Anything else - candidate.Matched++ - return -} - -// jsonFieldName returns the JSON field name for a struct field. -// Returns ("", false) if the field should be skipped (json:"-"). -func jsonFieldName(field reflect.StructField) (string, bool) { - jsonTag := field.Tag.Get("json") - if jsonTag == "-" { - return "", false - } - if jsonTag != "" { - parts := strings.Split(jsonTag, ",") - if parts[0] != "" { - return parts[0], true - } - } - return field.Name, true -} - -// findActiveUnionVariant detects if a struct is a union type and returns the active variant. -// A union type is detected by having fields with the `union:"member"` tag. -// Returns (false, nil, invalid) if not a union type. -// Returns (true, nil, invalid) if union type but no active variant found (all nil). -// Returns (true, field, value) if union type with an active variant. -func findActiveUnionVariant(typ reflect.Type, val reflect.Value) (bool, *reflect.StructField, reflect.Value) { - if typ.Kind() != reflect.Struct { - return false, nil, reflect.Value{} - } - - var activeVariant *reflect.StructField - var activeValue reflect.Value - isUnion := false - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - - // Look for fields tagged as union members - if field.Tag.Get("union") != "member" { - continue - } - - isUnion = true - - // All union variants are pointers - only set active if non-nil - fieldVal := val.Field(i) - if !fieldVal.IsNil() { - activeVariant = &field - activeValue = fieldVal - } - } - - return isUnion, activeVariant, activeValue -} - -func isExact(val reflect.Value) bool { - if !val.IsValid() { - return true - } - - // If not addressable, make an addressable copy - if !val.CanAddr() && val.CanInterface() { - ptr := reflect.New(val.Type()) - ptr.Elem().Set(val) - val = ptr.Elem() - } - - if val.CanInterface() && val.CanAddr() { - ptrVal := val.Addr() - if method := ptrVal.MethodByName("IsExact"); method.IsValid() { - results := method.Call(nil) - if len(results) == 1 && results[0].Kind() == reflect.Bool { - return results[0].Bool() - } - } - } - return true -} diff --git a/internal/deployserverclient/internal/utils/union_test.go b/internal/deployserverclient/internal/utils/union_test.go deleted file mode 100644 index 81fb52e8..00000000 --- a/internal/deployserverclient/internal/utils/union_test.go +++ /dev/null @@ -1,588 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 673d423325b0 - -package utils - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// makeCandidates unmarshals payload into each type and returns candidates -func makeCandidates(t *testing.T, payload string, types ...any) []UnionCandidate { - t.Helper() - candidates := make([]UnionCandidate, len(types)) - for i, typ := range types { - val := reflect.New(reflect.TypeOf(typ)).Interface() - require.NoError(t, UnmarshalJSON([]byte(payload), val, "", false, nil)) - candidates[i] = UnionCandidate{Type: typ, Value: reflect.ValueOf(val).Elem().Interface()} - } - return candidates -} - -func TestPickBestUnionCandidate_SelectsTypeWithMoreMatchedFields(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - Bar string `json:"bar"` - } - - payload := `{"foo": "", "bar": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_EmptyCandidates(t *testing.T) { - result := PickBestUnionCandidate([]UnionCandidate{}, []byte(`{"foo": "test"}`)) - assert.Nil(t, result) -} - -func TestPickBestUnionCandidate_PrefersFewerUnmatchedFields(t *testing.T) { - type A struct { - Foo string `json:"foo"` - Bar string `json:"bar"` // not in payload - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": "test"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) // fewer unmatched fields -} - -func TestPickBestUnionCandidate_NestedStructs(t *testing.T) { - type InnerA struct { - Value string `json:"value"` - } - type InnerB struct { - Value string `json:"value"` - Extra string `json:"extra"` - } - type A struct { - Nested InnerA `json:"nested"` - } - type B struct { - Nested InnerB `json:"nested"` - } - - payload := `{"nested": {"value": "test", "extra": "data"}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ArrayFields(t *testing.T) { - type ItemA struct { - Name string `json:"name"` - } - type ItemB struct { - Name string `json:"name"` - Value string `json:"value"` - } - type A = []ItemA - type B = []ItemB - - payload := `[{"name": "a", "value": "1"}, {"name": "b", "value": "2"}]` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_PreservesOrderOnTie(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": "test"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -func TestPickBestUnionCandidate_OptionalPointerFields(t *testing.T) { - type A struct { - Foo *string `json:"foo"` - } - type B struct { - Foo *string `json:"foo"` - Bar *string `json:"bar"` - } - - payload := `{"foo": "test", "bar": "value"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_OptionalPointerStructs(t *testing.T) { - type InnerA struct { - Name string `json:"name"` - } - type InnerB struct { - Name string `json:"name"` - Value string `json:"value"` - } - type A struct { - Nested *InnerA `json:"nested"` - } - type B struct { - Nested *InnerB `json:"nested"` - } - - payload := `{"nested": {"name": "test", "value": "data"}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullPointerField(t *testing.T) { - type A struct { - Bar *string `json:"bar"` - } - type B struct { - Foo *string `json:"foo"` - } - - payload := `{"foo": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedPointerField(t *testing.T) { - type InnerA struct { - Bar *string `json:"bar"` - } - type InnerB struct { - Bar *bool `json:"bar"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"bar": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -func TestPickBestUnionCandidate_NullNonPointerField(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - Bar string `json:"bar"` - } - - payload := `{"foo": "", "bar": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedFieldDifferentStructs(t *testing.T) { - type InnerA struct { - Bar string `json:"bar"` - } - type InnerB struct { - Baz *string `json:"baz"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"baz": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedFieldBothPresent(t *testing.T) { - type InnerA struct { - Bar *string `json:"bar"` - } - type InnerB struct { - Baz *string `json:"baz"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"bar": null, "baz": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -// EnumA represents an enum with values 1 or 2 -type EnumA int - -func (e *EnumA) IsExact() bool { - return *e == 1 || *e == 2 -} - -func (e *EnumA) UnmarshalJSON(data []byte) error { - var v int - if err := json.Unmarshal(data, &v); err != nil { - return err - } - *e = EnumA(v) - return nil -} - -// EnumB represents an enum with values 3 or 4 -type EnumB int - -func (e *EnumB) IsExact() bool { - return *e == 3 || *e == 4 -} - -func (e *EnumB) UnmarshalJSON(data []byte) error { - var v int - if err := json.Unmarshal(data, &v); err != nil { - return err - } - *e = EnumB(v) - return nil -} - -func TestPickBestUnionCandidate_EnumDiscrimination(t *testing.T) { - type A struct { - Val EnumA `json:"a"` - } - type B struct { - Val EnumB `json:"a"` - } - - payload := `{"a": 4}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ThreeWayFieldDiscrimination(t *testing.T) { - type A struct { - FieldA string `json:"a"` - FieldB string `json:"b"` - } - type B struct { - FieldA string `json:"a"` - FieldC string `json:"c"` - } - type C struct { - FieldB string `json:"b"` - FieldC string `json:"c"` - } - - payload := `{"b": "", "c": ""}` - candidates := makeCandidates(t, payload, A{}, B{}, C{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, C{}, result.Type) -} - -func TestPickBestUnionCandidate_ConstFieldDiscrimination(t *testing.T) { - type A struct { - ConstA string `json:"a" const:"x"` - ConstB string `json:"b" const:"1"` - } - type B struct { - ConstA string `json:"a" const:"x"` - ConstC string `json:"c" const:"1"` - } - type C struct { - ConstB string `json:"b" const:"1"` - ConstC string `json:"c" const:"1"` - } - - payload := `{"b": "1", "c": "1"}` - candidates := makeCandidates(t, payload, A{}, B{}, C{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, C{}, result.Type) -} - -func TestPickBestUnionCandidate_NullPayload(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B *string // nullable type - - payload := `null` - - candidates := makeCandidates(t, payload, A{}, B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isB := result.Type.(B) - assert.True(t, isB, "expected B (nullable type) to win for null payload") -} - -func TestPickBestUnionCandidate_PrimitiveStringTypes(t *testing.T) { - // When both types are strings, first wins on tie - type A = string - type B = string - - payload := `"asdf"` - - candidates := makeCandidates(t, payload, A(""), B("")) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (first type) to win on tie") -} - -func TestPickBestUnionCandidate_NullPointerPrimitives(t *testing.T) { - // A: *string, B: *float64 - // payload: null - // Both can be null, first wins - type A = *string - type B = *float64 - - payload := `null` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (*string) to win for null payload (first wins on tie)") -} - -func TestPickBestUnionCandidate_NullPointersMatchOverMissingFields(t *testing.T) { - // A: { a: string } - // B: { b: *string, c: *string } - // payload: { "b": null, "c": null } - // B wins because it has 2 matched fields, A has 0 - type A struct { - A string `json:"a"` - } - type B struct { - B *string `json:"b"` - C *string `json:"c"` - } - - payload := `{"b": null, "c": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_MapOfStructs(t *testing.T) { - // A: map[string]{ a: string } - // B: map[string]{ b: string } - // payload: { "x": null, "y": null, "z": { "b": "b" } } - // B wins because it has a matched field in the nested struct - type InnerA struct { - A string `json:"a"` - } - type InnerB struct { - B string `json:"b"` - } - type A = map[string]*InnerA - type B = map[string]*InnerB - - payload := `{"x": null, "y": null, "z": {"b": "b"}}` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isB := result.Type.(B) - assert.True(t, isB, "expected B (map with matching struct field) to win") -} - -func TestPickBestUnionCandidate_NullPointerStructVsString(t *testing.T) { - // A: *{foo: string}, B: *string - // payload: null - type Inner struct { - Foo string `json:"foo"` - } - type A = *Inner - type B = *string - - payload := `null` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (*struct) to win for null payload (first wins on tie)") -} - -func TestPickBestUnionCandidate_MapWithNestedStructsVsSimpleField(t *testing.T) { - // A: { a: string } - // B: { b: map[string]{ id: string, name: string } } - // payload: { "a": "", "b": { "foo": { "id": "", "name": "" } } } - // B should win because it has more matched fields (id, name in nested struct) - type Inner struct { - ID string `json:"id"` - Name string `json:"name"` - } - type A struct { - A string `json:"a"` - } - type B struct { - B map[string]Inner `json:"b"` - } - - payload := `{"a": "", "b": {"foo": {"id": "", "name": ""}}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_AdditionalPropertiesWins(t *testing.T) { - type A struct { - A string `json:"a"` - } - type B struct { - B string `json:"b"` - AdditionalProperties map[string]string `additionalProperties:"true"` - } - - payload := `{"a": "", "b": "", "c": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_StructVsMapOfStrings(t *testing.T) { - // A: map[string]string - // B: { id: string } - // payload: { "id": "", "foo": "" } - // B should win because struct fields are more specific than map - type A = map[string]string - type B struct { - ID string `json:"id"` - } - - payload := `{"id": "", "foo": ""}` - - candidates := makeCandidates(t, payload, A(nil), B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) -} - -func TestPickBestUnionCandidate_AdditionalPropertiesVsExactField(t *testing.T) { - // A: { id: *string, additionalProperties: true } - // B: { foo: string } - // payload: { "foo": "" } - // B should win because it has an exact field match, while A only matches via additionalProperties - type A struct { - ID *string `json:"id"` - AdditionalProperties map[string]string `additionalProperties:"true"` - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ArrayOfNullableStructs(t *testing.T) { - // A: Array<{ foo: *string } | null> - // B: Array<{ bar: *string } | null> - // payload: [null, null, { "bar": "" }] - // B should win because it has a matching field in the non-null element - type ItemA struct { - Foo *string `json:"foo"` - } - type ItemB struct { - Bar *string `json:"bar"` - } - type A = []*ItemA - type B = []*ItemB - - payload := `[null, null, {"bar": ""}]` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_AnyFieldType(t *testing.T) { - // A: { a: string } - // B: { b: any } - // payload: { "b": "asdf" } - // B should win because it has a matching field - type A struct { - A string `json:"a"` - } - type B struct { - B any `json:"b"` - } - - payload := `{"b": "asdf"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} diff --git a/internal/deployserverclient/internal/utils/utils.go b/internal/deployserverclient/internal/utils/utils.go deleted file mode 100644 index a3c2bcdf..00000000 --- a/internal/deployserverclient/internal/utils/utils.go +++ /dev/null @@ -1,387 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: ea3d8f125526 - -package utils - -import ( - "bytes" - "context" - "fmt" - "io" - "math/big" - "net/http" - "os" - "reflect" - "regexp" - "strconv" - "strings" - "time" -) - -const ( - queryParamTagKey = "queryParam" - headerParamTagKey = "header" - pathParamTagKey = "pathParam" -) - -var ( - paramRegex = regexp.MustCompile(`({.*?})`) - SerializationMethodToContentType = map[string]string{ - "json": "application/json", - "form": "application/x-www-form-urlencoded", - "multipart": "multipart/form-data", - "raw": "application/octet-stream", - "string": "text/plain", - } -) - -func UnmarshalJsonFromResponseBody(body io.Reader, out interface{}, tag string) error { - data, err := io.ReadAll(body) - if err != nil { - return fmt.Errorf("error reading response body: %w", err) - } - if err := UnmarshalJSON(data, out, reflect.StructTag(tag), true, nil); err != nil { - return fmt.Errorf("error unmarshaling json response body: %w", err) - } - - return nil -} - -func UnmarshalJsonFromString(json string, out interface{}, tag string) error { - if err := UnmarshalJSON([]byte(json), out, reflect.StructTag(tag), true, nil); err != nil { - return fmt.Errorf("error unmarshalling json response body: %w", err) - } - - return nil -} - -func ReplaceParameters(stringWithParams string, params map[string]string) string { - if len(params) == 0 { - return stringWithParams - } - - return paramRegex.ReplaceAllStringFunc(stringWithParams, func(match string) string { - match = match[1 : len(match)-1] - return params[match] - }) -} - -func Contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -func MatchStatusCodes(expectedCodes []string, statusCode int) bool { - for _, codeStr := range expectedCodes { - code, err := strconv.Atoi(codeStr) - if err == nil { - if code == statusCode { - return true - } - continue - } - - codeRange, err := strconv.Atoi(string(codeStr[0])) - if err != nil { - continue - } - - if statusCode >= (codeRange*100) && statusCode < ((codeRange+1)*100) { - return true - } - } - - return false -} - -func AsSecuritySource(security interface{}) func(context.Context) (interface{}, error) { - return func(context.Context) (interface{}, error) { - return security, nil - } -} - -func getCaseInsensitiveEnvVar(envVar string) string { - if value := os.Getenv(envVar); value != "" { - return value - } - - if value := os.Getenv(strings.ToUpper(envVar)); value != "" { - return value - } - - return "" -} - -func ValueFromEnvVar(envVar string, field interface{}) interface{} { - value := getCaseInsensitiveEnvVar(envVar) - if value == "" { - return nil - } - - t := reflect.TypeOf(field) - if t.Kind() != reflect.Ptr { - return nil - } - t = t.Elem() - - switch t.Kind() { - case reflect.String: - return value - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if i, err := strconv.ParseInt(value, 10, 64); err == nil { - return i - } - case reflect.Float32, reflect.Float64: - if f, err := strconv.ParseFloat(value, 64); err == nil { - return f - } - case reflect.Bool: - if b, err := strconv.ParseBool(value); err == nil { - return b - } - } - return nil -} - -func parseConstTag(field reflect.StructField) *string { - value := field.Tag.Get("const") - - if value == "" { - return nil - } - - return &value -} - -func parseDefaultTag(field reflect.StructField) *string { - value := field.Tag.Get("default") - - if value == "" { - return nil - } - - return &value -} - -func parseStructTag(tagKey string, field reflect.StructField) map[string]string { - tag := field.Tag.Get(tagKey) - if tag == "" { - return nil - } - - values := map[string]string{} - - options := strings.Split(tag, ",") - for _, optionConf := range options { - parts := strings.Split(optionConf, "=") - - switch len(parts) { - case 1: - // flag option - parts = append(parts, "true") - case 2: - // key=value option - default: - // invalid option - continue - } - - values[parts[0]] = parts[1] - } - - return values -} - -func parseParamTag(tagKey string, field reflect.StructField, defaultStyle string, defaultExplode bool) *paramTag { - // example `{tagKey}:"style=simple,explode=false,name=apiID"` - // example `{tagKey}:"inline"` - values := parseStructTag(tagKey, field) - if values == nil { - return nil - } - - tag := ¶mTag{ - Style: defaultStyle, - Explode: defaultExplode, - ParamName: strings.ToLower(field.Name), - } - - for k, v := range values { - switch k { - case "inline": - tag.Inline = v == "true" - case "style": - tag.Style = v - case "explode": - tag.Explode = v == "true" - case "name": - tag.ParamName = v - case "serialization": - tag.Serialization = v - } - } - - return tag -} - -func valToString(val interface{}) string { - switch v := val.(type) { - case time.Time: - return v.Format(time.RFC3339Nano) - case big.Int: - return v.String() - default: - return fmt.Sprintf("%v", v) - } -} - -func populateFromGlobals(fieldType reflect.StructField, valType reflect.Value, paramType string, globals interface{}) (reflect.StructField, reflect.Value, bool) { - if globals == nil { - return fieldType, valType, false - } - - globalsStruct := reflect.TypeOf(globals) - globalsStructVal := reflect.ValueOf(globals) - - globalsField, found := globalsStruct.FieldByName(fieldType.Name) - if !found { - return fieldType, valType, false - } - - if fieldType.Type.Kind() != reflect.Ptr || !valType.IsNil() { - return fieldType, valType, true - } - - globalsVal := globalsStructVal.FieldByName(fieldType.Name) - - if !globalsVal.IsValid() { - return fieldType, valType, false - } - - switch paramType { - case queryParamTagKey: - qpTag := parseQueryParamTag(globalsField) - if qpTag == nil { - return fieldType, valType, false - } - default: - tag := parseParamTag(paramType, fieldType, "simple", false) - if tag == nil { - return fieldType, valType, false - } - } - - return globalsField, globalsVal, true -} - -func isNil(typ reflect.Type, val reflect.Value) bool { - // `reflect.TypeOf(nil) == nil` so calling typ.Kind() will cause a nil pointer - // dereference panic. Catch it and return early. - // https://github.com/golang/go/issues/51649 - // https://github.com/golang/go/issues/54208 - if typ == nil { - return true - } - - if typ.Kind() == reflect.Ptr || typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice || typ.Kind() == reflect.Interface { - return val.IsNil() - } - - return false -} - -func isEmptyContainer(typ reflect.Type, val reflect.Value) bool { - if isNil(typ, val) { - return true - } - - switch typ.Kind() { - case reflect.Slice, reflect.Array: - return val.Len() == 0 - case reflect.Map: - return val.Len() == 0 - default: - return false - } -} - -func isZeroValue(v reflect.Value) bool { - switch v.Kind() { - case reflect.String: - return v.String() == "" - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Float32, reflect.Float64: - return v.IsZero() - default: - return false - } -} - -func setFieldValue(fieldVal reflect.Value, value string) bool { - if !fieldVal.CanSet() { - return false - } - - switch fieldVal.Kind() { - case reflect.String: - fieldVal.SetString(value) - return true - case reflect.Bool: - if boolValue, err := strconv.ParseBool(value); err == nil { - fieldVal.SetBool(boolValue) - return true - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if intValue, err := strconv.ParseInt(value, 10, 64); err == nil { - fieldVal.SetInt(intValue) - return true - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - if uintValue, err := strconv.ParseUint(value, 10, 64); err == nil { - fieldVal.SetUint(uintValue) - return true - } - case reflect.Float32, reflect.Float64: - if floatValue, err := strconv.ParseFloat(value, 64); err == nil { - fieldVal.SetFloat(floatValue) - return true - } - default: - return false - } - - return false -} - -func contains(arr []string, str string) bool { - for _, a := range arr { - if a == str { - return true - } - } - return false -} - -func DrainBody(res *http.Response) { - io.Copy(io.Discard, res.Body) - res.Body.Close() - res.Body = io.NopCloser(bytes.NewReader(nil)) -} - -func ConsumeRawBody(res *http.Response) ([]byte, error) { - defer res.Body.Close() - - rawBody, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - res.Body = io.NopCloser(bytes.NewBuffer(rawBody)) - - return rawBody, nil -} diff --git a/internal/deployserverclient/models/apierrors/apierror.go b/internal/deployserverclient/models/apierrors/apierror.go deleted file mode 100644 index 7974d2a9..00000000 --- a/internal/deployserverclient/models/apierrors/apierror.go +++ /dev/null @@ -1,36 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: ae42ad944744 - -package apierrors - -import ( - "fmt" - "net/http" -) - -type APIError struct { - Message string - StatusCode int - Body string - RawResponse *http.Response -} - -var _ error = &APIError{} - -func NewAPIError(message string, statusCode int, body string, httpRes *http.Response) *APIError { - return &APIError{ - Message: message, - StatusCode: statusCode, - Body: body, - RawResponse: httpRes, - } -} - -func (e *APIError) Error() string { - body := "" - if len(e.Body) > 0 { - body = fmt.Sprintf("\n%s", e.Body) - } - - return fmt.Sprintf("%s: Status %d%s", e.Message, e.StatusCode, body) -} diff --git a/internal/deployserverclient/models/apierrors/error.go b/internal/deployserverclient/models/apierrors/error.go deleted file mode 100644 index 3216263c..00000000 --- a/internal/deployserverclient/models/apierrors/error.go +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: fa6526d8455d - -package apierrors - -import ( - "encoding/json" -) - -type Error struct { - ErrorCode string `json:"errorCode"` - ErrorMessage *string `json:"errorMessage,omitempty"` -} - -var _ error = &Error{} - -func (e *Error) Error() string { - data, _ := json.Marshal(e) - return string(data) -} diff --git a/internal/deployserverclient/models/components/app.go b/internal/deployserverclient/models/components/app.go deleted file mode 100644 index c56bdf01..00000000 --- a/internal/deployserverclient/models/components/app.go +++ /dev/null @@ -1,63 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 5575cc5558ac - -package components - -type App struct { - // Unique identifier for the app - ID string `json:"id"` - // Name of the app - Name string `json:"name"` - // Optional existing stack ID claimed by this app - StackID *string `json:"stackId,omitempty"` - // Stack region ID (set when the app's stack lives in a specific region) - StackRegionID *string `json:"stackRegionId,omitempty"` - State *State `json:"state,omitempty"` - // The manifest the app is currently bound to (apps.manifest_id), with - // catalog metadata and divergence vs the most recent applied deployment. - // Populated by GET /apps/{id} when a manifest is bound. - // - CurrentManifest *AppCurrentManifest `json:"currentManifest,omitempty"` -} - -func (a *App) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *App) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *App) GetStackID() *string { - if a == nil { - return nil - } - return a.StackID -} - -func (a *App) GetStackRegionID() *string { - if a == nil { - return nil - } - return a.StackRegionID -} - -func (a *App) GetState() *State { - if a == nil { - return nil - } - return a.State -} - -func (a *App) GetCurrentManifest() *AppCurrentManifest { - if a == nil { - return nil - } - return a.CurrentManifest -} diff --git a/internal/deployserverclient/models/components/appcurrentmanifest.go b/internal/deployserverclient/models/components/appcurrentmanifest.go deleted file mode 100644 index e7de2cd9..00000000 --- a/internal/deployserverclient/models/components/appcurrentmanifest.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 196f8c8444aa - -package components - -// AppCurrentManifest - The manifest the app is currently bound to (apps.manifest_id), with -// catalog metadata and divergence vs the most recent applied deployment. -// Populated by GET /apps/{id} when a manifest is bound. -type AppCurrentManifest struct { - // ID of the manifest the app is bound to - ID string `json:"id"` - // Name of the bound manifest - Name string `json:"name"` - // Latest version available in the manifest's lineage - LatestVersion int64 `json:"latestVersion"` - // Relationship between the app's bound manifest and what's currently - // deployed (last `applied` deployment, ignoring destroys). - // - Divergence *AppManifestDivergence `json:"divergence,omitempty"` -} - -func (a *AppCurrentManifest) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *AppCurrentManifest) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AppCurrentManifest) GetLatestVersion() int64 { - if a == nil { - return 0 - } - return a.LatestVersion -} - -func (a *AppCurrentManifest) GetDivergence() *AppManifestDivergence { - if a == nil { - return nil - } - return a.Divergence -} diff --git a/internal/deployserverclient/models/components/appmanifestdivergence.go b/internal/deployserverclient/models/components/appmanifestdivergence.go deleted file mode 100644 index c9e0e722..00000000 --- a/internal/deployserverclient/models/components/appmanifestdivergence.go +++ /dev/null @@ -1,90 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 9210c43db58e - -package components - -import ( - "encoding/json" - "fmt" -) - -// Kind - - `undeployed`: the app has a manifest bound but no successful deployment yet. -// - `synced`: deployed (manifestId, version) matches the bound manifest's latest version. -// - `behind`: deployed manifest matches the bound manifest but on an older version. -// - `rebound`: the app was rebound; deployed manifest is a different manifest. -type Kind string - -const ( - KindUndeployed Kind = "undeployed" - KindSynced Kind = "synced" - KindBehind Kind = "behind" - KindRebound Kind = "rebound" -) - -func (e Kind) ToPointer() *Kind { - return &e -} -func (e *Kind) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "undeployed": - fallthrough - case "synced": - fallthrough - case "behind": - fallthrough - case "rebound": - *e = Kind(v) - return nil - default: - return fmt.Errorf("invalid value for Kind: %v", v) - } -} - -// AppManifestDivergence - Relationship between the app's bound manifest and what's currently -// deployed (last `applied` deployment, ignoring destroys). -type AppManifestDivergence struct { - // - `undeployed`: the app has a manifest bound but no successful deployment yet. - // - `synced`: deployed (manifestId, version) matches the bound manifest's latest version. - // - `behind`: deployed manifest matches the bound manifest but on an older version. - // - `rebound`: the app was rebound; deployed manifest is a different manifest. - // - Kind Kind `json:"kind"` - // ID of the manifest reflected in the most recent applied deployment - DeployedManifestID *string `json:"deployedManifestId,omitempty"` - // Version reflected in the most recent applied deployment - DeployedVersion *int64 `json:"deployedVersion,omitempty"` - // Latest version available in the bound manifest's lineage - LatestVersion int64 `json:"latestVersion"` -} - -func (a *AppManifestDivergence) GetKind() Kind { - if a == nil { - return Kind("") - } - return a.Kind -} - -func (a *AppManifestDivergence) GetDeployedManifestID() *string { - if a == nil { - return nil - } - return a.DeployedManifestID -} - -func (a *AppManifestDivergence) GetDeployedVersion() *int64 { - if a == nil { - return nil - } - return a.DeployedVersion -} - -func (a *AppManifestDivergence) GetLatestVersion() int64 { - if a == nil { - return 0 - } - return a.LatestVersion -} diff --git a/internal/deployserverclient/models/components/appresponse.go b/internal/deployserverclient/models/components/appresponse.go deleted file mode 100644 index 33edf7fb..00000000 --- a/internal/deployserverclient/models/components/appresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: b091c10eea64 - -package components - -type AppResponse struct { - Data App `json:"data"` -} - -func (a *AppResponse) GetData() App { - if a == nil { - return App{} - } - return a.Data -} diff --git a/internal/deployserverclient/models/components/attachmanifestrequest.go b/internal/deployserverclient/models/components/attachmanifestrequest.go deleted file mode 100644 index 5cab118b..00000000 --- a/internal/deployserverclient/models/components/attachmanifestrequest.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: b536df2de3fc - -package components - -type AttachManifestRequest struct { - // ID of the manifest to bind to the app - ManifestID string `json:"manifestId"` -} - -func (a *AttachManifestRequest) GetManifestID() string { - if a == nil { - return "" - } - return a.ManifestID -} diff --git a/internal/deployserverclient/models/components/createapprequest.go b/internal/deployserverclient/models/components/createapprequest.go deleted file mode 100644 index f8efe948..00000000 --- a/internal/deployserverclient/models/components/createapprequest.go +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 348979031a84 - -package components - -type CreateAppRequest struct { - // Name of the app - Name string `json:"name"` - // Optional existing stack ID to claim - StackID *string `json:"stackId,omitempty"` -} - -func (c *CreateAppRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateAppRequest) GetStackID() *string { - if c == nil { - return nil - } - return c.StackID -} diff --git a/internal/deployserverclient/models/components/createdeploymentrequest.go b/internal/deployserverclient/models/components/createdeploymentrequest.go deleted file mode 100644 index 9b4eaf86..00000000 --- a/internal/deployserverclient/models/components/createdeploymentrequest.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 0f79a4986282 - -package components - -type CreateDeploymentRequest struct { - // ID of the app to deploy to - AppID string `json:"appId"` - // Manifest catalog ID - ManifestID string `json:"manifestId"` - // Manifest version - ManifestVersion int64 `json:"manifestVersion"` -} - -func (c *CreateDeploymentRequest) GetAppID() string { - if c == nil { - return "" - } - return c.AppID -} - -func (c *CreateDeploymentRequest) GetManifestID() string { - if c == nil { - return "" - } - return c.ManifestID -} - -func (c *CreateDeploymentRequest) GetManifestVersion() int64 { - if c == nil { - return 0 - } - return c.ManifestVersion -} diff --git a/internal/deployserverclient/models/components/createmanifestresponse.go b/internal/deployserverclient/models/components/createmanifestresponse.go deleted file mode 100644 index 4b428503..00000000 --- a/internal/deployserverclient/models/components/createmanifestresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: d9b2be34c541 - -package components - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "time" -) - -type Data struct { - ID string `json:"id"` - Version int64 `json:"version"` - Name string `json:"name"` - CreatedAt time.Time `json:"createdAt"` -} - -func (d Data) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *Data) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *Data) GetID() string { - if d == nil { - return "" - } - return d.ID -} - -func (d *Data) GetVersion() int64 { - if d == nil { - return 0 - } - return d.Version -} - -func (d *Data) GetName() string { - if d == nil { - return "" - } - return d.Name -} - -func (d *Data) GetCreatedAt() time.Time { - if d == nil { - return time.Time{} - } - return d.CreatedAt -} - -type CreateManifestResponse struct { - Data Data `json:"data"` -} - -func (c *CreateManifestResponse) GetData() Data { - if c == nil { - return Data{} - } - return c.Data -} diff --git a/internal/deployserverclient/models/components/createvariablerequest.go b/internal/deployserverclient/models/components/createvariablerequest.go deleted file mode 100644 index 9a299f37..00000000 --- a/internal/deployserverclient/models/components/createvariablerequest.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 69979df8f74a - -package components - -type CreateVariableRequest struct { - Variable VariableData `json:"variable"` -} - -func (c *CreateVariableRequest) GetVariable() VariableData { - if c == nil { - return VariableData{} - } - return c.Variable -} diff --git a/internal/deployserverclient/models/components/createvariableresponse.go b/internal/deployserverclient/models/components/createvariableresponse.go deleted file mode 100644 index 8486f09e..00000000 --- a/internal/deployserverclient/models/components/createvariableresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 40d20e4cda57 - -package components - -type CreateVariableResponse struct { - Data Variable `json:"data"` -} - -func (c *CreateVariableResponse) GetData() Variable { - if c == nil { - return Variable{} - } - return c.Data -} diff --git a/internal/deployserverclient/models/components/deleteappresponse.go b/internal/deployserverclient/models/components/deleteappresponse.go deleted file mode 100644 index 4d77c78b..00000000 --- a/internal/deployserverclient/models/components/deleteappresponse.go +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: de6e097ab360 - -package components - -type DeleteAppResponse struct { - // ID of the destroy deployment enqueued to clean up the app's - // resources. Empty when no destroy was needed (e.g. no prior - // applied deployment). Use `GET /deployments/{id}` to poll its - // status. - // - DestroyDeploymentID *string `json:"destroyDeploymentId,omitempty"` -} - -func (d *DeleteAppResponse) GetDestroyDeploymentID() *string { - if d == nil { - return nil - } - return d.DestroyDeploymentID -} diff --git a/internal/deployserverclient/models/components/deploymentresource.go b/internal/deployserverclient/models/components/deploymentresource.go deleted file mode 100644 index eae7f819..00000000 --- a/internal/deployserverclient/models/components/deploymentresource.go +++ /dev/null @@ -1,87 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a2fac3219c8a - -package components - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "time" -) - -type DeploymentResource struct { - ID string `json:"id"` - AppID string `json:"appId"` - ManifestID *string `json:"manifestId,omitempty"` - ManifestVersion *int64 `json:"manifestVersion,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - State *State `json:"state,omitempty"` -} - -func (d DeploymentResource) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeploymentResource) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeploymentResource) GetID() string { - if d == nil { - return "" - } - return d.ID -} - -func (d *DeploymentResource) GetAppID() string { - if d == nil { - return "" - } - return d.AppID -} - -func (d *DeploymentResource) GetManifestID() *string { - if d == nil { - return nil - } - return d.ManifestID -} - -func (d *DeploymentResource) GetManifestVersion() *int64 { - if d == nil { - return nil - } - return d.ManifestVersion -} - -func (d *DeploymentResource) GetStatus() string { - if d == nil { - return "" - } - return d.Status -} - -func (d *DeploymentResource) GetCreatedAt() time.Time { - if d == nil { - return time.Time{} - } - return d.CreatedAt -} - -func (d *DeploymentResource) GetUpdatedAt() time.Time { - if d == nil { - return time.Time{} - } - return d.UpdatedAt -} - -func (d *DeploymentResource) GetState() *State { - if d == nil { - return nil - } - return d.State -} diff --git a/internal/deployserverclient/models/components/deploymentresponse.go b/internal/deployserverclient/models/components/deploymentresponse.go deleted file mode 100644 index e3b7e32d..00000000 --- a/internal/deployserverclient/models/components/deploymentresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 38a6c1aca4c2 - -package components - -type DeploymentResponse struct { - Data DeploymentResource `json:"data"` -} - -func (d *DeploymentResponse) GetData() DeploymentResource { - if d == nil { - return DeploymentResource{} - } - return d.Data -} diff --git a/internal/deployserverclient/models/components/error.go b/internal/deployserverclient/models/components/error.go deleted file mode 100644 index 3d663dcc..00000000 --- a/internal/deployserverclient/models/components/error.go +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: c4d4f2fb500d - -package components - -type Error struct { - ErrorCode string `json:"errorCode"` - ErrorMessage *string `json:"errorMessage,omitempty"` -} - -func (e *Error) GetErrorCode() string { - if e == nil { - return "" - } - return e.ErrorCode -} - -func (e *Error) GetErrorMessage() *string { - if e == nil { - return nil - } - return e.ErrorMessage -} diff --git a/internal/deployserverclient/models/components/httpmetadata.go b/internal/deployserverclient/models/components/httpmetadata.go deleted file mode 100644 index 49b58c94..00000000 --- a/internal/deployserverclient/models/components/httpmetadata.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a9a9f8ef52de - -package components - -import ( - "net/http" -) - -type HTTPMetadata struct { - // Raw HTTP response; suitable for custom response parsing - Response *http.Response `json:"-"` - // Raw HTTP request; suitable for debugging - Request *http.Request `json:"-"` -} - -func (h *HTTPMetadata) GetResponse() *http.Response { - if h == nil { - return nil - } - return h.Response -} - -func (h *HTTPMetadata) GetRequest() *http.Request { - if h == nil { - return nil - } - return h.Request -} diff --git a/internal/deployserverclient/models/components/listappsresponse.go b/internal/deployserverclient/models/components/listappsresponse.go deleted file mode 100644 index b8eb4b25..00000000 --- a/internal/deployserverclient/models/components/listappsresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 445ef41fb62b - -package components - -// ListAppsResponseCursor - Cursor pagination envelope. `next` and `previous` are opaque tokens -// produced by the server; pass them back as `?cursor=` to fetch the -// adjacent page. `hasMore` is `true` when more results exist after the -// current page. `pageSize` echoes the page size used for this page. -type ListAppsResponseCursor struct { - // Number of items requested for this page - PageSize *int64 `json:"pageSize,omitempty"` - // True when more results exist after this page - HasMore bool `json:"hasMore"` - // Opaque cursor token for the previous page (empty when none) - Previous *string `json:"previous,omitempty"` - // Opaque cursor token for the next page (empty when none) - Next *string `json:"next,omitempty"` - Data []App `json:"data"` -} - -func (l *ListAppsResponseCursor) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListAppsResponseCursor) GetHasMore() bool { - if l == nil { - return false - } - return l.HasMore -} - -func (l *ListAppsResponseCursor) GetPrevious() *string { - if l == nil { - return nil - } - return l.Previous -} - -func (l *ListAppsResponseCursor) GetNext() *string { - if l == nil { - return nil - } - return l.Next -} - -func (l *ListAppsResponseCursor) GetData() []App { - if l == nil { - return []App{} - } - return l.Data -} - -type ListAppsResponse struct { - Cursor ListAppsResponseCursor `json:"cursor"` -} - -func (l *ListAppsResponse) GetCursor() ListAppsResponseCursor { - if l == nil { - return ListAppsResponseCursor{} - } - return l.Cursor -} diff --git a/internal/deployserverclient/models/components/listdeploymentsresponse.go b/internal/deployserverclient/models/components/listdeploymentsresponse.go deleted file mode 100644 index e6a14515..00000000 --- a/internal/deployserverclient/models/components/listdeploymentsresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 2c3a36f113ed - -package components - -// ListDeploymentsResponseCursor - Cursor pagination envelope. `next` and `previous` are opaque tokens -// produced by the server; pass them back as `?cursor=` to fetch the -// adjacent page. `hasMore` is `true` when more results exist after the -// current page. `pageSize` echoes the page size used for this page. -type ListDeploymentsResponseCursor struct { - // Number of items requested for this page - PageSize *int64 `json:"pageSize,omitempty"` - // True when more results exist after this page - HasMore bool `json:"hasMore"` - // Opaque cursor token for the previous page (empty when none) - Previous *string `json:"previous,omitempty"` - // Opaque cursor token for the next page (empty when none) - Next *string `json:"next,omitempty"` - Data []DeploymentResource `json:"data"` -} - -func (l *ListDeploymentsResponseCursor) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListDeploymentsResponseCursor) GetHasMore() bool { - if l == nil { - return false - } - return l.HasMore -} - -func (l *ListDeploymentsResponseCursor) GetPrevious() *string { - if l == nil { - return nil - } - return l.Previous -} - -func (l *ListDeploymentsResponseCursor) GetNext() *string { - if l == nil { - return nil - } - return l.Next -} - -func (l *ListDeploymentsResponseCursor) GetData() []DeploymentResource { - if l == nil { - return []DeploymentResource{} - } - return l.Data -} - -type ListDeploymentsResponse struct { - Cursor ListDeploymentsResponseCursor `json:"cursor"` -} - -func (l *ListDeploymentsResponse) GetCursor() ListDeploymentsResponseCursor { - if l == nil { - return ListDeploymentsResponseCursor{} - } - return l.Cursor -} diff --git a/internal/deployserverclient/models/components/listmanifestsresponse.go b/internal/deployserverclient/models/components/listmanifestsresponse.go deleted file mode 100644 index 2911e50d..00000000 --- a/internal/deployserverclient/models/components/listmanifestsresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: db96081c00d7 - -package components - -// ListManifestsResponseCursor - Cursor pagination envelope. `next` and `previous` are opaque tokens -// produced by the server; pass them back as `?cursor=` to fetch the -// adjacent page. `hasMore` is `true` when more results exist after the -// current page. `pageSize` echoes the page size used for this page. -type ListManifestsResponseCursor struct { - // Number of items requested for this page - PageSize *int64 `json:"pageSize,omitempty"` - // True when more results exist after this page - HasMore bool `json:"hasMore"` - // Opaque cursor token for the previous page (empty when none) - Previous *string `json:"previous,omitempty"` - // Opaque cursor token for the next page (empty when none) - Next *string `json:"next,omitempty"` - Data []Manifest `json:"data"` -} - -func (l *ListManifestsResponseCursor) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListManifestsResponseCursor) GetHasMore() bool { - if l == nil { - return false - } - return l.HasMore -} - -func (l *ListManifestsResponseCursor) GetPrevious() *string { - if l == nil { - return nil - } - return l.Previous -} - -func (l *ListManifestsResponseCursor) GetNext() *string { - if l == nil { - return nil - } - return l.Next -} - -func (l *ListManifestsResponseCursor) GetData() []Manifest { - if l == nil { - return []Manifest{} - } - return l.Data -} - -type ListManifestsResponse struct { - Cursor ListManifestsResponseCursor `json:"cursor"` -} - -func (l *ListManifestsResponse) GetCursor() ListManifestsResponseCursor { - if l == nil { - return ListManifestsResponseCursor{} - } - return l.Cursor -} diff --git a/internal/deployserverclient/models/components/listmanifestversionsresponse.go b/internal/deployserverclient/models/components/listmanifestversionsresponse.go deleted file mode 100644 index 6d02f172..00000000 --- a/internal/deployserverclient/models/components/listmanifestversionsresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 3e9aea6345f2 - -package components - -// ListManifestVersionsResponseCursor - Cursor pagination envelope. `next` and `previous` are opaque tokens -// produced by the server; pass them back as `?cursor=` to fetch the -// adjacent page. `hasMore` is `true` when more results exist after the -// current page. `pageSize` echoes the page size used for this page. -type ListManifestVersionsResponseCursor struct { - // Number of items requested for this page - PageSize *int64 `json:"pageSize,omitempty"` - // True when more results exist after this page - HasMore bool `json:"hasMore"` - // Opaque cursor token for the previous page (empty when none) - Previous *string `json:"previous,omitempty"` - // Opaque cursor token for the next page (empty when none) - Next *string `json:"next,omitempty"` - Data []ManifestVersion `json:"data"` -} - -func (l *ListManifestVersionsResponseCursor) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListManifestVersionsResponseCursor) GetHasMore() bool { - if l == nil { - return false - } - return l.HasMore -} - -func (l *ListManifestVersionsResponseCursor) GetPrevious() *string { - if l == nil { - return nil - } - return l.Previous -} - -func (l *ListManifestVersionsResponseCursor) GetNext() *string { - if l == nil { - return nil - } - return l.Next -} - -func (l *ListManifestVersionsResponseCursor) GetData() []ManifestVersion { - if l == nil { - return []ManifestVersion{} - } - return l.Data -} - -type ListManifestVersionsResponse struct { - Cursor ListManifestVersionsResponseCursor `json:"cursor"` -} - -func (l *ListManifestVersionsResponse) GetCursor() ListManifestVersionsResponseCursor { - if l == nil { - return ListManifestVersionsResponseCursor{} - } - return l.Cursor -} diff --git a/internal/deployserverclient/models/components/log.go b/internal/deployserverclient/models/components/log.go deleted file mode 100644 index 014faaf9..00000000 --- a/internal/deployserverclient/models/components/log.go +++ /dev/null @@ -1,89 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: ccfc4577d50d - -package components - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "time" -) - -// Diagnostic - Detailed log message -type Diagnostic struct { - // Severity of the log message - Severity string `json:"severity"` - Detail string `json:"detail"` - // Brief summary of the log message - Summary string `json:"summary"` -} - -func (d *Diagnostic) GetSeverity() string { - if d == nil { - return "" - } - return d.Severity -} - -func (d *Diagnostic) GetDetail() string { - if d == nil { - return "" - } - return d.Detail -} - -func (d *Diagnostic) GetSummary() string { - if d == nil { - return "" - } - return d.Summary -} - -type Log struct { - // Log message - Message string `json:"message"` - // Timestamp when the log was created - Timestamp time.Time `json:"timestamp"` - // Module or component that generated the log - Module string `json:"module"` - // Detailed log message - Diagnostic *Diagnostic `json:"diagnostic,omitempty"` -} - -func (l Log) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *Log) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *Log) GetMessage() string { - if l == nil { - return "" - } - return l.Message -} - -func (l *Log) GetTimestamp() time.Time { - if l == nil { - return time.Time{} - } - return l.Timestamp -} - -func (l *Log) GetModule() string { - if l == nil { - return "" - } - return l.Module -} - -func (l *Log) GetDiagnostic() *Diagnostic { - if l == nil { - return nil - } - return l.Diagnostic -} diff --git a/internal/deployserverclient/models/components/manifest.go b/internal/deployserverclient/models/components/manifest.go deleted file mode 100644 index 5057e763..00000000 --- a/internal/deployserverclient/models/components/manifest.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: f6810ad982e8 - -package components - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "time" -) - -type Manifest struct { - // Unique identifier for the manifest - ID string `json:"id"` - // Name of the manifest - Name string `json:"name"` - // Latest version number - LatestVersion int64 `json:"latestVersion"` - // Timestamp when the manifest was created - CreatedAt time.Time `json:"createdAt"` - // Timestamp when the manifest was last updated - UpdatedAt time.Time `json:"updatedAt"` -} - -func (m Manifest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(m, "", false) -} - -func (m *Manifest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &m, "", false, nil); err != nil { - return err - } - return nil -} - -func (m *Manifest) GetID() string { - if m == nil { - return "" - } - return m.ID -} - -func (m *Manifest) GetName() string { - if m == nil { - return "" - } - return m.Name -} - -func (m *Manifest) GetLatestVersion() int64 { - if m == nil { - return 0 - } - return m.LatestVersion -} - -func (m *Manifest) GetCreatedAt() time.Time { - if m == nil { - return time.Time{} - } - return m.CreatedAt -} - -func (m *Manifest) GetUpdatedAt() time.Time { - if m == nil { - return time.Time{} - } - return m.UpdatedAt -} diff --git a/internal/deployserverclient/models/components/manifestresponse.go b/internal/deployserverclient/models/components/manifestresponse.go deleted file mode 100644 index 9e920547..00000000 --- a/internal/deployserverclient/models/components/manifestresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 6a8226d38ac7 - -package components - -type ManifestResponse struct { - Data Manifest `json:"data"` -} - -func (m *ManifestResponse) GetData() Manifest { - if m == nil { - return Manifest{} - } - return m.Data -} diff --git a/internal/deployserverclient/models/components/manifestversion.go b/internal/deployserverclient/models/components/manifestversion.go deleted file mode 100644 index 0344b172..00000000 --- a/internal/deployserverclient/models/components/manifestversion.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a45a7b58ee06 - -package components - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "time" -) - -type ManifestVersion struct { - // ID of the manifest this version belongs to - ManifestID string `json:"manifestId"` - // Version number - Version int64 `json:"version"` - // Manifest content (base64-encoded). Null when listing versions, present when reading a specific version. - Content *string `json:"content,omitempty"` - // Timestamp when the version was created - CreatedAt time.Time `json:"createdAt"` -} - -func (m ManifestVersion) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(m, "", false) -} - -func (m *ManifestVersion) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &m, "", false, nil); err != nil { - return err - } - return nil -} - -func (m *ManifestVersion) GetManifestID() string { - if m == nil { - return "" - } - return m.ManifestID -} - -func (m *ManifestVersion) GetVersion() int64 { - if m == nil { - return 0 - } - return m.Version -} - -func (m *ManifestVersion) GetContent() *string { - if m == nil { - return nil - } - return m.Content -} - -func (m *ManifestVersion) GetCreatedAt() time.Time { - if m == nil { - return time.Time{} - } - return m.CreatedAt -} diff --git a/internal/deployserverclient/models/components/manifestversionresponse.go b/internal/deployserverclient/models/components/manifestversionresponse.go deleted file mode 100644 index 80e366ca..00000000 --- a/internal/deployserverclient/models/components/manifestversionresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: b26b78dc49ec - -package components - -type ManifestVersionResponse struct { - Data ManifestVersion `json:"data"` -} - -func (m *ManifestVersionResponse) GetData() ManifestVersion { - if m == nil { - return ManifestVersion{} - } - return m.Data -} diff --git a/internal/deployserverclient/models/components/readlogsresponse.go b/internal/deployserverclient/models/components/readlogsresponse.go deleted file mode 100644 index f2d84592..00000000 --- a/internal/deployserverclient/models/components/readlogsresponse.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a7185c432a68 - -package components - -type ReadLogsResponse struct { - Data []Log `json:"data,omitempty"` -} - -func (r *ReadLogsResponse) GetData() []Log { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/deployserverclient/models/components/readvariablesresponse.go b/internal/deployserverclient/models/components/readvariablesresponse.go deleted file mode 100644 index c707e31e..00000000 --- a/internal/deployserverclient/models/components/readvariablesresponse.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: d31e5b963c16 - -package components - -// ReadVariablesResponseCursor - Cursor pagination envelope. `next` and `previous` are opaque tokens -// produced by the server; pass them back as `?cursor=` to fetch the -// adjacent page. `hasMore` is `true` when more results exist after the -// current page. `pageSize` echoes the page size used for this page. -type ReadVariablesResponseCursor struct { - // Number of items requested for this page - PageSize *int64 `json:"pageSize,omitempty"` - // True when more results exist after this page - HasMore bool `json:"hasMore"` - // Opaque cursor token for the previous page (empty when none) - Previous *string `json:"previous,omitempty"` - // Opaque cursor token for the next page (empty when none) - Next *string `json:"next,omitempty"` - Data []Variable `json:"data"` -} - -func (r *ReadVariablesResponseCursor) GetPageSize() *int64 { - if r == nil { - return nil - } - return r.PageSize -} - -func (r *ReadVariablesResponseCursor) GetHasMore() bool { - if r == nil { - return false - } - return r.HasMore -} - -func (r *ReadVariablesResponseCursor) GetPrevious() *string { - if r == nil { - return nil - } - return r.Previous -} - -func (r *ReadVariablesResponseCursor) GetNext() *string { - if r == nil { - return nil - } - return r.Next -} - -func (r *ReadVariablesResponseCursor) GetData() []Variable { - if r == nil { - return []Variable{} - } - return r.Data -} - -type ReadVariablesResponse struct { - Cursor ReadVariablesResponseCursor `json:"cursor"` -} - -func (r *ReadVariablesResponse) GetCursor() ReadVariablesResponseCursor { - if r == nil { - return ReadVariablesResponseCursor{} - } - return r.Cursor -} diff --git a/internal/deployserverclient/models/components/security.go b/internal/deployserverclient/models/components/security.go deleted file mode 100644 index 5f41eea6..00000000 --- a/internal/deployserverclient/models/components/security.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: f6f99ec31c6f - -package components - -type Security struct { - BearerAuth *string `security:"scheme,type=http,subtype=bearer,name=Authorization,env=deployserver_bearer_auth"` -} - -func (s *Security) GetBearerAuth() *string { - if s == nil { - return nil - } - return s.BearerAuth -} diff --git a/internal/deployserverclient/models/components/state.go b/internal/deployserverclient/models/components/state.go deleted file mode 100644 index 4a14aefd..00000000 --- a/internal/deployserverclient/models/components/state.go +++ /dev/null @@ -1,19 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: d50be5cb7391 - -package components - -type State struct { - // The live stack details reported by Formance Cloud (a projection of - // the bound stack from Membership). Shape is a free-form object - // mirroring the upstream stack resource. - // - Stack map[string]any `json:"stack"` -} - -func (s *State) GetStack() map[string]any { - if s == nil { - return map[string]any{} - } - return s.Stack -} diff --git a/internal/deployserverclient/models/components/updateapprequest.go b/internal/deployserverclient/models/components/updateapprequest.go deleted file mode 100644 index 7045a1fb..00000000 --- a/internal/deployserverclient/models/components/updateapprequest.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 779f1f1f01f5 - -package components - -type UpdateAppRequest struct { - // Updated name for the app - Name string `json:"name"` -} - -func (u *UpdateAppRequest) GetName() string { - if u == nil { - return "" - } - return u.Name -} diff --git a/internal/deployserverclient/models/components/updatemanifestrequest.go b/internal/deployserverclient/models/components/updatemanifestrequest.go deleted file mode 100644 index 8cd2a385..00000000 --- a/internal/deployserverclient/models/components/updatemanifestrequest.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 7b78c92173e3 - -package components - -type UpdateManifestRequest struct { - // Updated name for the manifest - Name string `json:"name"` -} - -func (u *UpdateManifestRequest) GetName() string { - if u == nil { - return "" - } - return u.Name -} diff --git a/internal/deployserverclient/models/components/variable.go b/internal/deployserverclient/models/components/variable.go deleted file mode 100644 index b0a97d2a..00000000 --- a/internal/deployserverclient/models/components/variable.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 221e26113be3 - -package components - -type Variable struct { - // Snake-case identifier (case-insensitive at the API boundary, - // normalized to lowercase on storage to match `var.` in the - // generated HCL). Either `MONEYCORP_API_KEY` or - // `moneycorp_api_key` is accepted. - // - Key string `json:"key"` - // Value of the variable. Always treated as sensitive: `value` is - // redacted (`REDACTED`) on every read endpoint. - // - Value string `json:"value"` - // Description of the variable - Description *string `json:"description,omitempty"` - // Unique identifier for the variable - ID string `json:"id"` -} - -func (v *Variable) GetKey() string { - if v == nil { - return "" - } - return v.Key -} - -func (v *Variable) GetValue() string { - if v == nil { - return "" - } - return v.Value -} - -func (v *Variable) GetDescription() *string { - if v == nil { - return nil - } - return v.Description -} - -func (v *Variable) GetID() string { - if v == nil { - return "" - } - return v.ID -} diff --git a/internal/deployserverclient/models/components/variabledata.go b/internal/deployserverclient/models/components/variabledata.go deleted file mode 100644 index b1cfac06..00000000 --- a/internal/deployserverclient/models/components/variabledata.go +++ /dev/null @@ -1,40 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 703496f103a6 - -package components - -type VariableData struct { - // Snake-case identifier (case-insensitive at the API boundary, - // normalized to lowercase on storage to match `var.` in the - // generated HCL). Either `MONEYCORP_API_KEY` or - // `moneycorp_api_key` is accepted. - // - Key string `json:"key"` - // Value of the variable. Always treated as sensitive: `value` is - // redacted (`REDACTED`) on every read endpoint. - // - Value string `json:"value"` - // Description of the variable - Description *string `json:"description,omitempty"` -} - -func (v *VariableData) GetKey() string { - if v == nil { - return "" - } - return v.Key -} - -func (v *VariableData) GetValue() string { - if v == nil { - return "" - } - return v.Value -} - -func (v *VariableData) GetDescription() *string { - if v == nil { - return nil - } - return v.Description -} diff --git a/internal/deployserverclient/models/operations/attachappmanifest.go b/internal/deployserverclient/models/operations/attachappmanifest.go deleted file mode 100644 index 5833ba6a..00000000 --- a/internal/deployserverclient/models/operations/attachappmanifest.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 857d985e78ef - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type AttachAppManifestRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - AttachManifestRequest components.AttachManifestRequest `request:"mediaType=application/json"` -} - -func (a *AttachAppManifestRequest) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *AttachAppManifestRequest) GetAttachManifestRequest() components.AttachManifestRequest { - if a == nil { - return components.AttachManifestRequest{} - } - return a.AttachManifestRequest -} - -type AttachAppManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // manifestId missing or empty - Error *components.Error -} - -func (a *AttachAppManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if a == nil { - return components.HTTPMetadata{} - } - return a.HTTPMeta -} - -func (a *AttachAppManifestResponse) GetError() *components.Error { - if a == nil { - return nil - } - return a.Error -} diff --git a/internal/deployserverclient/models/operations/createapp.go b/internal/deployserverclient/models/operations/createapp.go deleted file mode 100644 index 3c06d4a1..00000000 --- a/internal/deployserverclient/models/operations/createapp.go +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 71c463fc6dcd - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type CreateAppResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // App created successfully - AppResponse *components.AppResponse - // Error - Error *components.Error -} - -func (c *CreateAppResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateAppResponse) GetAppResponse() *components.AppResponse { - if c == nil { - return nil - } - return c.AppResponse -} - -func (c *CreateAppResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/deployserverclient/models/operations/createappvariable.go b/internal/deployserverclient/models/operations/createappvariable.go deleted file mode 100644 index 6e6b587c..00000000 --- a/internal/deployserverclient/models/operations/createappvariable.go +++ /dev/null @@ -1,56 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: f14c8f4f9e23 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type CreateAppVariableRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - CreateVariableRequest components.CreateVariableRequest `request:"mediaType=application/json"` -} - -func (c *CreateAppVariableRequest) GetID() string { - if c == nil { - return "" - } - return c.ID -} - -func (c *CreateAppVariableRequest) GetCreateVariableRequest() components.CreateVariableRequest { - if c == nil { - return components.CreateVariableRequest{} - } - return c.CreateVariableRequest -} - -type CreateAppVariableResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Variable created successfully - CreateVariableResponse *components.CreateVariableResponse - // Error - Error *components.Error -} - -func (c *CreateAppVariableResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateAppVariableResponse) GetCreateVariableResponse() *components.CreateVariableResponse { - if c == nil { - return nil - } - return c.CreateVariableResponse -} - -func (c *CreateAppVariableResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/deployserverclient/models/operations/createdeployment.go b/internal/deployserverclient/models/operations/createdeployment.go deleted file mode 100644 index 03c8cee8..00000000 --- a/internal/deployserverclient/models/operations/createdeployment.go +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 326b5e3d00e2 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type CreateDeploymentResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Deployment created successfully - DeploymentResponse *components.DeploymentResponse - // Error - Error *components.Error -} - -func (c *CreateDeploymentResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateDeploymentResponse) GetDeploymentResponse() *components.DeploymentResponse { - if c == nil { - return nil - } - return c.DeploymentResponse -} - -func (c *CreateDeploymentResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/deployserverclient/models/operations/createmanifest.go b/internal/deployserverclient/models/operations/createmanifest.go deleted file mode 100644 index 8640b852..00000000 --- a/internal/deployserverclient/models/operations/createmanifest.go +++ /dev/null @@ -1,61 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 7be33bfb9044 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -// CreateManifestRequestBody - JSON manifest content -type CreateManifestRequestBody struct { -} - -type CreateManifestRequest struct { - // Name for the manifest - Name string `queryParam:"style=form,explode=true,name=name"` - RequestBody CreateManifestRequestBody `request:"mediaType=application/json"` -} - -func (c *CreateManifestRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateManifestRequest) GetRequestBody() CreateManifestRequestBody { - if c == nil { - return CreateManifestRequestBody{} - } - return c.RequestBody -} - -type CreateManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest created successfully - CreateManifestResponse *components.CreateManifestResponse - // Error - Error *components.Error -} - -func (c *CreateManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateManifestResponse) GetCreateManifestResponse() *components.CreateManifestResponse { - if c == nil { - return nil - } - return c.CreateManifestResponse -} - -func (c *CreateManifestResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/deployserverclient/models/operations/createmanifestraw.go b/internal/deployserverclient/models/operations/createmanifestraw.go deleted file mode 100644 index 8aad3fd7..00000000 --- a/internal/deployserverclient/models/operations/createmanifestraw.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 15dca3cfa2e6 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type CreateManifestRawRequest struct { - // Name for the manifest - Name string `queryParam:"style=form,explode=true,name=name"` - // This field accepts []byte data or io.Reader implementations, such as *os.File. - RequestBody any `request:"mediaType=application/yaml"` -} - -func (c *CreateManifestRawRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateManifestRawRequest) GetRequestBody() any { - if c == nil { - return nil - } - return c.RequestBody -} - -type CreateManifestRawResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest created successfully - CreateManifestResponse *components.CreateManifestResponse - // Error - Error *components.Error -} - -func (c *CreateManifestRawResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateManifestRawResponse) GetCreateManifestResponse() *components.CreateManifestResponse { - if c == nil { - return nil - } - return c.CreateManifestResponse -} - -func (c *CreateManifestRawResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/deployserverclient/models/operations/deleteapp.go b/internal/deployserverclient/models/operations/deleteapp.go deleted file mode 100644 index c6a72d8b..00000000 --- a/internal/deployserverclient/models/operations/deleteapp.go +++ /dev/null @@ -1,74 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: a1376fbe22d6 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type DeleteAppRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - // When `true`, the call blocks until the destroy deployment reaches a - // terminal status. Default `false` (returns 202 Accepted). - // - Wait *bool `default:"false" queryParam:"style=form,explode=true,name=wait"` -} - -func (d DeleteAppRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteAppRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteAppRequest) GetID() string { - if d == nil { - return "" - } - return d.ID -} - -func (d *DeleteAppRequest) GetWait() *bool { - if d == nil { - return nil - } - return d.Wait -} - -type DeleteAppResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Soft-delete accepted. Cleanup runs asynchronously. The - // `Location` header points at the destroy deployment if one was - // enqueued. - // - DeleteAppResponse *components.DeleteAppResponse - // Error - Error *components.Error -} - -func (d *DeleteAppResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteAppResponse) GetDeleteAppResponse() *components.DeleteAppResponse { - if d == nil { - return nil - } - return d.DeleteAppResponse -} - -func (d *DeleteAppResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/deployserverclient/models/operations/deleteappvariable.go b/internal/deployserverclient/models/operations/deleteappvariable.go deleted file mode 100644 index ad5019fe..00000000 --- a/internal/deployserverclient/models/operations/deleteappvariable.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 97496d329ee1 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type DeleteAppVariableRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - VariableID string `pathParam:"style=simple,explode=false,name=variableId"` -} - -func (d *DeleteAppVariableRequest) GetID() string { - if d == nil { - return "" - } - return d.ID -} - -func (d *DeleteAppVariableRequest) GetVariableID() string { - if d == nil { - return "" - } - return d.VariableID -} - -type DeleteAppVariableResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d *DeleteAppVariableResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteAppVariableResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/deployserverclient/models/operations/deletemanifest.go b/internal/deployserverclient/models/operations/deletemanifest.go deleted file mode 100644 index 06b43a2f..00000000 --- a/internal/deployserverclient/models/operations/deletemanifest.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 74acf540b4e9 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type DeleteManifestRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` -} - -func (d *DeleteManifestRequest) GetManifestID() string { - if d == nil { - return "" - } - return d.ManifestID -} - -type DeleteManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest is still referenced by deployments - Error *components.Error -} - -func (d *DeleteManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteManifestResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/deployserverclient/models/operations/detachappmanifest.go b/internal/deployserverclient/models/operations/detachappmanifest.go deleted file mode 100644 index dfa94ca5..00000000 --- a/internal/deployserverclient/models/operations/detachappmanifest.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 3b7d438b6e4b - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type DetachAppManifestRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` -} - -func (d *DetachAppManifestRequest) GetID() string { - if d == nil { - return "" - } - return d.ID -} - -type DetachAppManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d *DetachAppManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DetachAppManifestResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/deployserverclient/models/operations/listapps.go b/internal/deployserverclient/models/operations/listapps.go deleted file mode 100644 index 35573e66..00000000 --- a/internal/deployserverclient/models/operations/listapps.go +++ /dev/null @@ -1,64 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 5836489f2f6f - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ListAppsRequest struct { - // Maximum number of items to return on the first page. Capped at 100; - // ignored when `cursor` is supplied (subsequent pages reuse the page - // size baked into the cursor token). Defaults to 100. - // - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - // Opaque pagination token returned in `cursor.next` / `cursor.previous` - // on the previous page. Pass it back unchanged to fetch the adjacent - // page. Mutually exclusive with `pageSize` after the first request. - // - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` -} - -func (l *ListAppsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListAppsRequest) GetCursor() *string { - if l == nil { - return nil - } - return l.Cursor -} - -type ListAppsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Apps retrieved successfully - ListAppsResponse *components.ListAppsResponse - // Error - Error *components.Error -} - -func (l *ListAppsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListAppsResponse) GetListAppsResponse() *components.ListAppsResponse { - if l == nil { - return nil - } - return l.ListAppsResponse -} - -func (l *ListAppsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/deployserverclient/models/operations/listdeployments.go b/internal/deployserverclient/models/operations/listdeployments.go deleted file mode 100644 index 6c51c65b..00000000 --- a/internal/deployserverclient/models/operations/listdeployments.go +++ /dev/null @@ -1,72 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: d893847d4513 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ListDeploymentsRequest struct { - AppID *string `queryParam:"style=form,explode=true,name=appId"` - // Maximum number of items to return on the first page. Capped at 100; - // ignored when `cursor` is supplied (subsequent pages reuse the page - // size baked into the cursor token). Defaults to 100. - // - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - // Opaque pagination token returned in `cursor.next` / `cursor.previous` - // on the previous page. Pass it back unchanged to fetch the adjacent - // page. Mutually exclusive with `pageSize` after the first request. - // - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` -} - -func (l *ListDeploymentsRequest) GetAppID() *string { - if l == nil { - return nil - } - return l.AppID -} - -func (l *ListDeploymentsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListDeploymentsRequest) GetCursor() *string { - if l == nil { - return nil - } - return l.Cursor -} - -type ListDeploymentsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Deployments retrieved successfully - ListDeploymentsResponse *components.ListDeploymentsResponse - // Error - Error *components.Error -} - -func (l *ListDeploymentsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListDeploymentsResponse) GetListDeploymentsResponse() *components.ListDeploymentsResponse { - if l == nil { - return nil - } - return l.ListDeploymentsResponse -} - -func (l *ListDeploymentsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/deployserverclient/models/operations/listmanifests.go b/internal/deployserverclient/models/operations/listmanifests.go deleted file mode 100644 index fb5f4953..00000000 --- a/internal/deployserverclient/models/operations/listmanifests.go +++ /dev/null @@ -1,64 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: bc6fb185b576 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ListManifestsRequest struct { - // Maximum number of items to return on the first page. Capped at 100; - // ignored when `cursor` is supplied (subsequent pages reuse the page - // size baked into the cursor token). Defaults to 100. - // - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - // Opaque pagination token returned in `cursor.next` / `cursor.previous` - // on the previous page. Pass it back unchanged to fetch the adjacent - // page. Mutually exclusive with `pageSize` after the first request. - // - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` -} - -func (l *ListManifestsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListManifestsRequest) GetCursor() *string { - if l == nil { - return nil - } - return l.Cursor -} - -type ListManifestsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifests retrieved successfully - ListManifestsResponse *components.ListManifestsResponse - // Error - Error *components.Error -} - -func (l *ListManifestsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListManifestsResponse) GetListManifestsResponse() *components.ListManifestsResponse { - if l == nil { - return nil - } - return l.ListManifestsResponse -} - -func (l *ListManifestsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/deployserverclient/models/operations/listmanifestversions.go b/internal/deployserverclient/models/operations/listmanifestversions.go deleted file mode 100644 index 383a02ec..00000000 --- a/internal/deployserverclient/models/operations/listmanifestversions.go +++ /dev/null @@ -1,72 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: e8bf8a35a4b6 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ListManifestVersionsRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - // Maximum number of items to return on the first page. Capped at 100; - // ignored when `cursor` is supplied (subsequent pages reuse the page - // size baked into the cursor token). Defaults to 100. - // - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - // Opaque pagination token returned in `cursor.next` / `cursor.previous` - // on the previous page. Pass it back unchanged to fetch the adjacent - // page. Mutually exclusive with `pageSize` after the first request. - // - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` -} - -func (l *ListManifestVersionsRequest) GetManifestID() string { - if l == nil { - return "" - } - return l.ManifestID -} - -func (l *ListManifestVersionsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListManifestVersionsRequest) GetCursor() *string { - if l == nil { - return nil - } - return l.Cursor -} - -type ListManifestVersionsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest versions retrieved successfully - ListManifestVersionsResponse *components.ListManifestVersionsResponse - // Error - Error *components.Error -} - -func (l *ListManifestVersionsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListManifestVersionsResponse) GetListManifestVersionsResponse() *components.ListManifestVersionsResponse { - if l == nil { - return nil - } - return l.ListManifestVersionsResponse -} - -func (l *ListManifestVersionsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/deployserverclient/models/operations/options.go b/internal/deployserverclient/models/operations/options.go deleted file mode 100644 index 6c56bd90..00000000 --- a/internal/deployserverclient/models/operations/options.go +++ /dev/null @@ -1,119 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: fe4f274e8a8e - -package operations - -import ( - "errors" - "github.com/formancehq/fctl/internal/deployserverclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/deployserverclient/v3/retry" - "time" -) - -var ErrUnsupportedOption = errors.New("unsupported option") - -const ( - SupportedOptionRetries = "retries" - SupportedOptionTimeout = "timeout" - SupportedOptionAcceptHeaderOverride = "acceptHeaderOverride" - SupportedOptionURLOverride = "urlOverride" -) - -type AcceptHeaderEnum string - -const ( - AcceptHeaderEnumApplicationJson AcceptHeaderEnum = "application/json" - AcceptHeaderEnumApplicationXYaml AcceptHeaderEnum = "application/x-yaml" - AcceptHeaderEnumApplicationYaml AcceptHeaderEnum = "application/yaml" -) - -func (e AcceptHeaderEnum) ToPointer() *AcceptHeaderEnum { - return &e -} - -type Options struct { - ServerURL *string - Retries *retry.Config - Timeout *time.Duration - AcceptHeaderOverride *AcceptHeaderEnum - URLOverride *string - SetHeaders map[string]string -} - -type Option func(*Options, ...string) error - -// WithServerURL allows providing an alternative server URL. -func WithServerURL(serverURL string) Option { - return func(opts *Options, supportedOptions ...string) error { - opts.ServerURL = &serverURL - return nil - } -} - -// WithTemplatedServerURL allows providing an alternative server URL with templated parameters. -func WithTemplatedServerURL(serverURL string, params map[string]string) Option { - return func(opts *Options, supportedOptions ...string) error { - if params != nil { - serverURL = utils.ReplaceParameters(serverURL, params) - } - - opts.ServerURL = &serverURL - return nil - } -} - -// WithRetries allows customizing the default retry configuration. -func WithRetries(config retry.Config) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionRetries) { - return ErrUnsupportedOption - } - - opts.Retries = &config - return nil - } -} - -// WithOperationTimeout allows setting the request timeout applied for an operation. -func WithOperationTimeout(timeout time.Duration) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionRetries) { - return ErrUnsupportedOption - } - - opts.Timeout = &timeout - return nil - } -} - -func WithAcceptHeaderOverride(acceptHeaderOverride AcceptHeaderEnum) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionAcceptHeaderOverride) { - return ErrUnsupportedOption - } - - opts.AcceptHeaderOverride = &acceptHeaderOverride - return nil - } -} - -// WithURLOverride allows overriding the URL. -func WithURLOverride(urlOverride string) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionURLOverride) { - return ErrUnsupportedOption - } - - opts.URLOverride = &urlOverride - return nil - } -} - -// WithSetHeaders takes a map of headers that will applied to a request. If the -// request contains headers that are in the map then they will be overwritten. -func WithSetHeaders(hdrs map[string]string) Option { - return func(opts *Options, supportedOptions ...string) error { - opts.SetHeaders = hdrs - return nil - } -} diff --git a/internal/deployserverclient/models/operations/pushmanifestversion.go b/internal/deployserverclient/models/operations/pushmanifestversion.go deleted file mode 100644 index d8c0578c..00000000 --- a/internal/deployserverclient/models/operations/pushmanifestversion.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 0f7af438ef3a - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -// PushManifestVersionRequestBody - JSON manifest content -type PushManifestVersionRequestBody struct { -} - -type PushManifestVersionRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - RequestBody PushManifestVersionRequestBody `request:"mediaType=application/json"` -} - -func (p *PushManifestVersionRequest) GetManifestID() string { - if p == nil { - return "" - } - return p.ManifestID -} - -func (p *PushManifestVersionRequest) GetRequestBody() PushManifestVersionRequestBody { - if p == nil { - return PushManifestVersionRequestBody{} - } - return p.RequestBody -} - -type PushManifestVersionResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest version created successfully - ManifestVersionResponse *components.ManifestVersionResponse - // Error - Error *components.Error -} - -func (p *PushManifestVersionResponse) GetHTTPMeta() components.HTTPMetadata { - if p == nil { - return components.HTTPMetadata{} - } - return p.HTTPMeta -} - -func (p *PushManifestVersionResponse) GetManifestVersionResponse() *components.ManifestVersionResponse { - if p == nil { - return nil - } - return p.ManifestVersionResponse -} - -func (p *PushManifestVersionResponse) GetError() *components.Error { - if p == nil { - return nil - } - return p.Error -} diff --git a/internal/deployserverclient/models/operations/pushmanifestversionraw.go b/internal/deployserverclient/models/operations/pushmanifestversionraw.go deleted file mode 100644 index 03411e7c..00000000 --- a/internal/deployserverclient/models/operations/pushmanifestversionraw.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 4d4b476a2bc8 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type PushManifestVersionRawRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - // This field accepts []byte data or io.Reader implementations, such as *os.File. - RequestBody any `request:"mediaType=application/yaml"` -} - -func (p *PushManifestVersionRawRequest) GetManifestID() string { - if p == nil { - return "" - } - return p.ManifestID -} - -func (p *PushManifestVersionRawRequest) GetRequestBody() any { - if p == nil { - return nil - } - return p.RequestBody -} - -type PushManifestVersionRawResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest version created successfully - ManifestVersionResponse *components.ManifestVersionResponse - // Error - Error *components.Error -} - -func (p *PushManifestVersionRawResponse) GetHTTPMeta() components.HTTPMetadata { - if p == nil { - return components.HTTPMetadata{} - } - return p.HTTPMeta -} - -func (p *PushManifestVersionRawResponse) GetManifestVersionResponse() *components.ManifestVersionResponse { - if p == nil { - return nil - } - return p.ManifestVersionResponse -} - -func (p *PushManifestVersionRawResponse) GetError() *components.Error { - if p == nil { - return nil - } - return p.Error -} diff --git a/internal/deployserverclient/models/operations/readapp.go b/internal/deployserverclient/models/operations/readapp.go deleted file mode 100644 index fdaf235b..00000000 --- a/internal/deployserverclient/models/operations/readapp.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 3c52b6d3c897 - -package operations - -import ( - "encoding/json" - "fmt" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ReadAppInclude string - -const ( - ReadAppIncludeState ReadAppInclude = "state" -) - -func (e ReadAppInclude) ToPointer() *ReadAppInclude { - return &e -} -func (e *ReadAppInclude) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "state": - *e = ReadAppInclude(v) - return nil - default: - return fmt.Errorf("invalid value for ReadAppInclude: %v", v) - } -} - -type ReadAppRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - // Comma-separated list of related resources to include. - // - `state`: Include the live stack state reported by Formance Cloud - // (a projection of the bound stack from Membership, not a snapshot - // persisted by the proxy). - // - Include []ReadAppInclude `queryParam:"style=form,explode=true,name=include"` -} - -func (r *ReadAppRequest) GetID() string { - if r == nil { - return "" - } - return r.ID -} - -func (r *ReadAppRequest) GetInclude() []ReadAppInclude { - if r == nil { - return nil - } - return r.Include -} - -type ReadAppResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // App details retrieved successfully - AppResponse *components.AppResponse - // Error - Error *components.Error -} - -func (r *ReadAppResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadAppResponse) GetAppResponse() *components.AppResponse { - if r == nil { - return nil - } - return r.AppResponse -} - -func (r *ReadAppResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/readappvariables.go b/internal/deployserverclient/models/operations/readappvariables.go deleted file mode 100644 index b273ef6b..00000000 --- a/internal/deployserverclient/models/operations/readappvariables.go +++ /dev/null @@ -1,72 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 1285e58cdfc7 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ReadAppVariablesRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - // Maximum number of items to return on the first page. Capped at 100; - // ignored when `cursor` is supplied (subsequent pages reuse the page - // size baked into the cursor token). Defaults to 100. - // - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - // Opaque pagination token returned in `cursor.next` / `cursor.previous` - // on the previous page. Pass it back unchanged to fetch the adjacent - // page. Mutually exclusive with `pageSize` after the first request. - // - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` -} - -func (r *ReadAppVariablesRequest) GetID() string { - if r == nil { - return "" - } - return r.ID -} - -func (r *ReadAppVariablesRequest) GetPageSize() *int64 { - if r == nil { - return nil - } - return r.PageSize -} - -func (r *ReadAppVariablesRequest) GetCursor() *string { - if r == nil { - return nil - } - return r.Cursor -} - -type ReadAppVariablesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // App variables retrieved successfully - ReadVariablesResponse *components.ReadVariablesResponse - // Error - Error *components.Error -} - -func (r *ReadAppVariablesResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadAppVariablesResponse) GetReadVariablesResponse() *components.ReadVariablesResponse { - if r == nil { - return nil - } - return r.ReadVariablesResponse -} - -func (r *ReadAppVariablesResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/readdeployment.go b/internal/deployserverclient/models/operations/readdeployment.go deleted file mode 100644 index 43788beb..00000000 --- a/internal/deployserverclient/models/operations/readdeployment.go +++ /dev/null @@ -1,97 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: ae784249b40f - -package operations - -import ( - "encoding/json" - "fmt" - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "io" -) - -type ReadDeploymentInclude string - -const ( - ReadDeploymentIncludeState ReadDeploymentInclude = "state" -) - -func (e ReadDeploymentInclude) ToPointer() *ReadDeploymentInclude { - return &e -} -func (e *ReadDeploymentInclude) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "state": - *e = ReadDeploymentInclude(v) - return nil - default: - return fmt.Errorf("invalid value for ReadDeploymentInclude: %v", v) - } -} - -type ReadDeploymentRequest struct { - DeploymentID string `pathParam:"style=simple,explode=false,name=deploymentId"` - // Comma-separated list of related resources to include. - // - `state`: Include the live stack state reported by Formance Cloud - // for this deployment's bound stack. Note: this is the **live** - // projection from Membership, not a frozen per-deployment snapshot. - // - Include []ReadDeploymentInclude `queryParam:"style=form,explode=true,name=include"` -} - -func (r *ReadDeploymentRequest) GetDeploymentID() string { - if r == nil { - return "" - } - return r.DeploymentID -} - -func (r *ReadDeploymentRequest) GetInclude() []ReadDeploymentInclude { - if r == nil { - return nil - } - return r.Include -} - -type ReadDeploymentResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Deployment retrieved successfully - DeploymentResponse *components.DeploymentResponse - // Deployment retrieved successfully - // The Close method must be called on this field, even if it is not used, to prevent resource leaks. - ResponseStream io.ReadCloser - // Error - Error *components.Error -} - -func (r *ReadDeploymentResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadDeploymentResponse) GetDeploymentResponse() *components.DeploymentResponse { - if r == nil { - return nil - } - return r.DeploymentResponse -} - -func (r *ReadDeploymentResponse) GetResponseStream() io.ReadCloser { - if r == nil { - return nil - } - return r.ResponseStream -} - -func (r *ReadDeploymentResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/readdeploymentlogs.go b/internal/deployserverclient/models/operations/readdeploymentlogs.go deleted file mode 100644 index 0ac0a717..00000000 --- a/internal/deployserverclient/models/operations/readdeploymentlogs.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: b72515ab4199 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ReadDeploymentLogsRequest struct { - DeploymentID string `pathParam:"style=simple,explode=false,name=deploymentId"` -} - -func (r *ReadDeploymentLogsRequest) GetDeploymentID() string { - if r == nil { - return "" - } - return r.DeploymentID -} - -type ReadDeploymentLogsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Deployment logs retrieved successfully - ReadLogsResponse *components.ReadLogsResponse - // Error - Error *components.Error -} - -func (r *ReadDeploymentLogsResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadDeploymentLogsResponse) GetReadLogsResponse() *components.ReadLogsResponse { - if r == nil { - return nil - } - return r.ReadLogsResponse -} - -func (r *ReadDeploymentLogsResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/readmanifest.go b/internal/deployserverclient/models/operations/readmanifest.go deleted file mode 100644 index da0a84d4..00000000 --- a/internal/deployserverclient/models/operations/readmanifest.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 1c4b97e4c0d5 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type ReadManifestRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - // Comma-separated includes (e.g. "latest" to embed latest version content) - Include *string `queryParam:"style=form,explode=true,name=include"` -} - -func (r *ReadManifestRequest) GetManifestID() string { - if r == nil { - return "" - } - return r.ManifestID -} - -func (r *ReadManifestRequest) GetInclude() *string { - if r == nil { - return nil - } - return r.Include -} - -type ReadManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest retrieved successfully - ManifestResponse *components.ManifestResponse - // Error - Error *components.Error -} - -func (r *ReadManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadManifestResponse) GetManifestResponse() *components.ManifestResponse { - if r == nil { - return nil - } - return r.ManifestResponse -} - -func (r *ReadManifestResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/readmanifestversion.go b/internal/deployserverclient/models/operations/readmanifestversion.go deleted file mode 100644 index 586511fc..00000000 --- a/internal/deployserverclient/models/operations/readmanifestversion.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 998f6b496360 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" - "io" -) - -type ReadManifestVersionRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - // Version number or "latest" - Version string `pathParam:"style=simple,explode=false,name=version"` -} - -func (r *ReadManifestVersionRequest) GetManifestID() string { - if r == nil { - return "" - } - return r.ManifestID -} - -func (r *ReadManifestVersionRequest) GetVersion() string { - if r == nil { - return "" - } - return r.Version -} - -type ReadManifestVersionResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest version retrieved successfully - ManifestVersionResponse *components.ManifestVersionResponse - // Manifest version retrieved successfully - // The Close method must be called on this field, even if it is not used, to prevent resource leaks. - ResponseStream io.ReadCloser - // Error - Error *components.Error -} - -func (r *ReadManifestVersionResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadManifestVersionResponse) GetManifestVersionResponse() *components.ManifestVersionResponse { - if r == nil { - return nil - } - return r.ManifestVersionResponse -} - -func (r *ReadManifestVersionResponse) GetResponseStream() io.ReadCloser { - if r == nil { - return nil - } - return r.ResponseStream -} - -func (r *ReadManifestVersionResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/deployserverclient/models/operations/updateapp.go b/internal/deployserverclient/models/operations/updateapp.go deleted file mode 100644 index e0aa5003..00000000 --- a/internal/deployserverclient/models/operations/updateapp.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 5ea72623daf9 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type UpdateAppRequest struct { - ID string `pathParam:"style=simple,explode=false,name=id"` - UpdateAppRequest components.UpdateAppRequest `request:"mediaType=application/json"` -} - -func (u *UpdateAppRequest) GetID() string { - if u == nil { - return "" - } - return u.ID -} - -func (u *UpdateAppRequest) GetUpdateAppRequest() components.UpdateAppRequest { - if u == nil { - return components.UpdateAppRequest{} - } - return u.UpdateAppRequest -} - -type UpdateAppResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (u *UpdateAppResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdateAppResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/deployserverclient/models/operations/updatemanifest.go b/internal/deployserverclient/models/operations/updatemanifest.go deleted file mode 100644 index 3b64059f..00000000 --- a/internal/deployserverclient/models/operations/updatemanifest.go +++ /dev/null @@ -1,56 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 5a5e3776c473 - -package operations - -import ( - "github.com/formancehq/fctl/internal/deployserverclient/v3/models/components" -) - -type UpdateManifestRequest struct { - ManifestID string `pathParam:"style=simple,explode=false,name=manifestId"` - UpdateManifestRequest components.UpdateManifestRequest `request:"mediaType=application/json"` -} - -func (u *UpdateManifestRequest) GetManifestID() string { - if u == nil { - return "" - } - return u.ManifestID -} - -func (u *UpdateManifestRequest) GetUpdateManifestRequest() components.UpdateManifestRequest { - if u == nil { - return components.UpdateManifestRequest{} - } - return u.UpdateManifestRequest -} - -type UpdateManifestResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Manifest updated successfully - ManifestResponse *components.ManifestResponse - // Error - Error *components.Error -} - -func (u *UpdateManifestResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdateManifestResponse) GetManifestResponse() *components.ManifestResponse { - if u == nil { - return nil - } - return u.ManifestResponse -} - -func (u *UpdateManifestResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/deployserverclient/optionalnullable/optionalnullable.go b/internal/deployserverclient/optionalnullable/optionalnullable.go deleted file mode 100644 index b0b69dcd..00000000 --- a/internal/deployserverclient/optionalnullable/optionalnullable.go +++ /dev/null @@ -1,234 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 87d8340e5c33 - -package optionalnullable - -import ( - "bytes" - "encoding/json" - "reflect" -) - -// OptionalNullable represents a field that can distinguish between three states: -// 1. Set to a value: field is present with a non-nil value -// 2. Set to nil: field is present but explicitly set to null in JSON -// 3. Unset: field is omitted from JSON entirely -// -// This type is designed to work with JSON marshaling/unmarshaling and supports -// the `omitempty` struct tag to properly omit unset fields from JSON output. -// -// Usage: -// -// type User struct { -// Name OptionalNullable[string] `json:"name,omitempty"` -// Age OptionalNullable[int] `json:"age,omitempty"` -// Tags OptionalNullable[[]string] `json:"tags,omitempty"` -// } -// -// // Set to value -// name := "John" -// user.Name = From(&name) -// -// // Set to nil (will appear as "name": null in JSON) -// user.Name = From[string](nil) -// -// // Leave unset (will be omitted from JSON with omitempty) -// user := User{} -// -// WARNING: Do NOT use *OptionalNullable[T] as a field type. Always use OptionalNullable[T] directly. -// Using *OptionalNullable[T] will break the omitempty behavior and JSON marshaling. -// -// The type is implemented as a map[bool]*T where: -// - nil map represents unset state -// - Map with true key represents set state (value may be nil) -type OptionalNullable[T any] map[bool]*T - -// From creates a new OptionalNullable with the given value. -// Pass nil to create a OptionalNullable that is set to null. -// Pass a pointer to a value to create a OptionalNullable with that value. -// -// Examples: -// -// hello := "hello" -// From(&hello) // set to "hello" -// From[string](nil) // set to null -func From[T any](value *T) OptionalNullable[T] { - return map[bool]*T{ - true: value, - } -} - -// IsNull returns true if the OptionalNullable is explicitly set to nil. -// Returns false if the OptionalNullable is unset or has a value. -// -// Note: This differs from traditional null checks because unset fields -// return false, not true. Use IsSet() to check if a field was provided. -func (n OptionalNullable[T]) IsNull() bool { - v, ok := n[true] - return ok && v == nil -} - -// IsSet returns true if the OptionalNullable has been explicitly set (to either a value or nil). -// Returns false if the OptionalNullable is unset (omitted from JSON). -// -// This is the key method for distinguishing between: -// - Set to nil: IsSet() = true, IsNull() = true -// - Unset: IsSet() = false, IsNull() = false -func (n OptionalNullable[T]) IsSet() bool { - _, ok := n[true] - return ok -} - -// Get returns the internal pointer and whether the field was set. -// -// Return values: -// - (ptr, true): field was set (ptr may be nil if set to null) -// - (nil, false): field was unset/omitted -// -// This method provides direct access to the internal pointer representation. -func (n OptionalNullable[T]) Get() (*T, bool) { - v, ok := n[true] - return v, ok -} - -// GetOrZero returns the value and whether it was set. -// -// Return values: -// - (value, true): field was set to a non-nil value -// - (zero, true): field was explicitly set to nil -// - (zero, false): field was unset/omitted -// -// Examples: -// -// val, ok := nullable.GetOrZero() -// if !ok { -// // Field was unset/omitted -// } else if nullable.IsNull() { -// // Field was explicitly set to null -// } else { -// // Field has a value: val -// } -func (n OptionalNullable[T]) GetOrZero() (T, bool) { - var zero T - - if v, ok := n[true]; ok { - if v == nil { - return zero, true - } - return *v, true - } - return zero, false -} - -// GetUntyped returns the value as interface{} and whether it was set. -// This is useful for reflection-based code that needs to work with the value -// without knowing the specific type T. -// -// Return values: -// - (value, true): field was set to a non-nil value -// - (nil, true): field was explicitly set to nil -// - (nil, false): field was unset/omitted -func (n OptionalNullable[T]) GetUntyped() (interface{}, bool) { - if v, ok := n[true]; ok { - if v == nil { - return nil, true - } - return *v, true - } - return nil, false -} - -// Set sets the OptionalNullable to the given value pointer. -// Pass nil to set the field to null. -// Pass a pointer to a value to set the field to that value. -// -// Examples: -// -// nullable.Set(ptrFrom("hello")) // set to "hello" -// nullable.Set(nil) // set to null -func (n *OptionalNullable[T]) Set(value *T) { - *n = map[bool]*T{ - true: value, - } -} - -// Unset removes the value, making the field unset/omitted. -// After calling Unset(), IsSet() will return false and the field -// will be omitted from JSON output when using omitempty. -func (n *OptionalNullable[T]) Unset() { - *n = map[bool]*T{} -} - -// MarshalJSON implements json.Marshaler. -// -// Behavior: -// - Unset fields: omitted from JSON when struct field has omitempty tag -// - Null fields: serialized as "null" -// - Value fields: serialized as the actual value -// -// The omitempty behavior works because an empty map is considered -// a zero value by Go's JSON package. -func (n OptionalNullable[T]) MarshalJSON() ([]byte, error) { - if n.IsNull() { - return []byte("null"), nil - } - - return json.Marshal(n[true]) -} - -// UnmarshalJSON implements json.Unmarshaler. -// -// Behavior: -// - "null" in JSON: sets the field to null (IsSet=true, IsNull=true) -// - Any other value: sets the field to that value (IsSet=true, IsNull=false) -// - Missing from JSON: field remains unset (IsSet=false, IsNull=false) -func (n *OptionalNullable[T]) UnmarshalJSON(data []byte) error { - if bytes.Equal(data, []byte("null")) { - n.Set(nil) - return nil - } - var v T - if err := json.Unmarshal(data, &v); err != nil { - return err - } - n.Set(&v) - return nil -} - -// NullableInterface defines the interface that all OptionalNullable[T] types implement. -// This interface provides untyped access to optional nullable values for reflection-based code. -type OptionalNullableInterface interface { - GetUntyped() (interface{}, bool) -} - -// AsOptionalNullable attempts to convert a reflect.Value to a OptionalNullableInterface. -// This is a helper function for reflection-based code that needs to check -// if a value implements the optional nullable interface pattern. -// -// Returns: -// - (nullable, true): if the value implements OptionalNullableInterface -// - (nil, false): if the value does not implement OptionalNullableInterface -// -// Example usage: -// -// if nullable, ok := AsOptionalNullable(reflectValue); ok { -// if value, isSet := nullable.GetUntyped(); isSet { -// // Handle the nullable value -// } -// } -func AsOptionalNullable(v reflect.Value) (OptionalNullableInterface, bool) { - // Check if the value can be converted to an interface first - if !v.CanInterface() { - return nil, false - } - - // Check if the underlying value is a nil map (unset nullable) - if v.Kind() == reflect.Map && v.IsNil() { - return nil, false - } - - if nullable, ok := v.Interface().(OptionalNullableInterface); ok { - return nullable, true - } - return nil, false -} diff --git a/internal/deployserverclient/optionalnullable/optionalnullable_test.go b/internal/deployserverclient/optionalnullable/optionalnullable_test.go deleted file mode 100644 index 6b7cc5f0..00000000 --- a/internal/deployserverclient/optionalnullable/optionalnullable_test.go +++ /dev/null @@ -1,1807 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 5ca01ab4f4c2 - -package optionalnullable - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Test helper function to create pointers from values -func ptrFrom[T any](value T) *T { - return &value -} - -// Test helper types for comprehensive testing -type TestStruct struct { - Name string `json:"name"` - Age int `json:"age"` -} - -type TestContainer struct { - StringField OptionalNullable[string] `json:"string_field,omitempty"` - IntField OptionalNullable[int] `json:"int_field,omitempty"` - SliceField OptionalNullable[[]string] `json:"slice_field,omitempty"` - StructField OptionalNullable[TestStruct] `json:"struct_field,omitempty"` -} - -// TestNewNullable tests the From constructor -func TestNewNullable(t *testing.T) { - t.Parallel() - t.Run("with string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value for string - }) - - t.Run("with int value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("with slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - }) - - t.Run("with empty slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{})) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("with struct value", func(t *testing.T) { - t.Parallel() - val := TestStruct{Name: "John", Age: 30} - nullable := From(&val) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - v, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, val, v) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, got) - }) -} - -// TestNewNullableUnset tests the NewNullableUnset constructor -func TestNewNullableUnset(t *testing.T) { - t.Parallel() - t.Run("string type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value for string - }) - - t.Run("int type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, 0, got) // zero value for int - }) - - t.Run("slice type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) // zero value for slice is nil - }) -} - -// TestIsNull tests the IsNull method -func TestIsNull(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - assert.False(t, nullable.IsNull()) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - assert.True(t, nullable.IsNull()) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - assert.False(t, nullable.IsNull()) - }) -} - -// TestIsSet tests the IsSet method -func TestIsSet(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - assert.True(t, nullable.IsSet()) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - assert.True(t, nullable.IsSet()) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - assert.False(t, nullable.IsSet()) - }) -} - -// TestGet tests the Get method -func TestGet(t *testing.T) { - t.Parallel() - t.Run("with string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("with slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b"})) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, got) - }) - - t.Run("with nil slice pointer", func(t *testing.T) { - t.Parallel() - nullable := From[[]string](nil) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) // zero value for slice is nil - }) -} - -// TestPointer tests the Pointer method -func TestPointer(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - ptr, ok := nullable.Get() - assert.True(t, ok) - assert.NotNil(t, ptr) - assert.Equal(t, "test", *ptr) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - ptr, ok := nullable.Get() - assert.True(t, ok) - assert.Nil(t, ptr) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - ptr, ok := nullable.Get() - assert.False(t, ok) - assert.Nil(t, ptr) - }) -} - -// TestSet tests the Set method -func TestSet(t *testing.T) { - t.Parallel() - t.Run("set string value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - // Initially unset - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - // Set a value - nullable.Set(ptrFrom("test")) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("set int value", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[int]{} - - nullable.Set(ptrFrom(42)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("set slice value", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[[]string]{} - - slice := []string{"a", "b"} - nullable.Set(ptrFrom(slice)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, got) - }) - - t.Run("set empty slice", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[[]string]{} - - slice := []string{} - nullable.Set(ptrFrom(slice)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("overwrite existing value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("original")) - - // Verify original value - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "original", got) - - // Set new value - nullable.Set(ptrFrom("new")) - - got, ok = nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "new", got) - }) -} - -// TestUnset tests the Unset method -func TestUnset(t *testing.T) { - t.Parallel() - t.Run("unset from value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - // Initially set - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // After unset is not null - // Value is now internal to the map implementation - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unset from nil", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - // Initially set to nil - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) // Set to nil should be null - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // After unset is not null - }) - - t.Run("unset already unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - // Initially unset - assert.False(t, nullable.IsSet()) - - // Unset again - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Empty map is not null - }) -} - -// TestMarshalJSON tests JSON marshaling -func TestMarshalJSON(t *testing.T) { - t.Parallel() - t.Run("marshal string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `"test"`, string(data)) - }) - - t.Run("marshal int value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `42`, string(data)) - }) - - t.Run("marshal nil value", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `null`, string(data)) - }) - - t.Run("marshal slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `["a","b","c"]`, string(data)) - }) - - t.Run("marshal empty slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `[]`, string(data)) - }) - - t.Run("marshal struct value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(TestStruct{Name: "John", Age: 30})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `{"name":"John","age":30}`, string(data)) - }) - - // Note: Unset values are not tested here because the current implementation - // doesn't handle unset fields in marshaling (see TODO in the code) -} - -// TestUnmarshalJSON tests JSON unmarshaling -func TestUnmarshalJSON(t *testing.T) { - t.Parallel() - t.Run("unmarshal string value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`"test"`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("unmarshal int value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(`42`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("unmarshal null value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`null`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unmarshal slice value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(`["a","b","c"]`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - }) - - t.Run("unmarshal empty slice", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(`[]`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("unmarshal struct value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(`{"name":"John","age":30}`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, got) - }) - - t.Run("unmarshal invalid JSON", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`invalid`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) - - t.Run("unmarshal invalid JSON for int", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(`"not_a_number"`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) - - t.Run("unmarshal malformed JSON", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(`{invalid json`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) -} - -// TestJSONRoundTrip tests marshaling and unmarshaling together -func TestJSONRoundTrip(t *testing.T) { - t.Parallel() - t.Run("string value round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From(ptrFrom("test value")) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) - - t.Run("nil value round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From[string](nil) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) - - t.Run("slice round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From(ptrFrom([]string{"a", "b", "c"})) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[[]string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) -} - -// TestJSONToJSONRoundTrip tests starting with JSON and ensuring we can serialize back to the same JSON -func TestJSONToJSONRoundTrip(t *testing.T) { - t.Parallel() - t.Run("string value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `"hello world"` - - // Unmarshal from JSON - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "hello world", got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("null value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `null` - - // Unmarshal from JSON - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("int value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `42` - - // Unmarshal from JSON - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("slice value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `["a","b","c"]` - - // Unmarshal from JSON - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("empty slice JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `[]` - - // Unmarshal from JSON - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("struct value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `{"name":"Alice","age":25}` - - // Unmarshal from JSON - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "Alice", Age: 25}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) -} - -// TestContainerStates tests comprehensive state detection and serialization with TestContainer -func TestContainerStates(t *testing.T) { - t.Parallel() - t.Run("all fields set to values", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From(ptrFrom("hello")), - IntField: From(ptrFrom(42)), - SliceField: From(ptrFrom([]string{"a", "b"})), - StructField: From(ptrFrom(TestStruct{Name: "John", Age: 30})), - } - - // Verify all fields are set and not null - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - assert.True(t, container.IntField.IsSet()) - assert.False(t, container.IntField.IsNull()) - assert.True(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Verify values - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "hello", stringVal) - - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, intVal) - - sliceVal, ok := container.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, sliceVal) - - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, structVal) - - // Test JSON serialization - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Equal(t, "hello", result["string_field"]) - assert.Equal(t, float64(42), result["int_field"]) // JSON numbers are float64 - assert.Equal(t, []interface{}{"a", "b"}, result["slice_field"]) - structResult := result["struct_field"].(map[string]interface{}) - assert.Equal(t, "John", structResult["name"]) - assert.Equal(t, float64(30), structResult["age"]) - }) - - t.Run("all fields set to nil", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From[string](nil), - IntField: From[int](nil), - SliceField: From[[]string](nil), - StructField: From[TestStruct](nil), - } - - // Verify all fields are set but null - assert.True(t, container.StringField.IsSet()) - assert.True(t, container.StringField.IsNull()) - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - assert.True(t, container.SliceField.IsSet()) - assert.True(t, container.SliceField.IsNull()) - assert.True(t, container.StructField.IsSet()) - assert.True(t, container.StructField.IsNull()) - - // Verify GetOrZero() behavior for nil values - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) // set to nil still returns true - assert.Equal(t, "", stringVal) // zero value - - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, intVal) // zero value - - sliceVal, ok := container.SliceField.GetOrZero() - assert.True(t, ok) - assert.Nil(t, sliceVal) // zero value for slice is nil - - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{}, structVal) // zero value - - // Test JSON serialization - all should be null - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Nil(t, result["string_field"]) - assert.Nil(t, result["int_field"]) - assert.Nil(t, result["slice_field"]) - assert.Nil(t, result["struct_field"]) - }) - - t.Run("all fields unset", func(t *testing.T) { - t.Parallel() - container := TestContainer{} - - // Verify all fields are unset - assert.False(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) // unset is not null in new implementation - assert.False(t, container.IntField.IsSet()) - assert.False(t, container.IntField.IsNull()) - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) - assert.False(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Verify GetOrZero() behavior for unset values - stringVal, ok := container.StringField.GetOrZero() - assert.False(t, ok) // unset returns false - assert.Equal(t, "", stringVal) // zero value - - intVal, ok := container.IntField.GetOrZero() - assert.False(t, ok) - assert.Equal(t, 0, intVal) // zero value - - sliceVal, ok := container.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, sliceVal) // zero value - - structVal, ok := container.StructField.GetOrZero() - assert.False(t, ok) - assert.Equal(t, TestStruct{}, structVal) // zero value - - // Test JSON serialization - unset fields should be omitted due to omitempty - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - // With omitempty, unset fields should not appear in JSON - assert.NotContains(t, result, "string_field") - assert.NotContains(t, result, "int_field") - assert.NotContains(t, result, "slice_field") - assert.NotContains(t, result, "struct_field") - }) - - t.Run("slice field states: nil vs unset vs empty vs set", func(t *testing.T) { - t.Parallel() - // Test all possible slice states - nilSlice := TestContainer{ - SliceField: From[[]string](nil), // explicitly set to nil - } - unsetSlice := TestContainer{} // unset - emptySlice := TestContainer{ - SliceField: From(ptrFrom([]string{})), // empty slice - } - setSlice := TestContainer{ - SliceField: From(ptrFrom([]string{"a", "b"})), // slice with values - } - - // Verify nil slice - assert.True(t, nilSlice.SliceField.IsSet()) - assert.True(t, nilSlice.SliceField.IsNull()) - val, ok := nilSlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Nil(t, val) - - // Verify unset slice - assert.False(t, unsetSlice.SliceField.IsSet()) - assert.False(t, unsetSlice.SliceField.IsNull()) // Unset is not null - val, ok = unsetSlice.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, val) - - // Verify empty slice - assert.True(t, emptySlice.SliceField.IsSet()) - assert.False(t, emptySlice.SliceField.IsNull()) - val, ok = emptySlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, val) - - // Verify set slice - assert.True(t, setSlice.SliceField.IsSet()) - assert.False(t, setSlice.SliceField.IsNull()) - val, ok = setSlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, val) - - // Test JSON serialization for each state - nilData, err := json.Marshal(nilSlice) - require.NoError(t, err) - assert.Contains(t, string(nilData), `"slice_field":null`) - - unsetData, err := json.Marshal(unsetSlice) - require.NoError(t, err) - assert.NotContains(t, string(unsetData), "slice_field") // omitted due to omitempty - - emptyData, err := json.Marshal(emptySlice) - require.NoError(t, err) - assert.Contains(t, string(emptyData), `"slice_field":[]`) - - setData, err := json.Marshal(setSlice) - require.NoError(t, err) - assert.Contains(t, string(setData), `"slice_field":["a","b"]`) - }) - - t.Run("mixed states container", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From(ptrFrom("hello")), // set to value - IntField: From[int](nil), // set to nil - StructField: From(ptrFrom(TestStruct{Name: "Alice", Age: 25})), // set to value - } - - // Verify states - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) // Unset is not null - - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Test JSON serialization - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Equal(t, "hello", result["string_field"]) - assert.Nil(t, result["int_field"]) - assert.NotContains(t, result, "slice_field") // unset, so omitted - structResult := result["struct_field"].(map[string]interface{}) - assert.Equal(t, "Alice", structResult["name"]) - assert.Equal(t, float64(25), structResult["age"]) - }) - - t.Run("JSON unmarshaling preserves states", func(t *testing.T) { - t.Parallel() - // JSON with some fields missing, some null, some with values - jsonData := `{ - "string_field": "test", - "int_field": null, - "struct_field": {"name": "Bob", "age": 35} - }` - - var container TestContainer - err := json.Unmarshal([]byte(jsonData), &container) - require.NoError(t, err) - - // string_field: present with value - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", stringVal) - - // int_field: present but null - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, intVal) // zero value - - // slice_field: missing from JSON, should remain unset - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) // Unset is not null - sliceVal, ok := container.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, sliceVal) - - // struct_field: present with value - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "Bob", Age: 35}, structVal) - }) -} - -// TestNilVsUnsetDistinction tests the key feature of distinguishing nil from unset -func TestNilVsUnsetDistinction(t *testing.T) { - t.Parallel() - t.Run("explicit nil vs unset", func(t *testing.T) { - t.Parallel() - // Explicitly set to nil - explicitNil := From[string](nil) - - // Unset - var unset OptionalNullable[string] - - // Both are null, but only one is set - assert.True(t, explicitNil.IsNull()) - assert.True(t, explicitNil.IsSet()) - - assert.False(t, unset.IsNull()) // Unset is not null - assert.False(t, unset.IsSet()) - - // Get behavior differs - got1, ok1 := explicitNil.GetOrZero() - got2, ok2 := unset.GetOrZero() - - assert.True(t, ok1) // explicitly set to nil returns true - assert.False(t, ok2) // unset returns false - assert.Equal(t, "", got1) // both return zero value - assert.Equal(t, "", got2) - - // Get behavior differs - ptr1, ok1 := explicitNil.Get() - ptr2, ok2 := unset.Get() - - assert.True(t, ok1) // explicitly set to nil returns true - assert.False(t, ok2) // unset returns false - assert.Nil(t, ptr1) // both return nil pointer - assert.Nil(t, ptr2) - }) - - t.Run("empty slice vs nil slice vs unset", func(t *testing.T) { - t.Parallel() - // Empty slice - emptyNullable := From(ptrFrom([]string{})) - - // Nil slice - nilNullable := From[[]string](nil) - - // Unset - var unsetNullable OptionalNullable[[]string] - - // All have different characteristics - assert.True(t, emptyNullable.IsSet()) - assert.False(t, emptyNullable.IsNull()) - - assert.True(t, nilNullable.IsSet()) - assert.True(t, nilNullable.IsNull()) - - assert.False(t, unsetNullable.IsSet()) - assert.False(t, unsetNullable.IsNull()) // Unset is not null - - // Get behavior - got1, ok1 := emptyNullable.GetOrZero() - got2, ok2 := nilNullable.GetOrZero() - got3, ok3 := unsetNullable.GetOrZero() - - assert.True(t, ok1) - assert.Equal(t, []string{}, got1) - - assert.True(t, ok2) - assert.Nil(t, got2) - - assert.False(t, ok3) - assert.Nil(t, got3) - }) -} - -// TestJSONOmitEmpty tests behavior with omitempty tag -func TestJSONOmitEmpty(t *testing.T) { - t.Parallel() - t.Run("marshal with omitempty", func(t *testing.T) { - t.Parallel() - // Test container with various nullable states - container := TestContainer{ - StringField: From(ptrFrom("test")), - IntField: From(ptrFrom(42)), - StructField: From[TestStruct](nil), // explicitly nil - } - - data, err := json.Marshal(container) - require.NoError(t, err) - - // Parse back to verify structure - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - // Should contain set fields - assert.Contains(t, result, "string_field") - assert.Contains(t, result, "int_field") - assert.Contains(t, result, "struct_field") - - // Should not contain unset field (due to omitempty) - // Note: This depends on how the marshaling handles unset fields - // The current implementation doesn't handle this case properly (see TODO) - }) - - t.Run("unmarshal missing fields", func(t *testing.T) { - t.Parallel() - // JSON with some fields missing - jsonData := `{"string_field": "test", "int_field": null}` - - var container TestContainer - err := json.Unmarshal([]byte(jsonData), &container) - require.NoError(t, err) - - // Present fields should be set - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - got, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - - // Null field should be set to nil - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - - // Missing fields should remain unset - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.StructField.IsSet()) - }) -} - -// TestEdgeCases tests various edge cases -func TestEdgeCases(t *testing.T) { - t.Parallel() - t.Run("zero values", func(t *testing.T) { - t.Parallel() - // Test with zero values that are not nil - intNullable := From(ptrFrom(0)) - stringNullable := From(ptrFrom("")) - - assert.True(t, intNullable.IsSet()) - assert.False(t, intNullable.IsNull()) - got, ok := intNullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, got) - - assert.True(t, stringNullable.IsSet()) - assert.False(t, stringNullable.IsNull()) - got2, ok2 := stringNullable.GetOrZero() - assert.True(t, ok2) - assert.Equal(t, "", got2) - }) - - t.Run("pointer to pointer", func(t *testing.T) { - t.Parallel() - // Test with pointer to pointer type - inner := "test" - nullable := From(ptrFrom(&inner)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, &inner, got) - assert.Equal(t, "test", *got) - }) - - t.Run("complex struct", func(t *testing.T) { - t.Parallel() - complexStruct := struct { - Name string - Values []int - Metadata map[string]string - }{ - Name: "complex", - Values: []int{1, 2, 3}, - Metadata: map[string]string{ - "key1": "value1", - "key2": "value2", - }, - } - - nullable := From(ptrFrom(complexStruct)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, complexStruct, got) - }) -} - -// TestDoublePointers tests comprehensive double pointer scenarios -func TestDoublePointers(t *testing.T) { - t.Parallel() - - t.Run("string double pointer with value", func(t *testing.T) { - t.Parallel() - inner := "hello world" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, &inner, got) - assert.Equal(t, "hello world", *got) - }) - - t.Run("int double pointer with value", func(t *testing.T) { - t.Parallel() - inner := 42 - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, &inner, got) - assert.Equal(t, 42, *got) - }) - - t.Run("double pointer to nil", func(t *testing.T) { - t.Parallel() - var ptr *string = nil - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Nil(t, got) - }) - - t.Run("nil double pointer", func(t *testing.T) { - t.Parallel() - nullable := From[*string](nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) // zero value for **string is nil - }) - - t.Run("unset double pointer", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) // zero value for **string is nil - }) - - t.Run("double pointer modification", func(t *testing.T) { - t.Parallel() - inner := "original" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - // Verify original value - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "original", *got) - - // Modify through double pointer - *got = "modified" - assert.Equal(t, "modified", inner) - assert.Equal(t, "modified", *got) - }) - - t.Run("double pointer to struct", func(t *testing.T) { - t.Parallel() - inner := TestStruct{Name: "Alice", Age: 30} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, TestStruct{Name: "Alice", Age: 30}, *got) - - // Modify through double pointer - (*got).Name = "Bob" - assert.Equal(t, "Bob", inner.Name) - assert.Equal(t, "Bob", (*got).Name) - }) - - t.Run("double pointer to slice", func(t *testing.T) { - t.Parallel() - inner := []string{"a", "b", "c"} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, []string{"a", "b", "c"}, *got) - - // Modify through double pointer - *got = append(*got, "d") - assert.Equal(t, []string{"a", "b", "c", "d"}, inner) - assert.Equal(t, []string{"a", "b", "c", "d"}, *got) - }) - - t.Run("double pointer to empty slice", func(t *testing.T) { - t.Parallel() - inner := []string{} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, []string{}, *got) - }) - - t.Run("double pointer to nil slice", func(t *testing.T) { - t.Parallel() - var inner []string = nil - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Nil(t, *got) - }) - - t.Run("double pointer JSON marshaling", func(t *testing.T) { - t.Parallel() - inner := "json test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `"json test"`, string(data)) - }) - - t.Run("double pointer JSON unmarshaling", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - err := json.Unmarshal([]byte(`"json test"`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.NotNil(t, got) - assert.Equal(t, "json test", *got) - }) - - t.Run("double pointer JSON null marshaling", func(t *testing.T) { - t.Parallel() - nullable := From[*string](nil) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `null`, string(data)) - }) - - t.Run("double pointer JSON null unmarshaling", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - err := json.Unmarshal([]byte(`null`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) - }) - - t.Run("double pointer round trip", func(t *testing.T) { - t.Parallel() - inner := "round trip test" - ptr := &inner - nullable1 := From(ptrFrom(ptr)) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[*string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare states - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - - // Values should be equal - assert.Equal(t, *got1, *got2) - }) - - t.Run("triple pointer", func(t *testing.T) { - t.Parallel() - inner := "triple" - ptr1 := &inner - ptr2 := &ptr1 - nullable := From(ptrFrom(ptr2)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr2, got) - assert.Equal(t, ptr1, *got) - assert.Equal(t, "triple", **got) - }) - - t.Run("double pointer set and unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - - // Initially unset - assert.False(t, nullable.IsSet()) - - // Set to double pointer - inner := "set test" - ptr := &inner - nullable.Set(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "set test", *got) - - // Set to nil - nullable.Set(nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok = nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok = nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) - }) - - t.Run("double pointer Get method", func(t *testing.T) { - t.Parallel() - inner := "get test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - // Test Get method - gotPtr, ok := nullable.Get() - assert.True(t, ok) - assert.NotNil(t, gotPtr) - assert.Equal(t, ptr, *gotPtr) - assert.Equal(t, "get test", **gotPtr) - - // Test with nil - nilNullable := From[*string](nil) - gotPtr, ok = nilNullable.Get() - assert.True(t, ok) - assert.Nil(t, gotPtr) - - // Test with unset - var unsetNullable OptionalNullable[*string] - gotPtr, ok = unsetNullable.Get() - assert.False(t, ok) - assert.Nil(t, gotPtr) - }) - - t.Run("double pointer zero values", func(t *testing.T) { - t.Parallel() - // Test with zero value string - inner := "" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", *got) - - // Test with zero value int - innerInt := 0 - ptrInt := &innerInt - nullableInt := From(ptrFrom(ptrInt)) - - assert.True(t, nullableInt.IsSet()) - assert.False(t, nullableInt.IsNull()) - - gotInt, okInt := nullableInt.GetOrZero() - assert.True(t, okInt) - assert.Equal(t, 0, *gotInt) - }) -} - -// TestAsOptionalNullable tests the AsOptionalNullable helper function -func TestAsOptionalNullable(t *testing.T) { - t.Parallel() - - t.Run("with nullable string", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) - - t.Run("with nullable int", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, 42, value) - }) - - t.Run("with nullable nil", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Nil(t, value) - }) - - t.Run("with unset nullable", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable string", func(t *testing.T) { - t.Parallel() - regularString := "not nullable" - reflectValue := reflect.ValueOf(regularString) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable int", func(t *testing.T) { - t.Parallel() - regularInt := 42 - reflectValue := reflect.ValueOf(regularInt) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable map", func(t *testing.T) { - t.Parallel() - regularMap := map[string]int{"key": 42} - reflectValue := reflect.ValueOf(regularMap) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable struct", func(t *testing.T) { - t.Parallel() - regularStruct := TestStruct{Name: "test", Age: 30} - reflectValue := reflect.ValueOf(regularStruct) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with nullable double pointer", func(t *testing.T) { - t.Parallel() - inner := "test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, ptr, value) - assert.Equal(t, "test", *value.(*string)) - }) - - t.Run("with nullable slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, []string{"a", "b", "c"}, value) - }) - - t.Run("with nullable struct", func(t *testing.T) { - t.Parallel() - testStruct := TestStruct{Name: "Alice", Age: 25} - nullable := From(ptrFrom(testStruct)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, testStruct, value) - }) - - t.Run("with pointer to nullable", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - ptrToNullable := &nullable - reflectValue := reflect.ValueOf(ptrToNullable) - - // This should work since the pointer to nullable still contains a nullable - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) - - t.Run("with interface containing nullable", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - var iface interface{} = nullable - reflectValue := reflect.ValueOf(iface) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) -} diff --git a/internal/deployserverclient/retry/config.go b/internal/deployserverclient/retry/config.go deleted file mode 100644 index 2447d253..00000000 --- a/internal/deployserverclient/retry/config.go +++ /dev/null @@ -1,149 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 3ad757df0c14 - -package retry - -import ( - "errors" - "net/http" - "strconv" - "time" -) - -// BackoffStrategy defines the parameters for exponential backoff. This can be -// used to drive a retry loop for example. -type BackoffStrategy struct { - InitialInterval int - MaxInterval int - Exponent float64 - MaxElapsedTime int -} - -// Config configures a retry policy. -type Config struct { - // Strategy sets the algorithm to use for a retry loop. It can be one of: - // - "backoff": retry with exponential backoff and random jitter. - // - "none" or "": disables retries. - Strategy string - Backoff *BackoffStrategy - RetryConnectionErrors bool -} - -// PermanentError is an error that signals that some operation has terminally -// failed and should not be retried. -type PermanentError struct { - cause error -} - -// Permanent creates a PermanentError that signals to a retry loop that it -// should stop retrying an operation and return the underlying error. -func Permanent(cause error) error { - if IsPermanentError(cause) { - return cause - } - - return &PermanentError{ - cause: cause, - } -} - -func (e *PermanentError) Error() string { - return e.cause.Error() -} - -func (e *PermanentError) Unwrap() error { - return e.cause -} - -// TemporaryError represents a retryable error and signals to a retry loop that -// an operation may be retried with an optional wait interval. -type TemporaryError struct { - wait time.Duration - message string -} - -// Temporary creates a TemporaryError that signals to a retry loop that an -// operation can be retried. The error may also carry details about how long to -// wait before retrying. This wait interval may be used to override the retry -// policy in use. -func Temporary(message string) error { - return &TemporaryError{ - message: message, - } -} - -// TemporaryFromResponse creates a TemporaryError similar to Temporary but -// additionally parses the Retry-After header from a response to determine the -// wait interval before the next retry attempt. -func TemporaryFromResponse(message string, res *http.Response) error { - return &TemporaryError{ - wait: retryIntervalFromResponse(res), - message: message, - } -} - -func (e *TemporaryError) Error() string { - return e.message -} - -// RetryAfter returns the time to wait before retrying the request. The zero -// value should be interpreted by retry loops to mean they should fallback on -// their default policy whether expenonential, constant backoff or something -// else. It does not mean that an operation should be retried immediately. -func (e *TemporaryError) RetryAfter() time.Duration { - return e.wait -} - -func retryIntervalFromResponse(res *http.Response) time.Duration { - if res == nil { - return 0 - } - - retryVal := res.Header.Get("retry-after") - if retryVal == "" { - return 0 - } - - parsedNumber, err := strconv.ParseInt(retryVal, 10, 64) - if err == nil { - if parsedNumber < 0 { - return 0 - } else { - return time.Duration(parsedNumber) * time.Second - } - } - - parsedDate, err := time.Parse(time.RFC1123, retryVal) - if err == nil { - delta := time.Until(parsedDate) - if delta < 0 { - return 0 - } else { - return delta - } - } - - return 0 -} - -// IsPermanentError returns true if an error value is or contains a -// PermanentError in its chain of errors. -func IsPermanentError(err error) bool { - if err == nil { - return false - } - - var pe *PermanentError - return errors.As(err, &pe) -} - -// IsTemporaryError returns true if an error value is or contains a -// TemporaryError in its chain of errors. -func IsTemporaryError(err error) bool { - if err == nil { - return false - } - - var pe *TemporaryError - return errors.As(err, &pe) -} diff --git a/internal/deployserverclient/types/bigint.go b/internal/deployserverclient/types/bigint.go deleted file mode 100644 index a6e1e46e..00000000 --- a/internal/deployserverclient/types/bigint.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 92a51b8d9fdc - -package types - -import ( - "fmt" - "math/big" -) - -// MustNewBigIntFromString returns an instance of big.Int from a string -// The string is assumed to be base 10 and if it is not a valid big.Int -// then the function panics. -// Avoid using this function in production code. -func MustNewBigIntFromString(s string) *big.Int { - i, ok := new(big.Int).SetString(s, 10) - if !ok { - panic(fmt.Errorf("failed to parse string as big.Int")) - } - - return i -} diff --git a/internal/deployserverclient/types/date.go b/internal/deployserverclient/types/date.go deleted file mode 100644 index b4bdc8ec..00000000 --- a/internal/deployserverclient/types/date.go +++ /dev/null @@ -1,91 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 36df52783abf - -package types - -import ( - "encoding/json" - "fmt" - "strings" - "time" -) - -// Date is a wrapper around time.Time that allows for JSON marshaling a date string formatted as "2006-01-02". -type Date struct { - time.Time -} - -var ( - _ json.Marshaler = &Date{} - _ json.Unmarshaler = &Date{} - _ fmt.Stringer = &Date{} -) - -// NewDate returns an instance of Date from a time.Time. -func NewDate(t time.Time) *Date { - d := DateFromTime(t) - return &d -} - -// DateFromTime returns a Date from a time.Time. -func DateFromTime(t time.Time) Date { - return Date{t} -} - -// NewDateFromString returns an instance of Date from a string formatted as "2006-01-02". -func NewDateFromString(str string) (*Date, error) { - d, err := DateFromString(str) - if err != nil { - return nil, err - } - - return &d, nil -} - -// DateFromString returns a Date from a string formatted as "2006-01-02". -func DateFromString(str string) (Date, error) { - var d Date - var err error - - d.Time, err = time.Parse("2006-01-02", str) - return d, err -} - -// MustNewDateFromString returns an instance of Date from a string formatted as "2006-01-02" or panics. -// Avoid using this function in production code. -func MustNewDateFromString(str string) *Date { - d := MustDateFromString(str) - return &d -} - -// MustDateFromString returns a Date from a string formatted as "2006-01-02" or panics. -// Avoid using this function in production code. -func MustDateFromString(str string) Date { - d, err := DateFromString(str) - if err != nil { - panic(err) - } - return d -} - -func (d Date) GetTime() time.Time { - return d.Time -} - -func (d Date) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf(`"%s"`, d.Time.Format("2006-01-02"))), nil -} - -func (d *Date) UnmarshalJSON(data []byte) error { - var err error - - str := string(data) - str = strings.Trim(str, `"`) - - d.Time, err = time.Parse("2006-01-02", str) - return err -} - -func (d Date) String() string { - return d.Time.Format("2006-01-02") -} diff --git a/internal/deployserverclient/types/datetime.go b/internal/deployserverclient/types/datetime.go deleted file mode 100644 index 7739aadb..00000000 --- a/internal/deployserverclient/types/datetime.go +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: cea216cdc8d5 - -package types - -import "time" - -// MustTimeFromString returns a time.Time from a string formatted as "2006-01-02T15:04:05Z07:00" or panics. -// Avoid using this function in production code. -func MustTimeFromString(str string) time.Time { - t, err := time.Parse(time.RFC3339, str) - if err != nil { - panic(err) - } - - return t -} - -// MustNewTimeFromString returns an instance of time.Time from a string formatted as "2006-01-02T15:04:05Z07:00" or panics. -// Avoid using this function in production code. -func MustNewTimeFromString(str string) *time.Time { - t := MustTimeFromString(str) - return &t -} diff --git a/internal/deployserverclient/types/pointers.go b/internal/deployserverclient/types/pointers.go deleted file mode 100644 index 8c0b30be..00000000 --- a/internal/deployserverclient/types/pointers.go +++ /dev/null @@ -1,12 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// @generated-id: 38c23538f4e9 - -package types - -func String(s string) *string { return &s } -func Bool(b bool) *bool { return &b } -func Int(i int) *int { return &i } -func Int64(i int64) *int64 { return &i } -func Float32(f float32) *float32 { return &f } -func Float64(f float64) *float64 { return &f } -func Pointer[T any](v T) *T { return &v } diff --git a/internal/httpdebug/redact.go b/internal/httpdebug/redact.go new file mode 100644 index 00000000..0240ca12 --- /dev/null +++ b/internal/httpdebug/redact.go @@ -0,0 +1,160 @@ +package httpdebug + +import ( + "bytes" + "encoding/json" + "fmt" + "mime" + "net/http" + "net/url" + "slices" + "strings" +) + +const redacted = "[REDACTED]" + +func sensitive(key string) bool { + key = strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(key)) + for _, part := range []string{"authorization", "cookie", "secret", "token", "password", "assertion", "devicecode", "usercode", "credential", "apikey"} { + if strings.Contains(key, part) { + return true + } + } + // Error text can echo arbitrary submitted secrets, even on OAuth failures. + return strings.HasPrefix(key, "error") || slices.Contains([]string{"clear", "code", "message", "detail", "details", "value", "terraformstate", "tfstate"}, key) +} + +func safeURL(u *url.URL) string { + if u == nil { + return "" + } + copyURL := *u + copyURL.User, copyURL.Fragment, copyURL.RawFragment = nil, "", "" + query, err := url.ParseQuery(copyURL.RawQuery) + if err != nil { + copyURL.RawQuery = "omitted" + } else { + redactValues(query) + copyURL.RawQuery = query.Encode() + } + return copyURL.String() +} + +func redactValues(values url.Values) { + for key, entries := range values { + for i, value := range entries { + if sensitive(key) && !safeOAuthError(key, value) { + entries[i] = redacted + } else { + entries[i] = safeString(value) + } + } + } +} + +func safeOAuthError(key, value string) bool { + return key == "error" && slices.Contains([]string{ + "invalid_grant", "invalid_client", "invalid_scope", "invalid_request", + "access_denied", "expired_token", "unauthorized_client", "unsupported_grant_type", + "authorization_pending", "slow_down", + "server_error", "temporarily_unavailable", + }, value) +} + +func safeString(value string) string { + u, err := url.Parse(value) + if err == nil && u.IsAbs() && (u.Scheme == "http" || u.Scheme == "https") { + return safeURL(u) + } + return value +} + +func headerTrace(headers http.Header) string { + var trace strings.Builder + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + values := slices.Clone(headers[key]) + if sensitive(key) { + values = []string{redacted} + } else { + for i, value := range values { + values[i] = safeString(value) + } + } + if _, err := fmt.Fprintf(&trace, "header %q: %q\n", key, values); err != nil { + return trace.String() + } + } + return trace.String() +} + +func bodyTrace(contentType string, data []byte, size int64, complete bool) string { + kind, _, err := mime.ParseMediaType(contentType) + if err != nil { + kind = "unknown" + } + omitted := fmt.Sprintf("omitted (type=%q; observed-size=%d; preview-limit=%d)", kind, size, previewLimit) + if !complete { + return omitted + } + switch { + case kind == "application/json" || strings.HasSuffix(kind, "+json"): + return jsonTrace(data, omitted) + case kind == "application/x-www-form-urlencoded": + values, err := url.ParseQuery(string(data)) + if err != nil { + return omitted + } + redactValues(values) + return fmt.Sprintf("%q", values.Encode()) + default: + return omitted + } +} + +func jsonTrace(data []byte, omitted string) string { + if !json.Valid(data) { + return omitted + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return omitted + } + encoded, err := json.Marshal(redactJSON(value)) + if err != nil { + return omitted + } + return string(encoded) +} + +func redactJSON(value any) any { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + typed[key] = redactField(key, child) + } + case []any: + for i, child := range typed { + typed[i] = redactJSON(child) + } + case string: + return safeString(typed) + } + return value +} + +func redactField(key string, value any) any { + if text, ok := value.(string); ok && safeOAuthError(key, text) { + return text + } + if sensitive(key) { + return redacted + } + return redactJSON(value) +} diff --git a/internal/httpdebug/transport.go b/internal/httpdebug/transport.go new file mode 100644 index 00000000..5f4c7a83 --- /dev/null +++ b/internal/httpdebug/transport.go @@ -0,0 +1,122 @@ +// Package httpdebug provides bounded HTTP traces without consuming body streams. +package httpdebug + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" +) + +const previewLimit = 64 << 10 + +type transport struct { + base http.RoundTripper + out io.Writer + mu sync.Mutex + next atomic.Uint64 +} + +// New wraps base with redacted traces written to out (normally stderr). A nil +// base uses http.DefaultTransport; nil out disables tracing. Output failures +// never change an HTTP result. Response bodies are traced as the caller reads +// them, on EOF or Close, so tracing never waits for or drains a response stream. +func New(base http.RoundTripper, out io.Writer) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + if out == nil { + return base + } + return &transport{base: base, out: out} +} + +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + id := t.next.Add(1) + t.write(fmt.Sprintf("HTTP %d request %q %q\n%sbody: %s\n", id, req.Method, safeURL(req.URL), headerTrace(req.Header), requestBody(req))) + response, err := t.base.RoundTrip(req) + if err != nil { + // Transport errors can include raw URLs, credentials and server text. + t.write(fmt.Sprintf("HTTP %d transport error (details omitted)\n", id)) + } + if response != nil { + t.write(fmt.Sprintf("HTTP %d response %d\n%s", id, response.StatusCode, headerTrace(response.Header))) + if response.Body != nil { + response.Body = &tracedBody{ReadCloser: response.Body, transport: t, id: id, contentType: response.Header.Get("Content-Type")} + } else { + t.write(fmt.Sprintf("HTTP %d response body: empty\n", id)) + } + } + return response, err +} + +func (t *transport) write(trace string) { + t.mu.Lock() + defer t.mu.Unlock() + if _, err := io.WriteString(t.out, trace); err != nil { + return + } +} + +func requestBody(req *http.Request) string { + if req.Body == nil || req.Body == http.NoBody { + return "empty" + } + if req.GetBody == nil { + return fmt.Sprintf("omitted (non-replayable body; size=%d)", req.ContentLength) + } + body, err := req.GetBody() + if err != nil || body == nil { + return "omitted (body copy unavailable)" + } + data, readErr := io.ReadAll(io.LimitReader(body, previewLimit+1)) + closeErr := body.Close() + return bodyTrace(req.Header.Get("Content-Type"), data, int64(len(data)), readErr == nil && closeErr == nil && len(data) <= previewLimit) +} + +type tracedBody struct { + io.ReadCloser + transport *transport + id uint64 + contentType string + preview []byte + size int64 + once sync.Once + mu sync.Mutex +} + +func (b *tracedBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.mu.Lock() + b.size += int64(n) + remaining := previewLimit + 1 - len(b.preview) + if remaining > 0 { + b.preview = append(b.preview, p[:min(n, remaining)]...) + } + b.mu.Unlock() + if err != nil { + b.finish(errors.Is(err, io.EOF)) + } + return n, err +} + +func (b *tracedBody) Close() error { + err := b.ReadCloser.Close() + b.finish(false) + return err +} + +func (b *tracedBody) finish(complete bool) { + b.once.Do(func() { + b.mu.Lock() + defer b.mu.Unlock() + var trace bytes.Buffer + if _, err := fmt.Fprintf(&trace, "HTTP %d response body: %s\n", b.id, bodyTrace(b.contentType, b.preview, b.size, complete && b.size <= previewLimit)); err != nil { + return + } + b.transport.write(trace.String()) + }) +} diff --git a/internal/httpdebug/transport_test.go b/internal/httpdebug/transport_test.go new file mode 100644 index 00000000..dea9def2 --- /dev/null +++ b/internal/httpdebug/transport_test.go @@ -0,0 +1,344 @@ +package httpdebug_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "sync" + "testing" + + "github.com/formancehq/fctl/v4/internal/httpdebug" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func debugRequest(t *testing.T, method, endpoint, body, contentType string) *http.Request { + t.Helper() + r, err := http.NewRequestWithContext(t.Context(), method, endpoint, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + r.Header.Set("Content-Type", contentType) + return r +} + +func fixtureResponse(body, contentType string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(body))} +} + +func consumeResponse(t *testing.T, response *http.Response) string { + t.Helper() + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if err := response.Body.Close(); err != nil { + t.Fatal(err) + } + return string(data) +} + +func assertHidden(t *testing.T, trace string, secrets ...string) { + t.Helper() + for _, secret := range secrets { + if strings.Contains(trace, secret) { + t.Errorf("trace leaked %q: %s", secret, trace) + } + } +} + +func TestDeploySecretsAreRedactedWithoutChangingResponse(t *testing.T) { + body := `{"name":"visible-variable","variables":[{"key":"credential","value":"secret-variable"}],"terraformState":{"outputs":{"password":"secret-state"}},"tfstate":"secret-raw-state"}` + var trace bytes.Buffer + req := debugRequest(t, "POST", "https://example.test/variables", body, "application/json") + response, err := httpdebug.New(roundTripFunc(func(got *http.Request) (*http.Response, error) { + data, readErr := io.ReadAll(got.Body) + if readErr != nil || string(data) != body { + t.Fatalf("request body changed: %s %v", data, readErr) + } + if closeErr := got.Body.Close(); closeErr != nil { + t.Fatal(closeErr) + } + return fixtureResponse(body, "application/json"), nil + }), &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != body { + t.Fatal("response body changed") + } + assertHidden(t, trace.String(), "secret-variable", "secret-state", "secret-raw-state") + if !strings.Contains(trace.String(), "visible-variable") { + t.Fatal("trace lost non-secret variable name") + } +} + +func TestTraceRedactsNestedJSONHeadersAndURLsWithoutMutation(t *testing.T) { + requestBody := `{"client_secret":"secret-client","nested":[{"access_token":"secret-access","password":"secret-password","assertion":"secret-assertion"}],"name":"visible","amount":9007199254740993}` + responseBody := `{"id_token":"secret-id","refresh_token":"secret-refresh","error_description":"unstructured echoed credential","verification_uri_complete":"https://example.test/verify?user_code=secret-verification&locale=en","data":{"name":"response-visible"}}` + req := debugRequest(t, "POST", "https://username:secret-url@example.test/oauth?device_code=secret-device&user_code=secret-user&client_secret=secret-query&locale=en#secret-fragment", requestBody, "application/json") + req.Header.Set("Authorization", "Bearer secret-header") + req.Header.Set("Cookie", "sid=secret-cookie") + originalHeaders, originalURL := req.Header.Clone(), req.URL.String() + var trace bytes.Buffer + base := roundTripFunc(func(got *http.Request) (*http.Response, error) { + if got != req || got.Context() != t.Context() { + t.Fatal("transport replaced request or context") + } + data, err := io.ReadAll(got.Body) + if err != nil || string(data) != requestBody { + t.Fatalf("request body changed: %s %v", data, err) + } + if err := got.Body.Close(); err != nil { + t.Fatal(err) + } + response := fixtureResponse(responseBody, "application/json") + response.Header.Set("Set-Cookie", "sid=secret-set-cookie") + response.Header.Set("Location", "https://example.test/verify?user_code=secret-location") + return response, nil + }) + response, err := httpdebug.New(base, &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != responseBody { + t.Fatal("response body changed") + } + assertHidden(t, trace.String(), "secret-", "username", "unstructured echoed credential") + for _, visible := range []string{"POST", "response 200", "visible", "9007199254740993", "locale=en", "[REDACTED]"} { + if !strings.Contains(trace.String(), visible) { + t.Errorf("trace missing %q: %s", visible, &trace) + } + } + if req.URL.String() != originalURL || !reflect.DeepEqual(req.Header, originalHeaders) { + t.Fatal("redaction modified outgoing URL or headers") + } +} + +func TestFormTracingRedactsOAuthFieldsAndErrorText(t *testing.T) { + body := "client_id=fctl&client_secret=secret-form&password=secret-password&device_code=secret-device&user_code=secret-user&id_token_hint=secret-hint&assertion=secret-assertion&token=secret-token&error_description=echoed-secret&scope=openid" + req := debugRequest(t, "POST", "https://example.test/token", body, "application/x-www-form-urlencoded; charset=utf-8") + var trace bytes.Buffer + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(body, "application/x-www-form-urlencoded"), nil + }), &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != body { + t.Fatal("form response changed") + } + assertHidden(t, trace.String(), "secret-", "echoed-secret") + if !strings.Contains(trace.String(), "scope=openid") || !strings.Contains(trace.String(), "client_id=fctl") { + t.Fatal("non-sensitive form fields missing") + } +} + +func TestAuthSecretClearIsRedactedInNestedJSON(t *testing.T) { + body := `{"data":{"id":"s1","clear":"secret-created","nested":[{"clear":"secret-nested","lastDigits":"1234"}]},"clear":"secret-top-level"}` + var trace bytes.Buffer + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(body, "application/json"), nil + }), &trace).RoundTrip(debugRequest(t, "POST", "https://example.test/clients/c1/secrets", body, "application/json")) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != body { + t.Fatal("redaction changed the returned Auth secret") + } + assertHidden(t, trace.String(), "secret-created", "secret-nested", "secret-top-level") + if strings.Count(trace.String(), `"clear":"[REDACTED]"`) != 6 || !strings.Contains(trace.String(), `"lastDigits":"1234"`) { + t.Fatal("request and response did not redact every clear field or lost safe metadata") + } +} + +func TestUnknownMalformedAndOversizedBodiesAreNeverPrinted(t *testing.T) { + for _, tc := range []struct{ name, kind, body string }{ + {"unknown", "", "secret-raw"}, + {"text", "text/plain", "secret-text"}, + {"binary", "application/octet-stream", "secret-binary"}, + {"malformed json", "application/json", `{"secret":"secret-malformed"`}, + {"malformed form", "application/x-www-form-urlencoded", "name=secret-form%ZZ"}, + {"oversized json", "application/json", `{"visible":"secret-large` + strings.Repeat("x", 128<<10) + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var trace bytes.Buffer + req := debugRequest(t, "POST", "https://example.test", tc.body, tc.kind) + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(tc.body, tc.kind), nil + }), &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != tc.body { + t.Fatal("omitted response was truncated or changed") + } + assertHidden(t, trace.String(), "secret-") + if !strings.Contains(trace.String(), "omitted") || trace.Len() > 4096 { + t.Fatal("body trace was not bounded and summarized") + } + }) + } +} + +type trackedBody struct { + reader io.Reader + reads int + closed bool +} + +func (b *trackedBody) Read(p []byte) (int, error) { b.reads++; return b.reader.Read(p) } +func (b *trackedBody) Close() error { b.closed = true; return nil } + +func TestNonReplayableRequestAndResponseRemainLazy(t *testing.T) { + originalRequest := &trackedBody{reader: strings.NewReader("secret-stream")} + originalResponse := &trackedBody{reader: strings.NewReader(`{"name":"stream-visible"}`)} + req := debugRequest(t, "POST", "https://example.test", "", "application/json") + req.Body, req.GetBody = originalRequest, nil + var trace bytes.Buffer + response, err := httpdebug.New(roundTripFunc(func(got *http.Request) (*http.Response, error) { + if got.Body != originalRequest || originalRequest.reads != 0 || originalRequest.closed { + t.Fatal("tracing consumed or replaced non-replayable request") + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: originalResponse}, nil + }), &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if originalResponse.reads != 0 || originalResponse.closed { + t.Fatal("RoundTrip eagerly read response stream") + } + defer func() { + if err := response.Body.Close(); err != nil { + t.Error(err) + } + }() + if err := response.Body.Close(); err != nil { + t.Fatal(err) + } + if !originalResponse.closed || originalResponse.reads != 0 { + t.Fatal("Close drained response or failed to close it") + } + assertHidden(t, trace.String(), "secret-stream", "stream-visible") + if !strings.Contains(trace.String(), "non-replayable") { + t.Fatal("non-replayable request omission not reported") + } +} + +type failingBody struct{ err error } + +func (b failingBody) Read(p []byte) (int, error) { return copy(p, "secret-partial"), b.err } +func (b failingBody) Close() error { return b.err } + +func TestTransportAndBodyErrorsArePreservedButNotPrinted(t *testing.T) { + want := errors.New("secret-error https://example.test?token=secret-token") + req := debugRequest(t, "GET", "https://example.test", "", "") + var trace bytes.Buffer + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, want + }), &trace).RoundTrip(req) + if response != nil { + if closeErr := response.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + t.Fatal("transport error unexpectedly returned a response") + } + if !errors.Is(err, want) { + t.Fatal("changed transport error") + } + response, err = httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 400, Header: http.Header{"Content-Type": {"application/json"}}, Body: failingBody{want}}, nil + }), &trace).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + if string(data) != "secret-partial" || !errors.Is(err, want) { + t.Fatal("changed partial body or read error") + } + if err := response.Body.Close(); !errors.Is(err, want) { + t.Fatal("changed body close error") + } + assertHidden(t, trace.String(), "secret-") +} + +func TestOAuthErrorCodesRemainVisibleWithoutErrorText(t *testing.T) { + for _, kind := range []string{"application/json", "application/x-www-form-urlencoded"} { + for _, code := range []string{ + "invalid_grant", "invalid_client", "invalid_scope", "invalid_request", + "access_denied", "expired_token", "unauthorized_client", "unsupported_grant_type", + "authorization_pending", "slow_down", "server_error", "temporarily_unavailable", "secret-arbitrary-code", + } { + t.Run(kind+"/"+code, func(t *testing.T) { checkOAuthErrorTrace(t, kind, code) }) + } + } +} + +func checkOAuthErrorTrace(t *testing.T, kind, code string) { + t.Helper() + body := fmt.Sprintf(`{"error":%q,"error_description":"secret-echoed-description","nested":{"error":"secret-nested-error"}}`, code) + if kind == "application/x-www-form-urlencoded" { + body = url.Values{"error": {code}, "error_description": {"secret-echoed-description"}}.Encode() + } + var trace bytes.Buffer + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(body, kind), nil + }), &trace).RoundTrip(debugRequest(t, "POST", "https://example.test/token", body, kind)) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != body { + t.Fatal("OAuth error tracing changed the HTTP body") + } + assertHidden(t, trace.String(), "secret-") + if code != "secret-arbitrary-code" && !strings.Contains(trace.String(), code) { + t.Fatalf("recognized OAuth error code was hidden: %s", &trace) + } +} + +func TestConcurrentTracesUseSerializedWriter(t *testing.T) { + var trace bytes.Buffer + transport := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(`{"ok":true}`, "application/json"), nil + }), &trace) + req := debugRequest(t, "GET", "https://example.test", "", "") + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + response, err := transport.RoundTrip(req.Clone(t.Context())) + if err != nil { + t.Error(err) + return + } + consumeResponse(t, response) + }) + } + wg.Wait() + if strings.Count(trace.String(), "response body:") != 32 || strings.Count(trace.String(), "request") != 32 { + t.Fatal("concurrent traces missing or interleaved") + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("output unavailable") } + +func TestWriterFailureDoesNotChangeHTTPResponse(t *testing.T) { + req := debugRequest(t, "GET", "https://example.test", "", "") + response, err := httpdebug.New(roundTripFunc(func(*http.Request) (*http.Response, error) { + return fixtureResponse(`{"ok":true}`, "application/json"), nil + }), failingWriter{}).RoundTrip(req) + if err != nil { + t.Fatal(err) + } + if got := consumeResponse(t, response); got != `{"ok":true}` { + t.Fatal("failed trace output affected HTTP result") + } +} diff --git a/internal/interactive/interactive.go b/internal/interactive/interactive.go new file mode 100644 index 00000000..dfe2813d --- /dev/null +++ b/internal/interactive/interactive.go @@ -0,0 +1,228 @@ +// Package interactive owns terminal forms. Plugins describe fields using the +// public SDK; they never depend on this package or on the terminal UI library. +package interactive + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + "unicode" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" +) + +var ErrCanceled = errors.New("operation canceled") + +type Option struct{ Label, Value string } + +type Field struct { + Title, Description, Kind, Default string + Required, Secret bool + Options []Option + Validate func(string) error +} + +// Runner is an injectable terminal boundary for command integration tests. +type Runner interface { + Run(context.Context, *cobra.Command, []Field) ([]string, error) +} + +type runnerKey struct{} + +func WithRunner(ctx context.Context, runner Runner) context.Context { + return context.WithValue(ctx, runnerKey{}, runner) +} + +func Enabled(cmd *cobra.Command) bool { + disabled := flagValue(cmd, "no-input") == "true" + if disabled || truthy(os.Getenv("FCTL_NO_INPUT")) || truthy(os.Getenv("CI")) { + return false + } + if runner, ok := cmd.Context().Value(runnerKey{}).(Runner); ok && runner != nil { + return true + } + input, inOK := cmd.InOrStdin().(*os.File) + output, outOK := cmd.ErrOrStderr().(*os.File) + return inOK && outOK && term.IsTerminal(input.Fd()) && term.IsTerminal(output.Fd()) +} + +func truthy(value string) bool { + return value != "" && value != "0" && !strings.EqualFold(value, "false") +} + +func Select(ctx context.Context, cmd *cobra.Command, title string, options []Option) (string, error) { + if len(options) == 0 { + return "", fmt.Errorf("no choices available for %s", title) + } + values, err := Run(ctx, cmd, []Field{{Title: title, Kind: "select", Options: options, Required: true}}) + if err != nil { + return "", err + } + return values[0], nil +} + +func Confirm(ctx context.Context, cmd *cobra.Command, title string) (bool, error) { + values, err := Run(ctx, cmd, []Field{{Title: title, Kind: "confirm", Default: "false"}}) + if err != nil { + return false, err + } + return values[0] == "true", nil +} + +func Run(ctx context.Context, cmd *cobra.Command, fields []Field) ([]string, error) { + fields = safeFields(fields) + if err := ctx.Err(); err != nil { + return nil, err + } + if !Enabled(cmd) { + return nil, fmt.Errorf("interactive input requires a terminal; provide arguments and flags") + } + if runner, ok := ctx.Value(runnerKey{}).(Runner); ok && runner != nil { + values, err := runner.Run(ctx, cmd, fields) + if err != nil { + return nil, err + } + if len(values) != len(fields) { + return nil, fmt.Errorf("interactive runner returned an incorrect number of values") + } + for i, field := range fields { + if err := validate(field, values[i]); err != nil { + return nil, err + } + } + return values, nil + } + return runTerminal(ctx, cmd, fields) +} + +// SafeLabel prevents resource names from becoming terminal control sequences. +// Bound values stay exact; only text displayed by the terminal is escaped. +func SafeLabel(value string) string { + var result []string + for _, r := range value { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + result = append(result, fmt.Sprintf("\\u%04x", r)) + } else { + result = append(result, string(r)) + } + } + return strings.Join(result, "") +} + +func safeFields(fields []Field) []Field { + result := make([]Field, len(fields)) + for i, field := range fields { + field.Title, field.Description = SafeLabel(field.Title), SafeLabel(field.Description) + field.Options = append([]Option{}, field.Options...) + for j := range field.Options { + field.Options[j].Label = SafeLabel(field.Options[j].Label) + } + result[i] = field + } + return result +} + +func validate(field Field, value string) error { + if field.Required && strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", field.Title) + } + if field.Kind == "select" { + found := false + for _, option := range field.Options { + found = found || option.Value == value + } + if !found { + return fmt.Errorf("choose one of the available options") + } + } + if field.Validate != nil { + return field.Validate(value) + } + return nil +} + +func runTerminal(ctx context.Context, cmd *cobra.Command, fields []Field) ([]string, error) { + values := make([]string, len(fields)) + booleans := make([]bool, len(fields)) + form := terminalForm(cmd, fields, values, booleans) + color := flagValue(cmd, "color") + if color == "never" || (color != "always" && os.Getenv("NO_COLOR") != "") { + form.WithProgramOptions(tea.WithColorProfile(colorprofile.Ascii)) + } else if color == "always" { + form.WithProgramOptions(tea.WithColorProfile(colorprofile.TrueColor)) + } + // WithProgramOptions replaces Huh's program options. Bind streams last so + // forced color profiles cannot redirect terminal controls into JSON stdout. + form.WithInput(cmd.InOrStdin()).WithOutput(cmd.ErrOrStderr()) + if err := form.RunWithContext(ctx); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, tea.ErrProgramKilled) { + return nil, ErrCanceled + } + return nil, err + } + for i, field := range fields { + if field.Kind == "confirm" { + values[i] = strconv.FormatBool(booleans[i]) + } + if err := validate(field, values[i]); err != nil { + return nil, err + } + } + return values, nil +} + +func flagValue(cmd *cobra.Command, name string) string { + if flag := cmd.Flags().Lookup(name); flag != nil { + return flag.Value.String() + } + return "" +} + +func terminalControl(field Field, value *string, boolean *bool) huh.Field { + validator := func(value string) error { return validate(field, value) } + switch field.Kind { + case "select": + options := make([]huh.Option[string], 0, len(field.Options)) + for _, option := range field.Options { + options = append(options, huh.NewOption(option.Label, option.Value)) + } + return huh.NewSelect[string]().Title(field.Title).Description(field.Description).Options(options...).Value(value).Validate(validator) + case "confirm": + return huh.NewConfirm().Title(field.Title).Description(field.Description).Value(boolean) + case "text": + return huh.NewText().Title(field.Title).Description(field.Description).Value(value).Validate(validator) + default: + input := huh.NewInput().Title(field.Title).Description(field.Description).Value(value).Validate(validator) + if field.Secret { + input.EchoMode(huh.EchoModePassword) + } + return input + } +} + +func terminalForm(cmd *cobra.Command, fields []Field, values []string, booleans []bool) *huh.Form { + groups := make([]*huh.Group, 0, len(fields)) + for i, field := range fields { + values[i] = field.Default + booleans[i] = field.Default == "true" + group := huh.NewGroup(terminalControl(field, &values[i], &booleans[i])).Title(cmd.CommandPath()) + if len(fields) > 1 { + group.Description(fmt.Sprintf("Step %d of %d · Ctrl+C to cancel", i+1, len(fields))) + } + if len(fields) == 1 { + group.Description("Ctrl+C to cancel") + } + groups = append(groups, group) + } + return huh.NewForm(groups...) +} diff --git a/internal/interactive/interactive_test.go b/internal/interactive/interactive_test.go new file mode 100644 index 00000000..d1e0fdbc --- /dev/null +++ b/internal/interactive/interactive_test.go @@ -0,0 +1,250 @@ +package interactive_test + +import ( + "bytes" + "context" + "errors" + "reflect" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/interactive" +) + +type testRunner func(context.Context, *cobra.Command, []interactive.Field) ([]string, error) + +type enabledCase struct { + name, ci, noInput string + flag, injected, want bool +} + +type fieldValidationCase struct { + name string + fields []interactive.Field + values []string + wantError string + wantCause error +} + +func (r testRunner) Run(ctx context.Context, cmd *cobra.Command, fields []interactive.Field) ([]string, error) { + return r(ctx, cmd, fields) +} + +func bufferedCommand(t *testing.T, runner interactive.Runner) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "fixture"} + cmd.Flags().Bool("no-input", false, "disable interactive input") + cmd.SetIn(new(bytes.Buffer)) + cmd.SetOut(new(bytes.Buffer)) + cmd.SetErr(new(bytes.Buffer)) + ctx := t.Context() + if runner != nil { + ctx = interactive.WithRunner(ctx, runner) + } + cmd.SetContext(ctx) + return cmd +} + +func TestEnabledHonorsNoninteractiveControls(t *testing.T) { + for _, tc := range []enabledCase{ + {name: "buffered runner", injected: true, want: true}, + {name: "buffers without runner"}, + {name: "explicit no-input", flag: true, injected: true}, + {name: "CI", ci: "true", injected: true}, + {name: "numeric CI", ci: "1", injected: true}, + {name: "FCTL_NO_INPUT", noInput: "yes", injected: true}, + {name: "false controls", ci: "FALSE", noInput: "0", injected: true, want: true}, + {name: "zero controls", ci: "0", noInput: "false", injected: true, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { assertEnabledCase(t, tc) }) + } +} + +func assertEnabledCase(t *testing.T, tc enabledCase) { + t.Helper() + t.Setenv("CI", tc.ci) + t.Setenv("FCTL_NO_INPUT", tc.noInput) + var runner interactive.Runner + if tc.injected { + runner = testRunner(func(context.Context, *cobra.Command, []interactive.Field) ([]string, error) { + t.Fatal("disabled form or Enabled check invoked the runner") + return nil, nil + }) + } + cmd := bufferedCommand(t, runner) + if err := cmd.Flags().Set("no-input", map[bool]string{false: "false", true: "true"}[tc.flag]); err != nil { + t.Fatal(err) + } + if got := interactive.Enabled(cmd); got != tc.want { + t.Fatalf("Enabled = %v, want %v", got, tc.want) + } + if !tc.want { + if _, err := interactive.Run(cmd.Context(), cmd, []interactive.Field{{Title: "Name", Kind: "input"}}); err == nil { + t.Fatal("Run accepted a form with interactive input disabled") + } + } +} + +func TestRunUsesInjectedRunnerAndValidatesFields(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + validationErr := errors.New("custom validation failed") + for _, tc := range []fieldValidationCase{ + {"required", []interactive.Field{{Title: "Name", Kind: "input", Required: true}}, []string{" "}, "Name is required", nil}, + {"unknown choice", []interactive.Field{{Title: "Ledger", Kind: "select", Options: []interactive.Option{{Label: "Demo", Value: "demo"}}}}, []string{"other"}, "available options", nil}, + {"custom validation", []interactive.Field{{Title: "Amount", Kind: "input", Validate: func(string) error { return validationErr }}}, []string{"invalid"}, validationErr.Error(), validationErr}, + {"too few values", []interactive.Field{{Title: "Name", Kind: "input"}}, nil, "incorrect number of values", nil}, + {"too many values", []interactive.Field{{Title: "Name", Kind: "input"}}, []string{"one", "two"}, "incorrect number of values", nil}, + {"multiple fields", []interactive.Field{{Title: "Name", Kind: "input", Default: "demo", Required: true}, {Title: "Credential", Kind: "input", Description: "Private", Secret: true}}, []string{"demo", "fixture-secret"}, "", nil}, + } { + t.Run(tc.name, func(t *testing.T) { testFieldValidation(t, tc) }) + } +} + +func testFieldValidation(t *testing.T, tc fieldValidationCase) { + t.Helper() + calls := 0 + runner := testRunner(func(ctx context.Context, cmd *cobra.Command, fields []interactive.Field) ([]string, error) { + calls++ + if ctx != cmd.Context() { + t.Fatal("runner did not receive the command context") + } + assertFieldMetadata(t, fields, tc.fields) + return tc.values, nil + }) + cmd := bufferedCommand(t, runner) + values, err := interactive.Run(cmd.Context(), cmd, tc.fields) + if calls != 1 { + t.Fatalf("runner calls = %d, want 1", calls) + } + assertFieldResult(t, values, err, tc) +} + +func assertFieldMetadata(t *testing.T, got, want []interactive.Field) { + t.Helper() + if len(got) != len(want) { + t.Fatal("runner did not receive the complete schema") + } + for i, field := range got { + expected := want[i] + text := [4]string{field.Title, field.Kind, field.Default, field.Description} + expectedText := [4]string{expected.Title, expected.Kind, expected.Default, expected.Description} + if text != expectedText || field.Secret != expected.Secret || field.Required != expected.Required || !slices.Equal(field.Options, expected.Options) { + t.Fatalf("field %d metadata changed", i) + } + } +} + +func assertFieldResult(t *testing.T, values []string, err error, tc fieldValidationCase) { + t.Helper() + if tc.wantError == "" { + if err != nil || !reflect.DeepEqual(values, tc.values) { + t.Fatal("runner values were lost or changed") + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Run error = %v, want %q", err, tc.wantError) + } + if tc.wantCause != nil && !errors.Is(err, tc.wantCause) { + t.Fatal("custom validation error identity was lost") + } +} + +func TestRunCanceledContextDoesNotInvokeRunner(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + cmd := bufferedCommand(t, testRunner(func(context.Context, *cobra.Command, []interactive.Field) ([]string, error) { + t.Fatal("canceled operation invoked a form") + return nil, nil + })) + ctx, cancel := context.WithCancel(cmd.Context()) + cancel() + _, err := interactive.Run(ctx, cmd, []interactive.Field{{Title: "Name", Kind: "input"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run error = %v, want context.Canceled", err) + } +} + +func TestSelectAndConfirmThroughRunner(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + options := []interactive.Option{{Label: "Demo", Value: "demo"}, {Label: "Production", Value: "prod"}} + calls := 0 + cmd := bufferedCommand(t, testRunner(func(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + calls++ + values := make([]string, len(fields)) + for i, field := range fields { + values[i] = selectorAnswer(t, field, options) + } + return values, nil + })) + if _, err := interactive.Select(cmd.Context(), cmd, "Empty", nil); err == nil || calls != 0 { + t.Fatal("an empty selector reached the UI") + } + if selected, err := interactive.Select(cmd.Context(), cmd, "Ledger", options); err != nil || selected != "prod" { + t.Fatalf("Select = %q, %v", selected, err) + } + if accepted, err := interactive.Confirm(cmd.Context(), cmd, "Delete prod?"); err != nil || accepted { + t.Fatalf("Confirm = %v, %v; expected decline", accepted, err) + } +} + +func selectorAnswer(t *testing.T, field interactive.Field, options []interactive.Option) string { + t.Helper() + switch field.Kind { + case "select": + if field.Title != "Ledger" || !field.Required || !reflect.DeepEqual(field.Options, options) { + t.Fatal("selector lost its title, choices, or required constraint") + } + return "prod" + case "confirm": + if field.Title != "Delete prod?" || field.Default != "false" { + t.Fatal("destructive confirmation lost its title or safe default") + } + return "false" + default: + t.Fatalf("unexpected field %q of kind %q", field.Title, field.Kind) + return "" + } +} + +func TestRunPreservesRunnerCancellation(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + cmd := bufferedCommand(t, testRunner(func(context.Context, *cobra.Command, []interactive.Field) ([]string, error) { + return nil, interactive.ErrCanceled + })) + values, err := interactive.Run(cmd.Context(), cmd, []interactive.Field{{Title: "Credential", Kind: "input", Secret: true}}) + if !errors.Is(err, interactive.ErrCanceled) || values != nil { + t.Fatalf("Run returned %v, %v after cancellation", values, err) + } +} + +func TestRunEscapesDisplayTextWithoutChangingBoundValues(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + const resource = "ledger\x1b[31m\n\u202e" + fields := []interactive.Field{{ + Title: "Pick\x1b[31m", Description: "Line\nNext\u202e", Kind: "select", + Options: []interactive.Option{{Label: resource, Value: resource}}, + }} + cmd := bufferedCommand(t, testRunner(func(_ context.Context, _ *cobra.Command, received []interactive.Field) ([]string, error) { + field := received[0] + if field.Title != `Pick\u001b[31m` || field.Description != `Line\u000aNext\u202e` || field.Options[0].Label != `ledger\u001b[31m\u000a\u202e` || field.Options[0].Value != resource { + t.Fatal("terminal control characters were rendered or the bound identifier changed") + } + received[0].Options[0].Label = "runner changed the label" + return []string{resource}, nil + })) + values, err := interactive.Run(cmd.Context(), cmd, fields) + if err != nil || !reflect.DeepEqual(values, []string{resource}) { + t.Fatal("escaped display text prevented selection of the original identifier") + } + if fields[0].Title != "Pick\x1b[31m" || fields[0].Options[0].Label != resource { + t.Fatal("rendering mutated the caller's schema") + } +} diff --git a/internal/membershipclient/.gitattributes b/internal/membershipclient/.gitattributes deleted file mode 100644 index e6a99441..00000000 --- a/internal/membershipclient/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# This allows generated code to be indexed correctly -*.go linguist-generated=false \ No newline at end of file diff --git a/internal/membershipclient/.gitignore b/internal/membershipclient/.gitignore deleted file mode 100644 index 3bbfa14c..00000000 --- a/internal/membershipclient/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# .gitignore -.DS_Store -**/.speakeasy/temp/ -**/.speakeasy/logs/ -.speakeasy/reports -.env -.env.local diff --git a/internal/membershipclient/.speakeasy/gen.lock b/internal/membershipclient/.speakeasy/gen.lock deleted file mode 100644 index 8c4beca4..00000000 --- a/internal/membershipclient/.speakeasy/gen.lock +++ /dev/null @@ -1,2547 +0,0 @@ -lockVersion: 2.0.0 -id: 52f53439-fdea-4ef5-bb75-64ae4249026d -management: - docChecksum: 6b3804ce8f8681b821066d745cee255d - docVersion: 0.1.0 - speakeasyVersion: 1.759.2 - generationVersion: 2.869.23 - releaseVersion: 0.1.1 - configChecksum: 0f64eb7eb39232558b58f31d69c4a326 -persistentEdits: - generation_id: b6400492-7ae6-410b-92c1-4e656237250e - pristine_commit_hash: f954d7f87ccb7bfedf06599ae377ed60fccd4fe9 - pristine_tree_hash: 62ee69a3836ab27a0c2f6415bece4d1d29b15b4a -features: - go: - additionalDependencies: 0.1.0 - constsAndDefaults: 0.1.14 - core: 3.13.40 - defaultEnabledRetries: 0.2.0 - deprecations: 2.81.3 - envVarSecurityUsage: 0.3.2 - globalSecurity: 2.82.13 - globalSecurityCallbacks: 0.1.0 - globalSecurityFlattening: 0.1.0 - globalServerURLs: 2.83.1 - intellisenseMarkdownSupport: 0.1.0 - nullables: 0.2.1 - responseFormat: 0.1.2 - retries: 2.84.2 - sdkHooks: 0.3.0 - unions: 2.87.6 -trackedFiles: - .gitattributes: - id: 24139dae6567 - last_write_checksum: sha1:84b0115789fc3ea52e31df701d8a27f76077e29f - pristine_git_object: e6a994416d0f527912d2d272e2583458f4fc9bcb - USAGE.md: - id: 3aed33ce6e6f - last_write_checksum: sha1:4b083f4a4bbaf8cb2a3686024e6d8df16b3ec182 - pristine_git_object: f846dc443390d9f675f1a3a194c103f3d24531b5 - docs/models/apierrors/error.md: - id: 7c6e7719d689 - last_write_checksum: sha1:95bd54ddc30500355663c1a9ac0ab3384fe212d0 - pristine_git_object: 2d7d35a9eff74eec16d67438a41d4ef5e3c8f66c - docs/models/components/action.md: - id: 4c3e678208a3 - last_write_checksum: sha1:253d7b02847ac6f65e80dfa37d4803df6ca08498 - pristine_git_object: 22c3ebf94b5a6720dad3a8101aa22527098146c2 - docs/models/components/anyregion.md: - id: ed320d1b2243 - last_write_checksum: sha1:ac8cff35cd99c3fa8c0f08f24d06c7e3d91f037e - pristine_git_object: 8f4ea7e7be834ee3eb7a095267222410863aa910 - docs/models/components/application.md: - id: eaa211cc7a8e - last_write_checksum: sha1:46f74f530eea14640830a90fac7a45010623b295 - pristine_git_object: 169476ab0798b8e227d176429eb85a8213fc2aa3 - docs/models/components/applicationdata.md: - id: 16c790b3c2c4 - last_write_checksum: sha1:75cbdd31ac9ee58cec626cfb6eeb20d6ccc2a63b - pristine_git_object: 5bf57da0688be290c1cb9df8420efe50821d812b - docs/models/components/applicationwithscope.md: - id: 5da949454794 - last_write_checksum: sha1:1e7472bafd9c5f9fd2aa5f5812ea0c5bdd7dcdd6 - pristine_git_object: 97c7879165398eac7abd0fc686b6954eab372907 - docs/models/components/authenticationproviderresponse.md: - id: 53b29302eb82 - last_write_checksum: sha1:7945fe1daa50e22b0795c906a4b146ec52df99ff - pristine_git_object: f4056631bf309da23a5c37b1c4ee41587bbb1d13 - docs/models/components/authenticationproviderresponsegithubidpconfig.md: - id: 71543f546ce9 - last_write_checksum: sha1:bd21af96fb5a5496827877bf5cfa2ef086159f66 - pristine_git_object: dc7586c8a662d3ae374616c7d4f9621a27e9c858 - docs/models/components/authenticationproviderresponsegithubidpconfigconfig.md: - id: d96cbf1e0a6b - last_write_checksum: sha1:1d1609eed1b3ae330e27f77c79c346948166d9ff - pristine_git_object: 0301600da296a5f69cc1a08e5bf788b9787ec02b - docs/models/components/authenticationproviderresponsegithubidpconfigtype.md: - id: b7325a9aaf7d - last_write_checksum: sha1:a1c63479a4fe6bd617eaef941e57bac8f6650fd0 - pristine_git_object: e044234b910e94e23a99b2e31c59399ee60d05d8 - docs/models/components/authenticationproviderresponsegoogleidpconfig.md: - id: 4c42fb415022 - last_write_checksum: sha1:945d20ebe02f39ceb5be9dde0133e793f72faf07 - pristine_git_object: 61e163a56be3a499c8eff2529c9d368cbfaa7dc8 - docs/models/components/authenticationproviderresponsegoogleidpconfigconfig.md: - id: e7f77ef66f44 - last_write_checksum: sha1:3b7e7fe89b2ffb31b07596f0d1893ec94662b520 - pristine_git_object: b777b9382c909be120cde2fc6d5980a8ad81c060 - docs/models/components/authenticationproviderresponsegoogleidpconfigtype.md: - id: 2abdf28cff83 - last_write_checksum: sha1:55bd6ac04f77fdc879da037b5d3cc64bd04b14e9 - pristine_git_object: 51fec2e40824192867c9a7bc91bad77dfed90baa - docs/models/components/authenticationproviderresponsemicrosoftidpconfig.md: - id: 8b5b33141731 - last_write_checksum: sha1:694302413fbabc45de350d725f6029d0265d75fd - pristine_git_object: d9e83db508418c88c52e67beb110004a21d4271a - docs/models/components/authenticationproviderresponsemicrosoftidpconfigconfig.md: - id: 9512dc76c2ac - last_write_checksum: sha1:07c5191d918c3959c5a5d098b1dccc10fe37fad7 - pristine_git_object: 7e51fee94f66b39c34e8186d58177d0f39672bb1 - docs/models/components/authenticationproviderresponsemicrosoftidpconfigtype.md: - id: 15921cbf60d5 - last_write_checksum: sha1:244cd8cbfb9f0739a2de79597b041c0e3f52031a - pristine_git_object: 47134f90d0cafa27161719307199b15c69a95e48 - docs/models/components/authenticationproviderresponseoidcconfig.md: - id: 88cca02ef6fc - last_write_checksum: sha1:82e6f9875182f0242f45577452bfc2f7711f6b92 - pristine_git_object: d30dddf1a25a7307cf2327fa5ba4c03d5ea68815 - docs/models/components/authenticationproviderresponseoidcconfigconfig.md: - id: dda405be47e6 - last_write_checksum: sha1:0e27800ea12ee3c47dec7a30496a42106bed0a65 - pristine_git_object: 06d33b98463025fc332d09328c74eae8764d3aba - docs/models/components/authenticationproviderresponseoidcconfigtype.md: - id: 7e13933d0553 - last_write_checksum: sha1:431636636aa37e7ffbfd37dd48cccf6c57dc7a2f - pristine_git_object: 58a272cd18ff6a6be701401dc4b2b0f3036a7415 - docs/models/components/capability.md: - id: ffe0cca37421 - last_write_checksum: sha1:772eb686186a2ea9509b602bf9cf369c1995f820 - pristine_git_object: beeb69a1e3b7a423762ad7b936b7d9abe8d9a94f - docs/models/components/clusterstatus.md: - id: c52e542576ee - last_write_checksum: sha1:c3299d4ec1fcf9c95d4bd7bb09187e249df96619 - pristine_git_object: 0b60308ebeffb67fb55bf44a7e5e760f46abef36 - docs/models/components/createapplicationresponse.md: - id: ba163541ad2f - last_write_checksum: sha1:c3db7bcb3b66a6a3f0dc84f348cd6e9dfb6a6616 - pristine_git_object: 37f026822d0273d0736bad8ffeeedb3d625c4217 - docs/models/components/createapplicationscoperequest.md: - id: 94da9ed46113 - last_write_checksum: sha1:602a463ea085e80600f91ebc0b1a618f19e0339b - pristine_git_object: 55506603f3e49f3ded0f369acfc927da77a15936 - docs/models/components/createapplicationscoperesponse.md: - id: 9a58323b06dd - last_write_checksum: sha1:3f3f2bc8d3e74183412fea69c9317b633dd28251 - pristine_git_object: d8bb74632b6d76c0411b62f19541fc77f14e6332 - docs/models/components/createclientresponseresponse.md: - id: 20bdbe971ba9 - last_write_checksum: sha1:81b8943b83821b329bfd49c4a2fab6c6873d311a - pristine_git_object: 7082e1ff5e3b02508882c45ee563b5a8858d0439 - docs/models/components/createclientresponseresponsedata.md: - id: 818528e40a41 - last_write_checksum: sha1:3b82cf0ad9b4fa6381cead2d0383dcdcda136b56 - pristine_git_object: e90f0aaba3d245dfef6e2e6a61e161fe36110077 - docs/models/components/createclientresponseresponsesecret.md: - id: 854bbed89e16 - last_write_checksum: sha1:1f8d28a4331cb0727886226f01bbadbc96cf3a9e - pristine_git_object: 7bdb9ad43ace3ce369b06bb999a6702ea86a3cba - docs/models/components/createdprivateregionresponse.md: - id: 194bb5b8649a - last_write_checksum: sha1:cc1313482b823ea3ccff42180b691b5f69ec7f15 - pristine_git_object: c434fcf6165cbceea675115fe7fc199e057255d4 - docs/models/components/createinvitationresponse.md: - id: 3693270bae0e - last_write_checksum: sha1:e16b3214282bef7b11c09f2b1937e65c7c47b866 - pristine_git_object: 57404aaedf88dd37a3cc1dc07dc4ffae72747e86 - docs/models/components/createorganizationclientrequest.md: - id: 7037042c176e - last_write_checksum: sha1:23e37b6499db59860ab51e4e52cbf9daaf9a5dec - pristine_git_object: 1f71879e6bbe53b6ca93e995cf0d341b3dd6f095 - docs/models/components/createorganizationclientresponse.md: - id: fcd7a19d4f73 - last_write_checksum: sha1:7f98c16d17d27220fb623f40b4ee106a2406e632 - pristine_git_object: 07163fcac52fd0b9bfad7f2a04e91179546d1237 - docs/models/components/createorganizationrequest.md: - id: 0ceae077b9d1 - last_write_checksum: sha1:b35ba9f3a492213eb2e93b1afc473392b0f01bb6 - pristine_git_object: bc9587c150e66f10b2c0f4f126323c6e5155f2b6 - docs/models/components/createorganizationresponse.md: - id: 92b6ba9fe697 - last_write_checksum: sha1:46569fcd4ba9c634797c1fd1595082598336b852 - pristine_git_object: 5ab64439f3a5d20ee4c86096ee57516841a95acd - docs/models/components/createpolicyrequest.md: - id: 86507ccbf255 - last_write_checksum: sha1:88d13e95a50245c4099bd2655c72ba8bf2f6f697 - pristine_git_object: 832bdf26c028c0a33e37bcf2e2c87bc3dfd1703c - docs/models/components/createpolicyresponse.md: - id: 06fe504a7cec - last_write_checksum: sha1:8ec81e40ddee822bacc36f41dfd2c58026d46c8b - pristine_git_object: 202a71ef7e350834a0afe2dc80f3e6f2847c576b - docs/models/components/createprivateregionrequest.md: - id: e874a190f709 - last_write_checksum: sha1:34241b8f357def960f1c57b70963531192ecb580 - pristine_git_object: 8ff46b93c3928742ec1e3ef9fa07e2ce91d2cb70 - docs/models/components/createstackrequest.md: - id: 6c6b7ff9fb41 - last_write_checksum: sha1:3b3a10ed34a3ffa147e40710c6f25754c385992d - pristine_git_object: 9f48bf94ddaac3d02ded3049aa8aa8dcb0269cb5 - docs/models/components/createuserrequest.md: - id: 2a44ae2c089a - last_write_checksum: sha1:33114589fef4ebf946405c639eaeeeb9690fb3ab - pristine_git_object: 8f68c7cca4dfdd256384dbdebe8f9ad4af01fa7d - docs/models/components/createuserresponse.md: - id: 08537eaa4053 - last_write_checksum: sha1:334768597e669394d19cb751f106d430d5c7302f - pristine_git_object: 63bd364bdcd0c615259678b43bd1f57b2399eda5 - docs/models/components/cursor.md: - id: 34cc25901fd0 - last_write_checksum: sha1:caaa6dba1fd348dfde609c109910d2a37620bce0 - pristine_git_object: a428fcebd2f43cdbb184fb6cfbdc984d1fe6f7ab - docs/models/components/data.md: - id: 3f89c52c2b17 - last_write_checksum: sha1:f26b187f64bf2ddf5e0f53e4d270a8d239be0019 - pristine_git_object: 28c2f7867ffbcbdb06b9e446eedd845c25a0113d - docs/models/components/enableapplicationfororganizationresponse.md: - id: 6f20ba3c70cb - last_write_checksum: sha1:ca5ec0f49f11523e4d47884a515c72103461f116 - pristine_git_object: 67141a7e22f62514f978fc0b3b3a75fc65bc0036 - docs/models/components/error.md: - id: 8ccbea40d025 - last_write_checksum: sha1:e3115a5a7de0780066dc4a2256c42f7c0976447b - pristine_git_object: 5450343d4870b85f53cb47ab9190928cf72fb86f - docs/models/components/expectedstatus.md: - id: df158c15ac9f - last_write_checksum: sha1:98be7243aa5be5bb2c58a11b444550a5111111c6 - pristine_git_object: 2946c52f372e0777e9bf56e8ba89149f894e7ab2 - docs/models/components/getapplicationresponse.md: - id: a67d605cdf84 - last_write_checksum: sha1:b681e84d7f4af478a0f74e744f266d63102fb49d - pristine_git_object: 6f59f1e28a8741ed6d47e4a821c3862c4e0c8262 - docs/models/components/getregionresponse.md: - id: 031372dca18c - last_write_checksum: sha1:81ca0850d1a080a0c06455c112de38ca144b895a - pristine_git_object: 1d2d2886212bb06f647c036e549bca693cdb9f89 - docs/models/components/getregionversionsresponse.md: - id: 8d0c703798f6 - last_write_checksum: sha1:6c9116368ebe4e4e9c69d590c0a34427663e6610 - pristine_git_object: 7dc881604493f6a0805aac41b6c2c38d3299aaf9 - docs/models/components/httpmetadata.md: - id: 728948c70168 - last_write_checksum: sha1:7c40945dac218ce5e5e11e7c907d27a3ea14c417 - pristine_git_object: df1fdd59f3c85e4ba70573ff7954b12ac1d33d08 - docs/models/components/invitation.md: - id: f4d933ff0b90 - last_write_checksum: sha1:77ce0e174f0728e8494788b211ef41020611dbd6 - pristine_git_object: 0b20dc13084b2deeae5bacc92d41ece2c26b3fc8 - docs/models/components/invitationstatus.md: - id: 93e3a2644337 - last_write_checksum: sha1:1849e315598ac056d92288a9bd070eda23c8c2c4 - pristine_git_object: 17c724994a1cb1fb4f0dc92337a5462c2de36869 - docs/models/components/listapplicationsresponse.md: - id: a709efa0cdbc - last_write_checksum: sha1:8d3bfad271b27ebbf56c544823dc50fa7d07d112 - pristine_git_object: da707f358be29482490bc2fee1768b3ca7b17fa5 - docs/models/components/listinvitationsresponse.md: - id: fd3d6ed5f7f1 - last_write_checksum: sha1:ef104116d48f4481f22c0eb713fb34fbdcd1e97e - pristine_git_object: 45456e506a1be288a3f84730f37872c85afd65e5 - docs/models/components/listmodulesresponse.md: - id: c018ac6460bf - last_write_checksum: sha1:cfdbfa595756989efe02a688bff6bbcfd9ec006a - pristine_git_object: d1de1c153f2739128d7afdcdc7a01e75b961e83a - docs/models/components/listorganizationexpandedresponse.md: - id: c0c9bafde28c - last_write_checksum: sha1:60fd14c7b4def4392b54c5fca3d3c536e96118f9 - pristine_git_object: 326db67fbc8257fd0d64127eb58312408d395c54 - docs/models/components/listpoliciesresponse.md: - id: ed9974efb101 - last_write_checksum: sha1:673ae64722b6efe2e93115a47755dbe2a8031ab1 - pristine_git_object: 8a483704079f7acc437fac49dacac9b147bfe1d9 - docs/models/components/listregionsresponse.md: - id: c5f81a160036 - last_write_checksum: sha1:a9a1d33c4d9c0f0ce587587e9f519a79c6796b1b - pristine_git_object: bd98c631cd4f765ea1ffc31ce9f25ec3281c0d2e - docs/models/components/liststacksresponse.md: - id: eb76133842b4 - last_write_checksum: sha1:c5750b4aada3c3715248b5661e7059c4bcc8157c - pristine_git_object: c7bb25e15a88ebda8de4f767ec2c83e1b421e3db - docs/models/components/listusersresponse.md: - id: 32f8ed83491c - last_write_checksum: sha1:6a6993682d2d08eca06951e51065147f687d9aaf - pristine_git_object: 28623a623dbea14381bc074ddac2d676f3ec31bb - docs/models/components/log.md: - id: e1037d748e03 - last_write_checksum: sha1:991bed1aee64892a682da8e629d8e707f20efb70 - pristine_git_object: 01e6cf9090f23a934246627f44938975f7874946 - docs/models/components/logcursor.md: - id: 1260c27d92cd - last_write_checksum: sha1:4d46ddffedca7f261baee06576c5671d14dbcb7b - pristine_git_object: ef3f2af787842be2c4eba383fde41c42b50ca00e - docs/models/components/logcursordata.md: - id: 6f519125122e - last_write_checksum: sha1:b61e4e739de815c741c937c0406a43ea250800f0 - pristine_git_object: 9510d7ee15c95785b7593316576aeec643594f40 - docs/models/components/logdata.md: - id: 568bff177bc6 - last_write_checksum: sha1:2a64ba8c402f54251a44d2c4189db54032aa434c - pristine_git_object: 54cd59d9297b1a6ad8ceff046efd8553eb091b6a - docs/models/components/module.md: - id: 1efd5d46b0ce - last_write_checksum: sha1:323dfd9c795e48e6e2d6b9a6767e1234d5b6a140 - pristine_git_object: 146f3899672b57705a1688ead935c5c236223e07 - docs/models/components/modulestate.md: - id: e677dd51b84a - last_write_checksum: sha1:2cdb8bc7652d5e403a97ffc2b38e1add96888036 - pristine_git_object: ba2c8eb6a30102455721c7407fe59c98f66b58c6 - docs/models/components/modulestatus.md: - id: 6157bac7dac9 - last_write_checksum: sha1:ba06bf2c080c2c38c06323e18b866671f2ca92e6 - pristine_git_object: 9ddbed39d72fa75c130aaea173db67fa053d522c - docs/models/components/organizationaccess.md: - id: 3f7b74648c2e - last_write_checksum: sha1:8a62af299834679caf8c20808a3456f494553e59 - pristine_git_object: b38b056953ddefc6de4abbcd18ad463d0ad924d9 - docs/models/components/organizationapplication.md: - id: 2f42146dade1 - last_write_checksum: sha1:67af2bffbaac446b8b3125e41556e6cfa0dc6b65 - pristine_git_object: ca06e14b91b47f82d4e3abbc3def75caddb19e15 - docs/models/components/organizationclient.md: - id: 12e17271f777 - last_write_checksum: sha1:6e49e50f918591e8a2336dce0a17faacb55d1892 - pristine_git_object: d5ddab310bcdffde045d7214f91914fca2275e45 - docs/models/components/organizationclientsecret.md: - id: 6e5df914eee6 - last_write_checksum: sha1:0b810423e004ef5abe513d68123d1df0cde64729 - pristine_git_object: 8b5081e944b7613694214072c762ce48967aaaee - docs/models/components/organizationdata.md: - id: 73f992106d50 - last_write_checksum: sha1:ca8deb1611e8dc6a5a34d281b3a6cfa480f33602 - pristine_git_object: a5f2724f58a2a0f3367ef53953f992a4ab9a8cd6 - docs/models/components/organizationexpanded.md: - id: b525b67a86a9 - last_write_checksum: sha1:e366936a0ad77496dc3b14253bcccb4621dc1168 - pristine_git_object: 2b037ee65f0251e8dcd8d0267027a5c0b8253a55 - docs/models/components/organizationfeature.md: - id: 6c54a1f59b9d - last_write_checksum: sha1:1621fbb99ffa6a3a7311b60d5385c66cecf79f99 - pristine_git_object: 9f349c24a76c29bb1712640bb9350dc2aefa5688 - docs/models/components/organizationuser.md: - id: bf34b4c9ac61 - last_write_checksum: sha1:defcaa5f5dd7cf810f613cc11dd377ee55ebe159 - pristine_git_object: 0ddb10b1662da629030f39e3b9356bcf27356b51 - docs/models/components/policy.md: - id: 66c097e1389b - last_write_checksum: sha1:17f75e3d263d4746f638ee42a687686e810a3743 - pristine_git_object: f12402c8048748cc54a26ce756aa2655e74c75fa - docs/models/components/privateregion.md: - id: 6063bdd8bfeb - last_write_checksum: sha1:6fd579755ab77b5788b9ac225fd85fb21d6ec548 - pristine_git_object: a7ebe9411e634326344f904083f2afc996c0f3b5 - docs/models/components/privateregionsecret.md: - id: d30b40e1b6ab - last_write_checksum: sha1:defbcbdaf6d1433d648e6e9c8c85c010ad370154 - pristine_git_object: 346eabcc5110437fee6c86e00f143bd6c578df44 - docs/models/components/readorganizationclientresponse.md: - id: 30d8ce3f8d68 - last_write_checksum: sha1:89db77d0936c47d64976fa03c494229e4c6b99b1 - pristine_git_object: f408fef94efd36f3de1272e458af0d3d56a11cd9 - docs/models/components/readorganizationclientsresponse.md: - id: 17826be49fb0 - last_write_checksum: sha1:a1352a074abf29dcec38f9e4078249d9cd2be8ed - pristine_git_object: 7380695de4adc8030e85ed13a2e004d1f8f85f2d - docs/models/components/readorganizationclientsresponsedata.md: - id: a67f77499507 - last_write_checksum: sha1:d6cd2bdd1896b0af8ab2e6c1d7323073d30943c8 - pristine_git_object: d831730c466d36c2933e31226425324d7494c798 - docs/models/components/readorganizationresponse.md: - id: 1966dfc283cb - last_write_checksum: sha1:1c85da06faeaf50d76482d20fdcc5579b1342c70 - pristine_git_object: b1f615e84280e88015e27cc01101a943706e0a1e - docs/models/components/readorganizationuserresponse.md: - id: 6e34513c8f94 - last_write_checksum: sha1:7bc97465575e618812a44545e8339098e2024db4 - pristine_git_object: 145a39ae473a39a972ba8f91ae7023d51f4f350b - docs/models/components/readorganizationuserresponsedata.md: - id: 9dac00c7d455 - last_write_checksum: sha1:cc9e6a0b2630d5ad06410a6fb4f29db2b213b208 - pristine_git_object: 44eec94ae6c1bc547e718937be217804b778f816 - docs/models/components/readpolicyresponse.md: - id: d3439c3b4bfa - last_write_checksum: sha1:431887e51e0dbc7182bdcfac80396ce47fb8708d - pristine_git_object: ab514adad7bd35f2e93369a4933518df3fbc47be - docs/models/components/readstackresponse.md: - id: b78297ab7a25 - last_write_checksum: sha1:5647c2e835d59d6abacdbd4f512526121019b556 - pristine_git_object: 390cbe99a279850a29b1b1fe407d2747c419ab1b - docs/models/components/readstackuseraccess.md: - id: 4e744fd208e8 - last_write_checksum: sha1:7f1aecf662a52c5d01b9fc5be68d34133c36b3a2 - pristine_git_object: 6a222a55d4277d7b46273e2f9df08b09f76d8ec6 - docs/models/components/readstackuseraccessdata.md: - id: 20c18e64bd1f - last_write_checksum: sha1:c02c12428fd117d175874684bf412db08d29bd43 - pristine_git_object: 258d08587e99abe62bc078e242f9c9220d18597f - docs/models/components/readuserresponse.md: - id: 072cb121fa1c - last_write_checksum: sha1:1ba9f02110f33443f7ef66e00281ca1c16da4feb - pristine_git_object: dfe6eddb0205953bc2b749e66e30a94660dde47b - docs/models/components/region.md: - id: 4290b6a3b093 - last_write_checksum: sha1:47cd3a1ecfbeb2a67533e73774b974e7112b23c1 - pristine_git_object: 152ec242094e04fe3cbb3b8a56aaac1a541e3aac - docs/models/components/regioncapability.md: - id: 0053ec0b91fa - last_write_checksum: sha1:1047c65e96469c88cdfc64ea27654a9fdbac1ef5 - pristine_git_object: e8e992c213bb779575f081765846ddc508b22ae9 - docs/models/components/scope.md: - id: 849dc01be99d - last_write_checksum: sha1:be7fe70b8277cff2aba517a82e4401f35567ed92 - pristine_git_object: aa0724e2b7c345c82cc177cc3b2ae09bf42b723a - docs/models/components/security.md: - id: 54906b49ea28 - last_write_checksum: sha1:8595c27d0e8616b892fc64b13a4bb1c4fb402e95 - pristine_git_object: 8a7d90ae248e7009c277815279e6ea6e1c46b30f - docs/models/components/serverinfo.md: - id: 9c62235795e6 - last_write_checksum: sha1:bd831f5990c9d43f343e2c6bc5058cb0f6e4c20a - pristine_git_object: 4cdf0833cfc376021e6d9035683a9093e6fb14df - docs/models/components/stack.md: - id: 18f72a519773 - last_write_checksum: sha1:23d3ae1e27c18c88792fc4ae5e701eaacd5a615c - pristine_git_object: 9e3352ebf849de499381fc377a55f6058d731cfb - docs/models/components/stackdata.md: - id: 63fec4046a03 - last_write_checksum: sha1:bb400513c011b5fabbab4d94160f07b4bef055cc - pristine_git_object: 818a0c03b8eaab81dd14b2ab01a08311eca23096 - docs/models/components/stackstate.md: - id: e096d9c568b4 - last_write_checksum: sha1:df2d82fdc1a473bc7e0c38e9c290a5965b6a1a97 - pristine_git_object: e90441262373d92efcc72420ca6d516c8e57aced - docs/models/components/stackstatus.md: - id: 5ecee0eb4b7b - last_write_checksum: sha1:461d72bc7b4091905a6d96afd67ddf670690b7f2 - pristine_git_object: dc5e604c4d6b1e46220fae1020459988917aea42 - docs/models/components/stackuseraccessresponse.md: - id: 4f1161f16dba - last_write_checksum: sha1:6d3e0492b346ce4b7dbf05dfe2f030e74d10eddb - pristine_git_object: e7a4eda1a76b9246cae60f7f6b2b8a57b8e82ed9 - docs/models/components/stackuseraccessresponsedata.md: - id: b07677562c74 - last_write_checksum: sha1:65815322ac56128e04b932889a5f8ed914f99077 - pristine_git_object: 4c0f8d68b7da569a03878879d74b3dcb4cb6e165 - docs/models/components/stackversion.md: - id: 5d2f4a60279e - last_write_checksum: sha1:55333a7590ea413e54f7104e365de626156a2bba - pristine_git_object: cafd3ce5b9ae5c61bb4fd14a839ce7a19dd39cd1 - docs/models/components/systemrole.md: - id: ebff67d7d8d3 - last_write_checksum: sha1:b3e5feffeae3c253161561e7d7e73bf9f14ef69f - pristine_git_object: 54437e72784af8de93855ab4982a78e3df64c0b4 - docs/models/components/updateapplicationresponse.md: - id: 02558a645f9f - last_write_checksum: sha1:e660929ee36d3b53fc6a3d855f5b9998eb208dcd - pristine_git_object: 38df4eddd3be111a268e281c1c5cefbc2c3184f5 - docs/models/components/updateorganizationclientrequest.md: - id: e6c0b79d05ca - last_write_checksum: sha1:8550a53bf347b62aaef40f040c00fcf660af8e99 - pristine_git_object: c9adce3b6ae40262cae7a52ffc8a2f286e5ca11f - docs/models/components/updateorganizationuserrequest.md: - id: 883573900ac1 - last_write_checksum: sha1:1749385ccfae0f3d64556a981c222d2ff586439e - pristine_git_object: 66fd7f18c96ad23c7a90d495dd2e638bcd2f8f4e - docs/models/components/updatepolicyresponse.md: - id: 3907f8acf689 - last_write_checksum: sha1:8225726e61c1780a04cc14c3aa7c1f0de2c8bc75 - pristine_git_object: ee775c2504ef92254eeb02591fca014e185a3fa2 - docs/models/components/updatestackuserrequest.md: - id: 8a57d8a99cbd - last_write_checksum: sha1:5f88eba88482d0c15c4dce58eeb336993bbb5a91 - pristine_git_object: 1c128d558badc5bde03ba1a8ef040120ae1a4f1a - docs/models/components/upsertauthenticationproviderrequest.md: - id: e6b36a360357 - last_write_checksum: sha1:9ab73e5def181f291c6b1983f1c10608480402b1 - pristine_git_object: b2302b0dcb31f843b0143da1978663f27478cb22 - docs/models/components/upsertauthenticationproviderrequestgithubidpconfig.md: - id: 3f52b38ab5d4 - last_write_checksum: sha1:019cacde67d38ed18c13d1b0095e8a70cab3f7ed - pristine_git_object: 0a65c46bb264b7307495e304b56481b182d178a7 - docs/models/components/upsertauthenticationproviderrequestgithubidpconfigconfig.md: - id: bd2b6e150398 - last_write_checksum: sha1:2b0455e6fc390e252a021a7d5b038575e58ebdbd - pristine_git_object: 037d05b4886d940a75c57bdb6ec91c957e4dae02 - docs/models/components/upsertauthenticationproviderrequestgithubidpconfigtype.md: - id: 68c0d51d281b - last_write_checksum: sha1:e94cfd25598a9faa4502013eacff783b58705b0a - pristine_git_object: b6ae748fb978cf297d9b8103da2c84e8dc712be2 - docs/models/components/upsertauthenticationproviderrequestgoogleidpconfig.md: - id: "586195455263" - last_write_checksum: sha1:4a8a0be6f2b26ed23a7f85711cfe62ba14b894f5 - pristine_git_object: 5bbd3d89baf0a12db8677925fe58a0ddaf45238e - docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigconfig.md: - id: 6bb2715e4a09 - last_write_checksum: sha1:7eaff6bc691fa3889cd91bfe8bf76bb1d62d6325 - pristine_git_object: 4578dca9aefc6c9f4b3fa884098fd6b016dcec22 - docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigtype.md: - id: 09e091444b04 - last_write_checksum: sha1:47a81f639a42442f5d1ce6ec3f05d446d5c55b18 - pristine_git_object: bce4a247b320a7e345541f4b50c76092b29f9a0e - docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfig.md: - id: e4c5ef0c6b50 - last_write_checksum: sha1:a53a605bc4dfac2b956508b1ed597c4e6c8c9858 - pristine_git_object: fa077c47b340f99634bad1116ad9848e8ec1bde7 - docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigconfig.md: - id: 8c60cfa19dc7 - last_write_checksum: sha1:5493376dea70363c82c5a3b8bb7449393a307b37 - pristine_git_object: 511ee3852135d119640fb6792c2fbae949aacd59 - docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigtype.md: - id: cb3ba80b8ae1 - last_write_checksum: sha1:531c798af45e52740ff07573fbcbd9d6c47e7f37 - pristine_git_object: 35d6989caff9e908f4bb7c1aae0132a1a5a84409 - docs/models/components/upsertauthenticationproviderrequestoidcconfig.md: - id: a007638828d2 - last_write_checksum: sha1:1a82138a7027e82670500866fca55b556d40ebf5 - pristine_git_object: e41a891865d91ba40915b4044f6822c90335351c - docs/models/components/upsertauthenticationproviderrequestoidcconfigconfig.md: - id: 5751e4b3abf8 - last_write_checksum: sha1:52c574c0b2ccf3c93c371a308f4bf06a3d3d4e90 - pristine_git_object: f0c5488977216db340ee134707d4e722d2902a5b - docs/models/components/upsertauthenticationproviderrequestoidcconfigtype.md: - id: 5cc3201042ab - last_write_checksum: sha1:8a78d5a9efd30dd0aac996991123b61d441b3783 - pristine_git_object: 28edb24177f03380180f8ad2eec1b07a58d7b744 - docs/models/components/user.md: - id: 9615438c874c - last_write_checksum: sha1:0b54c8266861fb736300416b100431d107ec6bbb - pristine_git_object: 6683626a8956911feea65d8762eef8f040ff1cda - docs/models/components/version.md: - id: 2dfdc559e77f - last_write_checksum: sha1:cde383462ef056a7178dce0201e791ed4fad54de - pristine_git_object: 5b95829cf76f268a206a29a0bbc34edc3ed0f073 - docs/models/operations/acceptinvitationrequest.md: - id: 995e36a6ca88 - last_write_checksum: sha1:35b94170a4a956aae24d2976e63111f6ae982655 - pristine_git_object: 0c6182e2de48bb90a5ff398cc8b31667bdbcac42 - docs/models/operations/acceptinvitationresponse.md: - id: 8a0a66bde80b - last_write_checksum: sha1:20ea70d3396593802a7e683eb20e8a442130016b - pristine_git_object: 4e347ce26d13274822f5811260ac4f7cfb699e64 - docs/models/operations/addfeaturesrequest.md: - id: ab565e4fc3a9 - last_write_checksum: sha1:6633f9cd549d693d90751788d99338b499c42cc2 - pristine_git_object: 0fd8ac9956005ce73a0b0ac02d2d771cfe791132 - docs/models/operations/addfeaturesrequestbody.md: - id: ec8def27cf11 - last_write_checksum: sha1:1cb9fcada6ad0ef9ca2d6f53e27f8735c3661d84 - pristine_git_object: 26431d23864582350c6c05a888fcd9a5aebc06e6 - docs/models/operations/addfeaturesresponse.md: - id: 4c909c36f57e - last_write_checksum: sha1:acee489c5ef98e332dcc8eaeb763ce3d0108d4d9 - pristine_git_object: 546d90dca010e0689380b81cc6b089d48bd916a0 - docs/models/operations/addscopetopolicyrequest.md: - id: 54f4862a591a - last_write_checksum: sha1:1830e376cdc3ed9466c3bc72935e8d7874319072 - pristine_git_object: 2fa75e67bdd64a82ab6c914fe55c6721c1577905 - docs/models/operations/addscopetopolicyresponse.md: - id: a2aaf6a54f05 - last_write_checksum: sha1:3f2dd01fbc4153c6c991983cd1b93c88a6e15a7e - pristine_git_object: eb800412c893f9d9d8d3ec21cc42924b22686203 - docs/models/operations/createapplicationresponse.md: - id: b91b18ee990d - last_write_checksum: sha1:713dc7297678a1e5809a117135d5b2231d0ecb12 - pristine_git_object: 3eda4783ad85c4391e79ff31784bdb5fae140b9f - docs/models/operations/createapplicationscoperequest.md: - id: 0135b4caf0ac - last_write_checksum: sha1:366e5275fe85ac9906459a50aa1e17a47e408852 - pristine_git_object: 55e451124c90e60612db783ee128cd36e4552ea8 - docs/models/operations/createapplicationscoperesponse.md: - id: a0e8ff8816e7 - last_write_checksum: sha1:43db785dc7d276889fc21a9af8dafbf28df72b32 - pristine_git_object: 5a4657feda55c7a37bb044c6467632f9dc573ebe - docs/models/operations/createinvitationrequest.md: - id: bdc81ae26a84 - last_write_checksum: sha1:c7b9a6826e11eb2148125068124275baaf452894 - pristine_git_object: 5555e285b7ba274790fea51e0dce879433be7247 - docs/models/operations/createinvitationresponse.md: - id: 872dd141a713 - last_write_checksum: sha1:1bb30954f9e24b39699d6cd022476b3bd498f0af - pristine_git_object: 689f5932955dd8c35d4502386f126070bf1de6f1 - docs/models/operations/createorganizationclientrequest.md: - id: a07c8a002874 - last_write_checksum: sha1:c8ecd3268eafe0bdc4fcc11a0adf74398e5301a2 - pristine_git_object: 12d9adaeabc671b2d942c04f360b65e84b769636 - docs/models/operations/createorganizationclientresponse.md: - id: 113bc40209ca - last_write_checksum: sha1:91d102c81bc70ee531d4a0f89654cd8a4883c299 - pristine_git_object: 9bedc4342641deb148ed9f79952d8d6ac0198ab5 - docs/models/operations/createorganizationresponse.md: - id: 12b317bd216c - last_write_checksum: sha1:7b16c8b6457e4da81169323cba45db196718b1a2 - pristine_git_object: 4ee493028af48e4c6b03e953d3cc12b5ab237749 - docs/models/operations/createpolicyrequest.md: - id: cb4590659b79 - last_write_checksum: sha1:7f8a155b547458308dc65ebd8f0bf00e5768967e - pristine_git_object: e722756a315e6b97dcbe25b864369971733351f9 - docs/models/operations/createpolicyresponse.md: - id: 4eba7d2e39f0 - last_write_checksum: sha1:614700be13dadce9a5cb4d2ade74c671861738bf - pristine_git_object: 453d2a9e160285b2ce1beae234641539e1deef86 - docs/models/operations/createprivateregionrequest.md: - id: 517425a4d436 - last_write_checksum: sha1:83c54e9d0e852fdac5a191740fd8dabb6608008d - pristine_git_object: 113e96e79844b698bba959e10f11a1ab35d3d414 - docs/models/operations/createprivateregionresponse.md: - id: 6b87448dfeff - last_write_checksum: sha1:3d5729f7c831b06d98c27d5f561606a0a9e73761 - pristine_git_object: b4f9b0cfe6164c15ebcc74ab82010b5afd477b7d - docs/models/operations/createstackrequest.md: - id: 1f7ac88f7560 - last_write_checksum: sha1:489765c8052891d7e29230474b57fe5180856f18 - pristine_git_object: b7f11a22bb55c36e199754995efb6d67cbb245c9 - docs/models/operations/createstackresponse.md: - id: 182ba024068e - last_write_checksum: sha1:d9d89f872be3d79d8ae2fdd083001bc7b3bea0e7 - pristine_git_object: 6ac491ceef75d4a2879b446ee2237c5c59fac945 - docs/models/operations/createuserresponse.md: - id: d03941881016 - last_write_checksum: sha1:a3c5b72d1002524e5a12183cbda06c2a6cbd2e75 - pristine_git_object: d7c298153b5ef48291e3cbe1e09866cec0e51b5c - docs/models/operations/declineinvitationrequest.md: - id: 22e1d627217b - last_write_checksum: sha1:d4dc55eb0c2d914e8feb175dd1104a1824f31392 - pristine_git_object: 44fd91ac293e7e7b638c69c16b800dc9c4a45227 - docs/models/operations/declineinvitationresponse.md: - id: 3dc657820d1b - last_write_checksum: sha1:31137341fdbb991a4801d32a5013662f59653fde - pristine_git_object: 27aaf2476f0ac8ef509f5a1541f4718d3259bc27 - docs/models/operations/deleteapplicationrequest.md: - id: 1e2dac018265 - last_write_checksum: sha1:0b88ca0c08a105499cbd048451a7cd323823c4e2 - pristine_git_object: 63ed24d9101f4d087daeded8b2b6865eb60a9e12 - docs/models/operations/deleteapplicationresponse.md: - id: 7704ac212731 - last_write_checksum: sha1:2be10e4d403b86120622d2e0b46f58a2caa7ce99 - pristine_git_object: b971f2753145b2fc0f49aa2b54702cdbb8e1e81b - docs/models/operations/deleteapplicationscoperequest.md: - id: 661211e49fbb - last_write_checksum: sha1:1949ad3fa263bb3900524f8559ff9263952e3964 - pristine_git_object: 4fcc6d334fb2278db72aca814dada150a9ce1201 - docs/models/operations/deleteapplicationscoperesponse.md: - id: e440aa831d50 - last_write_checksum: sha1:3aa43b41b8982b15969e41f8d5733ddf73e6fc50 - pristine_git_object: 79dd9bc803575737645a0fcb2a79b64334eaeec2 - docs/models/operations/deleteauthenticationproviderrequest.md: - id: 148cba0f9b1c - last_write_checksum: sha1:cab741adaad898338a83207bc117a68d82a76879 - pristine_git_object: 78e165855170c0c190d0080794d8e8a7dd615db3 - docs/models/operations/deleteauthenticationproviderresponse.md: - id: 96b3f080c58d - last_write_checksum: sha1:76a62e3c2f0676e5bcf55941ce1d974e62acc565 - pristine_git_object: bc65ba5ca4f2e36bf73b5fe2aee5ba085a682ad7 - docs/models/operations/deletefeaturerequest.md: - id: ff4387945505 - last_write_checksum: sha1:71aa7244de5cac7e9e8017c8464fa7c9610c17ff - pristine_git_object: beca2256040c1937d014597101dd0e78849241ac - docs/models/operations/deletefeatureresponse.md: - id: 38bcf90a1cb8 - last_write_checksum: sha1:d0ec5d7d4295d6b936d11f1fbdeedbda584527fa - pristine_git_object: a14743c5aa828b5ccc6b25ba038a1dd32c3fc164 - docs/models/operations/deleteinvitationrequest.md: - id: 2f222b3c4513 - last_write_checksum: sha1:248b7d9385bda22a2c124affdd7a5c491bc8a81a - pristine_git_object: edf412c92c2cc3f2c1f35b7e841c6f0509208da1 - docs/models/operations/deleteinvitationresponse.md: - id: e312587b3390 - last_write_checksum: sha1:52c408c0b616de16afe58d1634bedcbc54144752 - pristine_git_object: 85fc3a0d426bc63fdf58c5374f89f391119c4b20 - docs/models/operations/deleteorganizationclientrequest.md: - id: 005ad3189ca3 - last_write_checksum: sha1:bfcbb08c0e61fb8268bce32c3a05eac207d9c3d3 - pristine_git_object: 63f75ac58a317464116063f5cff38664334f6f64 - docs/models/operations/deleteorganizationclientresponse.md: - id: 134585adb0f3 - last_write_checksum: sha1:2fa31226e5483ce8afb364732dc6a2f626c8c260 - pristine_git_object: 605b5ae7429f4fbade883a6467301dbc1f20cd97 - docs/models/operations/deleteorganizationrequest.md: - id: fffd7611b3e4 - last_write_checksum: sha1:2d7e6788db28e48965ec5fbb632f91c87ee401e1 - pristine_git_object: e90ddb62626f1d37fa69cfac80b51793275227f2 - docs/models/operations/deleteorganizationresponse.md: - id: 7fc423b19c24 - last_write_checksum: sha1:a2c75c3f2466e41ec1a40388f7ebb4e9629330dd - pristine_git_object: a4bc77bd6d550e90e985a3fa3d0a180c91190a39 - docs/models/operations/deletepolicyrequest.md: - id: 99e167555687 - last_write_checksum: sha1:c7c2ade44a55583a8a73102bb3eb5331d2b3f07c - pristine_git_object: 0ce617947e79313b069b616fda9316ee1728c298 - docs/models/operations/deletepolicyresponse.md: - id: 1f93283b104e - last_write_checksum: sha1:4336b39cd0ffb064340c293edbd3d96300ccc007 - pristine_git_object: ea21efd59e2cc361e468a17f6846d3d7f12ad6e7 - docs/models/operations/deleteregionrequest.md: - id: 2c38554c302b - last_write_checksum: sha1:c74f810f04fba1d7baa497c5c6cfd08483554e9a - pristine_git_object: fafeea909dd1c00092a531271c7111a67fe91275 - docs/models/operations/deleteregionresponse.md: - id: 583e2a1705a6 - last_write_checksum: sha1:45f60e5384d1fdd7ad25d412f4f884f13d7bb065 - pristine_git_object: 4e9e874b4b8a528bd57f9aba271a55239a17a36c - docs/models/operations/deletestackrequest.md: - id: a48acde73b3b - last_write_checksum: sha1:cdde3f32b2568a5c1a756466a4c3d9e71864ff9d - pristine_git_object: 3f1d6f4515876c82bee38b1d2d88692758654046 - docs/models/operations/deletestackresponse.md: - id: 395fd5bc17d7 - last_write_checksum: sha1:8b599a0f1f9ffdeb5a972cea21d4d485f74bc355 - pristine_git_object: 9c5d9afabb90a971b559893f4a9cb840e4e47f84 - docs/models/operations/deletestackuseraccessrequest.md: - id: 6a0f33c32e21 - last_write_checksum: sha1:e97e71af7f4d4086ece6f2d1be02f54dc017fd3c - pristine_git_object: ab2ab428d59f8baad48134aeca7c9734488fdde3 - docs/models/operations/deletestackuseraccessresponse.md: - id: d49b4e83f825 - last_write_checksum: sha1:68b8a78e2e60f331a29b66274add1b1e7fe6850f - pristine_git_object: 9c85b70de27d940a0bc2b09bffa29c943db3c4d7 - docs/models/operations/deleteuserfromorganizationrequest.md: - id: ccad196c7dc9 - last_write_checksum: sha1:3def31379ff91020358f4bdb0aca356c6fe7ad36 - pristine_git_object: b7ebca5c7d36ef217890b77f6b7bf0305ec9f49b - docs/models/operations/deleteuserfromorganizationresponse.md: - id: 0bdc6527774f - last_write_checksum: sha1:094bb33129e9e83c2c34a124ff1e71d1a8edb798 - pristine_git_object: 4f0b4966f1da976c76e6956e3407622852c2ed5d - docs/models/operations/disableapplicationfororganizationrequest.md: - id: 4fbce85f0123 - last_write_checksum: sha1:0bcb80d8207480c771f5bd51f26b10bd635bd4fc - pristine_git_object: 7b1f7b872ae7d94770fbe8776275e2e4a59af8af - docs/models/operations/disableapplicationfororganizationresponse.md: - id: 1725029e00bb - last_write_checksum: sha1:a574ff69f4b5435d889dad23d9fba373314036c0 - pristine_git_object: 2866f843fc55b0e0881bedb4e15c23019fae8c4d - docs/models/operations/disablemodulerequest.md: - id: 4f50e73dc735 - last_write_checksum: sha1:15183e621ecc2cd85df1ac0951539f0808ae883c - pristine_git_object: ec4903ae1df3d96821cac430bc8e41c8dc38e4b4 - docs/models/operations/disablemoduleresponse.md: - id: 9b8052b35fca - last_write_checksum: sha1:cb3dc0f832cd59197c4c2e3dec29534c86af3978 - pristine_git_object: 69e6624db03255b569f87ec47061fcb52c324089 - docs/models/operations/disablestackrequest.md: - id: b023ed28b25d - last_write_checksum: sha1:cce5a60c912d35f9c345f62a20447268610fce84 - pristine_git_object: 9b4725011c3a669dfc317c46299cd51b4014685f - docs/models/operations/disablestackresponse.md: - id: dcddcde0492d - last_write_checksum: sha1:5fd370f026417d5f34398d3edf6d97dc56f963b4 - pristine_git_object: efb01b56ddc4fc06049390aaf444ea794cab040f - docs/models/operations/disablestargaterequest.md: - id: 8c8014ad17e5 - last_write_checksum: sha1:77aa10a0ac4e07b19c1c9cc050b815fff249d06c - pristine_git_object: 673d50d6a69c36b0013535b221566af6b0a07649 - docs/models/operations/disablestargateresponse.md: - id: 298aac4c2221 - last_write_checksum: sha1:e306b98d4b3375464280ae127cb46f4fdce6096d - pristine_git_object: e8309b3ab6c069a57ca8df2631a56814e3774c65 - docs/models/operations/enableapplicationfororganizationrequest.md: - id: 2dae8c68ca9f - last_write_checksum: sha1:87e5765323131b55d645015333127b3610241664 - pristine_git_object: 6ec4ee20eec9b83c3dd9f9c203083a635a799712 - docs/models/operations/enableapplicationfororganizationresponse.md: - id: 81c6affb0115 - last_write_checksum: sha1:ab2cb8205ef9d0d48efd9952517953d68a8931a8 - pristine_git_object: 397d75dd5c5a1732f6b609c22b9b81874ef0b59f - docs/models/operations/enablemodulerequest.md: - id: ea8e9b66af91 - last_write_checksum: sha1:d544bfcf2d9898218945e3b35284da4d5310ae21 - pristine_git_object: 8189499bcd6ea7a639d1d008449d609859fc4d98 - docs/models/operations/enablemoduleresponse.md: - id: 7afdb667bab1 - last_write_checksum: sha1:3275cf1121be502a74a17dc6fd68988ea96f5d16 - pristine_git_object: a8c8a9ec6756ea72085599dd378a980f2b9fc442 - docs/models/operations/enablestackrequest.md: - id: 9ee3664d8e8a - last_write_checksum: sha1:9a00b597aa7f110731b0ab7dbadfbd3c684fa4fb - pristine_git_object: 4609ce2b7ddcf11595ad59e270b672ee65b784bd - docs/models/operations/enablestackresponse.md: - id: 5a2dab21877c - last_write_checksum: sha1:9767abf15618bfd6438b871caa88880fc34d6a7b - pristine_git_object: 4be2a9c5219bf73e666f188ad0599e650077c300 - docs/models/operations/enablestargaterequest.md: - id: f7bbb9aef27d - last_write_checksum: sha1:24bd8f6f40add04e5fbcb175b77153b0c6fde67f - pristine_git_object: 66ccb1f37ac298f9847e63173347ca841ece1f98 - docs/models/operations/enablestargateresponse.md: - id: 9d21c7e676be - last_write_checksum: sha1:f0689afe0f8529835710a51222eea9978345ecbd - pristine_git_object: 93c972919d47f77911bbfc05786539ed66d5c399 - docs/models/operations/getapplicationrequest.md: - id: 0be1ff5c895b - last_write_checksum: sha1:846ea7ef1a31c3afe0a946f450d72c8a9b254e68 - pristine_git_object: b4da5dbf576ff631c35fe5186a46d25e83624e35 - docs/models/operations/getapplicationresponse.md: - id: 9820494ce8db - last_write_checksum: sha1:c829dcd1fa32fc454ce724ccac198ba4ba5d0040 - pristine_git_object: 82b78c86213ddda89741331a10d21db35b405508 - docs/models/operations/getorganizationapplicationrequest.md: - id: 0cbc2c97a2a9 - last_write_checksum: sha1:297cb649ed04553d37a432f7c683807f7504e531 - pristine_git_object: e5603107f87aa66c202afc5ccac75fe478e35f50 - docs/models/operations/getorganizationapplicationresponse.md: - id: 79c3059a7c4f - last_write_checksum: sha1:823ed7dba8d37814be95f4d4f60ecbeaa2bc6c58 - pristine_git_object: 4f0ac1b39b5623c6e839c13036b17c711759d07d - docs/models/operations/getregionrequest.md: - id: d30c71f83299 - last_write_checksum: sha1:1cd37a3806988c2b187f0e6b695b7addc404788a - pristine_git_object: 9c7f0ee2028d3698e11dd3769680b36d10bed023 - docs/models/operations/getregionresponse.md: - id: 1a2f4a27f808 - last_write_checksum: sha1:722859fbe2479b106d00e2620446d826f1f44c38 - pristine_git_object: c037d81261286efae6af82b2644d63ec5e226601 - docs/models/operations/getregionversionsrequest.md: - id: 90df2f1d8800 - last_write_checksum: sha1:845a439aa46cd5139b80144b43f76cf5118aa790 - pristine_git_object: 78fd844f773451984731724252b2f928c6796c85 - docs/models/operations/getregionversionsresponse.md: - id: 522f8e2b217f - last_write_checksum: sha1:c772409d59d5e38514dd708fbbece3dabd285752 - pristine_git_object: ebcbaccc7940628148f836f652cf731235731bdc - docs/models/operations/getserverinforesponse.md: - id: ec706831711f - last_write_checksum: sha1:430533b72970976a420e030acff3ec009eb6a1aa - pristine_git_object: 18ed5ab96dd0509e668c42bc8151e8ae3ca76573 - docs/models/operations/getstackrequest.md: - id: 9763ae1f8bd7 - last_write_checksum: sha1:6c6f8e5eac736e6d0bd949e9a42a648a93d271e5 - pristine_git_object: c07649d1665115378e0b6664a31afc2830136693 - docs/models/operations/getstackresponse.md: - id: dc96835e433f - last_write_checksum: sha1:b292ecdf628e479fb0661edbfbd8d2b3b108a3eb - pristine_git_object: 4da443b6feb607ae23924839d4b82d7202526bad - docs/models/operations/listapplicationsresponse.md: - id: fb53dba81cd7 - last_write_checksum: sha1:6c57d1f55f7f40854812c63da92d77ac039a8641 - pristine_git_object: 17a69ea04e25329cc32f88fdadf3d91556fb7532 - docs/models/operations/listfeaturesrequest.md: - id: b96073c3b9f9 - last_write_checksum: sha1:8cc3cfb3b35e9492b10d56af4499a60f798980db - pristine_git_object: 7044d6f33ab6cc97efef93bfbd0093127b133a4d - docs/models/operations/listfeaturesresponse.md: - id: 488bbcddc59e - last_write_checksum: sha1:f8e90d0c42ef9f48374c96c27615ec8ef19ad31f - pristine_git_object: c90b0e07373bdff428b256c115d94d3884c9a178 - docs/models/operations/listfeaturesresponsebody.md: - id: 26ab2123e79c - last_write_checksum: sha1:a93890a61a3e1374377c30a97e2905c5e56a8c9a - pristine_git_object: 7e779c7097a7642005a010b542846a68b4e34190 - docs/models/operations/listinvitationsrequest.md: - id: ec5af6b3561b - last_write_checksum: sha1:86d4768b282351c80eb4ccafff2f0f4c6529624e - pristine_git_object: 4ac2adee3df54a8d876cdd26c1bc9e4765ad65d3 - docs/models/operations/listinvitationsresponse.md: - id: bca47864a7b8 - last_write_checksum: sha1:3c6773ab1918e160e057f7c64459b442d41f579d - pristine_git_object: ce6f2ec133b4e74783280c282f94ca28b2171c38 - docs/models/operations/listlogsrequest.md: - id: 4b4ebef03190 - last_write_checksum: sha1:95810d79027ee3e322f46a37b1efac2d8ba5ae9d - pristine_git_object: 2ecf8e57079cefa6a6b700f208496eb982567a09 - docs/models/operations/listlogsresponse.md: - id: 56c193f6df42 - last_write_checksum: sha1:e3b80e3ffe2c09bca0e5f813d3ed5a5446590d36 - pristine_git_object: 868f447eb95af80b0f84fda15fcac6ba5ca14860 - docs/models/operations/listmodulesrequest.md: - id: 9baba7c546d5 - last_write_checksum: sha1:7970d05114dbea2dc8a3b0636466081dd68b1c5a - pristine_git_object: 0b8c4570dc9d8de7b79bef0550901f4ca4636344 - docs/models/operations/listmodulesresponse.md: - id: 9f47e51f5b15 - last_write_checksum: sha1:b3b9b9dba70722cead4ef812d4182cdee3c265f6 - pristine_git_object: 33ea1354796fed040c2e2103073a9a686e054c55 - docs/models/operations/listorganizationapplicationsrequest.md: - id: f6bdfdc1ba9d - last_write_checksum: sha1:f04242ce63d6bb657c9f7c3bd29868244cbe24e1 - pristine_git_object: ce53bc070f63f9529b92e7b1ac118db8108b09bc - docs/models/operations/listorganizationapplicationsresponse.md: - id: 279d08bf2ad4 - last_write_checksum: sha1:3ad8cf6b26eb53c322cbcdadd95ab8179f3502a4 - pristine_git_object: f2124c61fbec157cee87df9c65c0ae16d2409d21 - docs/models/operations/listorganizationinvitationsrequest.md: - id: cb7bad630d93 - last_write_checksum: sha1:cf34ef917225f64df5655b48d7931f95facee8ee - pristine_git_object: d8018620b1b87fa5f4f8a8c6161c69749e743e07 - docs/models/operations/listorganizationinvitationsresponse.md: - id: 12b7095614f2 - last_write_checksum: sha1:715f0219a02a5ef5723f7e2d91e9306e25722906 - pristine_git_object: 226c656e6a99538f1760f1bcddd973df1d23a194 - docs/models/operations/listorganizationsexpandedresponse.md: - id: 3c3cd1834f82 - last_write_checksum: sha1:6d74508359a7edab8dd773f70c5140a6b75f631e - pristine_git_object: 3c9d88e02aab0869104131508923489c8085aa35 - docs/models/operations/listorganizationsrequest.md: - id: 656820540c33 - last_write_checksum: sha1:b7090fe30a0cbc20316f65a4f58f89912f09d17c - pristine_git_object: cc09766aaa3c306ecbf5d15fd56680d6196e129a - docs/models/operations/listorganizationsresponse.md: - id: c32888db1e5d - last_write_checksum: sha1:ef7b595da22914384e6530a7732d29175bcd283a - pristine_git_object: 1241dd496fda34a5351b717db36ba20239f71f2b - docs/models/operations/listpoliciesrequest.md: - id: d5bb60ba18b2 - last_write_checksum: sha1:4903dc3ec1bb4aff6f688867d5dbfcf197042be1 - pristine_git_object: 85c58db1a43c7f9739a59227fa0676f4a93d3a36 - docs/models/operations/listpoliciesresponse.md: - id: 93a9022d32cc - last_write_checksum: sha1:205478b07e42d6273e1fc5a3a22f335ca156030f - pristine_git_object: c1ed2ff1181bc6c68566da8f93ecdc367d5d0754 - docs/models/operations/listregionsrequest.md: - id: 6484234533d9 - last_write_checksum: sha1:e82ebb48ec1dd49d62f5f1c7c523504baf5c6b0d - pristine_git_object: d1720f9b2f1605e63daff8aa810e92a28ead5e57 - docs/models/operations/listregionsresponse.md: - id: 122d97468918 - last_write_checksum: sha1:73dd3da5fbad6c0935a51f341bff00bebc4e21c5 - pristine_git_object: ad308a3c949ba355b742b34f4892c766c624f650 - docs/models/operations/liststacksrequest.md: - id: f9f8dea32bfb - last_write_checksum: sha1:59e62af799563f4f453f268ea0eb7f3a66162c03 - pristine_git_object: 1ab2fce1e80bda5248ede2cefbf1b7c78ddc0b09 - docs/models/operations/liststacksresponse.md: - id: cea87761895a - last_write_checksum: sha1:0085976d43c8bd8b4b859c2d55d775bf2383526c - pristine_git_object: 86c4e7107313b9dd12b5b172404c494e1ae0f9dd - docs/models/operations/liststackusersaccessesrequest.md: - id: d54b88db06a4 - last_write_checksum: sha1:5a2e676d5efc1f715dddaa17a7176dbc8191e8d2 - pristine_git_object: 4c7c46def8aefcf99b3e2c786cf48d08d278da9b - docs/models/operations/liststackusersaccessesresponse.md: - id: 3780bab03093 - last_write_checksum: sha1:f6e344bb8df39fe211962a1b9b9a02d2916c8ec5 - pristine_git_object: edad0b697716d5922d0237037c5e657994ed436c - docs/models/operations/listusersoforganizationrequest.md: - id: 0ab1f68b729b - last_write_checksum: sha1:594e4c11de0c0718e315ac779477c4eff9b7b7f9 - pristine_git_object: 3b4693b049be2cde36a69f977e4107b55db92aac - docs/models/operations/listusersoforganizationresponse.md: - id: b17ff95b5935 - last_write_checksum: sha1:15ee139e5c0dc3d2c5bfca677248ccf48a2b5e6e - pristine_git_object: 499a7b18e2715bd1d9c43e976a2b3b3e2c1c1863 - docs/models/operations/option.md: - id: 2ac1bd52a5fb - last_write_checksum: sha1:fe547d023107891cf2bc8a7ce908431e9de85fe6 - pristine_git_object: 3c058ecd1a956fbbcfa6178053e2951ae4f26186 - docs/models/operations/organizationclientcreaterequest.md: - id: 6b2c1117064b - last_write_checksum: sha1:d8d84777d2fe38e767db8e145787afcdf4ba2b2e - pristine_git_object: bbec7011b1390b5979c6ff445e15ef53023bc4ed - docs/models/operations/organizationclientcreateresponse.md: - id: cb6fe9160eed - last_write_checksum: sha1:d7d7a19634c8c6a2486e3dd37380e2a12bf6d18d - pristine_git_object: c1491d81abdb025971829167122350f63519456f - docs/models/operations/organizationclientdeleterequest.md: - id: 74d393b052a6 - last_write_checksum: sha1:72e132a4427c506f3fee195a9fe546ef0aa86226 - pristine_git_object: 390076108b2fabf33ca8bf91f1b41400be324db1 - docs/models/operations/organizationclientdeleteresponse.md: - id: 356ec0ce5360 - last_write_checksum: sha1:72edeeadb6756866eaa45e3bc0e745263bbc72c8 - pristine_git_object: f82700c0651a0f4bf0a059ca1422d101fb157ac3 - docs/models/operations/organizationclientreadrequest.md: - id: a2abd3e92a64 - last_write_checksum: sha1:2f3662324bbb0c86e84f227e110aa6e05aaf9533 - pristine_git_object: 1615ba91ef16793ba2636068f94e372d38db8d8f - docs/models/operations/organizationclientreadresponse.md: - id: 0063823f03f9 - last_write_checksum: sha1:8eaa69477fba8db5c7600491f9d967283ea7713a - pristine_git_object: 4eb9b82350aa761cf5d54e207c2411df62e3aa0d - docs/models/operations/organizationclientsreadrequest.md: - id: ff204fb8e986 - last_write_checksum: sha1:6149e3390a43ed64b26f3009c26f0cf1997a8ddf - pristine_git_object: e42bda3577d25a4fc3ffa756a422bbd4d8630089 - docs/models/operations/organizationclientsreadresponse.md: - id: 75e52fcc326c - last_write_checksum: sha1:6b30e6be91b078787b196d44f8138abebeba9cd4 - pristine_git_object: 4b3c5a04df56a651c012d6770c1f363af12652b0 - docs/models/operations/organizationclientupdaterequest.md: - id: 96f96e9c19c5 - last_write_checksum: sha1:87a1cc660ddecf5a4edb0ad91b49350909c582de - pristine_git_object: 5e1bcf746d1f54bb81249224931283783048d096 - docs/models/operations/organizationclientupdateresponse.md: - id: 67eea7c327c2 - last_write_checksum: sha1:8da087510f2152f2ffe05adc26c3030564be55f2 - pristine_git_object: ee493197f20036e83d820173dd54cb0e4384e2a0 - docs/models/operations/readauthenticationproviderrequest.md: - id: c767ee2af458 - last_write_checksum: sha1:a48887fbc8de49ddb3a056bb576011235f127114 - pristine_git_object: 60fe98b43772177932ef923320633df3a67c5349 - docs/models/operations/readauthenticationproviderresponse.md: - id: 575a2f9a5a4a - last_write_checksum: sha1:adf909e765a13530e4d33ff848c47a79adaec1d5 - pristine_git_object: 221b6c7a160ae4d8b05d5e1fe12a9eab2bc224c3 - docs/models/operations/readconnecteduserresponse.md: - id: c6a8bb87298d - last_write_checksum: sha1:4f051ea41e5cabd064df1606e72dce166397dede - pristine_git_object: b8f57de8e35883b85efc22da12ab64d1579ccb60 - docs/models/operations/readorganizationclientrequest.md: - id: 9a7bdd6dcc9c - last_write_checksum: sha1:06a16e403a3c06dd82851653c76f98350eeba419 - pristine_git_object: d393b062214f6181e3e4ac40c128023a10f76a74 - docs/models/operations/readorganizationclientresponse.md: - id: aa3b152a55b1 - last_write_checksum: sha1:c5dc84e296937ff5f9c022f0fed4914a17b6fccd - pristine_git_object: 802a5f1a427f98ca6c8dc4eee8e42d913f8ec157 - docs/models/operations/readorganizationrequest.md: - id: 955b32552b26 - last_write_checksum: sha1:6897f7408406b869209d085f55723b993fb0638f - pristine_git_object: 18ba2c283953298af60a30a0e9fe94c58cd721c8 - docs/models/operations/readorganizationresponse.md: - id: e8f0be366cc6 - last_write_checksum: sha1:700197355b0ee540e1f87e429298a495f692559e - pristine_git_object: 826d5758ed20ccbc38c93d55b9b6fb94c329f470 - docs/models/operations/readpolicyrequest.md: - id: 643840542c7e - last_write_checksum: sha1:ad6a59b9cf4e6ad35f1a2b837b6aefe2e4b3cee1 - pristine_git_object: 5a642310c4d1e02877214041b9cf297147e9ddd9 - docs/models/operations/readpolicyresponse.md: - id: 2564abec3694 - last_write_checksum: sha1:dcc668eb655a5900bad1d133f6018c9eb7328770 - pristine_git_object: d202ab5efdefa316818c3c240064badb449699c2 - docs/models/operations/readstackuseraccessrequest.md: - id: c6f224a65bb3 - last_write_checksum: sha1:6f71bc2bf87f81ca9aaacbbf8d5bc617f7728a33 - pristine_git_object: 1666e00debac08868dc1745520e970aca7987b28 - docs/models/operations/readstackuseraccessresponse.md: - id: 4e9a4d875d04 - last_write_checksum: sha1:078897df2a455d7176b0c153e617528579d24195 - pristine_git_object: 1c50f09cb7f288e42b8744dee18d21e838ee839a - docs/models/operations/readuseroforganizationrequest.md: - id: e96a19189a75 - last_write_checksum: sha1:e55462f9442631bdb84fc135b3a78647d3a4d592 - pristine_git_object: bd3cfcb3267a30c7e53e0a59d5d63fb32a642917 - docs/models/operations/readuseroforganizationresponse.md: - id: 1c0e10247f6c - last_write_checksum: sha1:ce6c0f726a806b2cd7c81f57be10efcd71111183 - pristine_git_object: b2daf9d2497197ceb3fb6a32a34a3dbe8e797f71 - docs/models/operations/removescopefrompolicyrequest.md: - id: 6e9d2cb53b65 - last_write_checksum: sha1:20a8112ccda43cfe4f3457e6da5dab4adfd09c65 - pristine_git_object: 5614a28029e1905d84f0f64a3d6ff23a3618a3c7 - docs/models/operations/removescopefrompolicyresponse.md: - id: 1270951e8390 - last_write_checksum: sha1:604d973b3e6f1ef81c44c45a406c83f0cc73cc5d - pristine_git_object: 3f9ac299f6597d02df3b7f78f394237fab53da5c - docs/models/operations/restorestackrequest.md: - id: abd2bfe27e7b - last_write_checksum: sha1:969b1796d0695032c55194fd256a7d01d674c724 - pristine_git_object: d556af236f7406e88df5b2e1cf7e51aa9b87f058 - docs/models/operations/restorestackresponse.md: - id: 7636b9ae5688 - last_write_checksum: sha1:df4daae809354ce44a50ad3339a79639ec28fcb3 - pristine_git_object: 08680741829e7af9b4d699809f87e705e4906cc3 - docs/models/operations/updateapplicationrequest.md: - id: b54c22266c17 - last_write_checksum: sha1:63863204bec64e492a9edbbbd83080311e3e8d3f - pristine_git_object: f756b99eca67646ed21bb512f1feee88d3ed387d - docs/models/operations/updateapplicationresponse.md: - id: 59ccefb2ebd3 - last_write_checksum: sha1:49aa35bd63fff4b0f98b7d3fd53f01cff4321a95 - pristine_git_object: a762a04612db1528c22cabc4f2ce8197228a3712 - docs/models/operations/updateorganizationrequest.md: - id: 443648f7cb2d - last_write_checksum: sha1:78132d307ffda84c6e2db93bad14205d7e84311e - pristine_git_object: 7106e5a5f036ed35dfa42fcd4be26f796b8939cf - docs/models/operations/updateorganizationresponse.md: - id: c85d429c788f - last_write_checksum: sha1:1a3da759edc78280434c54e29e1de0268b95d503 - pristine_git_object: 7a89e4f81a15b0240ba7f33467e7d217da0fa960 - docs/models/operations/updatepolicyrequest.md: - id: 5c4811a44826 - last_write_checksum: sha1:7060dad54b8fbbc4746e833f6dd866baca7a96d1 - pristine_git_object: 0dc9fa56447c3c1df468fd0fd3477995c55a0bf2 - docs/models/operations/updatepolicyresponse.md: - id: f7adfcb44808 - last_write_checksum: sha1:d8778f2851b8ecf90a1a6c80b872ad267fc97e28 - pristine_git_object: dc0a8e9515f1ec941296c0cfef6a58a9a1682f05 - docs/models/operations/updatestackrequest.md: - id: a60cf3b9dd49 - last_write_checksum: sha1:cd53b2c32fb59c92f9e52fb2dfcfefaaa6e30678 - pristine_git_object: ea3d90628cedb2c8a2412234bc70de6fe8750364 - docs/models/operations/updatestackresponse.md: - id: 49a64fffdfc0 - last_write_checksum: sha1:f52dd5b7f95fb5b8eed422c0a6975b4506b7a498 - pristine_git_object: 53b36d1e324241a96c1088ffd9c2587a7ed121b1 - docs/models/operations/upgradestackrequest.md: - id: a21171432734 - last_write_checksum: sha1:4af9ad1bf5367d5dac8bb87b90f097ded19d551e - pristine_git_object: e613895df7b8ea200f28ec786c4d103335cc91ee - docs/models/operations/upgradestackresponse.md: - id: 6e330080f503 - last_write_checksum: sha1:da7ee6234afbf2143877708d6a79efebb5d518c5 - pristine_git_object: 5c5455f0d94496fefd188ae310a3bc491274eba3 - docs/models/operations/upsertauthenticationproviderrequest.md: - id: f530a3e9a4c0 - last_write_checksum: sha1:2132246935ccc1ce5c0f5dac07792238c807e46f - pristine_git_object: 0854aa1686f0a84857431f803c1a2f7cda80917a - docs/models/operations/upsertauthenticationproviderresponse.md: - id: 056b3931ae6b - last_write_checksum: sha1:1687dcca9b35f060ee89b3f86923e1c6725018c8 - pristine_git_object: c4b912d3883e67b8da85e36565012dead71c6c54 - docs/models/operations/upsertorganizationuserrequest.md: - id: 9592a5284dd5 - last_write_checksum: sha1:7f321c8ee4b6227581302cc1ba3157d111c05f93 - pristine_git_object: 9251ed17df318472f23eee395a96b6e2eec116a5 - docs/models/operations/upsertorganizationuserresponse.md: - id: e62b602b9991 - last_write_checksum: sha1:5b032014668fda4970190d8c530537a7cb2a09ba - pristine_git_object: 70438f1f55495b78720a21e5a0e7627e4608985a - docs/models/operations/upsertstackuseraccessrequest.md: - id: 77f8a428c3d4 - last_write_checksum: sha1:077862ccb0a6fd76af1ad4d11bfbac3ed96a0381 - pristine_git_object: 1913222af2fe4d29ffc7b65fe67276b4ce5cc6f6 - docs/models/operations/upsertstackuseraccessresponse.md: - id: 97de5df5d9f3 - last_write_checksum: sha1:96c9a1b860ceef0f80d497aa2c505d9f8bd75b70 - pristine_git_object: 6cc606a71047d26b3caa24fd1f80dc5805094d60 - docs/sdks/sdk/README.md: - id: 623e0731a2f1 - last_write_checksum: sha1:2b4da341d6e1e498af025a9144e1e8bdb0630f33 - pristine_git_object: 12a9409e4b15684a934c0194230b849c57fd20f7 - go.mod: - id: c47645c391ad - last_write_checksum: sha1:47e8f3e5032b9aecc8833953c5f98a281c69e19a - pristine_git_object: 6d85ab89449fa319cab01e82c8fa10bcb981c078 - internal/config/sdkconfiguration.go: - id: 33b8bfac5265 - last_write_checksum: sha1:c24608b5e5215356da8eaaae812d87a8d6b2320b - pristine_git_object: f682bc12c3a87704b78abdcb714a4227e0fc0486 - internal/hooks/hooks.go: - id: 336be70ed5c0 - last_write_checksum: sha1:0758dc1873e709ec91978bea7d60210f206f169b - pristine_git_object: 312da6f943355bc06e396d510bd1773d43049f1b - internal/hooks/oauth2scopes.go: - id: 3ec4b8158405 - last_write_checksum: sha1:b99befb2e6754b1d897a01ec4b1264bea30b7d82 - pristine_git_object: f7113d8cb1712a67dca182284094c47ae46fb7bd - internal/utils/contenttype.go: - id: 928a56fe6755 - last_write_checksum: sha1:ab5cfd860d02c6e6a37f89e2b5649a9859947fb1 - pristine_git_object: f6487e01eb987c0aa49e7aa50b5ff24830314c63 - internal/utils/env.go: - id: ac8bab30c73d - last_write_checksum: sha1:1e0bcb2d2caed9f3657f1dbc99b55811bed0298c - pristine_git_object: 110d464eacf2387b347bad1cb903666354a339e9 - internal/utils/form.go: - id: 8ea133d76535 - last_write_checksum: sha1:38c791575387d94328d14ee94ff00fe2cdddc629 - pristine_git_object: c6071f6f7eaf762effdfc17b678a84bb554a7806 - internal/utils/headers.go: - id: 90d4f18d4adf - last_write_checksum: sha1:0760ba9bcd8eaf71ab693a4372290977497f7103 - pristine_git_object: d02696f93e4f8ad8e46d2f6616a7c4da6c856fe1 - internal/utils/json.go: - id: ad03e3c502d8 - last_write_checksum: sha1:e08fa1d407cf4c05f1400f176915a5d3a9830a8c - pristine_git_object: 2e19a1ee8d5a316882cffb81b59ba2c932aba274 - internal/utils/pathparams.go: - id: 1f3e0c30fd90 - last_write_checksum: sha1:09ff986a21d07a565a34d4ec15284aa5181f104c - pristine_git_object: 99d6ad448520d91d5e8cf4a1c1c333e606307973 - internal/utils/queryparams.go: - id: e7f604e65a58 - last_write_checksum: sha1:3018aabbfcad991d0be46788a64b020f0f1d84d5 - pristine_git_object: 42812c6ea346711debba226e32b7d335c7f48578 - internal/utils/requestbody.go: - id: 22445ec1c044 - last_write_checksum: sha1:534f1be6464a54c20d241b384bda06f4ba46ff0d - pristine_git_object: e6eee43c4ff28ccef2ad8c905b36dd5ec0e4389e - internal/utils/retries.go: - id: b5e011d15c2d - last_write_checksum: sha1:2ca7971cf904be70617e91de0b8242ad48d8f2fc - pristine_git_object: 8a5879945df5ea3f1c5719710e68dc58387ef936 - internal/utils/security.go: - id: eeb54c55241b - last_write_checksum: sha1:573140faaff017cb3bd31e579c6b3949f3d8365b - pristine_git_object: 77566aa2c1127569d9de4a1782b58f852d926d0a - internal/utils/union.go: - id: db86c85746b6 - last_write_checksum: sha1:08e26b97217e3d5ba6199406c8f784c626e128eb - pristine_git_object: eb89df328b7da6bbb882b6dea6a0efa95069fb63 - internal/utils/union_test.go: - id: 4b56247171bd - last_write_checksum: sha1:83ac73c1901de4c6db90f65cf5879163f3d47a00 - pristine_git_object: f66814c73ace94d8e71d785fd6161fb405745754 - internal/utils/utils.go: - id: 1133b2a27019 - last_write_checksum: sha1:c03378176a107205b8938606faef3fd8fb8f91a0 - pristine_git_object: a614e37673d3ea81eabc6139236edfb22a15dc55 - models/apierrors/apierror.go: - id: 4cecf27a5a54 - last_write_checksum: sha1:e699d3325ea82ea2b0d29656396210f0513c3391 - pristine_git_object: 61a9628a6533548245be5af929f1d4f96dd780b5 - models/apierrors/error.go: - id: 4b4731adf85f - last_write_checksum: sha1:0d2208a3f90426e6b97f4427f33bf0eff89bb952 - pristine_git_object: 306f886ced9e0019f96070f72a6498112869434d - models/components/action.go: - id: e2b750da2b65 - last_write_checksum: sha1:dc8ee3f77d8fa4e0c6da86953d0fa53fadcce271 - pristine_git_object: 876588598a7a2941d7df8606f59c6d92850b9c2a - models/components/anyregion.go: - id: fe03a828a26f - last_write_checksum: sha1:cda545f3702a83a6f37c4e06b1bbc8bf258cd62d - pristine_git_object: 83b822f938b04d403e3041ec7d79f35f6293802d - models/components/application.go: - id: d580034bf8ac - last_write_checksum: sha1:3948549560f46bd77f46879acba0d49223b71c68 - pristine_git_object: c0b4ab012e59ffc212e3d77238a9af9ce5e6dce6 - models/components/applicationdata.go: - id: 428b2a7d6def - last_write_checksum: sha1:0c2cd5dac96a9b263a87d1e04f923508801110ce - pristine_git_object: 6e0542547dab0e0857b8920a8089d1a36ad14a2b - models/components/applicationwithscope.go: - id: 0c0fb59c928b - last_write_checksum: sha1:c6f8ee419578378f13b787770bb60f96d553ae66 - pristine_git_object: 5bd02b4cd61c125480c499e6bcf48f27cdd62bca - models/components/authenticationproviderresponse.go: - id: 83d497c3e3ff - last_write_checksum: sha1:c5d506c9a0cee80fbeff86101e007b7a4b71e8c8 - pristine_git_object: a73a6bcd315e462ba3266d6cffede86cb7d64115 - models/components/capability.go: - id: 8626e576675e - last_write_checksum: sha1:ca4ea2a1eab2384253a4f4f73dac1b649bb9223f - pristine_git_object: 3ebfc59fee18aac49eee2210e83897b1fb325273 - models/components/createapplicationresponse.go: - id: 0192b35a8de6 - last_write_checksum: sha1:0e1fd7d4573011125b77cecc3a50999ad4e484c8 - pristine_git_object: 7a9862434039081525738f9693e4fbc00f7c053b - models/components/createapplicationscoperequest.go: - id: "572017526434" - last_write_checksum: sha1:a93001d9ed1c1590e1721ea1eb1ad833450d625c - pristine_git_object: e2917d617eb3801bc14744ec1374d0ff9fa45f87 - models/components/createapplicationscoperesponse.go: - id: 4ec62a6da2f6 - last_write_checksum: sha1:1756bbc7586773204f2b4ee551194bfc9295f00e - pristine_git_object: e1ca8adf52bd4a9fe6b4f7533c07d6a13fa000b0 - models/components/createclientresponseresponse.go: - id: 4a6257f3450c - last_write_checksum: sha1:09ca76c40d4674a6499bd28f6f42bb0df9cfd502 - pristine_git_object: 5509b402012cee6da5cd6e56a67c2e937e066320 - models/components/createdprivateregionresponse.go: - id: 01922812251b - last_write_checksum: sha1:bfc35c72c4de0765e77c7cd74669071431d8aea5 - pristine_git_object: c678f954db963fa98ecd28d9af0994a341e3765c - models/components/createinvitationresponse.go: - id: 6f7778753179 - last_write_checksum: sha1:5fbdd057c661d466a777396bed35ecac551e513b - pristine_git_object: 8454c990e591527b6c9c234fd02fa5fb24a07498 - models/components/createorganizationclientrequest.go: - id: 60c1cfb2e2ae - last_write_checksum: sha1:41d773b6b1a436b830d704fda18da877cb57aaf2 - pristine_git_object: d301132cb3bf557201dd8ec66adc6cee308fdff5 - models/components/createorganizationclientresponse.go: - id: 7b62d8c9c913 - last_write_checksum: sha1:e876a102a1de101c818e888ef536653b01b7477c - pristine_git_object: 0d82449f0bea110eeced103b567434c98e57e595 - models/components/createorganizationrequest.go: - id: 61c166f87930 - last_write_checksum: sha1:034418e7061aeec2a674c3f24d776a523211c7b1 - pristine_git_object: 71acd60d67e847c63ba5ca0a93c74876324eeba9 - models/components/createorganizationresponse.go: - id: be5eb3897433 - last_write_checksum: sha1:ac31a740ffa0800f1d199fc05ffa9ec3a95e95c2 - pristine_git_object: 5b684da58d2be1c7ed1b11579c1a4069604a7ed4 - models/components/createpolicyrequest.go: - id: 60f3b5b21b3b - last_write_checksum: sha1:d5a7b3e1a8deaeb6cbe977133a5d3338ee7fde3b - pristine_git_object: f8719470a140441ef55f38fed50fe42c08a711f0 - models/components/createpolicyresponse.go: - id: d82d3bc2b0c7 - last_write_checksum: sha1:1406f4ed3ddd0c9e7985b1bfc34587d0154bc4e9 - pristine_git_object: a8c07d6080790d5964132e6aee8de4196d211d6d - models/components/createprivateregionrequest.go: - id: 23b3e35d7165 - last_write_checksum: sha1:deefde0e3da86cc39541caf474499ecfff2b84a8 - pristine_git_object: 3d3ab8b9fc99bbabd067a1907ca9d77460652647 - models/components/createstackrequest.go: - id: 7b7464dcdc13 - last_write_checksum: sha1:35b17edb2c8ea5f69f207e051d189a4024c0fb36 - pristine_git_object: 53ec05209ddeb7a2cbd9d9f0a873141740c1bdcb - models/components/createuserrequest.go: - id: cef7feda6934 - last_write_checksum: sha1:4ad87f59cc91c6e1c7d15ebec20635d64e55f9e5 - pristine_git_object: 82ad0b40d70e12c37036b2f2ef7da50ff3bf4042 - models/components/createuserresponse.go: - id: a03d9da895b5 - last_write_checksum: sha1:b5258b6629e686d808c5ba8d3a529d9f8d839de3 - pristine_git_object: 4f63509f0e85b8cd42b813efa3a3345e7dfd1b7b - models/components/enableapplicationfororganizationresponse.go: - id: 5fa99d91f735 - last_write_checksum: sha1:7b821e2fecce0b73d8c2bed59a2328582766a7f5 - pristine_git_object: 400401a018a3c6715acde55f6d063bc6d714db5f - models/components/error.go: - id: 101a721ad2e7 - last_write_checksum: sha1:71dc66f31d1e3bd6d676750550ebdb65fa173d04 - pristine_git_object: 12883bebf2d5d7dadc2d5098c1727bc738710347 - models/components/getapplicationresponse.go: - id: 9dab7cb46ee8 - last_write_checksum: sha1:dd32a5362de19fd22f544dc2c24ef89a86dddea9 - pristine_git_object: 83a5e03e6bef70cfe7f22fd364e4a474474719a7 - models/components/getregionresponse.go: - id: 291c2ace066f - last_write_checksum: sha1:1b6e5e706287712dcca92c6491bb25a8516c8db6 - pristine_git_object: 357ae5860be656a9e083a00541347808261d98c7 - models/components/getregionversionsresponse.go: - id: 4466c6fb3d07 - last_write_checksum: sha1:976fcd77ec274499f4d368d7f305334378d72821 - pristine_git_object: ec7e1e9befd94f6183ecf7c15d49347d1f7b2dea - models/components/httpmetadata.go: - id: 790bad4a7abb - last_write_checksum: sha1:01a20fcff33179809ff9920ee431c7adabbd98f1 - pristine_git_object: 148ec47fb2dea6c188ec68eb3532d93169608168 - models/components/invitation.go: - id: 168c9ceddf8f - last_write_checksum: sha1:bc4b730f2b3970b167dcef777ba81a5d3cf51463 - pristine_git_object: 663ad1bd2ce174abf771cc0b229f17147b0d47ed - models/components/listapplicationsresponse.go: - id: 9a964cd68c50 - last_write_checksum: sha1:c64a0d2314583d073effbd6aef87822e6597887e - pristine_git_object: 92ab99b9dbb29786dba351aecb81552444554d0c - models/components/listinvitationsresponse.go: - id: 4bf354a71cda - last_write_checksum: sha1:d4a8a597e5e0aefab6aa76df92bb8456b38c190a - pristine_git_object: ed5ab458344d14b050bc1e02e9163786bd820e7a - models/components/listmodulesresponse.go: - id: 7346c4c23fa5 - last_write_checksum: sha1:25d2e9737dbda292be21906988b1905f6578aac5 - pristine_git_object: abea7bedb033d6c527339d550fcbc261579aa9e5 - models/components/listorganizationexpandedresponse.go: - id: 43c63aecdbbf - last_write_checksum: sha1:43b7b989994a4874ff6d45c3604178deaf432646 - pristine_git_object: fe89517c5ec5943a9e9825561c59844c39467b6f - models/components/listpoliciesresponse.go: - id: dd1d6d7d7ac7 - last_write_checksum: sha1:1804f3e341ee35a809fe2b42095ffdbf1f3830b5 - pristine_git_object: f28cf4d5feb119dc42f8f7f5f81136f1a260c0c1 - models/components/listregionsresponse.go: - id: 16a4a054dcb8 - last_write_checksum: sha1:7242070ad15c400b5027dd16d9dda06a55e4dd73 - pristine_git_object: fd19e9a5583cc88776d209498c0949c479f2bc96 - models/components/liststacksresponse.go: - id: 0f013cc84b22 - last_write_checksum: sha1:f8c1e6818dabd9eb4729c67df47fdf7b8f6916d8 - pristine_git_object: 44635fb17b4724e4c16f5555b3fff3c101a9064e - models/components/listusersresponse.go: - id: 68b582bdd6c5 - last_write_checksum: sha1:c3972bc04c924ac54d3e3733a18a62d026542424 - pristine_git_object: a2e90bed393d8f8ae6c1699df1d4befa1e4caf78 - models/components/log.go: - id: "076958063552" - last_write_checksum: sha1:54236f5f0d9d64f948bada506a9be4a27d138431 - pristine_git_object: de1a986ce7f8b4f26862831f3063d8ced2c814fd - models/components/logcursor.go: - id: f0fca9d8b9f0 - last_write_checksum: sha1:dc0c3aee5aed96677e380c1a3b73a729f2f2a5b1 - pristine_git_object: 1394bcd08d1306b1362ddeb97e7d1038527cb17c - models/components/module.go: - id: 5b73add3d2fa - last_write_checksum: sha1:ebc0ad8b9cef063f0d4483811b62fa626d89f8d8 - pristine_git_object: e31ac524b1d536c1a3f775bba411b05b321e2bf9 - models/components/organizationapplication.go: - id: de1fe130c67d - last_write_checksum: sha1:35e232a679bcbdb4c0905211bed5a55c4b36774e - pristine_git_object: 2391b325e10a62f50864e8efc10dc47bdbe5fbe2 - models/components/organizationclient.go: - id: 42cdd9e5d31e - last_write_checksum: sha1:86386e33d149f8b8a5e4125f348f2e7793553e15 - pristine_git_object: 851405a0b7924285fbd0ab019386f183e2e64f65 - models/components/organizationdata.go: - id: 046c1b05c938 - last_write_checksum: sha1:94224515a6c8f3fd0e0f70980a306768ada6113d - pristine_git_object: f0b6b4bb89ede50b4586e4c762cf0c4fe93e9cf8 - models/components/organizationexpanded.go: - id: eeb1a80f6449 - last_write_checksum: sha1:582a66c58173f188c0d9ce3decd3e2b3b92bcf72 - pristine_git_object: a4f9af46d8865ea02233fb53f69304e5cc0a82b7 - models/components/organizationfeature.go: - id: d0f1449b2e05 - last_write_checksum: sha1:713b41252ff35c7d9f0774448c5986b470f49387 - pristine_git_object: 25a5a2fb4677f3fbbabb629dbfe5275a36b3eaa5 - models/components/policy.go: - id: b8dcd5f63d86 - last_write_checksum: sha1:3d5d90e3dc15215661623217c23d7491263c4c44 - pristine_git_object: dd3703865b68f88cdcdda87ed39a845275cbcbd1 - models/components/privateregion.go: - id: dbcd0d2f6e3d - last_write_checksum: sha1:a423abde169312173352959a3ef74f48b8e3bebb - pristine_git_object: 4f6a0413263a3bee3bd909d93412fc9745f053ba - models/components/privateregionsecret.go: - id: 9ee2746cd4b3 - last_write_checksum: sha1:93271ad422df2428aa9a7e674794fa908d845ef1 - pristine_git_object: bde1ff35b86ec94306ea4c46fb106ed40e9bef1a - models/components/readorganizationclientresponse.go: - id: 40b3797afb78 - last_write_checksum: sha1:85a22b2f07151c49288be4de70a9674bda19df79 - pristine_git_object: 340420979b3964ffd325158150f654ceab613288 - models/components/readorganizationclientsresponse.go: - id: 6f5d21e4a098 - last_write_checksum: sha1:4f5148385bdfb759659fef8efe319c420c0cc6bd - pristine_git_object: 5a451ddc28273b2484b609e92ac71e1a6ffa46e6 - models/components/readorganizationresponse.go: - id: 9143e17f65f0 - last_write_checksum: sha1:1a7c0c53fe4e83e148f93896887f56deb9f0e81a - pristine_git_object: faad21bf2a1c9585bd593c0f6a2dc526b76c91cd - models/components/readorganizationuserresponse.go: - id: 9f93b32ebcab - last_write_checksum: sha1:3f81817a6ebc7d251d9a0a14cd0bb69988c2e662 - pristine_git_object: d0f8411d911fdd4569664a922a616542b77baf9d - models/components/readpolicyresponse.go: - id: fc70539e4ac9 - last_write_checksum: sha1:04e09c04d25339ddd36a589f3c35cfde8d317a6f - pristine_git_object: 81404ae1a7b39cd642106114ec34b0c5a7bf05aa - models/components/readstackresponse.go: - id: 210efe80ee88 - last_write_checksum: sha1:090d9f427f75ca2e4eb6c8b4e293614ccf83ad1e - pristine_git_object: 93aed36d5710d338ccdd0b0d798b548507841192 - models/components/readstackuseraccess.go: - id: 09cb71b27e28 - last_write_checksum: sha1:d4f4961d853d9a8f006d2d5ef6cdb5e903153da0 - pristine_git_object: b9f6b076cd27c3fe148ee8c9e31ac20f1a78355f - models/components/readuserresponse.go: - id: 64ff19b50d75 - last_write_checksum: sha1:84973b01b8c848534d771693f24f6f392246a3ba - pristine_git_object: 470d6714a8bdf28368bf203d0926fb20ed6185f7 - models/components/region.go: - id: 0c0389bfd469 - last_write_checksum: sha1:27790db9598bb52a4f06184e1255e9f59c35d5df - pristine_git_object: 29b71993ad8c358d9533a67a2681759c21650c7d - models/components/regioncapability.go: - id: 93011c142f1b - last_write_checksum: sha1:1d33b1bbcb245205c1ce54c24c25375d37a7db00 - pristine_git_object: 81ec2c85afe5c0c1533f5bc7246c687bcf123c7e - models/components/scope.go: - id: e66ed673e4d1 - last_write_checksum: sha1:bc85f513e61c5299cd93dbdf9663deb58389acb6 - pristine_git_object: de760f22f6b56541d218a5c727c3503f5aeadbcf - models/components/security.go: - id: 3a5eba5fe9e0 - last_write_checksum: sha1:57916d655f2f61c6b60243d8b0837f80657b6d51 - pristine_git_object: 86ed75574ad83c3094d43e24eb40ae8e4beecc11 - models/components/serverinfo.go: - id: 35054e9e289a - last_write_checksum: sha1:a195ac9d918b9f140a4cd808642796fdeb8d4e00 - pristine_git_object: 043ef845cdc50174257835d3482456c328ad559a - models/components/stack.go: - id: 6a5465dead58 - last_write_checksum: sha1:a4b12f958c8f11648e9f1f3ec8e1369d069c48e5 - pristine_git_object: 291749ddc7f93b55149b0ac3ce3a1462c4814191 - models/components/stackdata.go: - id: a982ca7c97e2 - last_write_checksum: sha1:35faba9feda3e16fe575f97ab6947d1b1c85d6ed - pristine_git_object: 638c3755abaa3c05ad2a267692734468374d27db - models/components/stackuseraccessresponse.go: - id: d16d6af80ee3 - last_write_checksum: sha1:c4e248a6b0f348a6dc3913345d6902c94c9bea8a - pristine_git_object: 9ed46e3147ac8135664ca6994e5bef33733be6c0 - models/components/stackversion.go: - id: 1a88b6da58ab - last_write_checksum: sha1:6d1a832c69747892bf09e5d792598ab1d6c4e933 - pristine_git_object: 2d9440df3ed282b2d5aa4508911454be5c4b10cb - models/components/systemrole.go: - id: 3e10bfebf0bc - last_write_checksum: sha1:389546252fd573d888bb93644a40bf63c73b68f1 - pristine_git_object: ca8251d10ce8ee31f3c89d347d8564123d874b49 - models/components/updateapplicationresponse.go: - id: b0fae458d7ff - last_write_checksum: sha1:1d4dd8d0855995719db082432bb4b1f93181054e - pristine_git_object: 05dd3c28a0e212c758faf05041f0544e35b57e2b - models/components/updateorganizationclientrequest.go: - id: 0955218b5d08 - last_write_checksum: sha1:ca912e855a01508b4c887e5936f408d6587d8360 - pristine_git_object: ab8aca553d52e8d26b1f261b94588b9e070a5196 - models/components/updateorganizationuserrequest.go: - id: 46635055b98a - last_write_checksum: sha1:6746cf357472e6e3153ccfaccf5a9d3dd5b5e12e - pristine_git_object: caa167b972b6862eae9f4c1e02d062b097032913 - models/components/updatepolicyresponse.go: - id: 581adbcbf8b1 - last_write_checksum: sha1:8d81841a898f970e07ebf986dd922666f5af6652 - pristine_git_object: f61071c8b2a505bb34bf8469a843b61f3de6ca01 - models/components/updatestackuserrequest.go: - id: 7818895180b6 - last_write_checksum: sha1:b14651f7c7feeb57c44810f459189b236cd70e02 - pristine_git_object: 8bf7a622de047b867c9a0e42ade127a553f9e906 - models/components/upsertauthenticationproviderrequest.go: - id: 24b1efca7f28 - last_write_checksum: sha1:cbfa89b2ab7a8f38f3035bcbf4fd6d0857e971ae - pristine_git_object: d4b6ad3ea18bf7c13c1a4a3b59eab68a086c23b9 - models/components/user.go: - id: 972e0ca973c7 - last_write_checksum: sha1:84673b3651fb9ac4f56592fff21afed41f3dffbe - pristine_git_object: 44b070867702535005640594b9b76ef4b8acaff8 - models/components/version.go: - id: 9ebc6923017b - last_write_checksum: sha1:3575c4d27d219fe6ece686335061e57d3f5ad3f2 - pristine_git_object: 5fde6f1bed907f07ce701e4a7803885535e041d2 - models/operations/acceptinvitation.go: - id: b2dbe7050e71 - last_write_checksum: sha1:4c6b0ace44b18abd8b00eb40f3ac62523a8af33d - pristine_git_object: b2a21752740c09aa9b9e40d20ff74fe1f421db26 - models/operations/addfeatures.go: - id: b5903c03301b - last_write_checksum: sha1:8705813f19a06f38a23f3787cc72985c6e40c1d7 - pristine_git_object: 5bc4270ebee6f6fe6c8529054f11036abb1126a7 - models/operations/addscopetopolicy.go: - id: a87ab4e57d8a - last_write_checksum: sha1:c45ea529c52d0803531140d577a2c3f70c28a67f - pristine_git_object: e54a28fd2d509d07409bf3043fad7048e8c259c8 - models/operations/createapplication.go: - id: 867e24c5a189 - last_write_checksum: sha1:362f11574c8f7d59c0dd8b63d50ff822225bc348 - pristine_git_object: e5a5eac0a1341ab66c8bffb8c94fa72e54c862d2 - models/operations/createapplicationscope.go: - id: 023105eee69f - last_write_checksum: sha1:c50bb5d30a62e49c8846b99ac5d44cc12311d62e - pristine_git_object: b441443108e51cfda9e12a7f7bcec48c4d267bf7 - models/operations/createinvitation.go: - id: 3eb923db3050 - last_write_checksum: sha1:4bf61003d9b497951d628136064aaf3646732436 - pristine_git_object: 2d7addb6c3bc36d3532fad04fad40660ea282097 - models/operations/createorganization.go: - id: 2ca44359474a - last_write_checksum: sha1:a695f3732a0222f88f2d3ab9b5fa8703c6c57dd0 - pristine_git_object: 1a6153ee3e5f6c268e153e04cc3fff0a28bebec3 - models/operations/createorganizationclient.go: - id: e93e030d0100 - last_write_checksum: sha1:7441929a0fe28a33d28ee309ff5665b636333012 - pristine_git_object: 18b85e2d0e89fd654775519d844fa363006ba461 - models/operations/createpolicy.go: - id: c4f4e83168ed - last_write_checksum: sha1:ffe4f7a7c760f74c4b204ffdf5231faf6b1a5c49 - pristine_git_object: c6a72cdb9a34ca07be59739b972b7feb3fd16af5 - models/operations/createprivateregion.go: - id: b6de6c27c0c3 - last_write_checksum: sha1:7a3980f1cf90b2d20ea3e59fa43a263a39dd8576 - pristine_git_object: b357d6e700dcf611f051ca3e0991f49741352467 - models/operations/createstack.go: - id: 15500f3accbc - last_write_checksum: sha1:a8129d61bbdf9e91468bdaa8aa9d4e5f12e75622 - pristine_git_object: c63b99e6a9943bbf1d872f7f696d63e078ee2e8d - models/operations/createuser.go: - id: d87bb8734894 - last_write_checksum: sha1:632c7fa8d3f2260127c0052ca3693038c3cfd4ca - pristine_git_object: 8f70eb95554684c5ba7fb755ee3aaf60693afab6 - models/operations/declineinvitation.go: - id: a9b61c355275 - last_write_checksum: sha1:fe653364a666528747c3313fbffdc76a4d65b143 - pristine_git_object: 57ecd677814b7409591f94228600b52ff60a5f01 - models/operations/deleteapplication.go: - id: 6c07dc05314b - last_write_checksum: sha1:36ec44a19aacafcf151c325eefd2c2b8c0ee2abc - pristine_git_object: 0f9abee4bc43b08460fe0a19518ec6b2b8f5e134 - models/operations/deleteapplicationscope.go: - id: 177b5b7f092a - last_write_checksum: sha1:bf4b16407ce6f7bc83e63249d7959078b3515041 - pristine_git_object: 77a1478b19245127856e072ac3a69ce73625e466 - models/operations/deleteauthenticationprovider.go: - id: f7aaf22aaf53 - last_write_checksum: sha1:c94f7ed9c0caa3b67f1d7dfc6fa75988df937930 - pristine_git_object: 6d2dab8466a930c7332c6edf710e7cd6201bd56f - models/operations/deletefeature.go: - id: 5c67a81d8151 - last_write_checksum: sha1:2c64dc09d626fa5fa70bf3f615ce4b32ca742668 - pristine_git_object: 216eb1af51f1f4147d65f62f5f3083f1f8d0652f - models/operations/deleteinvitation.go: - id: 38e0684ffe40 - last_write_checksum: sha1:9a2969dedc163c1b4ffc48715eab8bedefcfc2a0 - pristine_git_object: 33bc96f93f906c80e99a9f4bb1baf07db6a1e4d7 - models/operations/deleteorganization.go: - id: d64f94c78d32 - last_write_checksum: sha1:2cfb138eec3d6c108c49d16695bf56cab730956d - pristine_git_object: 4501dbe9951f420d7073a8db6a37118f3b7905f1 - models/operations/deleteorganizationclient.go: - id: 34a42a8fe166 - last_write_checksum: sha1:f1bb6ca65e595ef06097b4d6fb8f17beabbd4fdf - pristine_git_object: 964a3b99fef25ac603d5e68a0c63ff737bdcdbd2 - models/operations/deletepolicy.go: - id: 3730c2fff3ff - last_write_checksum: sha1:c3bdefa4f06dfcadbf96a753b2b2fb3ac35917e0 - pristine_git_object: a005d68710eafe3a503132e758529c46389dcb24 - models/operations/deleteregion.go: - id: 00a6c9ea62df - last_write_checksum: sha1:578f89cc62519f6feefa3184c99d9b4593929100 - pristine_git_object: 240ce5b360af4e290427e3fc5572676c74a0be13 - models/operations/deletestack.go: - id: 6ad2ac5acf9b - last_write_checksum: sha1:20b5d0c89481d0622baeadef3ee5d9169c7791c8 - pristine_git_object: 1c07fb3462aa034b329b185ea4e1b7f8709ecdb0 - models/operations/deletestackuseraccess.go: - id: e9057ce3fda7 - last_write_checksum: sha1:ec42caafb2e9e921165d1a3a9823c7c385329242 - pristine_git_object: a4f8614bde69c3c56312adaa9d3d876b80421c8f - models/operations/deleteuserfromorganization.go: - id: 3ea7e0b5c462 - last_write_checksum: sha1:b209b3833f723c996f10b9afcaeacb73ebb769ad - pristine_git_object: e1311eeb11883fa58f094d0cd05fbdaf94352616 - models/operations/disableapplicationfororganization.go: - id: 9308e790b58e - last_write_checksum: sha1:569ef9dd511bd55f2599c7a25b5cdb7381f7a63e - pristine_git_object: 1556883cc041ac6420351aeb7a65759411a80a60 - models/operations/disablemodule.go: - id: 64a432c2dc06 - last_write_checksum: sha1:9bb05466b6c5568f684618886bc75aad3446d5d4 - pristine_git_object: 4be555babf8de4319e8c4fd9abb9b060fda5a99d - models/operations/disablestack.go: - id: 97c348a8d1a2 - last_write_checksum: sha1:15beb83b75c68940abf4065dc08aa23d6dd5847f - pristine_git_object: 244b6fdd38b8b52df21d7b2524a81602af1501a9 - models/operations/disablestargate.go: - id: 838980f300e8 - last_write_checksum: sha1:b6b5c421fb853786638e379a6d6199cbd66ec5ed - pristine_git_object: 7262df8e3eb4bad35d80c14a83f7c21a3285a6d6 - models/operations/enableapplicationfororganization.go: - id: b71683751945 - last_write_checksum: sha1:a62f2439682a9b0ab11bef12c76653eac18c5d73 - pristine_git_object: 110fb8adc9e03586be01383dbd47ea1d11dd2064 - models/operations/enablemodule.go: - id: 1051a312f4b8 - last_write_checksum: sha1:369109651dbb65da537c17b960520849afd317ca - pristine_git_object: 01d4863bd4ebddfff8a7979c89848e9dca8da750 - models/operations/enablestack.go: - id: 20735b4d292c - last_write_checksum: sha1:5fac7932981c478fd8accef7a7b09285a7470a06 - pristine_git_object: 7b227ed97c7b2d768f2d30fc2530c1ebb16992d5 - models/operations/enablestargate.go: - id: 03fb5795b85a - last_write_checksum: sha1:4582bd26e6d7a5b1b2f5b4b1e7fe72b57a1a1cf4 - pristine_git_object: 7143179ad6dbf4171bbe1d4a3957db745655867f - models/operations/getapplication.go: - id: 42d16731b7be - last_write_checksum: sha1:ab914144f4b39314bb7b40d49a27bf9b28f1c6d5 - pristine_git_object: 5ec26d7ee7509ddccc767af96e240ebd4928cc95 - models/operations/getorganizationapplication.go: - id: 7a6606f9c048 - last_write_checksum: sha1:14929a8fd2b26a4e031f5523e97aad1ea3f0d29c - pristine_git_object: 5adaed79350b9785de3c848073f7b2e5a1d8cea1 - models/operations/getregion.go: - id: 7f9fc31b1d0f - last_write_checksum: sha1:4627e1ed4eb8bd69884c2058e0cc96edccb0f5de - pristine_git_object: d9e0fae5d133521a1112afc386a8f14db1a9fe85 - models/operations/getregionversions.go: - id: 60fce60df714 - last_write_checksum: sha1:0541a695062e24f327ae483856581b74cbf9379d - pristine_git_object: 834223d8f9e0435ab03e30bfcbcebbaa6c0df55d - models/operations/getserverinfo.go: - id: 1efda5965ab3 - last_write_checksum: sha1:1af72611d9a5cc6a024f933b330c03eb36b3cf5a - pristine_git_object: d08fb004a696562b40d45c9c001983de4ca2e6cd - models/operations/getstack.go: - id: ca6eb2e735ac - last_write_checksum: sha1:d4c5727af78afce6025aee5319a72a516d241ff1 - pristine_git_object: 074a0f0870941b88381cc55780a1c7cba44ef4d4 - models/operations/listapplications.go: - id: 487385b51f41 - last_write_checksum: sha1:1dbbc34f9ef8d3fef410f180bd249de8e25f21d6 - pristine_git_object: e8b2d925ce187fa282faa6bdb3cfb6881bda926e - models/operations/listfeatures.go: - id: 874827d858f4 - last_write_checksum: sha1:82382ab0da540b4b0131a367bddbcd9419e4855e - pristine_git_object: 58355744cef4ea83a0baf958f7c4e388627f7192 - models/operations/listinvitations.go: - id: 6376f1c23332 - last_write_checksum: sha1:d1e465e431635d5551bf0d706c36e0bca7836877 - pristine_git_object: 9b9f551b7bed66a0bc8b2685fba2230b5324477f - models/operations/listlogs.go: - id: 9496ae02b1f7 - last_write_checksum: sha1:13c9903578552d8869df654303ec13dcb501040d - pristine_git_object: 65b936375046a4a5161451272b53421d0d0f5690 - models/operations/listmodules.go: - id: fb3ee0669a0a - last_write_checksum: sha1:c353a4ee8fa4940a21c6df03586e22830a49cb37 - pristine_git_object: 71c275cbaa1099c29ea543e0e8365b8cf54bf7bb - models/operations/listorganizationapplications.go: - id: fbbf5a3870f4 - last_write_checksum: sha1:108f982b6d332c425b7f94e5c27c52e419a7a362 - pristine_git_object: 48a72960ad7fde12b83bc7e12868499ec0937ffb - models/operations/listorganizationinvitations.go: - id: 1b1e7ec51de0 - last_write_checksum: sha1:df19a8b1df9588d496e3788ce147b7856024742a - pristine_git_object: dc7cddbb1982d90fd133ccd8cf25f650b4c957b6 - models/operations/listorganizations.go: - id: 40a05c896d0e - last_write_checksum: sha1:0bcc9b259797f6026102d4a449bbbe12bdad24a4 - pristine_git_object: d17cca6fd72406d2def46e3a9f4839476edda003 - models/operations/listorganizationsexpanded.go: - id: 73f4888ff1af - last_write_checksum: sha1:65d614c36422a9dd5c2f32ffd47af319283f8fca - pristine_git_object: aec2e01bdbc451ae623299ec1ce5dc3b1ebed060 - models/operations/listpolicies.go: - id: f7d6ae86f12a - last_write_checksum: sha1:dc102e4d4321ef9602a2ded4a72026a75a55a30b - pristine_git_object: f4c813f0492a3145b891fc0ac0ab1268293b148d - models/operations/listregions.go: - id: 1477d8e90fb1 - last_write_checksum: sha1:432c220ae635709fea81bba065e278f61689e5cd - pristine_git_object: c36c0ea091a5216ba8e18ad7d7995f4360765220 - models/operations/liststacks.go: - id: 1b6e77c2a677 - last_write_checksum: sha1:42d13848bbf9258b4e49f0a0b22680397c13a8f6 - pristine_git_object: 7e893ab5839e802904270d1c68b01f959caf40b6 - models/operations/liststackusersaccesses.go: - id: 9abd06019fb8 - last_write_checksum: sha1:474d582fe3d762918497e50283ee17ccca41a401 - pristine_git_object: e9684bec4d6cd180763c4a95a2411ffeeb83119c - models/operations/listusersoforganization.go: - id: d0f7797644e3 - last_write_checksum: sha1:0d78ebfe5e49904f4288298a5a6a78e39950e4a5 - pristine_git_object: 5be59764c7ee233501f9b21b8e88b9ef2cad8f3f - models/operations/options.go: - id: d20dd4db9865 - last_write_checksum: sha1:a1cc7092e256270887591302bfe32e003865208c - pristine_git_object: 78b481e66a23f7ba32f9ac5daad4ab07cc37157a - models/operations/organizationclientcreate.go: - id: 83b1c8757f0e - last_write_checksum: sha1:777eed974eca21718c944a30d50977830ea4ab50 - pristine_git_object: c89b7bbf27d9a401711fd0af84b8274e396bf78f - models/operations/organizationclientdelete.go: - id: 5d855d1067b6 - last_write_checksum: sha1:29dc658396fd93b6bbbe45fca22d9c0bb0a31cf5 - pristine_git_object: e7d93017439a7e29015374d5c87ff3451b3eec19 - models/operations/organizationclientread.go: - id: d5d451caee63 - last_write_checksum: sha1:0109e6716066f38e3390d918835195cbf37cc694 - pristine_git_object: 1c73d2b5d0fee720f1a195e3bc04531b3fcf63ab - models/operations/organizationclientsread.go: - id: c2411215be95 - last_write_checksum: sha1:8e41a7d0f798e018dedb904ce85453a78903f4ee - pristine_git_object: 76cd124ca45b894b1d92f62ddc1c1252ac04ae72 - models/operations/organizationclientupdate.go: - id: 93791734f602 - last_write_checksum: sha1:538b980a31944513d1cdad9779acf4ef177dee72 - pristine_git_object: 639dc3402c96d4e7b2b6a23e7be61df3a5852057 - models/operations/readauthenticationprovider.go: - id: 50f4795e78b2 - last_write_checksum: sha1:ab8bf78fadd30f2c836139bf93a27ead0b1bf1fa - pristine_git_object: 5df2cc6e910d7c81d40e6794287b0f69f15ca1d4 - models/operations/readconnecteduser.go: - id: 0aab243366fe - last_write_checksum: sha1:1e00f2e40498f00980d3134b26d23cc52b4f7ff6 - pristine_git_object: cabf151fab5da9de842ba660262970f81621c319 - models/operations/readorganization.go: - id: 9e9d620a8e29 - last_write_checksum: sha1:b0b80f5890b90a127634457244912e49b7e99421 - pristine_git_object: 0054d4afade848b4b283df89aba0c34baa9440ac - models/operations/readorganizationclient.go: - id: 2029aa9a5757 - last_write_checksum: sha1:43a90a21aca739f2d2b92f2db62ffabac9b90447 - pristine_git_object: d5daa746378c2a3bbac118e43b631501d892085e - models/operations/readpolicy.go: - id: e405533c2235 - last_write_checksum: sha1:7079ddd5dc9cf6a05a6f4838ea2c1698e07b5757 - pristine_git_object: 639139452c078bebd58551ee86f0188eb392dd8d - models/operations/readstackuseraccess.go: - id: 1efc9edb1f25 - last_write_checksum: sha1:433119b046b83b5bf2a404657ab7f3cb817ea462 - pristine_git_object: 1c4c321851e90bca9c29132631dab1f3920898f6 - models/operations/readuseroforganization.go: - id: c8c3cc40b7df - last_write_checksum: sha1:24851bd41f0b0a025a4ab9d205bbea5a0ae64bb5 - pristine_git_object: ae9f8ac999891f85d944e2c58a9ebc2fc03c0359 - models/operations/removescopefrompolicy.go: - id: 5d407978b5d9 - last_write_checksum: sha1:80a5aa4e1d136281a6a3704d62acfddeee3114b3 - pristine_git_object: f90459008dda0a0ce804a59e637e63edf33c5446 - models/operations/restorestack.go: - id: 4d3fcf508488 - last_write_checksum: sha1:2c706432c49442a9a955fe8b7baadd0293771049 - pristine_git_object: d2c33124d74fb86dd8abd3b9a02b0fcba26b68d3 - models/operations/updateapplication.go: - id: d918ab47cf0c - last_write_checksum: sha1:c266f25ad106c240aa8102a5988c90f19e97caae - pristine_git_object: c4d621cf33758dbd4b17621d15eae19aecf2e937 - models/operations/updateorganization.go: - id: cb4261116a05 - last_write_checksum: sha1:959c096faeb3e02787e3657a65ee80ba3c3c05d8 - pristine_git_object: f737555c0b8ea41072d6515e24b68f10b6ddb6c3 - models/operations/updatepolicy.go: - id: dcf7246793ca - last_write_checksum: sha1:2bf4ce4a314e36e673a18dde24cfc46ead548a42 - pristine_git_object: 1592f34568096d3902dfdfacc092044234528b6d - models/operations/updatestack.go: - id: 87a333b579d9 - last_write_checksum: sha1:390d7072c6241c30d2028c545e1e2c8cc570a129 - pristine_git_object: 1603b6c14737e66639b661769b8c20a5d5d73e58 - models/operations/upgradestack.go: - id: 51166fa95218 - last_write_checksum: sha1:05f824b2b4e4909ddbdaf536980b39df6e6d5d87 - pristine_git_object: dc9d36a66321a3b362a4fc665eae3bf251b33978 - models/operations/upsertauthenticationprovider.go: - id: 9f39a71d6d60 - last_write_checksum: sha1:8668b94519481f2627af17705990dc666c399824 - pristine_git_object: b3001a7a6b7800d913260b2419aac408f5e5e922 - models/operations/upsertorganizationuser.go: - id: 4dd242c66ae3 - last_write_checksum: sha1:11b078b80b9a13219197270f987d8067d6472133 - pristine_git_object: b4393c66412bd09b899fc4b938a56271947502ce - models/operations/upsertstackuseraccess.go: - id: 633b23719f29 - last_write_checksum: sha1:34fac87ad99c5f44bebc82fe73d5ce6935311892 - pristine_git_object: 9e9d14dc9046ba6a3fc8793ab30d69ef0d3c12b6 - optionalnullable/optionalnullable.go: - id: ce60f259ead3 - last_write_checksum: sha1:d6aff1a420c31e025ea21d46cf056e305ae76fe8 - pristine_git_object: c6739be0619b6726565e0f11855ffe44b0e5607e - optionalnullable/optionalnullable_test.go: - id: ad67101ebca3 - last_write_checksum: sha1:2e9433eff1651ac77d6678ea76880d9a4980cbc2 - pristine_git_object: e6e5a01c6e721e879e24a38a1f97e3e27d4686d4 - retry/config.go: - id: 82d673cb0389 - last_write_checksum: sha1:7f7d96b59a18e95bac847ae09c63bbd911ee8432 - pristine_git_object: 5b3dc7c122d8ab08905485342d7474f8f163888d - sdk.go: - id: 8db9b9536b0a - last_write_checksum: sha1:8fa539a35ad217ebd8e856bbd388c1009f27dabc - pristine_git_object: 1311a6078584d2f53ce229db0f1bc35991a54eb2 - types/bigint.go: - id: 6f911e1a03c3 - last_write_checksum: sha1:49b004005d0461fb04b846eca062b070b0360b31 - pristine_git_object: 9c6a086d51595888a6ff787ebcb8693559afee95 - types/date.go: - id: 231a623ce1b7 - last_write_checksum: sha1:3036a2e1ed15a1d484c093367eba000c38f384d0 - pristine_git_object: 5b2782f219efe6bfbce46fc2dec68998eb759438 - types/datetime.go: - id: 5023aac30a47 - last_write_checksum: sha1:196d050af6450669214e9ae4fd28e56263ae2b5d - pristine_git_object: 3eff332daa04e34cec5ffb10add670bafd8ac454 - types/pointers.go: - id: 6d0d0a6cb6f3 - last_write_checksum: sha1:fc2275ea006257ba9ba4ff6f2a2cdb6205371eef - pristine_git_object: 35c439d2661e08fcb6a854096fd7b9a8561b3938 -examples: - getServerInfo: - speakeasy-default-get-server-info: - responses: - "200": - application/json: {"version": ""} - default: - application/json: {"errorCode": ""} - listOrganizations: - speakeasy-default-list-organizations: - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - createOrganization: - speakeasy-default-create-organization: - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - listOrganizationsExpanded: - speakeasy-default-list-organizations-expanded: - responses: - "200": - application/json: {} - readOrganization: - speakeasy-default-read-organization: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - updateOrganization: - speakeasy-default-update-organization: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteOrganization: - speakeasy-default-delete-organization: - parameters: - path: - organizationId: "" - responses: - default: - application/json: {"errorCode": ""} - readAuthenticationProvider: - speakeasy-default-read-authentication-provider: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - upsertAuthenticationProvider: - speakeasy-default-upsert-authentication-provider: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteAuthenticationProvider: - speakeasy-default-delete-authentication-provider: - parameters: - path: - organizationId: "" - responses: - default: - application/json: {"errorCode": ""} - listFeatures: - speakeasy-default-list-features: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {"data": [{"organizationID": "", "name": "", "createdAt": "2025-11-09T12:05:56.871Z"}]} - default: - application/json: {"errorCode": ""} - addFeatures: - speakeasy-default-add-features: - parameters: - path: - organizationId: "" - responses: - default: - application/json: {"errorCode": ""} - deleteFeature: - speakeasy-default-delete-feature: - parameters: - path: - organizationId: "" - name: "" - responses: - default: - application/json: {"errorCode": ""} - readOrganizationClient: - speakeasy-default-read-organization-client: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - createOrganizationClient: - speakeasy-default-create-organization-client: - parameters: - path: - organizationId: "" - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteOrganizationClient: - speakeasy-default-delete-organization-client: - parameters: - path: - organizationId: "" - responses: - default: - application/json: {"errorCode": ""} - organizationClientsRead: - speakeasy-default-organization-clients-read: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {"data": {"pageSize": 15, "hasMore": false, "previous": "YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol=", "next": "", "data": [{"id": "", "secret": {"lastDigits": ""}, "name": "", "description": "even furthermore sarcastic at tinderbox resort guacamole incandescence redound than", "createdAt": "2024-10-01T11:29:19.916Z", "updatedAt": "2024-12-23T20:03:23.769Z"}]}} - default: - application/json: {"errorCode": ""} - organizationClientCreate: - speakeasy-default-organization-client-create: - parameters: - path: - organizationId: "" - responses: - "201": - application/json: {"data": {"id": "", "secret": {"lastDigits": ""}, "name": "", "description": "reclassify rapid likewise separate carp pish upsell quintuple", "createdAt": "2024-01-13T09:33:21.504Z", "updatedAt": "2023-08-27T21:07:22.984Z"}} - default: - application/json: {"errorCode": ""} - organizationClientRead: - speakeasy-default-organization-client-read: - parameters: - path: - organizationId: "" - clientId: "" - responses: - "200": - application/json: {"data": {"id": "", "secret": {"lastDigits": ""}, "name": "", "description": "oh solution gosh amid nor orient narrate within gah fooey", "createdAt": "2025-05-24T11:12:14.293Z", "updatedAt": "2024-11-19T21:20:10.690Z"}} - default: - application/json: {"errorCode": ""} - organizationClientDelete: - speakeasy-default-organization-client-delete: - parameters: - path: - organizationId: "" - clientId: "" - responses: - default: - application/json: {"errorCode": ""} - organizationClientUpdate: - speakeasy-default-organization-client-update: - parameters: - path: - organizationId: "" - clientId: "" - responses: - default: - application/json: {"errorCode": ""} - listLogs: - speakeasy-default-list-logs: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {"data": {"pageSize": 15, "hasMore": false, "previous": "YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol=", "next": "", "data": [{"seq": "1e6f8b6e-1b6b-4b3b-8b3b-1b6b4b3b8b3b", "organizationId": "", "userId": "", "action": "", "date": "2024-08-30T19:05:54.946Z", "data": {}}]}} - default: - application/json: {"errorCode": ""} - listUsersOfOrganization: - speakeasy-default-list-users-of-organization: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - readUserOfOrganization: - speakeasy-default-read-user-of-organization: - parameters: - path: - organizationId: "" - userId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - upsertOrganizationUser: - speakeasy-default-upsert-organization-user: - parameters: - path: - organizationId: "" - userId: "" - responses: - default: - application/json: {"errorCode": ""} - deleteUserFromOrganization: - speakeasy-default-delete-user-from-organization: - parameters: - path: - organizationId: "" - userId: "" - responses: - default: - application/json: {"errorCode": ""} - listPolicies: - speakeasy-default-list-policies: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - createPolicy: - speakeasy-default-create-policy: - parameters: - path: - organizationId: "" - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - readPolicy: - speakeasy-default-read-policy: - parameters: - path: - organizationId: "" - policyId: 831591 - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - updatePolicy: - speakeasy-default-update-policy: - parameters: - path: - organizationId: "" - policyId: 127460 - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deletePolicy: - speakeasy-default-delete-policy: - parameters: - path: - organizationId: "" - policyId: 114294 - responses: - default: - application/json: {"errorCode": ""} - addScopeToPolicy: - speakeasy-default-add-scope-to-policy: - parameters: - path: - organizationId: "" - policyId: 328027 - scopeId: 675877 - responses: - default: - application/json: {"errorCode": ""} - removeScopeFromPolicy: - speakeasy-default-remove-scope-from-policy: - parameters: - path: - organizationId: "" - policyId: 995736 - scopeId: 485996 - responses: - default: - application/json: {"errorCode": ""} - listStacks: - speakeasy-default-list-stacks: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - createStack: - speakeasy-default-create-stack: - parameters: - path: - organizationId: "" - responses: - "202": - application/json: {} - default: - application/json: {"errorCode": ""} - listModules: - speakeasy-default-list-modules: - parameters: - path: - organizationId: "" - stackId: "" - responses: - "200": - application/json: {"data": []} - default: - application/json: {"errorCode": ""} - enableModule: - speakeasy-default-enable-module: - parameters: - path: - organizationId: "" - stackId: "" - query: - name: "" - responses: - default: - application/json: {"errorCode": ""} - disableModule: - speakeasy-default-disable-module: - parameters: - path: - organizationId: "" - stackId: "" - query: - name: "" - responses: - default: - application/json: {"errorCode": ""} - upgradeStack: - speakeasy-default-upgrade-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - getStack: - speakeasy-default-get-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - updateStack: - speakeasy-default-update-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteStack: - speakeasy-default-delete-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - listStackUsersAccesses: - speakeasy-default-list-stack-users-accesses: - parameters: - path: - organizationId: "" - stackId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - readStackUserAccess: - speakeasy-default-read-stack-user-access: - parameters: - path: - organizationId: "" - stackId: "" - userId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteStackUserAccess: - speakeasy-default-delete-stack-user-access: - parameters: - path: - organizationId: "" - stackId: "" - userId: "" - responses: - default: - application/json: {"errorCode": ""} - upsertStackUserAccess: - speakeasy-default-upsert-stack-user-access: - parameters: - path: - organizationId: "" - stackId: "" - userId: "" - responses: - default: - application/json: {"errorCode": ""} - disableStack: - speakeasy-default-disable-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - enableStack: - speakeasy-default-enable-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - restoreStack: - speakeasy-default-restore-stack: - parameters: - path: - organizationId: "" - stackId: "" - responses: - "202": - application/json: {} - default: - application/json: {"errorCode": ""} - enableStargate: - speakeasy-default-enable-stargate: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - disableStargate: - speakeasy-default-disable-stargate: - parameters: - path: - organizationId: "" - stackId: "" - responses: - default: - application/json: {"errorCode": ""} - listInvitations: - speakeasy-default-list-invitations: - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - acceptInvitation: - speakeasy-default-accept-invitation: - parameters: - path: - invitationId: "" - responses: - default: - application/json: {"errorCode": ""} - declineInvitation: - speakeasy-default-decline-invitation: - parameters: - path: - invitationId: "" - responses: - default: - application/json: {"errorCode": ""} - listOrganizationInvitations: - speakeasy-default-list-organization-invitations: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - createInvitation: - speakeasy-default-create-invitation: - parameters: - path: - organizationId: "" - query: - email: "Manley_Hoeger@hotmail.com" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteInvitation: - speakeasy-default-delete-invitation: - parameters: - path: - organizationId: "" - invitationId: "" - responses: - default: - application/json: {"errorCode": ""} - listRegions: - speakeasy-default-list-regions: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {"data": []} - default: - application/json: {"errorCode": ""} - createPrivateRegion: - speakeasy-default-create-private-region: - parameters: - path: - organizationId: "" - responses: - "200": - application/json: {"data": {"id": "", "baseUrl": "https://wide-eyed-kinase.org", "createdAt": "2024-12-31T09:20:38.935Z", "active": true, "name": "", "capabilities": {}, "agentID": "", "outdated": true, "organizationID": "", "creatorID": ""}} - default: - application/json: {"errorCode": ""} - "201": - application/json: {"data": {"id": "", "baseUrl": "https://wide-eyed-kinase.org", "createdAt": "2024-12-31T09:20:38.935Z", "active": true, "name": "", "capabilities": {}, "agentID": "", "outdated": true, "organizationID": "", "creatorID": ""}} - getRegion: - speakeasy-default-get-region: - parameters: - path: - organizationId: "" - regionID: "" - responses: - "200": - application/json: {"data": {"id": "", "baseUrl": "https://critical-pine.info/", "createdAt": "2024-12-31T04:45:57.185Z", "active": false, "name": "", "capabilities": {}, "agentID": "", "outdated": false, "public": true}} - default: - application/json: {"errorCode": ""} - deleteRegion: - speakeasy-default-delete-region: - parameters: - path: - organizationId: "" - regionID: "" - responses: - default: - application/json: {"errorCode": ""} - getRegionVersions: - speakeasy-default-get-region-versions: - parameters: - path: - organizationId: "" - regionID: "" - responses: - "200": - application/json: {"data": []} - default: - application/json: {"errorCode": ""} - listOrganizationApplications: - speakeasy-default-list-organization-applications: - parameters: - path: - organizationId: "" - query: - pageSize: 15 - page: 0 - responses: - "200": - application/json: {"cursor": {"pageSize": 15, "hasMore": false, "previous": "YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol=", "next": "", "data": []}} - default: - application/json: {"errorCode": ""} - getOrganizationApplication: - speakeasy-default-get-organization-application: - parameters: - path: - organizationId: "" - applicationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - enableApplicationForOrganization: - speakeasy-default-enable-application-for-organization: - parameters: - path: - organizationId: "" - applicationId: "" - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - disableApplicationForOrganization: - speakeasy-default-disable-application-for-organization: - parameters: - path: - organizationId: "" - applicationId: "" - responses: - default: - application/json: {"errorCode": ""} - listApplications: - speakeasy-default-list-applications: - responses: - "200": - application/json: {"cursor": {"pageSize": 15, "hasMore": false, "previous": "YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol=", "next": "", "data": []}} - default: - application/json: {"errorCode": ""} - createApplication: - speakeasy-default-create-application: - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - getApplication: - speakeasy-default-get-application: - parameters: - path: - applicationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - updateApplication: - speakeasy-default-update-application: - parameters: - path: - applicationId: "" - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteApplication: - speakeasy-default-delete-application: - parameters: - path: - applicationId: "" - responses: - default: - application/json: {"errorCode": ""} - createApplicationScope: - speakeasy-default-create-application-scope: - parameters: - path: - applicationId: "550e8400-e29b-41d4-a716-446655440000" - responses: - "201": - application/json: {} - default: - application/json: {"errorCode": ""} - deleteApplicationScope: - speakeasy-default-delete-application-scope: - parameters: - path: - applicationId: "550e8400-e29b-41d4-a716-446655440000" - scopeID: 115177 - responses: - "400": - application/json: {"errorCode": ""} - "500": - application/json: {"errorCode": ""} - default: - application/json: {"errorCode": ""} - createUser: - speakeasy-default-create-user: - requestBody: - application/json: {"email": "user@example.com"} - responses: - "201": - application/json: {} - "400": - application/json: {"errorCode": ""} - "500": - application/json: {"errorCode": ""} - default: - application/json: {"errorCode": ""} - readConnectedUser: - speakeasy-default-read-connected-user: - responses: - "200": - application/json: {} - default: - application/json: {"errorCode": ""} -examplesVersion: 1.0.2 diff --git a/internal/membershipclient/.speakeasy/gen.yaml b/internal/membershipclient/.speakeasy/gen.yaml deleted file mode 100644 index 9dc19c3c..00000000 --- a/internal/membershipclient/.speakeasy/gen.yaml +++ /dev/null @@ -1,68 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: SDK - maintainOpenAPIOrder: true - usageSnippets: - optionalPropertyRendering: withExample - sdkInitStyle: constructor - useClassNamesForArrayFields: true - fixes: - nameResolutionDec2023: true - nameResolutionFeb2025: true - parameterOrderingFeb2024: true - requestResponseComponentNamesFeb2024: true - securityFeb2025: true - sharedErrorComponentsApr2025: true - sharedNestedComponentsJan2026: false - nameOverrideFeb2026: false - auth: - oAuth2ClientCredentialsEnabled: true - oAuth2PasswordEnabled: true - hoistGlobalSecurity: true - inferSSEOverload: true - sdkHooksConfigAccess: true - schemas: - allOfMergeStrategy: shallowMerge - requestBodyFieldName: body - versioningStrategy: automatic - persistentEdits: {} - tests: - generateTests: false - generateNewTests: true - skipResponseBodyAssertions: false -go: - version: 0.1.1 - additionalDependencies: {} - baseErrorName: SDKError - clientServerStatusCodesAsErrors: true - codeSamples: - output: false - defaultErrorName: APIError - enableCustomCodeRegions: false - enableSkipDeserialization: false - flattenGlobalSecurity: true - forwardCompatibleEnumsByDefault: true - forwardCompatibleUnionsByDefault: "false" - imports: - option: openapi - paths: - callbacks: models/callbacks - errors: models/apierrors - operations: models/operations - shared: models/components - webhooks: models/webhooks - includeEmptyObjects: true - inferUnionDiscriminators: false - inputModelSuffix: input - maxMethodParams: 0 - methodArguments: require-security-and-request - modulePath: github.com/formancehq/fctl/internal/membershipclient/v3 - multipartArrayFormat: standard - nullableOptionalWrapper: true - outputModelSuffix: output - packageName: membershipclient - respectRequiredFields: false - respectTitlesForPrimitiveUnionMembers: false - responseFormat: envelope-http - sdkPackageName: membershipclient - unionStrategy: populated-fields diff --git a/internal/membershipclient/.speakeasy/workflow.lock b/internal/membershipclient/.speakeasy/workflow.lock deleted file mode 100644 index 869116e8..00000000 --- a/internal/membershipclient/.speakeasy/workflow.lock +++ /dev/null @@ -1,34 +0,0 @@ -speakeasyVersion: 1.759.2 -sources: - membership: - sourceNamespace: membership - sourceRevisionDigest: sha256:43d2b208ac1a3492602f5ebe5ee335548bc06ecf07f8e8047c86ae807b1b1354 - sourceBlobDigest: sha256:cc9d8d2f899357b7a2062c6d771067ca22ce09546f07087e255c7096e2830e48 - tags: - - latest - - 0.1.0 -targets: - membership: - source: membership - sourceNamespace: membership - sourceRevisionDigest: sha256:43d2b208ac1a3492602f5ebe5ee335548bc06ecf07f8e8047c86ae807b1b1354 - sourceBlobDigest: sha256:cc9d8d2f899357b7a2062c6d771067ca22ce09546f07087e255c7096e2830e48 -workflow: - workflowVersion: 1.0.0 - speakeasyVersion: 1.759.2 - sources: - membership: - inputs: - - location: ../../openapi/membership.yaml - registry: - location: registry.speakeasyapi.dev/formance/formance/membership - targets: - membership: - target: go - source: membership - codeSamples: - registry: - location: registry.speakeasyapi.dev/formance/formance/membership-go-code-samples - labelOverride: - fixedValue: Go (SDK) - blocking: false diff --git a/internal/membershipclient/.speakeasy/workflow.yaml b/internal/membershipclient/.speakeasy/workflow.yaml deleted file mode 100644 index 8915375a..00000000 --- a/internal/membershipclient/.speakeasy/workflow.yaml +++ /dev/null @@ -1,18 +0,0 @@ -workflowVersion: 1.0.0 -speakeasyVersion: 1.759.2 # Should be synced with flake.nix speakeasy version. -sources: - membership: - inputs: - - location: ../../openapi/membership.yaml - registry: - location: registry.speakeasyapi.dev/formance/formance/membership -targets: - membership: - target: go - source: membership - codeSamples: - registry: - location: registry.speakeasyapi.dev/formance/formance/membership-go-code-samples - labelOverride: - fixedValue: Go (SDK) - blocking: false diff --git a/internal/membershipclient/CONTRIBUTING.md b/internal/membershipclient/CONTRIBUTING.md deleted file mode 100644 index d585717f..00000000 --- a/internal/membershipclient/CONTRIBUTING.md +++ /dev/null @@ -1,26 +0,0 @@ -# Contributing to This Repository - -Thank you for your interest in contributing to this repository. Please note that this repository contains generated code. As such, we do not accept direct changes or pull requests. Instead, we encourage you to follow the guidelines below to report issues and suggest improvements. - -## How to Report Issues - -If you encounter any bugs or have suggestions for improvements, please open an issue on GitHub. When reporting an issue, please provide as much detail as possible to help us reproduce the problem. This includes: - -- A clear and descriptive title -- Steps to reproduce the issue -- Expected and actual behavior -- Any relevant logs, screenshots, or error messages -- Information about your environment (e.g., operating system, software versions) - - For example can be collected using the `npx envinfo` command from your terminal if you have Node.js installed - -## Issue Triage and Upstream Fixes - -We will review and triage issues as quickly as possible. Our goal is to address bugs and incorporate improvements in the upstream source code. Fixes will be included in the next generation of the generated code. - -## Contact - -If you have any questions or need further assistance, please feel free to reach out by opening an issue. - -Thank you for your understanding and cooperation! - -The Maintainers diff --git a/internal/membershipclient/README.md b/internal/membershipclient/README.md deleted file mode 100644 index ec643a5d..00000000 --- a/internal/membershipclient/README.md +++ /dev/null @@ -1,543 +0,0 @@ -# undefined - -Developer-friendly & type-safe Go SDK specifically catered to leverage *undefined* API. - - - - -

-> [!IMPORTANT] -> This SDK is not yet ready for production use. To complete setup please follow the steps outlined in your [workspace](https://app.speakeasy.com/org/formance/formance). Delete this section before > publishing to a package manager. - - -## Summary - - - - - -## Table of Contents - -* [undefined](#undefined) - * [SDK Installation](#sdk-installation) - * [SDK Example Usage](#sdk-example-usage) - * [Authentication](#authentication) - * [Available Resources and Operations](#available-resources-and-operations) - * [Retries](#retries) - * [Error Handling](#error-handling) - * [Server Selection](#server-selection) - * [Custom HTTP Client](#custom-http-client) -* [Development](#development) - * [Maturity](#maturity) - * [Contributions](#contributions) - - - - -## SDK Installation - -To add the SDK as a dependency to your project: -```bash -go get github.com/formancehq/fctl/internal/membershipclient/v3 -``` - - - -## SDK Example Usage - -### Example - -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - - - -## Authentication - -### Per-Client Security Schemes - -This SDK supports the following security scheme globally: - -| Name | Type | Scheme | -| -------- | ------ | ------------ | -| `Oauth2` | oauth2 | OAuth2 token | - -You can configure it using the `WithSecurity` option when initializing the SDK client instance. For example: -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - - - -## Available Resources and Operations - -
-Available methods - -### [SDK](docs/sdks/sdk/README.md) - -* [GetServerInfo](docs/sdks/sdk/README.md#getserverinfo) - Get server info -* [ListOrganizations](docs/sdks/sdk/README.md#listorganizations) - List organizations of the connected user -* [CreateOrganization](docs/sdks/sdk/README.md#createorganization) - Create organization -* [~~ListOrganizationsExpanded~~](docs/sdks/sdk/README.md#listorganizationsexpanded) - List organizations of the connected user with expanded data :warning: **Deprecated** -* [ReadOrganization](docs/sdks/sdk/README.md#readorganization) - Read organization -* [UpdateOrganization](docs/sdks/sdk/README.md#updateorganization) - Update organization -* [DeleteOrganization](docs/sdks/sdk/README.md#deleteorganization) - Delete organization -* [ReadAuthenticationProvider](docs/sdks/sdk/README.md#readauthenticationprovider) - Read authentication provider -* [UpsertAuthenticationProvider](docs/sdks/sdk/README.md#upsertauthenticationprovider) - Upsert an authentication provider -* [DeleteAuthenticationProvider](docs/sdks/sdk/README.md#deleteauthenticationprovider) - Delete authentication provider -* [ListFeatures](docs/sdks/sdk/README.md#listfeatures) - List features -* [AddFeatures](docs/sdks/sdk/README.md#addfeatures) - Add Features -* [DeleteFeature](docs/sdks/sdk/README.md#deletefeature) - Delete feature -* [~~ReadOrganizationClient~~](docs/sdks/sdk/README.md#readorganizationclient) - Read organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [~~CreateOrganizationClient~~](docs/sdks/sdk/README.md#createorganizationclient) - Create organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [~~DeleteOrganizationClient~~](docs/sdks/sdk/README.md#deleteorganizationclient) - Delete organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [OrganizationClientsRead](docs/sdks/sdk/README.md#organizationclientsread) - Read organization clients -* [OrganizationClientCreate](docs/sdks/sdk/README.md#organizationclientcreate) - Create organization client -* [OrganizationClientRead](docs/sdks/sdk/README.md#organizationclientread) - Read organization client -* [OrganizationClientDelete](docs/sdks/sdk/README.md#organizationclientdelete) - Delete organization client -* [OrganizationClientUpdate](docs/sdks/sdk/README.md#organizationclientupdate) - Update organization client -* [ListLogs](docs/sdks/sdk/README.md#listlogs) - List logs -* [ListUsersOfOrganization](docs/sdks/sdk/README.md#listusersoforganization) - List users of organization -* [ReadUserOfOrganization](docs/sdks/sdk/README.md#readuseroforganization) - Read user of organization -* [UpsertOrganizationUser](docs/sdks/sdk/README.md#upsertorganizationuser) - Update user within an organization -* [DeleteUserFromOrganization](docs/sdks/sdk/README.md#deleteuserfromorganization) - delete user from organization -* [ListPolicies](docs/sdks/sdk/README.md#listpolicies) - List policies of organization -* [CreatePolicy](docs/sdks/sdk/README.md#createpolicy) - Create policy -* [ReadPolicy](docs/sdks/sdk/README.md#readpolicy) - Read policy with scopes -* [UpdatePolicy](docs/sdks/sdk/README.md#updatepolicy) - Update policy -* [DeletePolicy](docs/sdks/sdk/README.md#deletepolicy) - Delete policy -* [AddScopeToPolicy](docs/sdks/sdk/README.md#addscopetopolicy) - Add scope to policy -* [RemoveScopeFromPolicy](docs/sdks/sdk/README.md#removescopefrompolicy) - Remove scope from policy -* [ListStacks](docs/sdks/sdk/README.md#liststacks) - List stacks -* [CreateStack](docs/sdks/sdk/README.md#createstack) - Create stack -* [ListModules](docs/sdks/sdk/README.md#listmodules) - List modules of a stack -* [EnableModule](docs/sdks/sdk/README.md#enablemodule) - enable module -* [DisableModule](docs/sdks/sdk/README.md#disablemodule) - disable module -* [UpgradeStack](docs/sdks/sdk/README.md#upgradestack) - Upgrade stack -* [GetStack](docs/sdks/sdk/README.md#getstack) - Find stack -* [UpdateStack](docs/sdks/sdk/README.md#updatestack) - Update stack -* [DeleteStack](docs/sdks/sdk/README.md#deletestack) - Delete stack -* [ListStackUsersAccesses](docs/sdks/sdk/README.md#liststackusersaccesses) - List stack users accesses within an organization -* [ReadStackUserAccess](docs/sdks/sdk/README.md#readstackuseraccess) - Read stack user access within an organization -* [DeleteStackUserAccess](docs/sdks/sdk/README.md#deletestackuseraccess) - Delete stack user access within an organization -* [UpsertStackUserAccess](docs/sdks/sdk/README.md#upsertstackuseraccess) - Update stack user access within an organization -* [DisableStack](docs/sdks/sdk/README.md#disablestack) - Disable stack -* [EnableStack](docs/sdks/sdk/README.md#enablestack) - Enable stack -* [RestoreStack](docs/sdks/sdk/README.md#restorestack) - Restore stack -* [EnableStargate](docs/sdks/sdk/README.md#enablestargate) - Enable stargate on a stack -* [DisableStargate](docs/sdks/sdk/README.md#disablestargate) - Disable stargate on a stack -* [ListInvitations](docs/sdks/sdk/README.md#listinvitations) - List invitations of the user -* [AcceptInvitation](docs/sdks/sdk/README.md#acceptinvitation) - Accept invitation -* [DeclineInvitation](docs/sdks/sdk/README.md#declineinvitation) - Decline invitation -* [ListOrganizationInvitations](docs/sdks/sdk/README.md#listorganizationinvitations) - List invitations of the organization -* [CreateInvitation](docs/sdks/sdk/README.md#createinvitation) - Create invitation -* [DeleteInvitation](docs/sdks/sdk/README.md#deleteinvitation) - Delete invitation -* [ListRegions](docs/sdks/sdk/README.md#listregions) - List regions -* [CreatePrivateRegion](docs/sdks/sdk/README.md#createprivateregion) - Create a private region -* [GetRegion](docs/sdks/sdk/README.md#getregion) - Get region -* [DeleteRegion](docs/sdks/sdk/README.md#deleteregion) - Delete region -* [GetRegionVersions](docs/sdks/sdk/README.md#getregionversions) - Get region versions -* [ListOrganizationApplications](docs/sdks/sdk/README.md#listorganizationapplications) - List applications enabled for organization -* [GetOrganizationApplication](docs/sdks/sdk/README.md#getorganizationapplication) - Get application for organization -* [EnableApplicationForOrganization](docs/sdks/sdk/README.md#enableapplicationfororganization) - Enable application for organization -* [DisableApplicationForOrganization](docs/sdks/sdk/README.md#disableapplicationfororganization) - Disable application for organization -* [ListApplications](docs/sdks/sdk/README.md#listapplications) - List applications -* [CreateApplication](docs/sdks/sdk/README.md#createapplication) - Create application -* [GetApplication](docs/sdks/sdk/README.md#getapplication) - Get application -* [UpdateApplication](docs/sdks/sdk/README.md#updateapplication) - Update application -* [DeleteApplication](docs/sdks/sdk/README.md#deleteapplication) - Delete application -* [CreateApplicationScope](docs/sdks/sdk/README.md#createapplicationscope) - Create application scope -* [DeleteApplicationScope](docs/sdks/sdk/README.md#deleteapplicationscope) - Delete application scope -* [CreateUser](docs/sdks/sdk/README.md#createuser) - Create user -* [ReadConnectedUser](docs/sdks/sdk/README.md#readconnecteduser) - Read user - -
- - - -## Retries - -Some of the endpoints in this SDK support retries. If you use the SDK without any configuration, it will fall back to the default retry strategy provided by the API. However, the default retry strategy can be overridden on a per-operation basis, or across the entire SDK. - -To change the default retry strategy for a single API call, simply provide a `retry.Config` object to the call by using the `WithRetries` option: -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "log" - "models/operations" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx, operations.WithRetries( - retry.Config{ - Strategy: "backoff", - Backoff: &retry.BackoffStrategy{ - InitialInterval: 1, - MaxInterval: 50, - Exponent: 1.1, - MaxElapsedTime: 100, - }, - RetryConnectionErrors: false, - })) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - -If you'd like to override the default retry strategy for all operations that support retries, you can use the `WithRetryConfig` option at SDK initialization: -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithRetryConfig( - retry.Config{ - Strategy: "backoff", - Backoff: &retry.BackoffStrategy{ - InitialInterval: 1, - MaxInterval: 50, - Exponent: 1.1, - MaxElapsedTime: 100, - }, - RetryConnectionErrors: false, - }), - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - - - -## Error Handling - -Handling errors in this SDK should largely match your expectations. All operations return a response object or an error, they will never return both. - -By Default, an API error will return `apierrors.APIError`. When custom error responses are specified for an operation, the SDK may also return their associated error. You can refer to respective *Errors* tables in SDK docs for more details on possible error types for each operation. - -For example, the `DeleteApplicationScope` function may return the following errors: - -| Error Type | Status Code | Content Type | -| ------------------ | ----------- | ---------------- | -| apierrors.Error | 400, 404 | application/json | -| apierrors.Error | 500 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -### Example - -```go -package main - -import ( - "context" - "errors" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/apierrors" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteApplicationScope(ctx, operations.DeleteApplicationScopeRequest{ - ApplicationID: "550e8400-e29b-41d4-a716-446655440000", - ScopeID: 115177, - }) - if err != nil { - - var e *apierrors.Error - if errors.As(err, &e) { - // handle error - log.Fatal(e.Error()) - } - - var e *apierrors.Error - if errors.As(err, &e) { - // handle error - log.Fatal(e.Error()) - } - - var e *apierrors.APIError - if errors.As(err, &e) { - // handle error - log.Fatal(e.Error()) - } - } -} - -``` - - - -## Server Selection - -### Override Server URL Per-Client - -The default server can be overridden globally using the `WithServerURL(serverURL string)` option when initializing the SDK client instance. For example: -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithServerURL("http://localhost:8080"), - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - - - -## Custom HTTP Client - -The Go SDK makes API calls that wrap an internal HTTP client. The requirements for the HTTP client are very simple. It must match this interface: - -```go -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} -``` - -The built-in `net/http` client satisfies this interface and a default client based on the built-in is provided by default. To replace this default with a client of your own, you can implement this interface yourself or provide your own client configured as desired. Here's a simple example, which adds a client with a 30 second timeout. - -```go -import ( - "net/http" - "time" - - "github.com/formancehq/fctl/internal/membershipclient/v3" -) - -var ( - httpClient = &http.Client{Timeout: 30 * time.Second} - sdkClient = membershipclient.New(membershipclient.WithClient(httpClient)) -) -``` - -This can be a convenient way to configure timeouts, cookies, proxies, custom headers, and other low-level configuration. - - - - -# Development - -## Maturity - -This SDK is in beta, and there may be breaking changes between versions without a major version update. Therefore, we recommend pinning usage -to a specific package version. This way, you can install the same version each time without breaking changes unless you are intentionally -looking for the latest version. - -## Contributions - -While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation. -We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release. - -### SDK Created by [Speakeasy](https://www.speakeasy.com/?utm_source=undefined&utm_campaign=go) - - \ No newline at end of file diff --git a/internal/membershipclient/USAGE.md b/internal/membershipclient/USAGE.md deleted file mode 100644 index f846dc44..00000000 --- a/internal/membershipclient/USAGE.md +++ /dev/null @@ -1,28 +0,0 @@ - -```go -package main - -import ( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} - -``` - \ No newline at end of file diff --git a/internal/membershipclient/docs/models/apierrors/error.md b/internal/membershipclient/docs/models/apierrors/error.md deleted file mode 100644 index 2d7d35a9..00000000 --- a/internal/membershipclient/docs/models/apierrors/error.md +++ /dev/null @@ -1,10 +0,0 @@ -# Error - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `ErrorCode` | `string` | :heavy_check_mark: | N/A | -| `ErrorMessage` | `*string` | :heavy_minus_sign: | N/A | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/action.md b/internal/membershipclient/docs/models/components/action.md deleted file mode 100644 index 22c3ebf9..00000000 --- a/internal/membershipclient/docs/models/components/action.md +++ /dev/null @@ -1,57 +0,0 @@ -# Action - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.ActionAgentsConnected -``` - - -## Values - -| Name | Value | -| --------------------------------- | --------------------------------- | -| `ActionAgentsConnected` | agents.connected | -| `ActionAgentsDisconnected` | agents.disconnected | -| `ActionInvitationsCreated` | invitations.created | -| `ActionInvitationsAccepted` | invitations.accepted | -| `ActionInvitationsRejected` | invitations.rejected | -| `ActionInvitationsCancelled` | invitations.cancelled | -| `ActionOrganizationsCreated` | organizations.created | -| `ActionOrganizationsUpdated` | organizations.updated | -| `ActionOrganizationsDeleted` | organizations.deleted | -| `ActionOrganizationsUserDeleted` | organizations.user.deleted | -| `ActionOrganizationsUserUpdated` | organizations.user.updated | -| `ActionRegionsCreated` | regions.created | -| `ActionRegionsDeleted` | regions.deleted | -| `ActionUsersCreated` | users.created | -| `ActionUsersDeleted` | users.deleted | -| `ActionStacksDisposal` | stacks.disposal | -| `ActionStacksDisposalReset` | stacks.disposal-reset | -| `ActionStacksWarned` | stacks.warned | -| `ActionStacksPruned` | stacks.pruned | -| `ActionStacksStatusUpdated` | stacks.status.updated | -| `ActionStacksCreated` | stacks.created | -| `ActionStacksUpdated` | stacks.updated | -| `ActionStacksDeleted` | stacks.deleted | -| `ActionStacksRestored` | stacks.restored | -| `ActionStacksDisabled` | stacks.disabled | -| `ActionStacksEnabled` | stacks.enabled | -| `ActionStacksUpgraded` | stacks.upgraded | -| `ActionStacksStargateEnabled` | stacks.stargate.enabled | -| `ActionStacksStargateDisabled` | stacks.stargate.disabled | -| `ActionStacksUserUpdated` | stacks.user.updated | -| `ActionStacksUserDeleted` | stacks.user.deleted | -| `ActionStacksReachnessUpdated` | stacks.reachness.updated | -| `ActionStacksModuleEnabled` | stacks.module.enabled | -| `ActionStacksModuleDisabled` | stacks.module.disabled | -| `ActionStacksModuleStatusUpdated` | stacks.module.status.updated | -| `ActionPoliciesCreated` | policies.created | -| `ActionPoliciesUpdated` | policies.updated | -| `ActionPoliciesDeleted` | policies.deleted | -| `ActionPoliciesScopeAdded` | policies.scope.added | -| `ActionPoliciesScopeRemoved` | policies.scope.removed | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/anyregion.md b/internal/membershipclient/docs/models/components/anyregion.md deleted file mode 100644 index 8f4ea7e7..00000000 --- a/internal/membershipclient/docs/models/components/anyregion.md +++ /dev/null @@ -1,24 +0,0 @@ -# AnyRegion - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `BaseURL` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | `string` | :heavy_check_mark: | N/A | -| `Active` | `bool` | :heavy_check_mark: | N/A | -| `LastPing` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `Capabilities` | [components.RegionCapability](../../models/components/regioncapability.md) | :heavy_check_mark: | N/A | -| `AgentID` | `string` | :heavy_check_mark: | N/A | -| `Outdated` | `bool` | :heavy_check_mark: | N/A | -| `CreatorID` | `*string` | :heavy_minus_sign: | N/A | -| `Version` | `*string` | :heavy_minus_sign: | N/A | -| `ClientID` | `*string` | :heavy_minus_sign: | N/A | -| `OrganizationID` | `*string` | :heavy_minus_sign: | N/A | -| `Creator` | [*components.User](../../models/components/user.md) | :heavy_minus_sign: | N/A | -| `Production` | `*bool` | :heavy_minus_sign: | N/A | -| `Public` | `bool` | :heavy_check_mark: | N/A | -| `Secret` | [*components.PrivateRegionSecret](../../models/components/privateregionsecret.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/application.md b/internal/membershipclient/docs/models/components/application.md deleted file mode 100644 index 169476ab..00000000 --- a/internal/membershipclient/docs/models/components/application.md +++ /dev/null @@ -1,14 +0,0 @@ -# Application - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Application name | -| `Description` | `*string` | :heavy_minus_sign: | Application description | -| `URL` | `string` | :heavy_check_mark: | Application URL (must be unique) | -| `Alias` | `string` | :heavy_check_mark: | Application alias | -| `ID` | `string` | :heavy_check_mark: | Application ID | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/applicationdata.md b/internal/membershipclient/docs/models/components/applicationdata.md deleted file mode 100644 index 5bf57da0..00000000 --- a/internal/membershipclient/docs/models/components/applicationdata.md +++ /dev/null @@ -1,11 +0,0 @@ -# ApplicationData - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------- | -------------------------------- | -------------------------------- | -------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Application name | -| `Description` | `*string` | :heavy_minus_sign: | Application description | -| `URL` | `string` | :heavy_check_mark: | Application URL (must be unique) | -| `Alias` | `string` | :heavy_check_mark: | Application alias | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/applicationwithscope.md b/internal/membershipclient/docs/models/components/applicationwithscope.md deleted file mode 100644 index 97c78791..00000000 --- a/internal/membershipclient/docs/models/components/applicationwithscope.md +++ /dev/null @@ -1,15 +0,0 @@ -# ApplicationWithScope - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | -| `Name` | `string` | :heavy_check_mark: | Application name | -| `Description` | `*string` | :heavy_minus_sign: | Application description | -| `URL` | `string` | :heavy_check_mark: | Application URL (must be unique) | -| `Alias` | `string` | :heavy_check_mark: | Application alias | -| `ID` | `string` | :heavy_check_mark: | Application ID | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date | -| `Scopes` | [][components.Scope](../../models/components/scope.md) | :heavy_check_mark: | List of scopes associated with this application | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponse.md b/internal/membershipclient/docs/models/components/authenticationproviderresponse.md deleted file mode 100644 index f4056631..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# AuthenticationProviderResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| `Data` | [*components.Data](../../models/components/data.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfig.md deleted file mode 100644 index dc7586c8..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfig.md +++ /dev/null @@ -1,16 +0,0 @@ -# AuthenticationProviderResponseGithubIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Type` | [components.AuthenticationProviderResponseGithubIDPConfigType](../../models/components/authenticationproviderresponsegithubidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.AuthenticationProviderResponseGithubIDPConfigConfig](../../models/components/authenticationproviderresponsegithubidpconfigconfig.md) | :heavy_check_mark: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | Unique identifier for the authentication provider | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date of the authentication provider | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date of the authentication provider | -| `RedirectURI` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigconfig.md deleted file mode 100644 index 0301600d..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigconfig.md +++ /dev/null @@ -1,7 +0,0 @@ -# AuthenticationProviderResponseGithubIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigtype.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigtype.md deleted file mode 100644 index e044234b..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegithubidpconfigtype.md +++ /dev/null @@ -1,26 +0,0 @@ -# AuthenticationProviderResponseGithubIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.AuthenticationProviderResponseGithubIDPConfigTypeOidc - -// Open enum: custom values can be created with a direct type cast -custom := components.AuthenticationProviderResponseGithubIDPConfigType("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------------------------------------------ | ------------------------------------------------------------ | -| `AuthenticationProviderResponseGithubIDPConfigTypeOidc` | oidc | -| `AuthenticationProviderResponseGithubIDPConfigTypeGoogle` | google | -| `AuthenticationProviderResponseGithubIDPConfigTypeGithub` | github | -| `AuthenticationProviderResponseGithubIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfig.md deleted file mode 100644 index 61e163a5..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfig.md +++ /dev/null @@ -1,16 +0,0 @@ -# AuthenticationProviderResponseGoogleIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Type` | [components.AuthenticationProviderResponseGoogleIDPConfigType](../../models/components/authenticationproviderresponsegoogleidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.AuthenticationProviderResponseGoogleIDPConfigConfig](../../models/components/authenticationproviderresponsegoogleidpconfigconfig.md) | :heavy_check_mark: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | Unique identifier for the authentication provider | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date of the authentication provider | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date of the authentication provider | -| `RedirectURI` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigconfig.md deleted file mode 100644 index b777b938..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigconfig.md +++ /dev/null @@ -1,7 +0,0 @@ -# AuthenticationProviderResponseGoogleIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigtype.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigtype.md deleted file mode 100644 index 51fec2e4..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsegoogleidpconfigtype.md +++ /dev/null @@ -1,26 +0,0 @@ -# AuthenticationProviderResponseGoogleIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.AuthenticationProviderResponseGoogleIDPConfigTypeOidc - -// Open enum: custom values can be created with a direct type cast -custom := components.AuthenticationProviderResponseGoogleIDPConfigType("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------------------------------------------ | ------------------------------------------------------------ | -| `AuthenticationProviderResponseGoogleIDPConfigTypeOidc` | oidc | -| `AuthenticationProviderResponseGoogleIDPConfigTypeGoogle` | google | -| `AuthenticationProviderResponseGoogleIDPConfigTypeGithub` | github | -| `AuthenticationProviderResponseGoogleIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfig.md deleted file mode 100644 index d9e83db5..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfig.md +++ /dev/null @@ -1,16 +0,0 @@ -# AuthenticationProviderResponseMicrosoftIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Type` | [components.AuthenticationProviderResponseMicrosoftIDPConfigType](../../models/components/authenticationproviderresponsemicrosoftidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.AuthenticationProviderResponseMicrosoftIDPConfigConfig](../../models/components/authenticationproviderresponsemicrosoftidpconfigconfig.md) | :heavy_check_mark: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | Unique identifier for the authentication provider | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date of the authentication provider | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date of the authentication provider | -| `RedirectURI` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigconfig.md deleted file mode 100644 index 7e51fee9..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigconfig.md +++ /dev/null @@ -1,8 +0,0 @@ -# AuthenticationProviderResponseMicrosoftIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------- | -------------------------------------- | -------------------------------------- | -------------------------------------- | -| `Tenant` | `*string` | :heavy_minus_sign: | Tenant ID for Microsoft authentication | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigtype.md b/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigtype.md deleted file mode 100644 index 47134f90..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponsemicrosoftidpconfigtype.md +++ /dev/null @@ -1,26 +0,0 @@ -# AuthenticationProviderResponseMicrosoftIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.AuthenticationProviderResponseMicrosoftIDPConfigTypeOidc - -// Open enum: custom values can be created with a direct type cast -custom := components.AuthenticationProviderResponseMicrosoftIDPConfigType("custom_value") -``` - - -## Values - -| Name | Value | -| --------------------------------------------------------------- | --------------------------------------------------------------- | -| `AuthenticationProviderResponseMicrosoftIDPConfigTypeOidc` | oidc | -| `AuthenticationProviderResponseMicrosoftIDPConfigTypeGoogle` | google | -| `AuthenticationProviderResponseMicrosoftIDPConfigTypeGithub` | github | -| `AuthenticationProviderResponseMicrosoftIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfig.md deleted file mode 100644 index d30dddf1..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfig.md +++ /dev/null @@ -1,16 +0,0 @@ -# AuthenticationProviderResponseOIDCConfig - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `Type` | [components.AuthenticationProviderResponseOIDCConfigType](../../models/components/authenticationproviderresponseoidcconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.AuthenticationProviderResponseOIDCConfigConfig](../../models/components/authenticationproviderresponseoidcconfigconfig.md) | :heavy_check_mark: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | Unique identifier for the authentication provider | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date of the authentication provider | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date of the authentication provider | -| `RedirectURI` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigconfig.md b/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigconfig.md deleted file mode 100644 index 06d33b98..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigconfig.md +++ /dev/null @@ -1,9 +0,0 @@ -# AuthenticationProviderResponseOIDCConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Issuer` | `string` | :heavy_check_mark: | OIDC issuer URL | -| `DiscoveryPath` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigtype.md b/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigtype.md deleted file mode 100644 index 58a272cd..00000000 --- a/internal/membershipclient/docs/models/components/authenticationproviderresponseoidcconfigtype.md +++ /dev/null @@ -1,26 +0,0 @@ -# AuthenticationProviderResponseOIDCConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.AuthenticationProviderResponseOIDCConfigTypeOidc - -// Open enum: custom values can be created with a direct type cast -custom := components.AuthenticationProviderResponseOIDCConfigType("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------------------------------------- | ------------------------------------------------------- | -| `AuthenticationProviderResponseOIDCConfigTypeOidc` | oidc | -| `AuthenticationProviderResponseOIDCConfigTypeGoogle` | google | -| `AuthenticationProviderResponseOIDCConfigTypeGithub` | github | -| `AuthenticationProviderResponseOIDCConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/capability.md b/internal/membershipclient/docs/models/components/capability.md deleted file mode 100644 index beeb69a1..00000000 --- a/internal/membershipclient/docs/models/components/capability.md +++ /dev/null @@ -1,18 +0,0 @@ -# Capability - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.CapabilityModuleSelection -``` - - -## Values - -| Name | Value | -| --------------------------- | --------------------------- | -| `CapabilityModuleSelection` | MODULE_SELECTION | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/clusterstatus.md b/internal/membershipclient/docs/models/components/clusterstatus.md deleted file mode 100644 index 0b60308e..00000000 --- a/internal/membershipclient/docs/models/components/clusterstatus.md +++ /dev/null @@ -1,7 +0,0 @@ -# ClusterStatus - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createapplicationresponse.md b/internal/membershipclient/docs/models/components/createapplicationresponse.md deleted file mode 100644 index 37f02682..00000000 --- a/internal/membershipclient/docs/models/components/createapplicationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | -| `Data` | [*components.Application](../../models/components/application.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createapplicationscoperequest.md b/internal/membershipclient/docs/models/components/createapplicationscoperequest.md deleted file mode 100644 index 55506603..00000000 --- a/internal/membershipclient/docs/models/components/createapplicationscoperequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateApplicationScopeRequest - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -| `Description` | `*string` | :heavy_minus_sign: | Scope description | -| `Label` | `string` | :heavy_check_mark: | The OAuth2 scope label (e.g., "custom:read") | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createapplicationscoperesponse.md b/internal/membershipclient/docs/models/components/createapplicationscoperesponse.md deleted file mode 100644 index d8bb7463..00000000 --- a/internal/membershipclient/docs/models/components/createapplicationscoperesponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateApplicationScopeResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | -| `Data` | [*components.Scope](../../models/components/scope.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createclientresponseresponse.md b/internal/membershipclient/docs/models/components/createclientresponseresponse.md deleted file mode 100644 index 7082e1ff..00000000 --- a/internal/membershipclient/docs/models/components/createclientresponseresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateClientResponseResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `Data` | [*components.CreateClientResponseResponseData](../../models/components/createclientresponseresponsedata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createclientresponseresponsedata.md b/internal/membershipclient/docs/models/components/createclientresponseresponsedata.md deleted file mode 100644 index e90f0aab..00000000 --- a/internal/membershipclient/docs/models/components/createclientresponseresponsedata.md +++ /dev/null @@ -1,14 +0,0 @@ -# CreateClientResponseResponseData - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | Organization ID | -| `Secret` | [components.CreateClientResponseResponseSecret](../../models/components/createclientresponseresponsesecret.md) | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | Name of the client | -| `Description` | `string` | :heavy_check_mark: | Description of the client | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `OwnerID` | `string` | :heavy_check_mark: | The ID of the owner of the client (DEPRECATED will be empty) | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createclientresponseresponsesecret.md b/internal/membershipclient/docs/models/components/createclientresponseresponsesecret.md deleted file mode 100644 index 7bdb9ad4..00000000 --- a/internal/membershipclient/docs/models/components/createclientresponseresponsesecret.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateClientResponseResponseSecret - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `LastDigits` | `string` | :heavy_check_mark: | N/A | -| `Clear` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createdprivateregionresponse.md b/internal/membershipclient/docs/models/components/createdprivateregionresponse.md deleted file mode 100644 index c434fcf6..00000000 --- a/internal/membershipclient/docs/models/components/createdprivateregionresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreatedPrivateRegionResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `Data` | [components.PrivateRegion](../../models/components/privateregion.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createinvitationresponse.md b/internal/membershipclient/docs/models/components/createinvitationresponse.md deleted file mode 100644 index 57404aae..00000000 --- a/internal/membershipclient/docs/models/components/createinvitationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateInvitationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | -| `Data` | [*components.Invitation](../../models/components/invitation.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createorganizationclientrequest.md b/internal/membershipclient/docs/models/components/createorganizationclientrequest.md deleted file mode 100644 index 1f71879e..00000000 --- a/internal/membershipclient/docs/models/components/createorganizationclientrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateOrganizationClientRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------- | ------------------------- | ------------------------- | ------------------------- | -| `Name` | `*string` | :heavy_minus_sign: | Name of the client | -| `Description` | `*string` | :heavy_minus_sign: | Description of the client | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createorganizationclientresponse.md b/internal/membershipclient/docs/models/components/createorganizationclientresponse.md deleted file mode 100644 index 07163fca..00000000 --- a/internal/membershipclient/docs/models/components/createorganizationclientresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateOrganizationClientResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `Data` | [components.OrganizationClient](../../models/components/organizationclient.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createorganizationrequest.md b/internal/membershipclient/docs/models/components/createorganizationrequest.md deleted file mode 100644 index bc9587c1..00000000 --- a/internal/membershipclient/docs/models/components/createorganizationrequest.md +++ /dev/null @@ -1,11 +0,0 @@ -# CreateOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Organization name | -| `Domain` | `*string` | :heavy_minus_sign: | Organization domain | -| `DefaultPolicyID` | `*int64` | :heavy_minus_sign: | Default policy ID applied to new users | -| `OwnerID` | `*string` | :heavy_minus_sign: | User ID of the owner of the organization (only when used with a non user agent) | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createorganizationresponse.md b/internal/membershipclient/docs/models/components/createorganizationresponse.md deleted file mode 100644 index 5ab64439..00000000 --- a/internal/membershipclient/docs/models/components/createorganizationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `Data` | [*components.OrganizationExpanded](../../models/components/organizationexpanded.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createpolicyrequest.md b/internal/membershipclient/docs/models/components/createpolicyrequest.md deleted file mode 100644 index 832bdf26..00000000 --- a/internal/membershipclient/docs/models/components/createpolicyrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreatePolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Name` | `string` | :heavy_check_mark: | Policy name | -| `Description` | `*string` | :heavy_minus_sign: | Policy description | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createpolicyresponse.md b/internal/membershipclient/docs/models/components/createpolicyresponse.md deleted file mode 100644 index 202a71ef..00000000 --- a/internal/membershipclient/docs/models/components/createpolicyresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreatePolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Data` | [*components.Policy](../../models/components/policy.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createprivateregionrequest.md b/internal/membershipclient/docs/models/components/createprivateregionrequest.md deleted file mode 100644 index 8ff46b93..00000000 --- a/internal/membershipclient/docs/models/components/createprivateregionrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreatePrivateRegionRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Name` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createstackrequest.md b/internal/membershipclient/docs/models/components/createstackrequest.md deleted file mode 100644 index 9f48bf94..00000000 --- a/internal/membershipclient/docs/models/components/createstackrequest.md +++ /dev/null @@ -1,11 +0,0 @@ -# CreateStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Stack name | -| `Metadata` | map[string]`string` | :heavy_minus_sign: | N/A | -| `Version` | `*string` | :heavy_minus_sign: | Supported only with agent version >= v0.7.0 | -| `RegionID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createuserrequest.md b/internal/membershipclient/docs/models/components/createuserrequest.md deleted file mode 100644 index 8f68c7cc..00000000 --- a/internal/membershipclient/docs/models/components/createuserrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateUserRequest - - -## Fields - -| Field | Type | Required | Description | Example | -| ----------------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- | -| `Email` | `string` | :heavy_check_mark: | Email address of the user to create | user@example.com | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/createuserresponse.md b/internal/membershipclient/docs/models/components/createuserresponse.md deleted file mode 100644 index 63bd364b..00000000 --- a/internal/membershipclient/docs/models/components/createuserresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateUserResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| `Data` | [*components.User](../../models/components/user.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/cursor.md b/internal/membershipclient/docs/models/components/cursor.md deleted file mode 100644 index a428fceb..00000000 --- a/internal/membershipclient/docs/models/components/cursor.md +++ /dev/null @@ -1,12 +0,0 @@ -# Cursor - - -## Fields - -| Field | Type | Required | Description | Example | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `PageSize` | `int64` | :heavy_check_mark: | N/A | 15 | -| `HasMore` | `bool` | :heavy_check_mark: | N/A | false | -| `Previous` | `*string` | :heavy_minus_sign: | N/A | YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol= | -| `Next` | `*string` | :heavy_minus_sign: | N/A | | -| `Data` | [][components.Application](../../models/components/application.md) | :heavy_check_mark: | N/A | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/data.md b/internal/membershipclient/docs/models/components/data.md deleted file mode 100644 index 28c2f786..00000000 --- a/internal/membershipclient/docs/models/components/data.md +++ /dev/null @@ -1,45 +0,0 @@ -# Data - - -## Supported Types - -### AuthenticationProviderResponseGoogleIDPConfig - -```go -data := components.CreateDataAuthenticationProviderResponseGoogleIDPConfig(components.AuthenticationProviderResponseGoogleIDPConfig{/* values here */}) -``` - -### AuthenticationProviderResponseMicrosoftIDPConfig - -```go -data := components.CreateDataAuthenticationProviderResponseMicrosoftIDPConfig(components.AuthenticationProviderResponseMicrosoftIDPConfig{/* values here */}) -``` - -### AuthenticationProviderResponseGithubIDPConfig - -```go -data := components.CreateDataAuthenticationProviderResponseGithubIDPConfig(components.AuthenticationProviderResponseGithubIDPConfig{/* values here */}) -``` - -### AuthenticationProviderResponseOIDCConfig - -```go -data := components.CreateDataAuthenticationProviderResponseOIDCConfig(components.AuthenticationProviderResponseOIDCConfig{/* values here */}) -``` - -## Union Discrimination - -Use the `Type` field to determine which variant is active, then access the corresponding field: - -```go -switch data.Type { - case components.DataTypeAuthenticationProviderResponseGoogleIDPConfig: - // data.AuthenticationProviderResponseGoogleIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - // data.AuthenticationProviderResponseMicrosoftIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseGithubIDPConfig: - // data.AuthenticationProviderResponseGithubIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseOIDCConfig: - // data.AuthenticationProviderResponseOIDCConfig is populated -} -``` diff --git a/internal/membershipclient/docs/models/components/enableapplicationfororganizationresponse.md b/internal/membershipclient/docs/models/components/enableapplicationfororganizationresponse.md deleted file mode 100644 index 67141a7e..00000000 --- a/internal/membershipclient/docs/models/components/enableapplicationfororganizationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# EnableApplicationForOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `Data` | [*components.OrganizationApplication](../../models/components/organizationapplication.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/error.md b/internal/membershipclient/docs/models/components/error.md deleted file mode 100644 index 5450343d..00000000 --- a/internal/membershipclient/docs/models/components/error.md +++ /dev/null @@ -1,9 +0,0 @@ -# Error - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ErrorCode` | `string` | :heavy_check_mark: | N/A | -| `ErrorMessage` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/expectedstatus.md b/internal/membershipclient/docs/models/components/expectedstatus.md deleted file mode 100644 index 2946c52f..00000000 --- a/internal/membershipclient/docs/models/components/expectedstatus.md +++ /dev/null @@ -1,23 +0,0 @@ -# ExpectedStatus - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.ExpectedStatusReady - -// Open enum: custom values can be created with a direct type cast -custom := components.ExpectedStatus("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------ | ------------------------ | -| `ExpectedStatusReady` | READY | -| `ExpectedStatusDisabled` | DISABLED | -| `ExpectedStatusDeleted` | DELETED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/getapplicationresponse.md b/internal/membershipclient/docs/models/components/getapplicationresponse.md deleted file mode 100644 index 6f59f1e2..00000000 --- a/internal/membershipclient/docs/models/components/getapplicationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# GetApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `Data` | [*components.ApplicationWithScope](../../models/components/applicationwithscope.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/getregionresponse.md b/internal/membershipclient/docs/models/components/getregionresponse.md deleted file mode 100644 index 1d2d2886..00000000 --- a/internal/membershipclient/docs/models/components/getregionresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# GetRegionResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | -| `Data` | [components.AnyRegion](../../models/components/anyregion.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/getregionversionsresponse.md b/internal/membershipclient/docs/models/components/getregionversionsresponse.md deleted file mode 100644 index 7dc88160..00000000 --- a/internal/membershipclient/docs/models/components/getregionversionsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# GetRegionVersionsResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | -| `Data` | [][components.Version](../../models/components/version.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/httpmetadata.md b/internal/membershipclient/docs/models/components/httpmetadata.md deleted file mode 100644 index df1fdd59..00000000 --- a/internal/membershipclient/docs/models/components/httpmetadata.md +++ /dev/null @@ -1,9 +0,0 @@ -# HTTPMetadata - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Response` | [*http.Response](https://pkg.go.dev/net/http#Response) | :heavy_check_mark: | Raw HTTP response; suitable for custom response parsing | -| `Request` | [*http.Request](https://pkg.go.dev/net/http#Request) | :heavy_check_mark: | Raw HTTP request; suitable for debugging | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/invitation.md b/internal/membershipclient/docs/models/components/invitation.md deleted file mode 100644 index 0b20dc13..00000000 --- a/internal/membershipclient/docs/models/components/invitation.md +++ /dev/null @@ -1,18 +0,0 @@ -# Invitation - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `UserEmail` | `string` | :heavy_check_mark: | N/A | -| `Status` | [components.InvitationStatus](../../models/components/invitationstatus.md) | :heavy_check_mark: | N/A | -| `CreationDate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `UpdatedAt` | `*string` | :heavy_minus_sign: | N/A | -| `UserID` | `*string` | :heavy_minus_sign: | N/A | -| `OrganizationAccess` | [*components.OrganizationAccess](../../models/components/organizationaccess.md) | :heavy_minus_sign: | N/A | -| `ExpiresAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `CreatorID` | `*string` | :heavy_minus_sign: | N/A | -| `LastUpdate` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/invitationstatus.md b/internal/membershipclient/docs/models/components/invitationstatus.md deleted file mode 100644 index 17c72499..00000000 --- a/internal/membershipclient/docs/models/components/invitationstatus.md +++ /dev/null @@ -1,24 +0,0 @@ -# InvitationStatus - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.InvitationStatusPending - -// Open enum: custom values can be created with a direct type cast -custom := components.InvitationStatus("custom_value") -``` - - -## Values - -| Name | Value | -| --------------------------- | --------------------------- | -| `InvitationStatusPending` | PENDING | -| `InvitationStatusAccepted` | ACCEPTED | -| `InvitationStatusRejected` | REJECTED | -| `InvitationStatusCancelled` | CANCELLED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listapplicationsresponse.md b/internal/membershipclient/docs/models/components/listapplicationsresponse.md deleted file mode 100644 index da707f35..00000000 --- a/internal/membershipclient/docs/models/components/listapplicationsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListApplicationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Cursor` | [*components.Cursor](../../models/components/cursor.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listinvitationsresponse.md b/internal/membershipclient/docs/models/components/listinvitationsresponse.md deleted file mode 100644 index 45456e50..00000000 --- a/internal/membershipclient/docs/models/components/listinvitationsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListInvitationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | -| `Data` | [][components.Invitation](../../models/components/invitation.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listmodulesresponse.md b/internal/membershipclient/docs/models/components/listmodulesresponse.md deleted file mode 100644 index d1de1c15..00000000 --- a/internal/membershipclient/docs/models/components/listmodulesresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListModulesResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `Data` | [][components.Module](../../models/components/module.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listorganizationexpandedresponse.md b/internal/membershipclient/docs/models/components/listorganizationexpandedresponse.md deleted file mode 100644 index 326db67f..00000000 --- a/internal/membershipclient/docs/models/components/listorganizationexpandedresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListOrganizationExpandedResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `Data` | [][components.OrganizationExpanded](../../models/components/organizationexpanded.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listpoliciesresponse.md b/internal/membershipclient/docs/models/components/listpoliciesresponse.md deleted file mode 100644 index 8a483704..00000000 --- a/internal/membershipclient/docs/models/components/listpoliciesresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListPoliciesResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `Data` | [][components.Policy](../../models/components/policy.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listregionsresponse.md b/internal/membershipclient/docs/models/components/listregionsresponse.md deleted file mode 100644 index bd98c631..00000000 --- a/internal/membershipclient/docs/models/components/listregionsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListRegionsResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | -| `Data` | [][components.AnyRegion](../../models/components/anyregion.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/liststacksresponse.md b/internal/membershipclient/docs/models/components/liststacksresponse.md deleted file mode 100644 index c7bb25e1..00000000 --- a/internal/membershipclient/docs/models/components/liststacksresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListStacksResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | -| `Data` | [][components.Stack](../../models/components/stack.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/listusersresponse.md b/internal/membershipclient/docs/models/components/listusersresponse.md deleted file mode 100644 index 28623a62..00000000 --- a/internal/membershipclient/docs/models/components/listusersresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListUsersResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `Data` | [][components.OrganizationUser](../../models/components/organizationuser.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/log.md b/internal/membershipclient/docs/models/components/log.md deleted file mode 100644 index 01e6cf90..00000000 --- a/internal/membershipclient/docs/models/components/log.md +++ /dev/null @@ -1,13 +0,0 @@ -# Log - - -## Fields - -| Field | Type | Required | Description | Example | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `Seq` | `string` | :heavy_check_mark: | N/A | 1e6f8b6e-1b6b-4b3b-8b3b-1b6b4b3b8b3b | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | | -| `UserID` | `string` | :heavy_check_mark: | N/A | | -| `Action` | `string` | :heavy_check_mark: | N/A | | -| `Date` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | | -| `Data` | [components.LogData](../../models/components/logdata.md) | :heavy_check_mark: | N/A | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/logcursor.md b/internal/membershipclient/docs/models/components/logcursor.md deleted file mode 100644 index ef3f2af7..00000000 --- a/internal/membershipclient/docs/models/components/logcursor.md +++ /dev/null @@ -1,8 +0,0 @@ -# LogCursor - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `Data` | [components.LogCursorData](../../models/components/logcursordata.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/logcursordata.md b/internal/membershipclient/docs/models/components/logcursordata.md deleted file mode 100644 index 9510d7ee..00000000 --- a/internal/membershipclient/docs/models/components/logcursordata.md +++ /dev/null @@ -1,12 +0,0 @@ -# LogCursorData - - -## Fields - -| Field | Type | Required | Description | Example | -| -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -| `PageSize` | `int64` | :heavy_check_mark: | N/A | 15 | -| `HasMore` | `bool` | :heavy_check_mark: | N/A | false | -| `Previous` | `*string` | :heavy_minus_sign: | N/A | YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol= | -| `Next` | `*string` | :heavy_minus_sign: | N/A | | -| `Data` | [][components.Log](../../models/components/log.md) | :heavy_check_mark: | N/A | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/logdata.md b/internal/membershipclient/docs/models/components/logdata.md deleted file mode 100644 index 54cd59d9..00000000 --- a/internal/membershipclient/docs/models/components/logdata.md +++ /dev/null @@ -1,7 +0,0 @@ -# LogData - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/module.md b/internal/membershipclient/docs/models/components/module.md deleted file mode 100644 index 146f3899..00000000 --- a/internal/membershipclient/docs/models/components/module.md +++ /dev/null @@ -1,13 +0,0 @@ -# Module - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `State` | [components.ModuleState](../../models/components/modulestate.md) | :heavy_check_mark: | N/A | -| `Status` | [components.ModuleStatus](../../models/components/modulestatus.md) | :heavy_check_mark: | N/A | -| `LastStatusUpdate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `LastStateUpdate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `ClusterStatus` | [*components.ClusterStatus](../../models/components/clusterstatus.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/modulestate.md b/internal/membershipclient/docs/models/components/modulestate.md deleted file mode 100644 index ba2c8eb6..00000000 --- a/internal/membershipclient/docs/models/components/modulestate.md +++ /dev/null @@ -1,22 +0,0 @@ -# ModuleState - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.ModuleStateEnabled - -// Open enum: custom values can be created with a direct type cast -custom := components.ModuleState("custom_value") -``` - - -## Values - -| Name | Value | -| --------------------- | --------------------- | -| `ModuleStateEnabled` | ENABLED | -| `ModuleStateDisabled` | DISABLED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/modulestatus.md b/internal/membershipclient/docs/models/components/modulestatus.md deleted file mode 100644 index 9ddbed39..00000000 --- a/internal/membershipclient/docs/models/components/modulestatus.md +++ /dev/null @@ -1,24 +0,0 @@ -# ModuleStatus - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.ModuleStatusUnknown - -// Open enum: custom values can be created with a direct type cast -custom := components.ModuleStatus("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------- | ------------------------- | -| `ModuleStatusUnknown` | UNKNOWN | -| `ModuleStatusProgressing` | PROGRESSING | -| `ModuleStatusReady` | READY | -| `ModuleStatusDeleted` | DELETED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationaccess.md b/internal/membershipclient/docs/models/components/organizationaccess.md deleted file mode 100644 index b38b0569..00000000 --- a/internal/membershipclient/docs/models/components/organizationaccess.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationAccess - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | -| `Email` | `string` | :heavy_check_mark: | N/A | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | Policy ID applied to the user | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationapplication.md b/internal/membershipclient/docs/models/components/organizationapplication.md deleted file mode 100644 index ca06e14b..00000000 --- a/internal/membershipclient/docs/models/components/organizationapplication.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationApplication - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | Organization ID | -| `ApplicationID` | `string` | :heavy_check_mark: | Application ID | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationclient.md b/internal/membershipclient/docs/models/components/organizationclient.md deleted file mode 100644 index d5ddab31..00000000 --- a/internal/membershipclient/docs/models/components/organizationclient.md +++ /dev/null @@ -1,13 +0,0 @@ -# OrganizationClient - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ID` | `string` | :heavy_check_mark: | Organization ID | -| `Secret` | [components.OrganizationClientSecret](../../models/components/organizationclientsecret.md) | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | Name of the client | -| `Description` | `string` | :heavy_check_mark: | Description of the client | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationclientsecret.md b/internal/membershipclient/docs/models/components/organizationclientsecret.md deleted file mode 100644 index 8b5081e9..00000000 --- a/internal/membershipclient/docs/models/components/organizationclientsecret.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientSecret - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `LastDigits` | `string` | :heavy_check_mark: | N/A | -| `Clear` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationdata.md b/internal/membershipclient/docs/models/components/organizationdata.md deleted file mode 100644 index a5f2724f..00000000 --- a/internal/membershipclient/docs/models/components/organizationdata.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationData - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------- | -------------------------------------- | -------------------------------------- | -------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Organization name | -| `Domain` | `*string` | :heavy_minus_sign: | Organization domain | -| `DefaultPolicyID` | `*int64` | :heavy_check_mark: | Default policy ID applied to new users | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationexpanded.md b/internal/membershipclient/docs/models/components/organizationexpanded.md deleted file mode 100644 index 2b037ee6..00000000 --- a/internal/membershipclient/docs/models/components/organizationexpanded.md +++ /dev/null @@ -1,19 +0,0 @@ -# OrganizationExpanded - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Organization name | -| `Domain` | `*string` | :heavy_minus_sign: | Organization domain | -| `DefaultPolicyID` | `*int64` | :heavy_check_mark: | Default policy ID applied to new users | -| `ID` | `string` | :heavy_check_mark: | Organization ID | -| `OwnerID` | `string` | :heavy_check_mark: | Owner ID | -| `AvailableStacks` | `*int64` | :heavy_minus_sign: | Number of available stacks | -| `AvailableSandboxes` | `*int64` | :heavy_minus_sign: | Number of available sandboxes | -| `CreatedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `UpdatedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `TotalStacks` | `*int64` | :heavy_minus_sign: | N/A | -| `TotalUsers` | `*int64` | :heavy_minus_sign: | N/A | -| `Owner` | [*components.User](../../models/components/user.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationfeature.md b/internal/membershipclient/docs/models/components/organizationfeature.md deleted file mode 100644 index 9f349c24..00000000 --- a/internal/membershipclient/docs/models/components/organizationfeature.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationFeature - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | ----------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/organizationuser.md b/internal/membershipclient/docs/models/components/organizationuser.md deleted file mode 100644 index 0ddb10b1..00000000 --- a/internal/membershipclient/docs/models/components/organizationuser.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationUser - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | -| `Email` | `string` | :heavy_check_mark: | N/A | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | Policy ID applied to the user | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/policy.md b/internal/membershipclient/docs/models/components/policy.md deleted file mode 100644 index f12402c8..00000000 --- a/internal/membershipclient/docs/models/components/policy.md +++ /dev/null @@ -1,15 +0,0 @@ -# Policy - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Policy name | -| `Description` | `*string` | :heavy_minus_sign: | Policy description | -| `ID` | `int64` | :heavy_check_mark: | Policy ID | -| `OrganizationID` | `*string` | :heavy_minus_sign: | Organization ID | -| `Protected` | `bool` | :heavy_check_mark: | Whether the policy is protected from modification | -| `Scopes` | [][components.Scope](../../models/components/scope.md) | :heavy_minus_sign: | List of scopes assigned to the policy (only present when fetched via GET endpoint) | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation date | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update date | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/privateregion.md b/internal/membershipclient/docs/models/components/privateregion.md deleted file mode 100644 index a7ebe941..00000000 --- a/internal/membershipclient/docs/models/components/privateregion.md +++ /dev/null @@ -1,21 +0,0 @@ -# PrivateRegion - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `BaseURL` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | `string` | :heavy_check_mark: | N/A | -| `Active` | `bool` | :heavy_check_mark: | N/A | -| `LastPing` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `Capabilities` | [components.RegionCapability](../../models/components/regioncapability.md) | :heavy_check_mark: | N/A | -| `AgentID` | `string` | :heavy_check_mark: | N/A | -| `Outdated` | `bool` | :heavy_check_mark: | N/A | -| `CreatorID` | `*string` | :heavy_minus_sign: | N/A | -| `Version` | `*string` | :heavy_minus_sign: | N/A | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `CreatorId1` | `string` | :heavy_check_mark: | N/A | -| `Secret` | [*components.PrivateRegionSecret](../../models/components/privateregionsecret.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/privateregionsecret.md b/internal/membershipclient/docs/models/components/privateregionsecret.md deleted file mode 100644 index 346eabcc..00000000 --- a/internal/membershipclient/docs/models/components/privateregionsecret.md +++ /dev/null @@ -1,9 +0,0 @@ -# PrivateRegionSecret - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `LastDigits` | `string` | :heavy_check_mark: | N/A | -| `Clear` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationclientresponse.md b/internal/membershipclient/docs/models/components/readorganizationclientresponse.md deleted file mode 100644 index f408fef9..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationclientresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadOrganizationClientResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `Data` | [components.OrganizationClient](../../models/components/organizationclient.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationclientsresponse.md b/internal/membershipclient/docs/models/components/readorganizationclientsresponse.md deleted file mode 100644 index 7380695d..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationclientsresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadOrganizationClientsResponse - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `Data` | [components.ReadOrganizationClientsResponseData](../../models/components/readorganizationclientsresponsedata.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationclientsresponsedata.md b/internal/membershipclient/docs/models/components/readorganizationclientsresponsedata.md deleted file mode 100644 index d831730c..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationclientsresponsedata.md +++ /dev/null @@ -1,12 +0,0 @@ -# ReadOrganizationClientsResponseData - - -## Fields - -| Field | Type | Required | Description | Example | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `PageSize` | `int64` | :heavy_check_mark: | N/A | 15 | -| `HasMore` | `bool` | :heavy_check_mark: | N/A | false | -| `Previous` | `*string` | :heavy_minus_sign: | N/A | YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol= | -| `Next` | `*string` | :heavy_minus_sign: | N/A | | -| `Data` | [][components.OrganizationClient](../../models/components/organizationclient.md) | :heavy_check_mark: | N/A | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationresponse.md b/internal/membershipclient/docs/models/components/readorganizationresponse.md deleted file mode 100644 index b1f615e8..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `Data` | [*components.OrganizationExpanded](../../models/components/organizationexpanded.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationuserresponse.md b/internal/membershipclient/docs/models/components/readorganizationuserresponse.md deleted file mode 100644 index 145a39ae..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationuserresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadOrganizationUserResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `Data` | [*components.ReadOrganizationUserResponseData](../../models/components/readorganizationuserresponsedata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readorganizationuserresponsedata.md b/internal/membershipclient/docs/models/components/readorganizationuserresponsedata.md deleted file mode 100644 index 44eec94a..00000000 --- a/internal/membershipclient/docs/models/components/readorganizationuserresponsedata.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadOrganizationUserResponseData - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | -| `Email` | `string` | :heavy_check_mark: | N/A | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | Policy ID applied to the user | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readpolicyresponse.md b/internal/membershipclient/docs/models/components/readpolicyresponse.md deleted file mode 100644 index ab514ada..00000000 --- a/internal/membershipclient/docs/models/components/readpolicyresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadPolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Data` | [*components.Policy](../../models/components/policy.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readstackresponse.md b/internal/membershipclient/docs/models/components/readstackresponse.md deleted file mode 100644 index 390cbe99..00000000 --- a/internal/membershipclient/docs/models/components/readstackresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | -| `Data` | [*components.Stack](../../models/components/stack.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readstackuseraccess.md b/internal/membershipclient/docs/models/components/readstackuseraccess.md deleted file mode 100644 index 6a222a55..00000000 --- a/internal/membershipclient/docs/models/components/readstackuseraccess.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadStackUserAccess - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `Data` | [*components.ReadStackUserAccessData](../../models/components/readstackuseraccessdata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readstackuseraccessdata.md b/internal/membershipclient/docs/models/components/readstackuseraccessdata.md deleted file mode 100644 index 258d0858..00000000 --- a/internal/membershipclient/docs/models/components/readstackuseraccessdata.md +++ /dev/null @@ -1,11 +0,0 @@ -# ReadStackUserAccessData - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | -| `StackID` | `string` | :heavy_check_mark: | Stack ID | -| `UserID` | `string` | :heavy_check_mark: | User ID | -| `Email` | `string` | :heavy_check_mark: | User email | -| `PolicyID` | `int64` | :heavy_check_mark: | Policy ID applied to the user for the stack | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/readuserresponse.md b/internal/membershipclient/docs/models/components/readuserresponse.md deleted file mode 100644 index dfe6eddb..00000000 --- a/internal/membershipclient/docs/models/components/readuserresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadUserResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | -| `Data` | [*components.User](../../models/components/user.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/region.md b/internal/membershipclient/docs/models/components/region.md deleted file mode 100644 index 152ec242..00000000 --- a/internal/membershipclient/docs/models/components/region.md +++ /dev/null @@ -1,18 +0,0 @@ -# Region - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `ID` | `string` | :heavy_check_mark: | N/A | -| `BaseURL` | `string` | :heavy_check_mark: | N/A | -| `CreatedAt` | `string` | :heavy_check_mark: | N/A | -| `Active` | `bool` | :heavy_check_mark: | N/A | -| `LastPing` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `Capabilities` | [components.RegionCapability](../../models/components/regioncapability.md) | :heavy_check_mark: | N/A | -| `AgentID` | `string` | :heavy_check_mark: | N/A | -| `Outdated` | `bool` | :heavy_check_mark: | N/A | -| `CreatorID` | `*string` | :heavy_minus_sign: | N/A | -| `Version` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/regioncapability.md b/internal/membershipclient/docs/models/components/regioncapability.md deleted file mode 100644 index e8e992c2..00000000 --- a/internal/membershipclient/docs/models/components/regioncapability.md +++ /dev/null @@ -1,9 +0,0 @@ -# RegionCapability - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ModuleList` | []`string` | :heavy_minus_sign: | N/A | -| `Ee` | []`string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/scope.md b/internal/membershipclient/docs/models/components/scope.md deleted file mode 100644 index aa0724e2..00000000 --- a/internal/membershipclient/docs/models/components/scope.md +++ /dev/null @@ -1,14 +0,0 @@ -# Scope - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | -| `ID` | `int64` | :heavy_check_mark: | Scope ID | -| `Label` | `string` | :heavy_check_mark: | The OAuth2 scope label (e.g., "custom:read") | -| `Description` | `*string` | :heavy_minus_sign: | Scope description | -| `ApplicationID` | `*string` | :heavy_minus_sign: | Application ID (null for global scopes) | -| `Protected` | `*bool` | :heavy_minus_sign: | Whether the scope is protected | -| `CreatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Creation timestamp | -| `UpdatedAt` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | Last update timestamp | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/security.md b/internal/membershipclient/docs/models/components/security.md deleted file mode 100644 index 8a7d90ae..00000000 --- a/internal/membershipclient/docs/models/components/security.md +++ /dev/null @@ -1,8 +0,0 @@ -# Security - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Oauth2` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/serverinfo.md b/internal/membershipclient/docs/models/components/serverinfo.md deleted file mode 100644 index 4cdf0833..00000000 --- a/internal/membershipclient/docs/models/components/serverinfo.md +++ /dev/null @@ -1,10 +0,0 @@ -# ServerInfo - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | -| `Version` | `string` | :heavy_check_mark: | N/A | -| `Capabilities` | [][components.Capability](../../models/components/capability.md) | :heavy_minus_sign: | N/A | -| `ConsoleURL` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stack.md b/internal/membershipclient/docs/models/components/stack.md deleted file mode 100644 index 9e3352eb..00000000 --- a/internal/membershipclient/docs/models/components/stack.md +++ /dev/null @@ -1,33 +0,0 @@ -# Stack - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | Stack name | -| `Metadata` | map[string]`string` | :heavy_minus_sign: | N/A | -| `Version` | `*string` | :heavy_minus_sign: | Supported only with agent version >= v0.7.0 | -| `Status` | [components.StackStatus](../../models/components/stackstatus.md) | :heavy_check_mark: | N/A | -| `State` | [components.StackState](../../models/components/stackstate.md) | :heavy_check_mark: | N/A | -| `ExpectedStatus` | [components.ExpectedStatus](../../models/components/expectedstatus.md) | :heavy_check_mark: | N/A | -| `LastStateUpdate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `LastExpectedStatusUpdate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `LastStatusUpdate` | [time.Time](https://pkg.go.dev/time#Time) | :heavy_check_mark: | N/A | -| `WarnedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `DisposableSince` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `Reachable` | `bool` | :heavy_check_mark: | Stack is reachable through Stargate | -| `LastReachableUpdate` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | Last time the stack was reachable | -| `ID` | `string` | :heavy_check_mark: | Stack ID | -| `OrganizationID` | `string` | :heavy_check_mark: | Organization ID | -| `URI` | `string` | :heavy_check_mark: | Base stack uri | -| `RegionID` | `string` | :heavy_check_mark: | The region where the stack is installed | -| `Region` | [*components.Region](../../models/components/region.md) | :heavy_minus_sign: | N/A | -| `StargateEnabled` | `bool` | :heavy_check_mark: | N/A | -| `CreatedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `DeletedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `DisabledAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `AuditEnabled` | `*bool` | :heavy_minus_sign: | N/A | -| `Synchronised` | `bool` | :heavy_check_mark: | N/A | -| `UpdatedAt` | [*time.Time](https://pkg.go.dev/time#Time) | :heavy_minus_sign: | N/A | -| `Modules` | [][components.Module](../../models/components/module.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackdata.md b/internal/membershipclient/docs/models/components/stackdata.md deleted file mode 100644 index 818a0c03..00000000 --- a/internal/membershipclient/docs/models/components/stackdata.md +++ /dev/null @@ -1,9 +0,0 @@ -# StackData - - -## Fields - -| Field | Type | Required | Description | -| ------------------- | ------------------- | ------------------- | ------------------- | -| `Name` | `string` | :heavy_check_mark: | Stack name | -| `Metadata` | map[string]`string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackstate.md b/internal/membershipclient/docs/models/components/stackstate.md deleted file mode 100644 index e9044126..00000000 --- a/internal/membershipclient/docs/models/components/stackstate.md +++ /dev/null @@ -1,23 +0,0 @@ -# StackState - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.StackStateActive - -// Open enum: custom values can be created with a direct type cast -custom := components.StackState("custom_value") -``` - - -## Values - -| Name | Value | -| -------------------- | -------------------- | -| `StackStateActive` | ACTIVE | -| `StackStateDisabled` | DISABLED | -| `StackStateDeleted` | DELETED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackstatus.md b/internal/membershipclient/docs/models/components/stackstatus.md deleted file mode 100644 index dc5e604c..00000000 --- a/internal/membershipclient/docs/models/components/stackstatus.md +++ /dev/null @@ -1,25 +0,0 @@ -# StackStatus - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.StackStatusUnknown - -// Open enum: custom values can be created with a direct type cast -custom := components.StackStatus("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------------ | ------------------------ | -| `StackStatusUnknown` | UNKNOWN | -| `StackStatusProgressing` | PROGRESSING | -| `StackStatusReady` | READY | -| `StackStatusDisabled` | DISABLED | -| `StackStatusDeleted` | DELETED | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackuseraccessresponse.md b/internal/membershipclient/docs/models/components/stackuseraccessresponse.md deleted file mode 100644 index e7a4eda1..00000000 --- a/internal/membershipclient/docs/models/components/stackuseraccessresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# StackUserAccessResponse - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `Data` | [][components.StackUserAccessResponseData](../../models/components/stackuseraccessresponsedata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackuseraccessresponsedata.md b/internal/membershipclient/docs/models/components/stackuseraccessresponsedata.md deleted file mode 100644 index 4c0f8d68..00000000 --- a/internal/membershipclient/docs/models/components/stackuseraccessresponsedata.md +++ /dev/null @@ -1,11 +0,0 @@ -# StackUserAccessResponseData - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | -| `StackID` | `string` | :heavy_check_mark: | Stack ID | -| `UserID` | `string` | :heavy_check_mark: | User ID | -| `Email` | `string` | :heavy_check_mark: | User email | -| `PolicyID` | `int64` | :heavy_check_mark: | Policy ID applied to the user for the stack | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/stackversion.md b/internal/membershipclient/docs/models/components/stackversion.md deleted file mode 100644 index cafd3ce5..00000000 --- a/internal/membershipclient/docs/models/components/stackversion.md +++ /dev/null @@ -1,8 +0,0 @@ -# StackVersion - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------- | -| `Version` | `*string` | :heavy_minus_sign: | Supported only with agent version >= v0.7.0 | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/systemrole.md b/internal/membershipclient/docs/models/components/systemrole.md deleted file mode 100644 index 54437e72..00000000 --- a/internal/membershipclient/docs/models/components/systemrole.md +++ /dev/null @@ -1,22 +0,0 @@ -# SystemRole - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.SystemRoleUser - -// Open enum: custom values can be created with a direct type cast -custom := components.SystemRole("custom_value") -``` - - -## Values - -| Name | Value | -| ------------------ | ------------------ | -| `SystemRoleUser` | USER | -| `SystemRoleSystem` | SYSTEM | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/updateapplicationresponse.md b/internal/membershipclient/docs/models/components/updateapplicationresponse.md deleted file mode 100644 index 38df4edd..00000000 --- a/internal/membershipclient/docs/models/components/updateapplicationresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdateApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | -| `Data` | [*components.Application](../../models/components/application.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/updateorganizationclientrequest.md b/internal/membershipclient/docs/models/components/updateorganizationclientrequest.md deleted file mode 100644 index c9adce3b..00000000 --- a/internal/membershipclient/docs/models/components/updateorganizationclientrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateOrganizationClientRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------- | ------------------------- | ------------------------- | ------------------------- | -| `Name` | `string` | :heavy_check_mark: | Name of the client | -| `Description` | `*string` | :heavy_minus_sign: | Description of the client | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/updateorganizationuserrequest.md b/internal/membershipclient/docs/models/components/updateorganizationuserrequest.md deleted file mode 100644 index 66fd7f18..00000000 --- a/internal/membershipclient/docs/models/components/updateorganizationuserrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdateOrganizationUserRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------- | ----------------------------- | ----------------------------- | ----------------------------- | -| `PolicyID` | `*int64` | :heavy_minus_sign: | Policy ID applied to the user | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/updatepolicyresponse.md b/internal/membershipclient/docs/models/components/updatepolicyresponse.md deleted file mode 100644 index ee775c25..00000000 --- a/internal/membershipclient/docs/models/components/updatepolicyresponse.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdatePolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `Data` | [*components.Policy](../../models/components/policy.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/updatestackuserrequest.md b/internal/membershipclient/docs/models/components/updatestackuserrequest.md deleted file mode 100644 index 1c128d55..00000000 --- a/internal/membershipclient/docs/models/components/updatestackuserrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpdateStackUserRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequest.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequest.md deleted file mode 100644 index b2302b0d..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequest.md +++ /dev/null @@ -1,45 +0,0 @@ -# UpsertAuthenticationProviderRequest - - -## Supported Types - -### UpsertAuthenticationProviderRequestGoogleIDPConfig - -```go -upsertAuthenticationProviderRequest := components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGoogleIDPConfig(components.UpsertAuthenticationProviderRequestGoogleIDPConfig{/* values here */}) -``` - -### UpsertAuthenticationProviderRequestMicrosoftIDPConfig - -```go -upsertAuthenticationProviderRequest := components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestMicrosoftIDPConfig(components.UpsertAuthenticationProviderRequestMicrosoftIDPConfig{/* values here */}) -``` - -### UpsertAuthenticationProviderRequestGithubIDPConfig - -```go -upsertAuthenticationProviderRequest := components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGithubIDPConfig(components.UpsertAuthenticationProviderRequestGithubIDPConfig{/* values here */}) -``` - -### UpsertAuthenticationProviderRequestOIDCConfig - -```go -upsertAuthenticationProviderRequest := components.CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestOIDCConfig(components.UpsertAuthenticationProviderRequestOIDCConfig{/* values here */}) -``` - -## Union Discrimination - -Use the `Type` field to determine which variant is active, then access the corresponding field: - -```go -switch upsertAuthenticationProviderRequest.Type { - case components.UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGoogleIDPConfig: - // upsertAuthenticationProviderRequest.UpsertAuthenticationProviderRequestGoogleIDPConfig is populated - case components.UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestMicrosoftIDPConfig: - // upsertAuthenticationProviderRequest.UpsertAuthenticationProviderRequestMicrosoftIDPConfig is populated - case components.UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGithubIDPConfig: - // upsertAuthenticationProviderRequest.UpsertAuthenticationProviderRequestGithubIDPConfig is populated - case components.UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestOIDCConfig: - // upsertAuthenticationProviderRequest.UpsertAuthenticationProviderRequestOIDCConfig is populated -} -``` diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfig.md deleted file mode 100644 index 0a65c46b..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfig.md +++ /dev/null @@ -1,12 +0,0 @@ -# UpsertAuthenticationProviderRequestGithubIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Type` | [components.UpsertAuthenticationProviderRequestGithubIDPConfigType](../../models/components/upsertauthenticationproviderrequestgithubidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.UpsertAuthenticationProviderRequestGithubIDPConfigConfig](../../models/components/upsertauthenticationproviderrequestgithubidpconfigconfig.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigconfig.md deleted file mode 100644 index 037d05b4..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigconfig.md +++ /dev/null @@ -1,7 +0,0 @@ -# UpsertAuthenticationProviderRequestGithubIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigtype.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigtype.md deleted file mode 100644 index b6ae748f..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgithubidpconfigtype.md +++ /dev/null @@ -1,23 +0,0 @@ -# UpsertAuthenticationProviderRequestGithubIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.UpsertAuthenticationProviderRequestGithubIDPConfigTypeOidc -``` - - -## Values - -| Name | Value | -| ----------------------------------------------------------------- | ----------------------------------------------------------------- | -| `UpsertAuthenticationProviderRequestGithubIDPConfigTypeOidc` | oidc | -| `UpsertAuthenticationProviderRequestGithubIDPConfigTypeGoogle` | google | -| `UpsertAuthenticationProviderRequestGithubIDPConfigTypeGithub` | github | -| `UpsertAuthenticationProviderRequestGithubIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfig.md deleted file mode 100644 index 5bbd3d89..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfig.md +++ /dev/null @@ -1,12 +0,0 @@ -# UpsertAuthenticationProviderRequestGoogleIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Type` | [components.UpsertAuthenticationProviderRequestGoogleIDPConfigType](../../models/components/upsertauthenticationproviderrequestgoogleidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.UpsertAuthenticationProviderRequestGoogleIDPConfigConfig](../../models/components/upsertauthenticationproviderrequestgoogleidpconfigconfig.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigconfig.md deleted file mode 100644 index 4578dca9..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigconfig.md +++ /dev/null @@ -1,7 +0,0 @@ -# UpsertAuthenticationProviderRequestGoogleIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ----------- | ----------- | ----------- | ----------- | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigtype.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigtype.md deleted file mode 100644 index bce4a247..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestgoogleidpconfigtype.md +++ /dev/null @@ -1,23 +0,0 @@ -# UpsertAuthenticationProviderRequestGoogleIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.UpsertAuthenticationProviderRequestGoogleIDPConfigTypeOidc -``` - - -## Values - -| Name | Value | -| ----------------------------------------------------------------- | ----------------------------------------------------------------- | -| `UpsertAuthenticationProviderRequestGoogleIDPConfigTypeOidc` | oidc | -| `UpsertAuthenticationProviderRequestGoogleIDPConfigTypeGoogle` | google | -| `UpsertAuthenticationProviderRequestGoogleIDPConfigTypeGithub` | github | -| `UpsertAuthenticationProviderRequestGoogleIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfig.md deleted file mode 100644 index fa077c47..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfig.md +++ /dev/null @@ -1,12 +0,0 @@ -# UpsertAuthenticationProviderRequestMicrosoftIDPConfig - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Type` | [components.UpsertAuthenticationProviderRequestMicrosoftIDPConfigType](../../models/components/upsertauthenticationproviderrequestmicrosoftidpconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig](../../models/components/upsertauthenticationproviderrequestmicrosoftidpconfigconfig.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigconfig.md deleted file mode 100644 index 511ee385..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigconfig.md +++ /dev/null @@ -1,8 +0,0 @@ -# UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------- | -------------------------------------- | -------------------------------------- | -------------------------------------- | -| `Tenant` | `*string` | :heavy_minus_sign: | Tenant ID for Microsoft authentication | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigtype.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigtype.md deleted file mode 100644 index 35d6989c..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestmicrosoftidpconfigtype.md +++ /dev/null @@ -1,23 +0,0 @@ -# UpsertAuthenticationProviderRequestMicrosoftIDPConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeOidc -``` - - -## Values - -| Name | Value | -| -------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeOidc` | oidc | -| `UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeGoogle` | google | -| `UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeGithub` | github | -| `UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfig.md deleted file mode 100644 index e41a8918..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfig.md +++ /dev/null @@ -1,12 +0,0 @@ -# UpsertAuthenticationProviderRequestOIDCConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `Type` | [components.UpsertAuthenticationProviderRequestOIDCConfigType](../../models/components/upsertauthenticationproviderrequestoidcconfigtype.md) | :heavy_check_mark: | Type of the authentication provider | -| `Name` | `string` | :heavy_check_mark: | Name of the authentication provider | -| `ClientID` | `string` | :heavy_check_mark: | Client ID for the authentication provider | -| `ClientSecret` | `string` | :heavy_check_mark: | Client secret for the authentication provider | -| `Config` | [components.UpsertAuthenticationProviderRequestOIDCConfigConfig](../../models/components/upsertauthenticationproviderrequestoidcconfigconfig.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigconfig.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigconfig.md deleted file mode 100644 index f0c54889..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigconfig.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpsertAuthenticationProviderRequestOIDCConfigConfig - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Issuer` | `string` | :heavy_check_mark: | OIDC issuer URL | -| `DiscoveryPath` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigtype.md b/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigtype.md deleted file mode 100644 index 28edb241..00000000 --- a/internal/membershipclient/docs/models/components/upsertauthenticationproviderrequestoidcconfigtype.md +++ /dev/null @@ -1,23 +0,0 @@ -# UpsertAuthenticationProviderRequestOIDCConfigType - -Type of the authentication provider - -## Example Usage - -```go -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -value := components.UpsertAuthenticationProviderRequestOIDCConfigTypeOidc -``` - - -## Values - -| Name | Value | -| ------------------------------------------------------------ | ------------------------------------------------------------ | -| `UpsertAuthenticationProviderRequestOIDCConfigTypeOidc` | oidc | -| `UpsertAuthenticationProviderRequestOIDCConfigTypeGoogle` | google | -| `UpsertAuthenticationProviderRequestOIDCConfigTypeGithub` | github | -| `UpsertAuthenticationProviderRequestOIDCConfigTypeMicrosoft` | microsoft | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/user.md b/internal/membershipclient/docs/models/components/user.md deleted file mode 100644 index 6683626a..00000000 --- a/internal/membershipclient/docs/models/components/user.md +++ /dev/null @@ -1,10 +0,0 @@ -# User - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | -| `Email` | `string` | :heavy_check_mark: | N/A | -| `ID` | `string` | :heavy_check_mark: | User ID | -| `Role` | [*components.SystemRole](../../models/components/systemrole.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/components/version.md b/internal/membershipclient/docs/models/components/version.md deleted file mode 100644 index 5b95829c..00000000 --- a/internal/membershipclient/docs/models/components/version.md +++ /dev/null @@ -1,11 +0,0 @@ -# Version - - -## Fields - -| Field | Type | Required | Description | -| -------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | -| `Name` | `string` | :heavy_check_mark: | N/A | -| `Versions` | map[string]`string` | :heavy_check_mark: | N/A | -| `RegionID` | `string` | :heavy_check_mark: | N/A | -| `Deprecated` | `*bool` | :heavy_minus_sign: | Indicates if this version is deprecated and should not be used | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/acceptinvitationrequest.md b/internal/membershipclient/docs/models/operations/acceptinvitationrequest.md deleted file mode 100644 index 0c6182e2..00000000 --- a/internal/membershipclient/docs/models/operations/acceptinvitationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# AcceptInvitationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `InvitationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/acceptinvitationresponse.md b/internal/membershipclient/docs/models/operations/acceptinvitationresponse.md deleted file mode 100644 index 4e347ce2..00000000 --- a/internal/membershipclient/docs/models/operations/acceptinvitationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# AcceptInvitationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/addfeaturesrequest.md b/internal/membershipclient/docs/models/operations/addfeaturesrequest.md deleted file mode 100644 index 0fd8ac99..00000000 --- a/internal/membershipclient/docs/models/operations/addfeaturesrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# AddFeaturesRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*operations.AddFeaturesRequestBody](../../models/operations/addfeaturesrequestbody.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/addfeaturesrequestbody.md b/internal/membershipclient/docs/models/operations/addfeaturesrequestbody.md deleted file mode 100644 index 26431d23..00000000 --- a/internal/membershipclient/docs/models/operations/addfeaturesrequestbody.md +++ /dev/null @@ -1,8 +0,0 @@ -# AddFeaturesRequestBody - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Features` | []`string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/addfeaturesresponse.md b/internal/membershipclient/docs/models/operations/addfeaturesresponse.md deleted file mode 100644 index 546d90dc..00000000 --- a/internal/membershipclient/docs/models/operations/addfeaturesresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# AddFeaturesResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/addscopetopolicyrequest.md b/internal/membershipclient/docs/models/operations/addscopetopolicyrequest.md deleted file mode 100644 index 2fa75e67..00000000 --- a/internal/membershipclient/docs/models/operations/addscopetopolicyrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# AddScopeToPolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | -| `ScopeID` | `int64` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/addscopetopolicyresponse.md b/internal/membershipclient/docs/models/operations/addscopetopolicyresponse.md deleted file mode 100644 index eb800412..00000000 --- a/internal/membershipclient/docs/models/operations/addscopetopolicyresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# AddScopeToPolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createapplicationresponse.md b/internal/membershipclient/docs/models/operations/createapplicationresponse.md deleted file mode 100644 index 3eda4783..00000000 --- a/internal/membershipclient/docs/models/operations/createapplicationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateApplicationResponse` | [*components.CreateApplicationResponse](../../models/components/createapplicationresponse.md) | :heavy_minus_sign: | Application created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createapplicationscoperequest.md b/internal/membershipclient/docs/models/operations/createapplicationscoperequest.md deleted file mode 100644 index 55e45112..00000000 --- a/internal/membershipclient/docs/models/operations/createapplicationscoperequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateApplicationScopeRequest - - -## Fields - -| Field | Type | Required | Description | Example | -| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `ApplicationID` | `string` | :heavy_check_mark: | The unique identifier of the application (UUID format) | 550e8400-e29b-41d4-a716-446655440000 | -| `Body` | [*components.CreateApplicationScopeRequest](../../models/components/createapplicationscoperequest.md) | :heavy_minus_sign: | N/A | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createapplicationscoperesponse.md b/internal/membershipclient/docs/models/operations/createapplicationscoperesponse.md deleted file mode 100644 index 5a4657fe..00000000 --- a/internal/membershipclient/docs/models/operations/createapplicationscoperesponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateApplicationScopeResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateApplicationScopeResponse` | [*components.CreateApplicationScopeResponse](../../models/components/createapplicationscoperesponse.md) | :heavy_minus_sign: | Application scope created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createinvitationrequest.md b/internal/membershipclient/docs/models/operations/createinvitationrequest.md deleted file mode 100644 index 5555e285..00000000 --- a/internal/membershipclient/docs/models/operations/createinvitationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateInvitationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Email` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createinvitationresponse.md b/internal/membershipclient/docs/models/operations/createinvitationresponse.md deleted file mode 100644 index 689f5932..00000000 --- a/internal/membershipclient/docs/models/operations/createinvitationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateInvitationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateInvitationResponse` | [*components.CreateInvitationResponse](../../models/components/createinvitationresponse.md) | :heavy_minus_sign: | Invitation already sent | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createorganizationclientrequest.md b/internal/membershipclient/docs/models/operations/createorganizationclientrequest.md deleted file mode 100644 index 12d9adae..00000000 --- a/internal/membershipclient/docs/models/operations/createorganizationclientrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# CreateOrganizationClientRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createorganizationclientresponse.md b/internal/membershipclient/docs/models/operations/createorganizationclientresponse.md deleted file mode 100644 index 9bedc434..00000000 --- a/internal/membershipclient/docs/models/operations/createorganizationclientresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateOrganizationClientResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateClientResponseResponse` | [*components.CreateClientResponseResponse](../../models/components/createclientresponseresponse.md) | :heavy_minus_sign: | Client organization created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createorganizationresponse.md b/internal/membershipclient/docs/models/operations/createorganizationresponse.md deleted file mode 100644 index 4ee49302..00000000 --- a/internal/membershipclient/docs/models/operations/createorganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateOrganizationResponse` | [*components.CreateOrganizationResponse](../../models/components/createorganizationresponse.md) | :heavy_minus_sign: | Organization created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createpolicyrequest.md b/internal/membershipclient/docs/models/operations/createpolicyrequest.md deleted file mode 100644 index e722756a..00000000 --- a/internal/membershipclient/docs/models/operations/createpolicyrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreatePolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.CreatePolicyRequest](../../models/components/createpolicyrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createpolicyresponse.md b/internal/membershipclient/docs/models/operations/createpolicyresponse.md deleted file mode 100644 index 453d2a9e..00000000 --- a/internal/membershipclient/docs/models/operations/createpolicyresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreatePolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreatePolicyResponse` | [*components.CreatePolicyResponse](../../models/components/createpolicyresponse.md) | :heavy_minus_sign: | Policy created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createprivateregionrequest.md b/internal/membershipclient/docs/models/operations/createprivateregionrequest.md deleted file mode 100644 index 113e96e7..00000000 --- a/internal/membershipclient/docs/models/operations/createprivateregionrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreatePrivateRegionRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.CreatePrivateRegionRequest](../../models/components/createprivateregionrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createprivateregionresponse.md b/internal/membershipclient/docs/models/operations/createprivateregionresponse.md deleted file mode 100644 index b4f9b0cf..00000000 --- a/internal/membershipclient/docs/models/operations/createprivateregionresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreatePrivateRegionResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreatedPrivateRegionResponse` | [*components.CreatedPrivateRegionResponse](../../models/components/createdprivateregionresponse.md) | :heavy_minus_sign: | Created region | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createstackrequest.md b/internal/membershipclient/docs/models/operations/createstackrequest.md deleted file mode 100644 index b7f11a22..00000000 --- a/internal/membershipclient/docs/models/operations/createstackrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# CreateStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.CreateStackRequest](../../models/components/createstackrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createstackresponse.md b/internal/membershipclient/docs/models/operations/createstackresponse.md deleted file mode 100644 index 6ac491ce..00000000 --- a/internal/membershipclient/docs/models/operations/createstackresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadStackResponse` | [*components.ReadStackResponse](../../models/components/readstackresponse.md) | :heavy_minus_sign: | Stack created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/createuserresponse.md b/internal/membershipclient/docs/models/operations/createuserresponse.md deleted file mode 100644 index d7c29815..00000000 --- a/internal/membershipclient/docs/models/operations/createuserresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# CreateUserResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateUserResponse` | [*components.CreateUserResponse](../../models/components/createuserresponse.md) | :heavy_minus_sign: | User created successfully | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Invalid request (missing or invalid email) | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/declineinvitationrequest.md b/internal/membershipclient/docs/models/operations/declineinvitationrequest.md deleted file mode 100644 index 44fd91ac..00000000 --- a/internal/membershipclient/docs/models/operations/declineinvitationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeclineInvitationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `InvitationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/declineinvitationresponse.md b/internal/membershipclient/docs/models/operations/declineinvitationresponse.md deleted file mode 100644 index 27aaf247..00000000 --- a/internal/membershipclient/docs/models/operations/declineinvitationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeclineInvitationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteapplicationrequest.md b/internal/membershipclient/docs/models/operations/deleteapplicationrequest.md deleted file mode 100644 index 63ed24d9..00000000 --- a/internal/membershipclient/docs/models/operations/deleteapplicationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteApplicationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteapplicationresponse.md b/internal/membershipclient/docs/models/operations/deleteapplicationresponse.md deleted file mode 100644 index b971f275..00000000 --- a/internal/membershipclient/docs/models/operations/deleteapplicationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteapplicationscoperequest.md b/internal/membershipclient/docs/models/operations/deleteapplicationscoperequest.md deleted file mode 100644 index 4fcc6d33..00000000 --- a/internal/membershipclient/docs/models/operations/deleteapplicationscoperequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteApplicationScopeRequest - - -## Fields - -| Field | Type | Required | Description | Example | -| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | -| `ApplicationID` | `string` | :heavy_check_mark: | The unique identifier of the application (UUID format) | 550e8400-e29b-41d4-a716-446655440000 | -| `ScopeID` | `int64` | :heavy_check_mark: | The unique identifier of the scope to operate on | | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteapplicationscoperesponse.md b/internal/membershipclient/docs/models/operations/deleteapplicationscoperesponse.md deleted file mode 100644 index 79dd9bc8..00000000 --- a/internal/membershipclient/docs/models/operations/deleteapplicationscoperesponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteApplicationScopeResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Invalid request (invalid UUID or empty scope ID) | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteauthenticationproviderrequest.md b/internal/membershipclient/docs/models/operations/deleteauthenticationproviderrequest.md deleted file mode 100644 index 78e16585..00000000 --- a/internal/membershipclient/docs/models/operations/deleteauthenticationproviderrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteAuthenticationProviderRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteauthenticationproviderresponse.md b/internal/membershipclient/docs/models/operations/deleteauthenticationproviderresponse.md deleted file mode 100644 index bc65ba5c..00000000 --- a/internal/membershipclient/docs/models/operations/deleteauthenticationproviderresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteAuthenticationProviderResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletefeaturerequest.md b/internal/membershipclient/docs/models/operations/deletefeaturerequest.md deleted file mode 100644 index beca2256..00000000 --- a/internal/membershipclient/docs/models/operations/deletefeaturerequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteFeatureRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletefeatureresponse.md b/internal/membershipclient/docs/models/operations/deletefeatureresponse.md deleted file mode 100644 index a14743c5..00000000 --- a/internal/membershipclient/docs/models/operations/deletefeatureresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteFeatureResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteinvitationrequest.md b/internal/membershipclient/docs/models/operations/deleteinvitationrequest.md deleted file mode 100644 index edf412c9..00000000 --- a/internal/membershipclient/docs/models/operations/deleteinvitationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteInvitationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `InvitationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteinvitationresponse.md b/internal/membershipclient/docs/models/operations/deleteinvitationresponse.md deleted file mode 100644 index 85fc3a0d..00000000 --- a/internal/membershipclient/docs/models/operations/deleteinvitationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteInvitationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteorganizationclientrequest.md b/internal/membershipclient/docs/models/operations/deleteorganizationclientrequest.md deleted file mode 100644 index 63f75ac5..00000000 --- a/internal/membershipclient/docs/models/operations/deleteorganizationclientrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteOrganizationClientRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteorganizationclientresponse.md b/internal/membershipclient/docs/models/operations/deleteorganizationclientresponse.md deleted file mode 100644 index 605b5ae7..00000000 --- a/internal/membershipclient/docs/models/operations/deleteorganizationclientresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteOrganizationClientResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteorganizationrequest.md b/internal/membershipclient/docs/models/operations/deleteorganizationrequest.md deleted file mode 100644 index e90ddb62..00000000 --- a/internal/membershipclient/docs/models/operations/deleteorganizationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# DeleteOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteorganizationresponse.md b/internal/membershipclient/docs/models/operations/deleteorganizationresponse.md deleted file mode 100644 index a4bc77bd..00000000 --- a/internal/membershipclient/docs/models/operations/deleteorganizationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletepolicyrequest.md b/internal/membershipclient/docs/models/operations/deletepolicyrequest.md deleted file mode 100644 index 0ce61794..00000000 --- a/internal/membershipclient/docs/models/operations/deletepolicyrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeletePolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletepolicyresponse.md b/internal/membershipclient/docs/models/operations/deletepolicyresponse.md deleted file mode 100644 index ea21efd5..00000000 --- a/internal/membershipclient/docs/models/operations/deletepolicyresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeletePolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteregionrequest.md b/internal/membershipclient/docs/models/operations/deleteregionrequest.md deleted file mode 100644 index fafeea90..00000000 --- a/internal/membershipclient/docs/models/operations/deleteregionrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteRegionRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `RegionID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteregionresponse.md b/internal/membershipclient/docs/models/operations/deleteregionresponse.md deleted file mode 100644 index 4e9e874b..00000000 --- a/internal/membershipclient/docs/models/operations/deleteregionresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteRegionResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletestackrequest.md b/internal/membershipclient/docs/models/operations/deletestackrequest.md deleted file mode 100644 index 3f1d6f45..00000000 --- a/internal/membershipclient/docs/models/operations/deletestackrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# DeleteStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `Force` | `*bool` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletestackresponse.md b/internal/membershipclient/docs/models/operations/deletestackresponse.md deleted file mode 100644 index 9c5d9afa..00000000 --- a/internal/membershipclient/docs/models/operations/deletestackresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletestackuseraccessrequest.md b/internal/membershipclient/docs/models/operations/deletestackuseraccessrequest.md deleted file mode 100644 index ab2ab428..00000000 --- a/internal/membershipclient/docs/models/operations/deletestackuseraccessrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# DeleteStackUserAccessRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deletestackuseraccessresponse.md b/internal/membershipclient/docs/models/operations/deletestackuseraccessresponse.md deleted file mode 100644 index 9c85b70d..00000000 --- a/internal/membershipclient/docs/models/operations/deletestackuseraccessresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteStackUserAccessResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteuserfromorganizationrequest.md b/internal/membershipclient/docs/models/operations/deleteuserfromorganizationrequest.md deleted file mode 100644 index b7ebca5c..00000000 --- a/internal/membershipclient/docs/models/operations/deleteuserfromorganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteUserFromOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/deleteuserfromorganizationresponse.md b/internal/membershipclient/docs/models/operations/deleteuserfromorganizationresponse.md deleted file mode 100644 index 4f0b4966..00000000 --- a/internal/membershipclient/docs/models/operations/deleteuserfromorganizationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DeleteUserFromOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disableapplicationfororganizationrequest.md b/internal/membershipclient/docs/models/operations/disableapplicationfororganizationrequest.md deleted file mode 100644 index 7b1f7b87..00000000 --- a/internal/membershipclient/docs/models/operations/disableapplicationfororganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableApplicationForOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disableapplicationfororganizationresponse.md b/internal/membershipclient/docs/models/operations/disableapplicationfororganizationresponse.md deleted file mode 100644 index 2866f843..00000000 --- a/internal/membershipclient/docs/models/operations/disableapplicationfororganizationresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableApplicationForOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablemodulerequest.md b/internal/membershipclient/docs/models/operations/disablemodulerequest.md deleted file mode 100644 index ec4903ae..00000000 --- a/internal/membershipclient/docs/models/operations/disablemodulerequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# DisableModuleRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablemoduleresponse.md b/internal/membershipclient/docs/models/operations/disablemoduleresponse.md deleted file mode 100644 index 69e6624d..00000000 --- a/internal/membershipclient/docs/models/operations/disablemoduleresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableModuleResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablestackrequest.md b/internal/membershipclient/docs/models/operations/disablestackrequest.md deleted file mode 100644 index 9b472501..00000000 --- a/internal/membershipclient/docs/models/operations/disablestackrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablestackresponse.md b/internal/membershipclient/docs/models/operations/disablestackresponse.md deleted file mode 100644 index efb01b56..00000000 --- a/internal/membershipclient/docs/models/operations/disablestackresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablestargaterequest.md b/internal/membershipclient/docs/models/operations/disablestargaterequest.md deleted file mode 100644 index 673d50d6..00000000 --- a/internal/membershipclient/docs/models/operations/disablestargaterequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableStargateRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/disablestargateresponse.md b/internal/membershipclient/docs/models/operations/disablestargateresponse.md deleted file mode 100644 index e8309b3a..00000000 --- a/internal/membershipclient/docs/models/operations/disablestargateresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# DisableStargateResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enableapplicationfororganizationrequest.md b/internal/membershipclient/docs/models/operations/enableapplicationfororganizationrequest.md deleted file mode 100644 index 6ec4ee20..00000000 --- a/internal/membershipclient/docs/models/operations/enableapplicationfororganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableApplicationForOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enableapplicationfororganizationresponse.md b/internal/membershipclient/docs/models/operations/enableapplicationfororganizationresponse.md deleted file mode 100644 index 397d75dd..00000000 --- a/internal/membershipclient/docs/models/operations/enableapplicationfororganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# EnableApplicationForOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `EnableApplicationForOrganizationResponse` | [*components.EnableApplicationForOrganizationResponse](../../models/components/enableapplicationfororganizationresponse.md) | :heavy_minus_sign: | Application enabled for organization | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablemodulerequest.md b/internal/membershipclient/docs/models/operations/enablemodulerequest.md deleted file mode 100644 index 8189499b..00000000 --- a/internal/membershipclient/docs/models/operations/enablemodulerequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# EnableModuleRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `Name` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablemoduleresponse.md b/internal/membershipclient/docs/models/operations/enablemoduleresponse.md deleted file mode 100644 index a8c8a9ec..00000000 --- a/internal/membershipclient/docs/models/operations/enablemoduleresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableModuleResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablestackrequest.md b/internal/membershipclient/docs/models/operations/enablestackrequest.md deleted file mode 100644 index 4609ce2b..00000000 --- a/internal/membershipclient/docs/models/operations/enablestackrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablestackresponse.md b/internal/membershipclient/docs/models/operations/enablestackresponse.md deleted file mode 100644 index 4be2a9c5..00000000 --- a/internal/membershipclient/docs/models/operations/enablestackresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablestargaterequest.md b/internal/membershipclient/docs/models/operations/enablestargaterequest.md deleted file mode 100644 index 66ccb1f3..00000000 --- a/internal/membershipclient/docs/models/operations/enablestargaterequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableStargateRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/enablestargateresponse.md b/internal/membershipclient/docs/models/operations/enablestargateresponse.md deleted file mode 100644 index 93c97291..00000000 --- a/internal/membershipclient/docs/models/operations/enablestargateresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# EnableStargateResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getapplicationrequest.md b/internal/membershipclient/docs/models/operations/getapplicationrequest.md deleted file mode 100644 index b4da5dbf..00000000 --- a/internal/membershipclient/docs/models/operations/getapplicationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# GetApplicationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getapplicationresponse.md b/internal/membershipclient/docs/models/operations/getapplicationresponse.md deleted file mode 100644 index 82b78c86..00000000 --- a/internal/membershipclient/docs/models/operations/getapplicationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `GetApplicationResponse` | [*components.GetApplicationResponse](../../models/components/getapplicationresponse.md) | :heavy_minus_sign: | Application details | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getorganizationapplicationrequest.md b/internal/membershipclient/docs/models/operations/getorganizationapplicationrequest.md deleted file mode 100644 index e5603107..00000000 --- a/internal/membershipclient/docs/models/operations/getorganizationapplicationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# GetOrganizationApplicationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getorganizationapplicationresponse.md b/internal/membershipclient/docs/models/operations/getorganizationapplicationresponse.md deleted file mode 100644 index 4f0ac1b3..00000000 --- a/internal/membershipclient/docs/models/operations/getorganizationapplicationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetOrganizationApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `GetApplicationResponse` | [*components.GetApplicationResponse](../../models/components/getapplicationresponse.md) | :heavy_minus_sign: | Application details | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getregionrequest.md b/internal/membershipclient/docs/models/operations/getregionrequest.md deleted file mode 100644 index 9c7f0ee2..00000000 --- a/internal/membershipclient/docs/models/operations/getregionrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# GetRegionRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `RegionID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getregionresponse.md b/internal/membershipclient/docs/models/operations/getregionresponse.md deleted file mode 100644 index c037d812..00000000 --- a/internal/membershipclient/docs/models/operations/getregionresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetRegionResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `GetRegionResponse` | [*components.GetRegionResponse](../../models/components/getregionresponse.md) | :heavy_minus_sign: | Get a specific region | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getregionversionsrequest.md b/internal/membershipclient/docs/models/operations/getregionversionsrequest.md deleted file mode 100644 index 78fd844f..00000000 --- a/internal/membershipclient/docs/models/operations/getregionversionsrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# GetRegionVersionsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `RegionID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getregionversionsresponse.md b/internal/membershipclient/docs/models/operations/getregionversionsresponse.md deleted file mode 100644 index ebcbaccc..00000000 --- a/internal/membershipclient/docs/models/operations/getregionversionsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetRegionVersionsResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `GetRegionVersionsResponse` | [*components.GetRegionVersionsResponse](../../models/components/getregionversionsresponse.md) | :heavy_minus_sign: | Get a specific region | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getserverinforesponse.md b/internal/membershipclient/docs/models/operations/getserverinforesponse.md deleted file mode 100644 index 18ed5ab9..00000000 --- a/internal/membershipclient/docs/models/operations/getserverinforesponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetServerInfoResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ServerInfo` | [*components.ServerInfo](../../models/components/serverinfo.md) | :heavy_minus_sign: | Server information | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getstackrequest.md b/internal/membershipclient/docs/models/operations/getstackrequest.md deleted file mode 100644 index c07649d1..00000000 --- a/internal/membershipclient/docs/models/operations/getstackrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# GetStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/getstackresponse.md b/internal/membershipclient/docs/models/operations/getstackresponse.md deleted file mode 100644 index 4da443b6..00000000 --- a/internal/membershipclient/docs/models/operations/getstackresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# GetStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadStackResponse` | [*components.ReadStackResponse](../../models/components/readstackresponse.md) | :heavy_minus_sign: | OK | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listapplicationsresponse.md b/internal/membershipclient/docs/models/operations/listapplicationsresponse.md deleted file mode 100644 index 17a69ea0..00000000 --- a/internal/membershipclient/docs/models/operations/listapplicationsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListApplicationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListApplicationsResponse` | [*components.ListApplicationsResponse](../../models/components/listapplicationsresponse.md) | :heavy_minus_sign: | List of applications | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listfeaturesrequest.md b/internal/membershipclient/docs/models/operations/listfeaturesrequest.md deleted file mode 100644 index 7044d6f3..00000000 --- a/internal/membershipclient/docs/models/operations/listfeaturesrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListFeaturesRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listfeaturesresponse.md b/internal/membershipclient/docs/models/operations/listfeaturesresponse.md deleted file mode 100644 index c90b0e07..00000000 --- a/internal/membershipclient/docs/models/operations/listfeaturesresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListFeaturesResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Object` | [*operations.ListFeaturesResponseBody](../../models/operations/listfeaturesresponsebody.md) | :heavy_minus_sign: | List of features | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listfeaturesresponsebody.md b/internal/membershipclient/docs/models/operations/listfeaturesresponsebody.md deleted file mode 100644 index 7e779c70..00000000 --- a/internal/membershipclient/docs/models/operations/listfeaturesresponsebody.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListFeaturesResponseBody - -List of features - - -## Fields - -| Field | Type | Required | Description | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `Data` | [][components.OrganizationFeature](../../models/components/organizationfeature.md) | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listinvitationsrequest.md b/internal/membershipclient/docs/models/operations/listinvitationsrequest.md deleted file mode 100644 index 4ac2adee..00000000 --- a/internal/membershipclient/docs/models/operations/listinvitationsrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListInvitationsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------- | ----------------------- | ----------------------- | ----------------------- | -| `Status` | `*string` | :heavy_minus_sign: | Status of organizations | -| `Organization` | `*string` | :heavy_minus_sign: | Status of organizations | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listinvitationsresponse.md b/internal/membershipclient/docs/models/operations/listinvitationsresponse.md deleted file mode 100644 index ce6f2ec1..00000000 --- a/internal/membershipclient/docs/models/operations/listinvitationsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListInvitationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListInvitationsResponse` | [*components.ListInvitationsResponse](../../models/components/listinvitationsresponse.md) | :heavy_minus_sign: | List of the invitations for the connected user | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listlogsrequest.md b/internal/membershipclient/docs/models/operations/listlogsrequest.md deleted file mode 100644 index 2ecf8e57..00000000 --- a/internal/membershipclient/docs/models/operations/listlogsrequest.md +++ /dev/null @@ -1,15 +0,0 @@ -# ListLogsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `*string` | :heavy_minus_sign: | N/A | -| `Cursor` | `*string` | :heavy_minus_sign: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | N/A | -| `Action` | [*components.Action](../../models/components/action.md) | :heavy_minus_sign: | N/A | -| `UserID` | `*string` | :heavy_minus_sign: | N/A | -| `Key` | `*string` | :heavy_minus_sign: | N/A | -| `Value` | `*string` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listlogsresponse.md b/internal/membershipclient/docs/models/operations/listlogsresponse.md deleted file mode 100644 index 868f447e..00000000 --- a/internal/membershipclient/docs/models/operations/listlogsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListLogsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `LogCursor` | [*components.LogCursor](../../models/components/logcursor.md) | :heavy_minus_sign: | Cursor of logs | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listmodulesrequest.md b/internal/membershipclient/docs/models/operations/listmodulesrequest.md deleted file mode 100644 index 0b8c4570..00000000 --- a/internal/membershipclient/docs/models/operations/listmodulesrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListModulesRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listmodulesresponse.md b/internal/membershipclient/docs/models/operations/listmodulesresponse.md deleted file mode 100644 index 33ea1354..00000000 --- a/internal/membershipclient/docs/models/operations/listmodulesresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListModulesResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListModulesResponse` | [*components.ListModulesResponse](../../models/components/listmodulesresponse.md) | :heavy_minus_sign: | List of modules | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationapplicationsrequest.md b/internal/membershipclient/docs/models/operations/listorganizationapplicationsrequest.md deleted file mode 100644 index ce53bc07..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationapplicationsrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListOrganizationApplicationsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | N/A | -| `Page` | `*int64` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationapplicationsresponse.md b/internal/membershipclient/docs/models/operations/listorganizationapplicationsresponse.md deleted file mode 100644 index f2124c61..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationapplicationsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListOrganizationApplicationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListApplicationsResponse` | [*components.ListApplicationsResponse](../../models/components/listapplicationsresponse.md) | :heavy_minus_sign: | List of applications enabled for organization | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationinvitationsrequest.md b/internal/membershipclient/docs/models/operations/listorganizationinvitationsrequest.md deleted file mode 100644 index d8018620..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationinvitationsrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListOrganizationInvitationsRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------- | ----------------------- | ----------------------- | ----------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Status` | `*string` | :heavy_minus_sign: | Status of organizations | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationinvitationsresponse.md b/internal/membershipclient/docs/models/operations/listorganizationinvitationsresponse.md deleted file mode 100644 index 226c656e..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationinvitationsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListOrganizationInvitationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListInvitationsResponse` | [*components.ListInvitationsResponse](../../models/components/listinvitationsresponse.md) | :heavy_minus_sign: | List of the invitations for the organization | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationsexpandedresponse.md b/internal/membershipclient/docs/models/operations/listorganizationsexpandedresponse.md deleted file mode 100644 index 3c9d88e0..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationsexpandedresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListOrganizationsExpandedResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListOrganizationExpandedResponse` | [*components.ListOrganizationExpandedResponse](../../models/components/listorganizationexpandedresponse.md) | :heavy_minus_sign: | List of organizations with expanded data | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationsrequest.md b/internal/membershipclient/docs/models/operations/listorganizationsrequest.md deleted file mode 100644 index cc09766a..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationsrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListOrganizationsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `Expand` | `*bool` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listorganizationsresponse.md b/internal/membershipclient/docs/models/operations/listorganizationsresponse.md deleted file mode 100644 index 1241dd49..00000000 --- a/internal/membershipclient/docs/models/operations/listorganizationsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListOrganizationsResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListOrganizationExpandedResponse` | [*components.ListOrganizationExpandedResponse](../../models/components/listorganizationexpandedresponse.md) | :heavy_minus_sign: | List of organizations | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listpoliciesrequest.md b/internal/membershipclient/docs/models/operations/listpoliciesrequest.md deleted file mode 100644 index 85c58db1..00000000 --- a/internal/membershipclient/docs/models/operations/listpoliciesrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListPoliciesRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listpoliciesresponse.md b/internal/membershipclient/docs/models/operations/listpoliciesresponse.md deleted file mode 100644 index c1ed2ff1..00000000 --- a/internal/membershipclient/docs/models/operations/listpoliciesresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListPoliciesResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListPoliciesResponse` | [*components.ListPoliciesResponse](../../models/components/listpoliciesresponse.md) | :heavy_minus_sign: | List of policies | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listregionsrequest.md b/internal/membershipclient/docs/models/operations/listregionsrequest.md deleted file mode 100644 index d1720f9b..00000000 --- a/internal/membershipclient/docs/models/operations/listregionsrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListRegionsRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listregionsresponse.md b/internal/membershipclient/docs/models/operations/listregionsresponse.md deleted file mode 100644 index ad308a3c..00000000 --- a/internal/membershipclient/docs/models/operations/listregionsresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListRegionsResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListRegionsResponse` | [*components.ListRegionsResponse](../../models/components/listregionsresponse.md) | :heavy_minus_sign: | List of the regions | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/liststacksrequest.md b/internal/membershipclient/docs/models/operations/liststacksrequest.md deleted file mode 100644 index 1ab2fce1..00000000 --- a/internal/membershipclient/docs/models/operations/liststacksrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListStacksRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `All` | `*bool` | :heavy_minus_sign: | Include deleted and disabled stacks | -| ~~`Deleted`~~ | `*bool` | :heavy_minus_sign: | : warning: ** DEPRECATED **: This will be removed in a future release, please migrate away from it as soon as possible.

Include deleted stacks | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/liststacksresponse.md b/internal/membershipclient/docs/models/operations/liststacksresponse.md deleted file mode 100644 index 86c4e710..00000000 --- a/internal/membershipclient/docs/models/operations/liststacksresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListStacksResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListStacksResponse` | [*components.ListStacksResponse](../../models/components/liststacksresponse.md) | :heavy_minus_sign: | List of stacks | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/liststackusersaccessesrequest.md b/internal/membershipclient/docs/models/operations/liststackusersaccessesrequest.md deleted file mode 100644 index 4c7c46de..00000000 --- a/internal/membershipclient/docs/models/operations/liststackusersaccessesrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ListStackUsersAccessesRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/liststackusersaccessesresponse.md b/internal/membershipclient/docs/models/operations/liststackusersaccessesresponse.md deleted file mode 100644 index edad0b69..00000000 --- a/internal/membershipclient/docs/models/operations/liststackusersaccessesresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListStackUsersAccessesResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `StackUserAccessResponse` | [*components.StackUserAccessResponse](../../models/components/stackuseraccessresponse.md) | :heavy_minus_sign: | Stack users access | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listusersoforganizationrequest.md b/internal/membershipclient/docs/models/operations/listusersoforganizationrequest.md deleted file mode 100644 index 3b4693b0..00000000 --- a/internal/membershipclient/docs/models/operations/listusersoforganizationrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListUsersOfOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/listusersoforganizationresponse.md b/internal/membershipclient/docs/models/operations/listusersoforganizationresponse.md deleted file mode 100644 index 499a7b18..00000000 --- a/internal/membershipclient/docs/models/operations/listusersoforganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ListUsersOfOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ListUsersResponse` | [*components.ListUsersResponse](../../models/components/listusersresponse.md) | :heavy_minus_sign: | List of users | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/option.md b/internal/membershipclient/docs/models/operations/option.md deleted file mode 100644 index 3c058ecd..00000000 --- a/internal/membershipclient/docs/models/operations/option.md +++ /dev/null @@ -1,147 +0,0 @@ -# Options - -## Global Options - -Global options are passed when initializing the SDK client and apply to all operations. - -### WithServerURL - -WithServerURL allows providing an alternative server URL. - -```go -membershipclient.WithServerURL("https://api.example.com") -``` - -### WithTemplatedServerURL - -WithTemplatedServerURL allows providing an alternative server URL with templated parameters. - -```go -membershipclient.WithTemplatedServerURL("https://{host}:{port}", map[string]string{ - "host": "api.example.com", - "port": "8080", -}) -``` - -### WithServerIndex - -WithServerIndex allows the overriding of the default server by index. - -```go -membershipclient.WithServerIndex(1) -``` - -### WithClient - -WithClient allows the overriding of the default HTTP client used by the SDK. - -```go -membershipclient.WithClient(httpClient) -``` - -### WithSecurity - -WithSecurity configures the SDK to use the provided security details. - -```go -membershipclient.WithSecurity(/* ... */) -``` - -### WithSecuritySource - -WithSecuritySource configures the SDK to invoke the provided function on each method call to determine authentication. - -```go -membershipclient.WithSecuritySource(/* ... */) -``` - -### WithRetryConfig - -WithRetryConfig allows setting the default retry configuration used by the SDK for all supported operations. - -```go -membershipclient.WithRetryConfig(retry.Config{ - Strategy: "backoff", - Backoff: retry.BackoffStrategy{ - InitialInterval: 500 * time.Millisecond, - MaxInterval: 60 * time.Second, - Exponent: 1.5, - MaxElapsedTime: 5 * time.Minute, - }, - RetryConnectionErrors: true, -}) -``` - -### WithTimeout - -WithTimeout sets the default request timeout for all operations. - -```go -membershipclient.WithTimeout(30 * time.Second) -``` - -## Per-Method Options - -Per-method options are passed as the last argument to individual methods and override any global settings for that request. - -### WithServerURL - -WithServerURL allows providing an alternative server URL for a single request. - -```go -operations.WithServerURL("http://api.example.com") -``` - -### WithTemplatedServerURL - -WithTemplatedServerURL allows providing an alternative server URL with templated parameters for a single request. - -```go -operations.WithTemplatedServerURL("http://{host}:{port}", map[string]string{ - "host": "api.example.com", - "port": "8080", -}) -``` - -### WithRetries - -WithRetries allows customizing the default retry configuration for a single request. - -```go -operations.WithRetries(retry.Config{ - Strategy: "backoff", - Backoff: retry.BackoffStrategy{ - InitialInterval: 500 * time.Millisecond, - MaxInterval: 60 * time.Second, - Exponent: 1.5, - MaxElapsedTime: 5 * time.Minute, - }, - RetryConnectionErrors: true, -}) -``` - -### WithOperationTimeout - -WithOperationTimeout allows setting the request timeout for a single request. - -```go -operations.WithOperationTimeout(30 * time.Second) -``` - -### WithSetHeaders - -WithSetHeaders allows setting custom headers on a per-request basis. If the request already contains headers matching the provided keys, they will be overwritten. - -```go -operations.WithSetHeaders(map[string]string{ - "X-Cache-TTL": "60", -}) -``` - -### WithURLOverride - -WithURLOverride allows overriding the default URL for an operation. - -```go -operations.WithURLOverride("/custom/path") -``` \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientcreaterequest.md b/internal/membershipclient/docs/models/operations/organizationclientcreaterequest.md deleted file mode 100644 index bbec7011..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientcreaterequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientCreateRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.CreateOrganizationClientRequest](../../models/components/createorganizationclientrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientcreateresponse.md b/internal/membershipclient/docs/models/operations/organizationclientcreateresponse.md deleted file mode 100644 index c1491d81..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientcreateresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationClientCreateResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateOrganizationClientResponse` | [*components.CreateOrganizationClientResponse](../../models/components/createorganizationclientresponse.md) | :heavy_minus_sign: | Organization client created | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientdeleterequest.md b/internal/membershipclient/docs/models/operations/organizationclientdeleterequest.md deleted file mode 100644 index 39007610..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientdeleterequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientDeleteRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ClientID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientdeleteresponse.md b/internal/membershipclient/docs/models/operations/organizationclientdeleteresponse.md deleted file mode 100644 index f82700c0..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientdeleteresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientDeleteResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientreadrequest.md b/internal/membershipclient/docs/models/operations/organizationclientreadrequest.md deleted file mode 100644 index 1615ba91..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientreadrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientReadRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ClientID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientreadresponse.md b/internal/membershipclient/docs/models/operations/organizationclientreadresponse.md deleted file mode 100644 index 4eb9b823..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientreadresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationClientReadResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadOrganizationClientResponse` | [*components.ReadOrganizationClientResponse](../../models/components/readorganizationclientresponse.md) | :heavy_minus_sign: | Organization client details | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientsreadrequest.md b/internal/membershipclient/docs/models/operations/organizationclientsreadrequest.md deleted file mode 100644 index e42bda35..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientsreadrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationClientsReadRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Cursor` | `*string` | :heavy_minus_sign: | N/A | -| `PageSize` | `*int64` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientsreadresponse.md b/internal/membershipclient/docs/models/operations/organizationclientsreadresponse.md deleted file mode 100644 index 4b3c5a04..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientsreadresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationClientsReadResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadOrganizationClientsResponse` | [*components.ReadOrganizationClientsResponse](../../models/components/readorganizationclientsresponse.md) | :heavy_minus_sign: | Organization client | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientupdaterequest.md b/internal/membershipclient/docs/models/operations/organizationclientupdaterequest.md deleted file mode 100644 index 5e1bcf74..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientupdaterequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# OrganizationClientUpdateRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `ClientID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.UpdateOrganizationClientRequest](../../models/components/updateorganizationclientrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/organizationclientupdateresponse.md b/internal/membershipclient/docs/models/operations/organizationclientupdateresponse.md deleted file mode 100644 index ee493197..00000000 --- a/internal/membershipclient/docs/models/operations/organizationclientupdateresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# OrganizationClientUpdateResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readauthenticationproviderrequest.md b/internal/membershipclient/docs/models/operations/readauthenticationproviderrequest.md deleted file mode 100644 index 60fe98b4..00000000 --- a/internal/membershipclient/docs/models/operations/readauthenticationproviderrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadAuthenticationProviderRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readauthenticationproviderresponse.md b/internal/membershipclient/docs/models/operations/readauthenticationproviderresponse.md deleted file mode 100644 index 221b6c7a..00000000 --- a/internal/membershipclient/docs/models/operations/readauthenticationproviderresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadAuthenticationProviderResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `AuthenticationProviderResponse` | [*components.AuthenticationProviderResponse](../../models/components/authenticationproviderresponse.md) | :heavy_minus_sign: | Authentication provider details | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readconnecteduserresponse.md b/internal/membershipclient/docs/models/operations/readconnecteduserresponse.md deleted file mode 100644 index b8f57de8..00000000 --- a/internal/membershipclient/docs/models/operations/readconnecteduserresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadConnectedUserResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadUserResponse` | [*components.ReadUserResponse](../../models/components/readuserresponse.md) | :heavy_minus_sign: | Read a user | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readorganizationclientrequest.md b/internal/membershipclient/docs/models/operations/readorganizationclientrequest.md deleted file mode 100644 index d393b062..00000000 --- a/internal/membershipclient/docs/models/operations/readorganizationclientrequest.md +++ /dev/null @@ -1,8 +0,0 @@ -# ReadOrganizationClientRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readorganizationclientresponse.md b/internal/membershipclient/docs/models/operations/readorganizationclientresponse.md deleted file mode 100644 index 802a5f1a..00000000 --- a/internal/membershipclient/docs/models/operations/readorganizationclientresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadOrganizationClientResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `CreateClientResponseResponse` | [*components.CreateClientResponseResponse](../../models/components/createclientresponseresponse.md) | :heavy_minus_sign: | Organization client | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readorganizationrequest.md b/internal/membershipclient/docs/models/operations/readorganizationrequest.md deleted file mode 100644 index 18ba2c28..00000000 --- a/internal/membershipclient/docs/models/operations/readorganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Expand` | `*bool` | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readorganizationresponse.md b/internal/membershipclient/docs/models/operations/readorganizationresponse.md deleted file mode 100644 index 826d5758..00000000 --- a/internal/membershipclient/docs/models/operations/readorganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadOrganizationResponse` | [*components.ReadOrganizationResponse](../../models/components/readorganizationresponse.md) | :heavy_minus_sign: | OK | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readpolicyrequest.md b/internal/membershipclient/docs/models/operations/readpolicyrequest.md deleted file mode 100644 index 5a642310..00000000 --- a/internal/membershipclient/docs/models/operations/readpolicyrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadPolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readpolicyresponse.md b/internal/membershipclient/docs/models/operations/readpolicyresponse.md deleted file mode 100644 index d202ab5e..00000000 --- a/internal/membershipclient/docs/models/operations/readpolicyresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadPolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadPolicyResponse` | [*components.ReadPolicyResponse](../../models/components/readpolicyresponse.md) | :heavy_minus_sign: | Policy with scopes | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readstackuseraccessrequest.md b/internal/membershipclient/docs/models/operations/readstackuseraccessrequest.md deleted file mode 100644 index 1666e00d..00000000 --- a/internal/membershipclient/docs/models/operations/readstackuseraccessrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadStackUserAccessRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readstackuseraccessresponse.md b/internal/membershipclient/docs/models/operations/readstackuseraccessresponse.md deleted file mode 100644 index 1c50f09c..00000000 --- a/internal/membershipclient/docs/models/operations/readstackuseraccessresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadStackUserAccessResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadStackUserAccess` | [*components.ReadStackUserAccess](../../models/components/readstackuseraccess.md) | :heavy_minus_sign: | Stack user access | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readuseroforganizationrequest.md b/internal/membershipclient/docs/models/operations/readuseroforganizationrequest.md deleted file mode 100644 index bd3cfcb3..00000000 --- a/internal/membershipclient/docs/models/operations/readuseroforganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# ReadUserOfOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/readuseroforganizationresponse.md b/internal/membershipclient/docs/models/operations/readuseroforganizationresponse.md deleted file mode 100644 index b2daf9d2..00000000 --- a/internal/membershipclient/docs/models/operations/readuseroforganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# ReadUserOfOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadOrganizationUserResponse` | [*components.ReadOrganizationUserResponse](../../models/components/readorganizationuserresponse.md) | :heavy_minus_sign: | Read a user | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/removescopefrompolicyrequest.md b/internal/membershipclient/docs/models/operations/removescopefrompolicyrequest.md deleted file mode 100644 index 5614a280..00000000 --- a/internal/membershipclient/docs/models/operations/removescopefrompolicyrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# RemoveScopeFromPolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | -| `ScopeID` | `int64` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/removescopefrompolicyresponse.md b/internal/membershipclient/docs/models/operations/removescopefrompolicyresponse.md deleted file mode 100644 index 3f9ac299..00000000 --- a/internal/membershipclient/docs/models/operations/removescopefrompolicyresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# RemoveScopeFromPolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/restorestackrequest.md b/internal/membershipclient/docs/models/operations/restorestackrequest.md deleted file mode 100644 index d556af23..00000000 --- a/internal/membershipclient/docs/models/operations/restorestackrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# RestoreStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/restorestackresponse.md b/internal/membershipclient/docs/models/operations/restorestackresponse.md deleted file mode 100644 index 08680741..00000000 --- a/internal/membershipclient/docs/models/operations/restorestackresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# RestoreStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadStackResponse` | [*components.ReadStackResponse](../../models/components/readstackresponse.md) | :heavy_minus_sign: | OK | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updateapplicationrequest.md b/internal/membershipclient/docs/models/operations/updateapplicationrequest.md deleted file mode 100644 index f756b99e..00000000 --- a/internal/membershipclient/docs/models/operations/updateapplicationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateApplicationRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| `ApplicationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.ApplicationData](../../models/components/applicationdata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updateapplicationresponse.md b/internal/membershipclient/docs/models/operations/updateapplicationresponse.md deleted file mode 100644 index a762a046..00000000 --- a/internal/membershipclient/docs/models/operations/updateapplicationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdateApplicationResponse - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `UpdateApplicationResponse` | [*components.UpdateApplicationResponse](../../models/components/updateapplicationresponse.md) | :heavy_minus_sign: | Application updated | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updateorganizationrequest.md b/internal/membershipclient/docs/models/operations/updateorganizationrequest.md deleted file mode 100644 index 7106e5a5..00000000 --- a/internal/membershipclient/docs/models/operations/updateorganizationrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpdateOrganizationRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.OrganizationData](../../models/components/organizationdata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updateorganizationresponse.md b/internal/membershipclient/docs/models/operations/updateorganizationresponse.md deleted file mode 100644 index 7a89e4f8..00000000 --- a/internal/membershipclient/docs/models/operations/updateorganizationresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdateOrganizationResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadOrganizationResponse` | [*components.ReadOrganizationResponse](../../models/components/readorganizationresponse.md) | :heavy_minus_sign: | Organization updated | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updatepolicyrequest.md b/internal/membershipclient/docs/models/operations/updatepolicyrequest.md deleted file mode 100644 index 0dc9fa56..00000000 --- a/internal/membershipclient/docs/models/operations/updatepolicyrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdatePolicyRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `PolicyID` | `int64` | :heavy_check_mark: | N/A | -| `Body` | [*components.CreatePolicyRequest](../../models/components/createpolicyrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updatepolicyresponse.md b/internal/membershipclient/docs/models/operations/updatepolicyresponse.md deleted file mode 100644 index dc0a8e95..00000000 --- a/internal/membershipclient/docs/models/operations/updatepolicyresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdatePolicyResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `UpdatePolicyResponse` | [*components.UpdatePolicyResponse](../../models/components/updatepolicyresponse.md) | :heavy_minus_sign: | Policy updated | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updatestackrequest.md b/internal/membershipclient/docs/models/operations/updatestackrequest.md deleted file mode 100644 index ea3d9062..00000000 --- a/internal/membershipclient/docs/models/operations/updatestackrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdateStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.StackData](../../models/components/stackdata.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/updatestackresponse.md b/internal/membershipclient/docs/models/operations/updatestackresponse.md deleted file mode 100644 index 53b36d1e..00000000 --- a/internal/membershipclient/docs/models/operations/updatestackresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpdateStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `ReadStackResponse` | [*components.ReadStackResponse](../../models/components/readstackresponse.md) | :heavy_minus_sign: | Updated stack | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upgradestackrequest.md b/internal/membershipclient/docs/models/operations/upgradestackrequest.md deleted file mode 100644 index e613895d..00000000 --- a/internal/membershipclient/docs/models/operations/upgradestackrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpgradeStackRequest - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.StackVersion](../../models/components/stackversion.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upgradestackresponse.md b/internal/membershipclient/docs/models/operations/upgradestackresponse.md deleted file mode 100644 index 5c5455f0..00000000 --- a/internal/membershipclient/docs/models/operations/upgradestackresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpgradeStackResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertauthenticationproviderrequest.md b/internal/membershipclient/docs/models/operations/upsertauthenticationproviderrequest.md deleted file mode 100644 index 0854aa16..00000000 --- a/internal/membershipclient/docs/models/operations/upsertauthenticationproviderrequest.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpsertAuthenticationProviderRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.UpsertAuthenticationProviderRequest](../../models/components/upsertauthenticationproviderrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertauthenticationproviderresponse.md b/internal/membershipclient/docs/models/operations/upsertauthenticationproviderresponse.md deleted file mode 100644 index c4b912d3..00000000 --- a/internal/membershipclient/docs/models/operations/upsertauthenticationproviderresponse.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpsertAuthenticationProviderResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `AuthenticationProviderResponse` | [*components.AuthenticationProviderResponse](../../models/components/authenticationproviderresponse.md) | :heavy_minus_sign: | Authentication provider created or updated | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertorganizationuserrequest.md b/internal/membershipclient/docs/models/operations/upsertorganizationuserrequest.md deleted file mode 100644 index 9251ed17..00000000 --- a/internal/membershipclient/docs/models/operations/upsertorganizationuserrequest.md +++ /dev/null @@ -1,10 +0,0 @@ -# UpsertOrganizationUserRequest - - -## Fields - -| Field | Type | Required | Description | -| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.UpdateOrganizationUserRequest](../../models/components/updateorganizationuserrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertorganizationuserresponse.md b/internal/membershipclient/docs/models/operations/upsertorganizationuserresponse.md deleted file mode 100644 index 70438f1f..00000000 --- a/internal/membershipclient/docs/models/operations/upsertorganizationuserresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpsertOrganizationUserResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertstackuseraccessrequest.md b/internal/membershipclient/docs/models/operations/upsertstackuseraccessrequest.md deleted file mode 100644 index 1913222a..00000000 --- a/internal/membershipclient/docs/models/operations/upsertstackuseraccessrequest.md +++ /dev/null @@ -1,11 +0,0 @@ -# UpsertStackUserAccessRequest - - -## Fields - -| Field | Type | Required | Description | -| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `OrganizationID` | `string` | :heavy_check_mark: | N/A | -| `StackID` | `string` | :heavy_check_mark: | N/A | -| `UserID` | `string` | :heavy_check_mark: | N/A | -| `Body` | [*components.UpdateStackUserRequest](../../models/components/updatestackuserrequest.md) | :heavy_minus_sign: | N/A | \ No newline at end of file diff --git a/internal/membershipclient/docs/models/operations/upsertstackuseraccessresponse.md b/internal/membershipclient/docs/models/operations/upsertstackuseraccessresponse.md deleted file mode 100644 index 6cc606a7..00000000 --- a/internal/membershipclient/docs/models/operations/upsertstackuseraccessresponse.md +++ /dev/null @@ -1,9 +0,0 @@ -# UpsertStackUserAccessResponse - - -## Fields - -| Field | Type | Required | Description | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `HTTPMeta` | [components.HTTPMetadata](../../models/components/httpmetadata.md) | :heavy_check_mark: | N/A | -| `Error` | [*components.Error](../../models/components/error.md) | :heavy_minus_sign: | Error | \ No newline at end of file diff --git a/internal/membershipclient/docs/sdks/sdk/README.md b/internal/membershipclient/docs/sdks/sdk/README.md deleted file mode 100644 index 12a9409e..00000000 --- a/internal/membershipclient/docs/sdks/sdk/README.md +++ /dev/null @@ -1,4184 +0,0 @@ -# SDK - -## Overview - -### Available Operations - -* [GetServerInfo](#getserverinfo) - Get server info -* [ListOrganizations](#listorganizations) - List organizations of the connected user -* [CreateOrganization](#createorganization) - Create organization -* [~~ListOrganizationsExpanded~~](#listorganizationsexpanded) - List organizations of the connected user with expanded data :warning: **Deprecated** -* [ReadOrganization](#readorganization) - Read organization -* [UpdateOrganization](#updateorganization) - Update organization -* [DeleteOrganization](#deleteorganization) - Delete organization -* [ReadAuthenticationProvider](#readauthenticationprovider) - Read authentication provider -* [UpsertAuthenticationProvider](#upsertauthenticationprovider) - Upsert an authentication provider -* [DeleteAuthenticationProvider](#deleteauthenticationprovider) - Delete authentication provider -* [ListFeatures](#listfeatures) - List features -* [AddFeatures](#addfeatures) - Add Features -* [DeleteFeature](#deletefeature) - Delete feature -* [~~ReadOrganizationClient~~](#readorganizationclient) - Read organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [~~CreateOrganizationClient~~](#createorganizationclient) - Create organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [~~DeleteOrganizationClient~~](#deleteorganizationclient) - Delete organization client (DEPRECATED) (until 12/31/2025) :warning: **Deprecated** -* [OrganizationClientsRead](#organizationclientsread) - Read organization clients -* [OrganizationClientCreate](#organizationclientcreate) - Create organization client -* [OrganizationClientRead](#organizationclientread) - Read organization client -* [OrganizationClientDelete](#organizationclientdelete) - Delete organization client -* [OrganizationClientUpdate](#organizationclientupdate) - Update organization client -* [ListLogs](#listlogs) - List logs -* [ListUsersOfOrganization](#listusersoforganization) - List users of organization -* [ReadUserOfOrganization](#readuseroforganization) - Read user of organization -* [UpsertOrganizationUser](#upsertorganizationuser) - Update user within an organization -* [DeleteUserFromOrganization](#deleteuserfromorganization) - delete user from organization -* [ListPolicies](#listpolicies) - List policies of organization -* [CreatePolicy](#createpolicy) - Create policy -* [ReadPolicy](#readpolicy) - Read policy with scopes -* [UpdatePolicy](#updatepolicy) - Update policy -* [DeletePolicy](#deletepolicy) - Delete policy -* [AddScopeToPolicy](#addscopetopolicy) - Add scope to policy -* [RemoveScopeFromPolicy](#removescopefrompolicy) - Remove scope from policy -* [ListStacks](#liststacks) - List stacks -* [CreateStack](#createstack) - Create stack -* [ListModules](#listmodules) - List modules of a stack -* [EnableModule](#enablemodule) - enable module -* [DisableModule](#disablemodule) - disable module -* [UpgradeStack](#upgradestack) - Upgrade stack -* [GetStack](#getstack) - Find stack -* [UpdateStack](#updatestack) - Update stack -* [DeleteStack](#deletestack) - Delete stack -* [ListStackUsersAccesses](#liststackusersaccesses) - List stack users accesses within an organization -* [ReadStackUserAccess](#readstackuseraccess) - Read stack user access within an organization -* [DeleteStackUserAccess](#deletestackuseraccess) - Delete stack user access within an organization -* [UpsertStackUserAccess](#upsertstackuseraccess) - Update stack user access within an organization -* [DisableStack](#disablestack) - Disable stack -* [EnableStack](#enablestack) - Enable stack -* [RestoreStack](#restorestack) - Restore stack -* [EnableStargate](#enablestargate) - Enable stargate on a stack -* [DisableStargate](#disablestargate) - Disable stargate on a stack -* [ListInvitations](#listinvitations) - List invitations of the user -* [AcceptInvitation](#acceptinvitation) - Accept invitation -* [DeclineInvitation](#declineinvitation) - Decline invitation -* [ListOrganizationInvitations](#listorganizationinvitations) - List invitations of the organization -* [CreateInvitation](#createinvitation) - Create invitation -* [DeleteInvitation](#deleteinvitation) - Delete invitation -* [ListRegions](#listregions) - List regions -* [CreatePrivateRegion](#createprivateregion) - Create a private region -* [GetRegion](#getregion) - Get region -* [DeleteRegion](#deleteregion) - Delete region -* [GetRegionVersions](#getregionversions) - Get region versions -* [ListOrganizationApplications](#listorganizationapplications) - List applications enabled for organization -* [GetOrganizationApplication](#getorganizationapplication) - Get application for organization -* [EnableApplicationForOrganization](#enableapplicationfororganization) - Enable application for organization -* [DisableApplicationForOrganization](#disableapplicationfororganization) - Disable application for organization -* [ListApplications](#listapplications) - List applications -* [CreateApplication](#createapplication) - Create application -* [GetApplication](#getapplication) - Get application -* [UpdateApplication](#updateapplication) - Update application -* [DeleteApplication](#deleteapplication) - Delete application -* [CreateApplicationScope](#createapplicationscope) - Create application scope -* [DeleteApplicationScope](#deleteapplicationscope) - Delete application scope -* [CreateUser](#createuser) - Create user -* [ReadConnectedUser](#readconnecteduser) - Read user - -## GetServerInfo - -Get server info - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetServerInfo(ctx) - if err != nil { - log.Fatal(err) - } - if res.ServerInfo != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetServerInfoResponse](../../models/operations/getserverinforesponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListOrganizations - -List organizations of the connected user - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListOrganizations(ctx, operations.ListOrganizationsRequest{}) - if err != nil { - log.Fatal(err) - } - if res.ListOrganizationExpandedResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListOrganizationsRequest](../../models/operations/listorganizationsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListOrganizationsResponse](../../models/operations/listorganizationsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateOrganization - -Create organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateOrganization(ctx, nil) - if err != nil { - log.Fatal(err) - } - if res.CreateOrganizationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [components.CreateOrganizationRequest](../../models/components/createorganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateOrganizationResponse](../../models/operations/createorganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ~~ListOrganizationsExpanded~~ - -List organizations of the connected user with expanded data - -> :warning: **DEPRECATED**: This will be removed in a future release, please migrate away from it as soon as possible. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListOrganizationsExpanded(ctx) - if err != nil { - log.Fatal(err) - } - if res.ListOrganizationExpandedResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListOrganizationsExpandedResponse](../../models/operations/listorganizationsexpandedresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadOrganization - -Read organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadOrganization(ctx, operations.ReadOrganizationRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadOrganizationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadOrganizationRequest](../../models/operations/readorganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadOrganizationResponse](../../models/operations/readorganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdateOrganization - -Update organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpdateOrganization(ctx, operations.UpdateOrganizationRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadOrganizationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpdateOrganizationRequest](../../models/operations/updateorganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdateOrganizationResponse](../../models/operations/updateorganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteOrganization - -Delete organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteOrganization(ctx, operations.DeleteOrganizationRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteOrganizationRequest](../../models/operations/deleteorganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteOrganizationResponse](../../models/operations/deleteorganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadAuthenticationProvider - -Read authentication provider - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadAuthenticationProvider(ctx, operations.ReadAuthenticationProviderRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.AuthenticationProviderResponse != nil { - switch res.AuthenticationProviderResponse.Data.Type { - case components.DataTypeAuthenticationProviderResponseGoogleIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseGoogleIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseMicrosoftIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseGithubIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseGithubIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseOIDCConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseOIDCConfig is populated - } - - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadAuthenticationProviderRequest](../../models/operations/readauthenticationproviderrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadAuthenticationProviderResponse](../../models/operations/readauthenticationproviderresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpsertAuthenticationProvider - -Upsert an authentication provider - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpsertAuthenticationProvider(ctx, operations.UpsertAuthenticationProviderRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.AuthenticationProviderResponse != nil { - switch res.AuthenticationProviderResponse.Data.Type { - case components.DataTypeAuthenticationProviderResponseGoogleIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseGoogleIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseMicrosoftIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseGithubIDPConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseGithubIDPConfig is populated - case components.DataTypeAuthenticationProviderResponseOIDCConfig: - // res.AuthenticationProviderResponse.Data.AuthenticationProviderResponseOIDCConfig is populated - } - - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpsertAuthenticationProviderRequest](../../models/operations/upsertauthenticationproviderrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpsertAuthenticationProviderResponse](../../models/operations/upsertauthenticationproviderresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteAuthenticationProvider - -Delete authentication provider - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteAuthenticationProvider(ctx, operations.DeleteAuthenticationProviderRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteAuthenticationProviderRequest](../../models/operations/deleteauthenticationproviderrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteAuthenticationProviderResponse](../../models/operations/deleteauthenticationproviderresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListFeatures - -List features - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListFeatures(ctx, operations.ListFeaturesRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Object != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListFeaturesRequest](../../models/operations/listfeaturesrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListFeaturesResponse](../../models/operations/listfeaturesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## AddFeatures - -Add Features - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.AddFeatures(ctx, operations.AddFeaturesRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.AddFeaturesRequest](../../models/operations/addfeaturesrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.AddFeaturesResponse](../../models/operations/addfeaturesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteFeature - -Delete feature - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteFeature(ctx, operations.DeleteFeatureRequest{ - OrganizationID: "", - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteFeatureRequest](../../models/operations/deletefeaturerequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteFeatureResponse](../../models/operations/deletefeatureresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ~~ReadOrganizationClient~~ - -Read organization client (DEPRECATED) (until 12/31/2025) - -> :warning: **DEPRECATED**: This will be removed in a future release, please migrate away from it as soon as possible. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadOrganizationClient(ctx, operations.ReadOrganizationClientRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateClientResponseResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadOrganizationClientRequest](../../models/operations/readorganizationclientrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadOrganizationClientResponse](../../models/operations/readorganizationclientresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ~~CreateOrganizationClient~~ - -Create organization client (DEPRECATED) (until 12/31/2025) - -> :warning: **DEPRECATED**: This will be removed in a future release, please migrate away from it as soon as possible. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateOrganizationClient(ctx, operations.CreateOrganizationClientRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateClientResponseResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreateOrganizationClientRequest](../../models/operations/createorganizationclientrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateOrganizationClientResponse](../../models/operations/createorganizationclientresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ~~DeleteOrganizationClient~~ - -Delete organization client (DEPRECATED) (until 12/31/2025) - -> :warning: **DEPRECATED**: This will be removed in a future release, please migrate away from it as soon as possible. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteOrganizationClient(ctx, operations.DeleteOrganizationClientRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteOrganizationClientRequest](../../models/operations/deleteorganizationclientrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteOrganizationClientResponse](../../models/operations/deleteorganizationclientresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## OrganizationClientsRead - -Read organization clients - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.OrganizationClientsRead(ctx, operations.OrganizationClientsReadRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadOrganizationClientsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.OrganizationClientsReadRequest](../../models/operations/organizationclientsreadrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.OrganizationClientsReadResponse](../../models/operations/organizationclientsreadresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## OrganizationClientCreate - -Create organization client - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.OrganizationClientCreate(ctx, operations.OrganizationClientCreateRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateOrganizationClientResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.OrganizationClientCreateRequest](../../models/operations/organizationclientcreaterequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.OrganizationClientCreateResponse](../../models/operations/organizationclientcreateresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## OrganizationClientRead - -Read organization client - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.OrganizationClientRead(ctx, operations.OrganizationClientReadRequest{ - OrganizationID: "", - ClientID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadOrganizationClientResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.OrganizationClientReadRequest](../../models/operations/organizationclientreadrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.OrganizationClientReadResponse](../../models/operations/organizationclientreadresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## OrganizationClientDelete - -Delete organization client - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.OrganizationClientDelete(ctx, operations.OrganizationClientDeleteRequest{ - OrganizationID: "", - ClientID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.OrganizationClientDeleteRequest](../../models/operations/organizationclientdeleterequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.OrganizationClientDeleteResponse](../../models/operations/organizationclientdeleteresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## OrganizationClientUpdate - -Update organization client - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.OrganizationClientUpdate(ctx, operations.OrganizationClientUpdateRequest{ - OrganizationID: "", - ClientID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.OrganizationClientUpdateRequest](../../models/operations/organizationclientupdaterequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.OrganizationClientUpdateResponse](../../models/operations/organizationclientupdateresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListLogs - -List logs - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListLogs(ctx, operations.ListLogsRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.LogCursor != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListLogsRequest](../../models/operations/listlogsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListLogsResponse](../../models/operations/listlogsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListUsersOfOrganization - -List users of organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListUsersOfOrganization(ctx, operations.ListUsersOfOrganizationRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListUsersResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListUsersOfOrganizationRequest](../../models/operations/listusersoforganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListUsersOfOrganizationResponse](../../models/operations/listusersoforganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadUserOfOrganization - -Read user of organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadUserOfOrganization(ctx, operations.ReadUserOfOrganizationRequest{ - OrganizationID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadOrganizationUserResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadUserOfOrganizationRequest](../../models/operations/readuseroforganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadUserOfOrganizationResponse](../../models/operations/readuseroforganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpsertOrganizationUser - -Update user within an organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpsertOrganizationUser(ctx, operations.UpsertOrganizationUserRequest{ - OrganizationID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpsertOrganizationUserRequest](../../models/operations/upsertorganizationuserrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpsertOrganizationUserResponse](../../models/operations/upsertorganizationuserresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteUserFromOrganization - -The owner of the organization can remove anyone while each user can leave any organization where it is not owner. - - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteUserFromOrganization(ctx, operations.DeleteUserFromOrganizationRequest{ - OrganizationID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteUserFromOrganizationRequest](../../models/operations/deleteuserfromorganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteUserFromOrganizationResponse](../../models/operations/deleteuserfromorganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListPolicies - -List policies of organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListPolicies(ctx, operations.ListPoliciesRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListPoliciesResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListPoliciesRequest](../../models/operations/listpoliciesrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListPoliciesResponse](../../models/operations/listpoliciesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreatePolicy - -Create policy - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreatePolicy(ctx, operations.CreatePolicyRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.CreatePolicyResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreatePolicyRequest](../../models/operations/createpolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreatePolicyResponse](../../models/operations/createpolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadPolicy - -Read policy with scopes - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadPolicy(ctx, operations.ReadPolicyRequest{ - OrganizationID: "", - PolicyID: 831591, - }) - if err != nil { - log.Fatal(err) - } - if res.ReadPolicyResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadPolicyRequest](../../models/operations/readpolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadPolicyResponse](../../models/operations/readpolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdatePolicy - -Update policy - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpdatePolicy(ctx, operations.UpdatePolicyRequest{ - OrganizationID: "", - PolicyID: 127460, - }) - if err != nil { - log.Fatal(err) - } - if res.UpdatePolicyResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpdatePolicyRequest](../../models/operations/updatepolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdatePolicyResponse](../../models/operations/updatepolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeletePolicy - -Delete policy - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeletePolicy(ctx, operations.DeletePolicyRequest{ - OrganizationID: "", - PolicyID: 114294, - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeletePolicyRequest](../../models/operations/deletepolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeletePolicyResponse](../../models/operations/deletepolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## AddScopeToPolicy - -Add scope to policy - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.AddScopeToPolicy(ctx, operations.AddScopeToPolicyRequest{ - OrganizationID: "", - PolicyID: 328027, - ScopeID: 675877, - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.AddScopeToPolicyRequest](../../models/operations/addscopetopolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.AddScopeToPolicyResponse](../../models/operations/addscopetopolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## RemoveScopeFromPolicy - -Remove scope from policy - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.RemoveScopeFromPolicy(ctx, operations.RemoveScopeFromPolicyRequest{ - OrganizationID: "", - PolicyID: 995736, - ScopeID: 485996, - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.RemoveScopeFromPolicyRequest](../../models/operations/removescopefrompolicyrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.RemoveScopeFromPolicyResponse](../../models/operations/removescopefrompolicyresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListStacks - -List stacks - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListStacks(ctx, operations.ListStacksRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListStacksResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListStacksRequest](../../models/operations/liststacksrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListStacksResponse](../../models/operations/liststacksresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateStack - -Create stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateStack(ctx, operations.CreateStackRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadStackResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreateStackRequest](../../models/operations/createstackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateStackResponse](../../models/operations/createstackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListModules - -List modules of a stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListModules(ctx, operations.ListModulesRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListModulesResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListModulesRequest](../../models/operations/listmodulesrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListModulesResponse](../../models/operations/listmodulesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## EnableModule - -enable module - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.EnableModule(ctx, operations.EnableModuleRequest{ - OrganizationID: "", - StackID: "", - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.EnableModuleRequest](../../models/operations/enablemodulerequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.EnableModuleResponse](../../models/operations/enablemoduleresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DisableModule - -disable module - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DisableModule(ctx, operations.DisableModuleRequest{ - OrganizationID: "", - StackID: "", - Name: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DisableModuleRequest](../../models/operations/disablemodulerequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DisableModuleResponse](../../models/operations/disablemoduleresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpgradeStack - -Upgrade stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpgradeStack(ctx, operations.UpgradeStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpgradeStackRequest](../../models/operations/upgradestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpgradeStackResponse](../../models/operations/upgradestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## GetStack - -Find stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetStack(ctx, operations.GetStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadStackResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.GetStackRequest](../../models/operations/getstackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetStackResponse](../../models/operations/getstackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdateStack - -Update stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpdateStack(ctx, operations.UpdateStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadStackResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpdateStackRequest](../../models/operations/updatestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdateStackResponse](../../models/operations/updatestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteStack - -Delete stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteStack(ctx, operations.DeleteStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteStackRequest](../../models/operations/deletestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteStackResponse](../../models/operations/deletestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListStackUsersAccesses - -List stack users accesses within an organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListStackUsersAccesses(ctx, operations.ListStackUsersAccessesRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.StackUserAccessResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListStackUsersAccessesRequest](../../models/operations/liststackusersaccessesrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListStackUsersAccessesResponse](../../models/operations/liststackusersaccessesresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadStackUserAccess - -Read stack user access within an organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadStackUserAccess(ctx, operations.ReadStackUserAccessRequest{ - OrganizationID: "", - StackID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadStackUserAccess != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ReadStackUserAccessRequest](../../models/operations/readstackuseraccessrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadStackUserAccessResponse](../../models/operations/readstackuseraccessresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteStackUserAccess - -Delete stack user access within an organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteStackUserAccess(ctx, operations.DeleteStackUserAccessRequest{ - OrganizationID: "", - StackID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteStackUserAccessRequest](../../models/operations/deletestackuseraccessrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteStackUserAccessResponse](../../models/operations/deletestackuseraccessresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpsertStackUserAccess - -Update stack user access within an organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpsertStackUserAccess(ctx, operations.UpsertStackUserAccessRequest{ - OrganizationID: "", - StackID: "", - UserID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpsertStackUserAccessRequest](../../models/operations/upsertstackuseraccessrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpsertStackUserAccessResponse](../../models/operations/upsertstackuseraccessresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DisableStack - -Disable stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DisableStack(ctx, operations.DisableStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DisableStackRequest](../../models/operations/disablestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DisableStackResponse](../../models/operations/disablestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## EnableStack - -Enable stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.EnableStack(ctx, operations.EnableStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.EnableStackRequest](../../models/operations/enablestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.EnableStackResponse](../../models/operations/enablestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## RestoreStack - -Restore stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.RestoreStack(ctx, operations.RestoreStackRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ReadStackResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.RestoreStackRequest](../../models/operations/restorestackrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.RestoreStackResponse](../../models/operations/restorestackresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## EnableStargate - -Enable stargate on a stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.EnableStargate(ctx, operations.EnableStargateRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.EnableStargateRequest](../../models/operations/enablestargaterequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.EnableStargateResponse](../../models/operations/enablestargateresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DisableStargate - -Disable stargate on a stack - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DisableStargate(ctx, operations.DisableStargateRequest{ - OrganizationID: "", - StackID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DisableStargateRequest](../../models/operations/disablestargaterequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DisableStargateResponse](../../models/operations/disablestargateresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListInvitations - -List invitations of the user - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListInvitations(ctx, operations.ListInvitationsRequest{}) - if err != nil { - log.Fatal(err) - } - if res.ListInvitationsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListInvitationsRequest](../../models/operations/listinvitationsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListInvitationsResponse](../../models/operations/listinvitationsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## AcceptInvitation - -Accept invitation - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.AcceptInvitation(ctx, operations.AcceptInvitationRequest{ - InvitationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.AcceptInvitationRequest](../../models/operations/acceptinvitationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.AcceptInvitationResponse](../../models/operations/acceptinvitationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeclineInvitation - -Decline invitation - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeclineInvitation(ctx, operations.DeclineInvitationRequest{ - InvitationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeclineInvitationRequest](../../models/operations/declineinvitationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeclineInvitationResponse](../../models/operations/declineinvitationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListOrganizationInvitations - -List invitations of the organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListOrganizationInvitations(ctx, operations.ListOrganizationInvitationsRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListInvitationsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListOrganizationInvitationsRequest](../../models/operations/listorganizationinvitationsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListOrganizationInvitationsResponse](../../models/operations/listorganizationinvitationsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateInvitation - -Create invitation - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateInvitation(ctx, operations.CreateInvitationRequest{ - OrganizationID: "", - Email: "Manley_Hoeger@hotmail.com", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateInvitationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreateInvitationRequest](../../models/operations/createinvitationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateInvitationResponse](../../models/operations/createinvitationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteInvitation - -Delete invitation - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteInvitation(ctx, operations.DeleteInvitationRequest{ - OrganizationID: "", - InvitationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteInvitationRequest](../../models/operations/deleteinvitationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteInvitationResponse](../../models/operations/deleteinvitationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListRegions - -List regions - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListRegions(ctx, operations.ListRegionsRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListRegionsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListRegionsRequest](../../models/operations/listregionsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListRegionsResponse](../../models/operations/listregionsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreatePrivateRegion - -Create a private region - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreatePrivateRegion(ctx, operations.CreatePrivateRegionRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.CreatedPrivateRegionResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreatePrivateRegionRequest](../../models/operations/createprivateregionrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreatePrivateRegionResponse](../../models/operations/createprivateregionresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## GetRegion - -Get region - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetRegion(ctx, operations.GetRegionRequest{ - OrganizationID: "", - RegionID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.GetRegionResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.GetRegionRequest](../../models/operations/getregionrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetRegionResponse](../../models/operations/getregionresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteRegion - -Delete region - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteRegion(ctx, operations.DeleteRegionRequest{ - OrganizationID: "", - RegionID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteRegionRequest](../../models/operations/deleteregionrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteRegionResponse](../../models/operations/deleteregionresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## GetRegionVersions - -Get region versions - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetRegionVersions(ctx, operations.GetRegionVersionsRequest{ - OrganizationID: "", - RegionID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.GetRegionVersionsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.GetRegionVersionsRequest](../../models/operations/getregionversionsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetRegionVersionsResponse](../../models/operations/getregionversionsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListOrganizationApplications - -List applications enabled for organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListOrganizationApplications(ctx, operations.ListOrganizationApplicationsRequest{ - OrganizationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.ListApplicationsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.ListOrganizationApplicationsRequest](../../models/operations/listorganizationapplicationsrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListOrganizationApplicationsResponse](../../models/operations/listorganizationapplicationsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## GetOrganizationApplication - -Get application for organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetOrganizationApplication(ctx, operations.GetOrganizationApplicationRequest{ - OrganizationID: "", - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.GetApplicationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.GetOrganizationApplicationRequest](../../models/operations/getorganizationapplicationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetOrganizationApplicationResponse](../../models/operations/getorganizationapplicationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## EnableApplicationForOrganization - -Enable application for organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.EnableApplicationForOrganization(ctx, operations.EnableApplicationForOrganizationRequest{ - OrganizationID: "", - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.EnableApplicationForOrganizationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.EnableApplicationForOrganizationRequest](../../models/operations/enableapplicationfororganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.EnableApplicationForOrganizationResponse](../../models/operations/enableapplicationfororganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DisableApplicationForOrganization - -Disable application for organization - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DisableApplicationForOrganization(ctx, operations.DisableApplicationForOrganizationRequest{ - OrganizationID: "", - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DisableApplicationForOrganizationRequest](../../models/operations/disableapplicationfororganizationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DisableApplicationForOrganizationResponse](../../models/operations/disableapplicationfororganizationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ListApplications - -List applications - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ListApplications(ctx) - if err != nil { - log.Fatal(err) - } - if res.ListApplicationsResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ListApplicationsResponse](../../models/operations/listapplicationsresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateApplication - -Create application - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateApplication(ctx, nil) - if err != nil { - log.Fatal(err) - } - if res.CreateApplicationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [components.ApplicationData](../../models/components/applicationdata.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateApplicationResponse](../../models/operations/createapplicationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## GetApplication - -Get application - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.GetApplication(ctx, operations.GetApplicationRequest{ - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.GetApplicationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.GetApplicationRequest](../../models/operations/getapplicationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.GetApplicationResponse](../../models/operations/getapplicationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## UpdateApplication - -Update application - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.UpdateApplication(ctx, operations.UpdateApplicationRequest{ - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.UpdateApplicationResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.UpdateApplicationRequest](../../models/operations/updateapplicationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.UpdateApplicationResponse](../../models/operations/updateapplicationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteApplication - -Delete application - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteApplication(ctx, operations.DeleteApplicationRequest{ - ApplicationID: "", - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteApplicationRequest](../../models/operations/deleteapplicationrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteApplicationResponse](../../models/operations/deleteapplicationresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateApplicationScope - -Create application scope - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateApplicationScope(ctx, operations.CreateApplicationScopeRequest{ - ApplicationID: "550e8400-e29b-41d4-a716-446655440000", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateApplicationScopeResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.CreateApplicationScopeRequest](../../models/operations/createapplicationscoperequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateApplicationScopeResponse](../../models/operations/createapplicationscoperesponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## DeleteApplicationScope - -Delete a specific scope from an application. This operation requires system administrator privileges. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.DeleteApplicationScope(ctx, operations.DeleteApplicationScopeRequest{ - ApplicationID: "550e8400-e29b-41d4-a716-446655440000", - ScopeID: 115177, - }) - if err != nil { - log.Fatal(err) - } - if res.Error != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [operations.DeleteApplicationScopeRequest](../../models/operations/deleteapplicationscoperequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.DeleteApplicationScopeResponse](../../models/operations/deleteapplicationscoperesponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.Error | 400, 404 | application/json | -| apierrors.Error | 500 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## CreateUser - -Create a new user in the system. This operation requires system administrator privileges. - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.CreateUser(ctx, components.CreateUserRequest{ - Email: "user@example.com", - }) - if err != nil { - log.Fatal(err) - } - if res.CreateUserResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `request` | [components.CreateUserRequest](../../models/components/createuserrequest.md) | :heavy_check_mark: | The request object to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.CreateUserResponse](../../models/operations/createuserresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.Error | 400 | application/json | -| apierrors.Error | 500 | application/json | -| apierrors.APIError | 4XX, 5XX | \*/\* | - -## ReadConnectedUser - -Read user - -### Example Usage - - -```go -package main - -import( - "context" - membershipclient "github.com/formancehq/fctl/internal/membershipclient/v3" - "log" -) - -func main() { - ctx := context.Background() - - s := membershipclient.New( - membershipclient.WithSecurity(""), - ) - - res, err := s.ReadConnectedUser(ctx) - if err != nil { - log.Fatal(err) - } - if res.ReadUserResponse != nil { - // handle response - } -} -``` - -### Parameters - -| Parameter | Type | Required | Description | -| -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -| `ctx` | [context.Context](https://pkg.go.dev/context#Context) | :heavy_check_mark: | The context to use for the request. | -| `opts` | [][operations.Option](../../models/operations/option.md) | :heavy_minus_sign: | The options for this request. | - -### Response - -**[*operations.ReadConnectedUserResponse](../../models/operations/readconnecteduserresponse.md), error** - -### Errors - -| Error Type | Status Code | Content Type | -| ------------------ | ------------------ | ------------------ | -| apierrors.APIError | 4XX, 5XX | \*/\* | \ No newline at end of file diff --git a/internal/membershipclient/go.mod b/internal/membershipclient/go.mod deleted file mode 100644 index 6d85ab89..00000000 --- a/internal/membershipclient/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module github.com/formancehq/fctl/internal/membershipclient/v3 - -go 1.22 - -require github.com/stretchr/testify v1.12.1 - -require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/internal/membershipclient/go.sum b/internal/membershipclient/go.sum deleted file mode 100644 index c2336837..00000000 --- a/internal/membershipclient/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -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= diff --git a/internal/membershipclient/internal/config/sdkconfiguration.go b/internal/membershipclient/internal/config/sdkconfiguration.go deleted file mode 100644 index f682bc12..00000000 --- a/internal/membershipclient/internal/config/sdkconfiguration.go +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package config - -import ( - "context" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "net/http" - "time" -) - -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -type SDKConfiguration struct { - Client HTTPClient - Security func(context.Context) (interface{}, error) - ServerURL string - ServerIndex int - ServerList []string - UserAgent string - RetryConfig *retry.Config - Timeout *time.Duration -} - -func (c *SDKConfiguration) GetServerDetails() (string, map[string]string) { - if c.ServerURL != "" { - return c.ServerURL, nil - } - - return c.ServerList[c.ServerIndex], nil -} diff --git a/internal/membershipclient/internal/hooks/hooks.go b/internal/membershipclient/internal/hooks/hooks.go deleted file mode 100644 index 312da6f9..00000000 --- a/internal/membershipclient/internal/hooks/hooks.go +++ /dev/null @@ -1,153 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package hooks - -import ( - "context" - "errors" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/config" - "net/http" -) - -type FailEarly struct { - Cause error -} - -var _ error = (*FailEarly)(nil) - -func (f *FailEarly) Error() string { - return f.Cause.Error() -} - -// HTTPClient provides an interface for supplying the SDK with a custom HTTP client -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -type HookContext struct { - SDK any - SDKConfiguration config.SDKConfiguration - BaseURL string - Context context.Context - OperationID string - OAuth2Scopes []string - SecuritySource func(context.Context) (interface{}, error) -} - -type BeforeRequestContext struct { - HookContext -} - -type AfterSuccessContext struct { - HookContext -} - -type AfterErrorContext struct { - HookContext -} - -// sdkInitHook is called when the SDK is initializing. The hook can modify and return a new baseURL and HTTP client to be used by the SDK. -type sdkInitHook interface { - SDKInit(config config.SDKConfiguration) config.SDKConfiguration -} - -// beforeRequestHook is called before the SDK sends a request. The hook can modify the request before it is sent or return an error to stop the request from being sent. -type beforeRequestHook interface { - BeforeRequest(hookCtx BeforeRequestContext, req *http.Request) (*http.Request, error) -} - -// afterSuccessHook is called after the SDK receives a response. The hook can modify the response before it is handled or return an error to stop the response from being handled. -type afterSuccessHook interface { - AfterSuccess(hookCtx AfterSuccessContext, res *http.Response) (*http.Response, error) -} - -// afterErrorHook is called after the SDK encounters an error, or a non-successful response. The hook can modify the response if available otherwise modify the error. -// All afterErrorHook hooks are called and returning an error won't stop the other hooks from being called. But if you want to stop the other hooks from being called, you can return a FailEarly error wrapping your error. -type afterErrorHook interface { - AfterError(hookCtx AfterErrorContext, res *http.Response, err error) (*http.Response, error) -} - -type Hooks struct { - sdkInitHooks []sdkInitHook - beforeRequestHook []beforeRequestHook - afterSuccessHook []afterSuccessHook - afterErrorHook []afterErrorHook -} - -var _ sdkInitHook = (*Hooks)(nil) -var _ beforeRequestHook = (*Hooks)(nil) -var _ afterSuccessHook = (*Hooks)(nil) -var _ afterErrorHook = (*Hooks)(nil) - -func New() *Hooks { - h := &Hooks{ - sdkInitHooks: []sdkInitHook{}, - beforeRequestHook: []beforeRequestHook{}, - afterSuccessHook: []afterSuccessHook{}, - afterErrorHook: []afterErrorHook{}, - } - - initHooks(h) - - return h -} - -// registerSDKInitHook registers a hook to be used by the SDK for the initialization event. -func (h *Hooks) registerSDKInitHook(hook sdkInitHook) { - h.sdkInitHooks = append(h.sdkInitHooks, hook) -} - -// registerBeforeRequestHook registers a hook to be used by the SDK for the before request event. -func (h *Hooks) registerBeforeRequestHook(hook beforeRequestHook) { - h.beforeRequestHook = append(h.beforeRequestHook, hook) -} - -// registerAfterSuccessHook registers a hook to be used by the SDK for the after success event. -func (h *Hooks) registerAfterSuccessHook(hook afterSuccessHook) { - h.afterSuccessHook = append(h.afterSuccessHook, hook) -} - -// registerAfterErrorHook registers a hook to be used by the SDK for the after error event. -func (h *Hooks) registerAfterErrorHook(hook afterErrorHook) { - h.afterErrorHook = append(h.afterErrorHook, hook) -} - -func (h *Hooks) SDKInit(config config.SDKConfiguration) config.SDKConfiguration { - for _, hook := range h.sdkInitHooks { - config = hook.SDKInit(config) - } - return config -} - -func (h *Hooks) BeforeRequest(hookCtx BeforeRequestContext, req *http.Request) (*http.Request, error) { - for _, hook := range h.beforeRequestHook { - var err error - req, err = hook.BeforeRequest(hookCtx, req) - if err != nil { - return req, err - } - } - return req, nil -} - -func (h *Hooks) AfterSuccess(hookCtx AfterSuccessContext, res *http.Response) (*http.Response, error) { - for _, hook := range h.afterSuccessHook { - var err error - res, err = hook.AfterSuccess(hookCtx, res) - if err != nil { - return res, err - } - } - return res, nil -} - -func (h *Hooks) AfterError(hookCtx AfterErrorContext, res *http.Response, err error) (*http.Response, error) { - for _, hook := range h.afterErrorHook { - res, err = hook.AfterError(hookCtx, res, err) - var fe *FailEarly - if errors.As(err, &fe) { - return nil, fe.Cause - } - } - return res, err -} diff --git a/internal/membershipclient/internal/hooks/oauth2scopes.go b/internal/membershipclient/internal/hooks/oauth2scopes.go deleted file mode 100644 index f7113d8c..00000000 --- a/internal/membershipclient/internal/hooks/oauth2scopes.go +++ /dev/null @@ -1,213 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package hooks - -// OAuth2Scope - Available scopes for the oauth2 OAuth 2.0 scheme (authorizationCode flow). -type OAuth2Scope string - -const ( - - // Enable OIDC - OAuth2ScopeOpenid OAuth2Scope = "openid" - - // Access to user profile information - OAuth2ScopeProfile OAuth2Scope = "profile" - - // Access to email address - OAuth2ScopeEmail OAuth2Scope = "email" - - // Access to phone number - OAuth2ScopePhone OAuth2Scope = "phone" - - // Access to postal address - OAuth2ScopeAddress OAuth2Scope = "address" - - // Allows obtaining refresh token - OAuth2ScopeOfflineAccess OAuth2Scope = "offline_access" - - // Includes organization and stack access information in tokens - OAuth2ScopeAccesses OAuth2Scope = "accesses" - - // Keep refresh token across sessions - OAuth2ScopeKeepRefreshToken OAuth2Scope = "keep_refresh_token" - - // Enable remember me functionality - OAuth2ScopeRememberMe OAuth2Scope = "remember_me" - - // Allow to provide id_token usable as id_token_hint for quick reauthentication - OAuth2ScopeOnBehalf OAuth2Scope = "on_behalf" - - // Full system administration access - OAuth2ScopeSystemAdmin OAuth2Scope = "system:Admin" - - // Read-only access to stack services - OAuth2ScopeStackRead OAuth2Scope = "stack:Read" - - // Write access to stack services - OAuth2ScopeStackWrite OAuth2Scope = "stack:Write" - - // Read organization details - OAuth2ScopeOrganizationRead OAuth2Scope = "organization:Read" - - // Create organization - OAuth2ScopeOrganizationCreate OAuth2Scope = "organization:Create" - - // Update organization details - OAuth2ScopeOrganizationUpdate OAuth2Scope = "organization:Update" - - // Delete organization - OAuth2ScopeOrganizationDelete OAuth2Scope = "organization:Delete" - - // List organization users - OAuth2ScopeOrganizationListUsers OAuth2Scope = "organization:ListUsers" - - // Read user details - OAuth2ScopeOrganizationReadUser OAuth2Scope = "organization:ReadUser" - - // Create/Invite users to organization - OAuth2ScopeOrganizationCreateUser OAuth2Scope = "organization:CreateUser" - - // Update user details - OAuth2ScopeOrganizationUpdateUser OAuth2Scope = "organization:UpdateUser" - - // Remove users from organization - OAuth2ScopeOrganizationDeleteUser OAuth2Scope = "organization:DeleteUser" - - // List organization policies - OAuth2ScopeOrganizationListPolicies OAuth2Scope = "organization:ListPolicies" - - // Read policy details - OAuth2ScopeOrganizationReadPolicy OAuth2Scope = "organization:ReadPolicy" - - // Create policies - OAuth2ScopeOrganizationCreatePolicy OAuth2Scope = "organization:CreatePolicy" - - // Update policies and manage their scopes - OAuth2ScopeOrganizationUpdatePolicy OAuth2Scope = "organization:UpdatePolicy" - - // Delete policies - OAuth2ScopeOrganizationDeletePolicy OAuth2Scope = "organization:DeletePolicy" - - // List organization invitations - OAuth2ScopeOrganizationListInvitations OAuth2Scope = "organization:ListInvitations" - - // Read invitation details - OAuth2ScopeOrganizationReadInvitation OAuth2Scope = "organization:ReadInvitation" - - // Create invitations - OAuth2ScopeOrganizationCreateInvitation OAuth2Scope = "organization:CreateInvitation" - - // Update invitations - OAuth2ScopeOrganizationUpdateInvitation OAuth2Scope = "organization:UpdateInvitation" - - // Accept invitations - OAuth2ScopeOrganizationAcceptInvitation OAuth2Scope = "organization:AcceptInvitation" - - // Reject invitations - OAuth2ScopeOrganizationRejectInvitation OAuth2Scope = "organization:RejectInvitation" - - // Delete invitations - OAuth2ScopeOrganizationDeleteInvitation OAuth2Scope = "organization:DeleteInvitation" - - // List organization regions - OAuth2ScopeOrganizationListRegions OAuth2Scope = "organization:ListRegions" - - // Read region details - OAuth2ScopeOrganizationReadRegion OAuth2Scope = "organization:ReadRegion" - - // Create regions - OAuth2ScopeOrganizationCreateRegion OAuth2Scope = "organization:CreateRegion" - - // Update regions - OAuth2ScopeOrganizationUpdateRegion OAuth2Scope = "organization:UpdateRegion" - - // Delete regions - OAuth2ScopeOrganizationDeleteRegion OAuth2Scope = "organization:DeleteRegion" - - // List organization stacks - OAuth2ScopeOrganizationListStacks OAuth2Scope = "organization:ListStacks" - - // Read stack details - OAuth2ScopeOrganizationReadStack OAuth2Scope = "organization:ReadStack" - - // Create stacks - OAuth2ScopeOrganizationCreateStack OAuth2Scope = "organization:CreateStack" - - // Update stacks - OAuth2ScopeOrganizationUpdateStack OAuth2Scope = "organization:UpdateStack" - - // Delete stacks - OAuth2ScopeOrganizationDeleteStack OAuth2Scope = "organization:DeleteStack" - - // Enable stacks - OAuth2ScopeOrganizationEnableStack OAuth2Scope = "organization:EnableStack" - - // Disable stacks - OAuth2ScopeOrganizationDisableStack OAuth2Scope = "organization:DisableStack" - - // Restore stacks - OAuth2ScopeOrganizationRestoreStack OAuth2Scope = "organization:RestoreStack" - - // Upgrade stacks - OAuth2ScopeOrganizationUpgradeStack OAuth2Scope = "organization:UpgradeStack" - - // List stack users - OAuth2ScopeOrganizationListStackUsers OAuth2Scope = "organization:ListStackUsers" - - // Read stack user details - OAuth2ScopeOrganizationReadStackUser OAuth2Scope = "organization:ReadStackUser" - - // Add users to stack - OAuth2ScopeOrganizationCreateStackUser OAuth2Scope = "organization:CreateStackUser" - - // Update stack user access - OAuth2ScopeOrganizationUpdateStackUser OAuth2Scope = "organization:UpdateStackUser" - - // Remove users from stack - OAuth2ScopeOrganizationDeleteStackUser OAuth2Scope = "organization:DeleteStackUser" - - // List stack modules - OAuth2ScopeOrganizationListStackModules OAuth2Scope = "organization:ListStackModules" - - // Enable stack modules - OAuth2ScopeOrganizationEnableStackModule OAuth2Scope = "organization:EnableStackModule" - - // Disable stack modules - OAuth2ScopeOrganizationDisableStackModule OAuth2Scope = "organization:DisableStackModule" - - // List organization clients - OAuth2ScopeOrganizationListClients OAuth2Scope = "organization:ListClients" - - // Read client details - OAuth2ScopeOrganizationReadClient OAuth2Scope = "organization:ReadClient" - - // Create clients - OAuth2ScopeOrganizationCreateClient OAuth2Scope = "organization:CreateClient" - - // Update clients - OAuth2ScopeOrganizationUpdateClient OAuth2Scope = "organization:UpdateClient" - - // Delete clients - OAuth2ScopeOrganizationDeleteClient OAuth2Scope = "organization:DeleteClient" - - // Read authentication provider configuration - OAuth2ScopeOrganizationReadAuthProvider OAuth2Scope = "organization:ReadAuthProvider" - - // Update authentication provider configuration - OAuth2ScopeOrganizationUpdateAuthProvider OAuth2Scope = "organization:UpdateAuthProvider" - - // Delete authentication provider configuration - OAuth2ScopeOrganizationDeleteAuthProvider OAuth2Scope = "organization:DeleteAuthProvider" - - // Read organization logs - OAuth2ScopeOrganizationReadLogs OAuth2Scope = "organization:ReadLogs" - - // List organization features - OAuth2ScopeOrganizationListFeatures OAuth2Scope = "organization:ListFeatures" - - // Read feature details - OAuth2ScopeOrganizationReadFeature OAuth2Scope = "organization:ReadFeature" - - // Update organization features - OAuth2ScopeOrganizationUpdateFeatures OAuth2Scope = "organization:UpdateFeatures" -) diff --git a/internal/membershipclient/internal/hooks/registration.go b/internal/membershipclient/internal/hooks/registration.go deleted file mode 100644 index fa131be8..00000000 --- a/internal/membershipclient/internal/hooks/registration.go +++ /dev/null @@ -1,18 +0,0 @@ -package hooks - -/* - * This file is only ever generated once on the first generation and then is free to be modified. - * Any hooks you wish to add should be registered in the initHooks function. Feel free to define - * your hooks in this file or in separate files in the hooks package. - * - * Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance. - */ - -func initHooks(h *Hooks) { - // exampleHook := &ExampleHook{} - - // h.registerSDKInitHook(exampleHook) - // h.registerBeforeRequestHook(exampleHook) - // h.registerAfterErrorHook(exampleHook) - // h.registerAfterSuccessHook(exampleHook) -} diff --git a/internal/membershipclient/internal/utils/contenttype.go b/internal/membershipclient/internal/utils/contenttype.go deleted file mode 100644 index f6487e01..00000000 --- a/internal/membershipclient/internal/utils/contenttype.go +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "fmt" - "mime" - "strings" -) - -func MatchContentType(contentType string, pattern string) bool { - if contentType == "" { - contentType = "application/octet-stream" - } - - if contentType == pattern || pattern == "*" || pattern == "*/*" { - return true - } - - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - - if mediaType == pattern { - return true - } - - parts := strings.Split(mediaType, "/") - if len(parts) == 2 { - if fmt.Sprintf("%s/*", parts[0]) == pattern || fmt.Sprintf("*/%s", parts[1]) == pattern { - return true - } - } - - return false -} diff --git a/internal/membershipclient/internal/utils/env.go b/internal/membershipclient/internal/utils/env.go deleted file mode 100644 index 110d464e..00000000 --- a/internal/membershipclient/internal/utils/env.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "os" -) - -// GetEnv returns the value of the environment variable named by the key or the defaultValue if the environment variable is not set. -func GetEnv(name, defaultValue string) string { - value := os.Getenv(name) - if value == "" { - return defaultValue - } - return value -} diff --git a/internal/membershipclient/internal/utils/form.go b/internal/membershipclient/internal/utils/form.go deleted file mode 100644 index c6071f6f..00000000 --- a/internal/membershipclient/internal/utils/form.go +++ /dev/null @@ -1,155 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "fmt" - "math/big" - "net/url" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/membershipclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/membershipclient/v3/types" -) - -func populateForm(paramName string, explode bool, objType reflect.Type, objValue reflect.Value, delimiter string, defaultValue *string, allowEmptyValue map[string]struct{}, getFieldName func(reflect.StructField) string) url.Values { - - formValues := url.Values{} - - if isNil(objType, objValue) { - if defaultValue != nil { - formValues.Add(paramName, *defaultValue) - } else if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - - return formValues - } - - if objType.Kind() == reflect.Pointer { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Struct: - switch objValue.Interface().(type) { - case time.Time: - formValues.Add(paramName, valToString(objValue.Interface())) - case types.Date: - formValues.Add(paramName, valToString(objValue.Interface())) - case big.Int: - formValues.Add(paramName, valToString(objValue.Interface())) - default: - var items []string - - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if valType.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - fieldName := getFieldName(fieldType) - if fieldName == "" { - continue - } - - if explode { - if valType.Kind() == reflect.Slice || valType.Kind() == reflect.Array { - for i := 0; i < valType.Len(); i++ { - formValues.Add(fieldName, valToString(valType.Index(i).Interface())) - } - } else { - formValues.Add(fieldName, valToString(valType.Interface())) - } - } else { - items = append(items, fmt.Sprintf("%s%s%s", fieldName, delimiter, valToString(valType.Interface()))) - } - } - - if len(items) > 0 { - formValues.Add(paramName, strings.Join(items, delimiter)) - } - } - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - formValues.Add(paramName, valToString(value)) - } - // If not set or explicitly null, skip adding to form - return formValues - } - - items := []string{} - - iter := objValue.MapRange() - for iter.Next() { - if explode { - formValues.Add(iter.Key().String(), valToString(iter.Value().Interface())) - } else { - items = append(items, fmt.Sprintf("%s%s%s", iter.Key().String(), delimiter, valToString(iter.Value().Interface()))) - } - } - - if len(items) > 0 { - formValues.Add(paramName, strings.Join(items, delimiter)) - } - case reflect.Slice, reflect.Array: - if objValue.Len() == 0 { - if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - } else { - values := parseDelimitedArray(explode, objValue, delimiter) - for _, v := range values { - formValues.Add(paramName, v) - } - } - default: - // For string types, use the value directly without conversion - if objType.Kind() == reflect.String { - stringValue := objValue.String() - formValues.Add(paramName, stringValue) - } else { - stringValue := valToString(objValue.Interface()) - if stringValue == "" { - if _, ok := allowEmptyValue[paramName]; ok { - formValues.Add(paramName, "") - } - } else if stringValue != "" { - formValues.Add(paramName, stringValue) - } - } - } - - return formValues -} - -func parseDelimitedArray(explode bool, objValue reflect.Value, delimiter string) []string { - values := []string{} - items := []string{} - - for i := 0; i < objValue.Len(); i++ { - if explode { - values = append(values, valToString(objValue.Index(i).Interface())) - } else { - items = append(items, valToString(objValue.Index(i).Interface())) - } - } - - if len(items) > 0 { - values = append(values, strings.Join(items, delimiter)) - } - - return values -} diff --git a/internal/membershipclient/internal/utils/headers.go b/internal/membershipclient/internal/utils/headers.go deleted file mode 100644 index d02696f9..00000000 --- a/internal/membershipclient/internal/utils/headers.go +++ /dev/null @@ -1,167 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "context" - "fmt" - "net/http" - "reflect" - "strings" - - "github.com/formancehq/fctl/internal/membershipclient/v3/optionalnullable" -) - -func PopulateHeaders(_ context.Context, req *http.Request, headers interface{}, globals interface{}) { - globalsAlreadyPopulated := populateHeaders(headers, globals, req.Header, []string{}) - if globals != nil { - _ = populateHeaders(globals, nil, req.Header, globalsAlreadyPopulated) - } -} - -func populateHeaders(headers interface{}, globals interface{}, reqHeaders http.Header, skipFields []string) []string { - headerParamsStructType, headerParamsValType := dereferencePointers(reflect.TypeOf(headers), reflect.ValueOf(headers)) - - globalsAlreadyPopulated := []string{} - - for i := 0; i < headerParamsStructType.NumField(); i++ { - fieldType := headerParamsStructType.Field(i) - valType := headerParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, headerParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - tag := parseParamTag(headerParamTagKey, fieldType, "simple", false) - if tag == nil { - continue - } - - defaultConstValue := handleDefaultConstHeaderValue(valType, fieldType.Tag) - if defaultConstValue != "" { - reqHeaders.Add(tag.ParamName, defaultConstValue) - continue - } - - value := serializeHeader(fieldType.Type, valType, tag.Explode) - if value != "" { - reqHeaders.Add(tag.ParamName, value) - } - } - - return globalsAlreadyPopulated -} - -func handleDefaultConstHeaderValue(v reflect.Value, tag reflect.StructTag) string { - constTag := tag.Get("const") - if constTag != "" { - return constTag - } - - if isNil(v.Type(), v) { - defaultTag := tag.Get("default") - if defaultTag != "" { - return defaultTag - } - } - - return "" -} - -func serializeHeader(objType reflect.Type, objValue reflect.Value, explode bool) string { - if isNil(objType, objValue) { - return "" - } - - if objType.Kind() == reflect.Pointer { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Struct: - items := []string{} - - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - tag := parseParamTag(headerParamTagKey, fieldType, "simple", false) - if tag == nil { - continue - } - - fieldName := tag.ParamName - - if fieldName == "" { - continue - } - - var value string - - defaultConstValue := handleDefaultConstHeaderValue(valType, fieldType.Tag) - if defaultConstValue != "" { - value = defaultConstValue - } else { - value = valToString(valType.Interface()) - } - - if explode { - items = append(items, fmt.Sprintf("%s=%s", fieldName, value)) - } else { - items = append(items, fieldName, value) - } - } - - return strings.Join(items, ",") - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - return valToString(value) - } - // If not set or explicitly null, return empty string - return "" - } - - items := []string{} - - iter := objValue.MapRange() - for iter.Next() { - if explode { - items = append(items, fmt.Sprintf("%s=%s", iter.Key().String(), valToString(iter.Value().Interface()))) - } else { - items = append(items, iter.Key().String(), valToString(iter.Value().Interface())) - } - } - - return strings.Join(items, ",") - case reflect.Slice, reflect.Array: - items := []string{} - - for i := 0; i < objValue.Len(); i++ { - items = append(items, valToString(objValue.Index(i).Interface())) - } - - return strings.Join(items, ",") - default: - return valToString(objValue.Interface()) - } -} diff --git a/internal/membershipclient/internal/utils/json.go b/internal/membershipclient/internal/utils/json.go deleted file mode 100644 index 2e19a1ee..00000000 --- a/internal/membershipclient/internal/utils/json.go +++ /dev/null @@ -1,743 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math/big" - "reflect" - "strconv" - "strings" - "time" - "unsafe" - - "github.com/formancehq/fctl/internal/membershipclient/v3/types" -) - -func MarshalJSON(v interface{}, tag reflect.StructTag, topLevel bool) ([]byte, error) { - // Handle nil interface early - if v == nil { - return []byte("null"), nil - } - - // Check for nil pointer before dereferencing to avoid creating invalid reflect.Value - origVal := reflect.ValueOf(v) - if origVal.Kind() == reflect.Ptr && origVal.IsNil() { - return []byte("null"), nil - } - - typ, val := dereferencePointers(reflect.TypeOf(v), origVal) - - switch { - case isModelType(typ): - // When topLevel=true, only use json.Marshal if the type has a custom MarshalJSON - // to ensure nested structs with custom tags (like integer:"string") are handled correctly - if topLevel && implementsJSONMarshaler(v) { - return json.Marshal(v) - } - - if isNil(typ, val) || !val.IsValid() { - return []byte("null"), nil - } - - out := map[string]json.RawMessage{} - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - fieldName := field.Name - - omitEmpty := false - omitZero := false - jsonTag := field.Tag.Get("json") - if jsonTag != "" { - for _, tag := range strings.Split(jsonTag, ",") { - if tag == "omitempty" { - omitEmpty = true - } else if tag == "omitzero" { - omitZero = true - } else { - fieldName = tag - } - } - } - - if (omitEmpty || omitZero) && field.Tag.Get("const") == "" { - // Both omitempty and omitzero skip zero values (including nil) - if isNil(field.Type, fieldVal) { - continue - } - - if omitZero && fieldVal.IsZero() { - continue - } - - if omitEmpty && fieldVal.Kind() != reflect.Struct && fieldVal.IsZero() { - continue - } - - if omitEmpty && isEmptyContainer(field.Type, fieldVal) { - continue - } - } - - if !field.IsExported() && field.Tag.Get("const") == "" { - continue - } - - additionalProperties := field.Tag.Get("additionalProperties") - if fieldName == "-" && additionalProperties == "" { - continue - } - - if additionalProperties == "true" { - if isNil(field.Type, fieldVal) { - continue - } - fieldVal := trueReflectValue(fieldVal) - if fieldVal.Type().Kind() != reflect.Map { - return nil, fmt.Errorf("additionalProperties must be a map") - } - - for _, key := range fieldVal.MapKeys() { - r, err := marshalValue(fieldVal.MapIndex(key).Interface(), field.Tag) - if err != nil { - return nil, err - } - - out[key.String()] = r - } - - continue - } - - var fv interface{} - - if field.IsExported() { - fv = fieldVal.Interface() - } else { - pt := reflect.New(typ).Elem() - pt.Set(val) - - pf := pt.Field(i) - - fv = reflect.NewAt(pf.Type(), unsafe.Pointer(pf.UnsafeAddr())).Elem().Interface() - } - - r, err := marshalValue(fv, field.Tag) - if err != nil { - return nil, err - } - - out[fieldName] = r - } - - return json.Marshal(out) - default: - return marshalValue(v, tag) - } -} - -func UnmarshalJSON(b []byte, v interface{}, tag reflect.StructTag, topLevel bool, requiredFields []string) error { - if reflect.TypeOf(v).Kind() != reflect.Ptr { - return errors.New("v must be a pointer") - } - - typ, val := dereferencePointers(reflect.TypeOf(v), reflect.ValueOf(v)) - - switch { - case isModelType(typ): - if bytes.Equal(b, []byte("null")) { - return json.Unmarshal(b, v) - } - // When topLevel=true, only use json.Unmarshal if the type has a custom UnmarshalJSON - // to ensure nested structs with custom tags (like integer:"string") are handled correctly - if topLevel && implementsJSONUnmarshaler(reflect.TypeOf(v)) { - return json.Unmarshal(b, v) - } - - var unmarshaled map[string]json.RawMessage - - if err := json.Unmarshal(b, &unmarshaled); err != nil { - return err - } - - missingFields := []string{} - for _, requiredField := range requiredFields { - if _, ok := unmarshaled[requiredField]; !ok { - missingFields = append(missingFields, requiredField) - } - } - if len(missingFields) > 0 { - return fmt.Errorf("missing required fields: %s", strings.Join(missingFields, ", ")) - } - - var additionalPropertiesField *reflect.StructField - var additionalPropertiesValue *reflect.Value - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - fieldName := field.Name - - jsonTag := field.Tag.Get("json") - if jsonTag != "" { - for _, tag := range strings.Split(jsonTag, ",") { - if tag != "omitempty" && tag != "omitzero" { - fieldName = tag - } - } - } - - if field.Tag.Get("additionalProperties") == "true" { - additionalPropertiesField = &field - additionalPropertiesValue = &fieldVal - continue - } - - // If we receive a value for a const field ignore it but mark it as unmarshaled - if field.Tag.Get("const") != "" { - if r, ok := unmarshaled[fieldName]; ok { - val := string(r) - - if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { - var err error - val, err = strconv.Unquote(val) - if err != nil { - return fmt.Errorf("failed to unquote const field `%s` value `%s`: %w", fieldName, val, err) - } - } - constValue := field.Tag.Get("const") - if val != constValue { - return fmt.Errorf("const field `%s` does not match expected value `%s` got `%s`", fieldName, constValue, val) - } - - delete(unmarshaled, fieldName) - } - } else if !field.IsExported() { - continue - } - - value, ok := unmarshaled[fieldName] - if !ok { - defaultTag, defaultOk := field.Tag.Lookup("default") - if defaultOk { - value = handleDefaultConstValue(defaultTag, fieldVal.Interface(), field.Tag) - ok = true - } - } else { - delete(unmarshaled, fieldName) - } - - if ok { - if err := unmarshalValue(value, fieldVal, field.Tag); err != nil { - return err - } - } - } - - keys := make([]string, 0, len(unmarshaled)) - for k := range unmarshaled { - keys = append(keys, k) - } - - if len(keys) > 0 { - if additionalPropertiesField != nil && additionalPropertiesValue != nil { - typeOfMap := additionalPropertiesField.Type - if additionalPropertiesValue.Type().Kind() == reflect.Interface { - typeOfMap = reflect.TypeOf(map[string]interface{}{}) - } else if additionalPropertiesValue.Type().Kind() != reflect.Map { - return fmt.Errorf("additionalProperties must be a map") - } - - mapValue := reflect.MakeMap(typeOfMap) - - for key, value := range unmarshaled { - val := reflect.New(typeOfMap.Elem()) - - if err := unmarshalValue(value, val, additionalPropertiesField.Tag); err != nil { - return err - } - - if val.Elem().Type().String() == typeOfMap.Elem().String() { - mapValue.SetMapIndex(reflect.ValueOf(key), val.Elem()) - } else { - mapValue.SetMapIndex(reflect.ValueOf(key), trueReflectValue(val)) - } - - } - if additionalPropertiesValue.Type().Kind() == reflect.Interface { - additionalPropertiesValue.Set(mapValue) - } else { - additionalPropertiesValue.Set(mapValue) - } - } - } - default: - return unmarshalValue(b, reflect.ValueOf(v), tag) - } - - return nil -} - -func marshalValue(v interface{}, tag reflect.StructTag) (json.RawMessage, error) { - constTag := tag.Get("const") - if constTag != "" { - return handleDefaultConstValue(constTag, v, tag), nil - } - - if isNil(reflect.TypeOf(v), reflect.ValueOf(v)) { - defaultTag, ok := tag.Lookup("default") - if ok { - return handleDefaultConstValue(defaultTag, v, tag), nil - } - - return []byte("null"), nil - } - - typ, val := dereferencePointers(reflect.TypeOf(v), reflect.ValueOf(v)) - switch typ.Kind() { - case reflect.Int64: - format := tag.Get("integer") - if format == "string" { - b := val.Interface().(int64) - return []byte(fmt.Sprintf(`"%d"`, b)), nil - } - case reflect.Float64: - format := tag.Get("number") - if format == "string" { - b := val.Interface().(float64) - return []byte(fmt.Sprintf(`"%g"`, b)), nil - } - case reflect.Map: - if isNil(typ, val) { - return []byte("null"), nil - } - - // Check if the map implements json.Marshaler (like optionalnullable.OptionalNullable[T]) - if marshaler, ok := val.Interface().(json.Marshaler); ok { - return marshaler.MarshalJSON() - } - - out := map[string]json.RawMessage{} - - for _, key := range val.MapKeys() { - itemVal := val.MapIndex(key) - - if isNil(itemVal.Type(), itemVal) { - out[key.String()] = []byte("null") - continue - } - - r, err := marshalValue(itemVal.Interface(), tag) - if err != nil { - return nil, err - } - - out[key.String()] = r - } - - return json.Marshal(out) - case reflect.Slice, reflect.Array: - if isNil(typ, val) { - return []byte("null"), nil - } - - out := []json.RawMessage{} - - for i := 0; i < val.Len(); i++ { - itemVal := val.Index(i) - - if isNil(itemVal.Type(), itemVal) { - out = append(out, []byte("null")) - continue - } - - r, err := marshalValue(itemVal.Interface(), tag) - if err != nil { - return nil, err - } - - out = append(out, r) - } - - return json.Marshal(out) - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - return []byte(fmt.Sprintf(`"%s"`, val.Interface().(time.Time).Format(time.RFC3339Nano))), nil - case reflect.TypeOf(big.Int{}): - format := tag.Get("bigint") - if format == "string" { - b := val.Interface().(big.Int) - return []byte(fmt.Sprintf(`"%s"`, (&b).String())), nil - } - default: - // For model types without custom MarshalJSON, use field processing - // to handle custom tags like integer:"string" - if isModelType(typ) && !implementsJSONMarshaler(v) { - return MarshalJSON(v, "", false) - } - } - } - - return json.Marshal(v) -} - -func implementsJSONMarshaler(v interface{}) bool { - marshalerType := reflect.TypeOf((*json.Marshaler)(nil)).Elem() - vType := reflect.TypeOf(v) - if vType.Implements(marshalerType) { - return true - } - if vType.Kind() == reflect.Ptr { - // For double pointers (e.g., **TypeA), check if the inner pointer type - // implements the interface (e.g., *TypeA) - if vType.Elem().Implements(marshalerType) { - return true - } - // Also check if pointer to element implements it - return reflect.PtrTo(vType.Elem()).Implements(marshalerType) - } - return reflect.PtrTo(vType).Implements(marshalerType) -} - -func handleDefaultConstValue(tagValue string, val interface{}, tag reflect.StructTag) json.RawMessage { - if tagValue == "null" { - return []byte("null") - } - - typ := dereferenceTypePointer(reflect.TypeOf(val)) - switch typ { - case reflect.TypeOf(time.Time{}): - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - case reflect.TypeOf(big.Int{}): - bigIntTag := tag.Get("bigint") - if bigIntTag == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(int64(0)): - format := tag.Get("integer") - if format == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(float64(0)): - format := tag.Get("number") - if format == "string" { - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - } - case reflect.TypeOf(types.Date{}): - return []byte(fmt.Sprintf(`"%s"`, tagValue)) - default: - if typ.Kind() == reflect.String { - return []byte(fmt.Sprintf("%q", tagValue)) - } - } - - return []byte(tagValue) -} - -func unmarshalValue(value json.RawMessage, v reflect.Value, tag reflect.StructTag) error { - if bytes.Equal(value, []byte("null")) { - if v.CanAddr() { - return json.Unmarshal(value, v.Addr().Interface()) - } else { - return json.Unmarshal(value, v.Interface()) - } - } - - typ := dereferenceTypePointer(v.Type()) - - switch typ.Kind() { - case reflect.Int64: - var b int64 - - format := tag.Get("integer") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var err error - b, err = strconv.ParseInt(s, 10, 64) - if err != nil { - return fmt.Errorf("failed to parse string as int64: %w", err) - } - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - } - case reflect.Float64: - var b float64 - - format := tag.Get("number") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var err error - b, err = strconv.ParseFloat(s, 64) - if err != nil { - return fmt.Errorf("failed to parse string as float64: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - } - case reflect.Map: - if bytes.Equal(value, []byte("null")) || !isComplexValueType(dereferenceTypePointer(typ.Elem())) { - if v.CanAddr() { - return json.Unmarshal(value, v.Addr().Interface()) - } else { - return json.Unmarshal(value, v.Interface()) - } - } - - var unmarshaled map[string]json.RawMessage - - if err := json.Unmarshal(value, &unmarshaled); err != nil { - return err - } - - m := reflect.MakeMap(typ) - - for k, value := range unmarshaled { - itemVal := reflect.New(typ.Elem()) - - if err := unmarshalValue(value, itemVal, tag); err != nil { - return err - } - - m.SetMapIndex(reflect.ValueOf(k), itemVal.Elem()) - } - - // Dereference pointer before setting the map value. - // v may be a pointer to a map (e.g., from reflect.ValueOf(&mapVar)). - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - v.Set(m) - return nil - case reflect.Slice, reflect.Array: - var unmarshaled []json.RawMessage - - if err := json.Unmarshal(value, &unmarshaled); err != nil { - return err - } - - arrVal := reflect.MakeSlice(typ, len(unmarshaled), len(unmarshaled)) - - for index, value := range unmarshaled { - itemVal := reflect.New(typ.Elem()) - - if err := unmarshalValue(value, itemVal, tag); err != nil { - return err - } - - arrVal.Index(index).Set(itemVal.Elem()) - } - - if v.Kind() == reflect.Pointer { - if v.IsNil() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(arrVal) - return nil - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - return fmt.Errorf("failed to parse string as time.Time: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() && v.CanSet() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(t)) - return nil - case reflect.TypeOf(big.Int{}): - var b *big.Int - - format := tag.Get("bigint") - if format == "string" { - var s string - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - var ok bool - b, ok = new(big.Int).SetString(s, 10) - if !ok { - return fmt.Errorf("failed to parse string as big.Int") - } - } else { - if err := json.Unmarshal(value, &b); err != nil { - return err - } - } - - if v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Ptr { - v = v.Elem() - } - - v.Set(reflect.ValueOf(b)) - return nil - case reflect.TypeOf(types.Date{}): - var s string - - if err := json.Unmarshal(value, &s); err != nil { - return err - } - - d, err := types.DateFromString(s) - if err != nil { - return fmt.Errorf("failed to parse string as types.Date: %w", err) - } - - if v.Kind() == reflect.Ptr { - if v.IsNil() && v.CanSet() { - v.Set(reflect.New(typ)) - } - v = v.Elem() - } - - v.Set(reflect.ValueOf(d)) - return nil - default: - // For model types without custom UnmarshalJSON, use field processing - // to handle custom tags like integer:"string" - if isModelType(typ) && !implementsJSONUnmarshaler(v.Type()) { - // If v is already a pointer, we can unmarshal directly into it - if v.Kind() == reflect.Ptr { - if v.IsNil() { - v.Set(reflect.New(v.Type().Elem())) - } - // Handle double pointers (e.g., **Struct for nullable array elements) - inner := v.Elem() - if inner.Kind() == reflect.Ptr { - if inner.IsNil() { - inner.Set(reflect.New(typ)) - } - return UnmarshalJSON(value, inner.Interface(), "", false, nil) - } - return UnmarshalJSON(value, v.Interface(), "", false, nil) - } - // For non-pointer struct values that are addressable - if v.CanAddr() { - return UnmarshalJSON(value, v.Addr().Interface(), "", false, nil) - } - // For non-addressable struct values, fall through to json.Unmarshal - } - } - } - - var val interface{} - - if v.CanAddr() { - val = v.Addr().Interface() - } else { - val = v.Interface() - } - - return json.Unmarshal(value, val) -} - -func implementsJSONUnmarshaler(typ reflect.Type) bool { - unmarshalerType := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() - if typ.Implements(unmarshalerType) { - return true - } - if typ.Kind() == reflect.Ptr { - // For double pointers (e.g., **TypeA), check if the inner pointer type - // implements the interface (e.g., *TypeA) - if typ.Elem().Implements(unmarshalerType) { - return true - } - // Also check if pointer to element implements it - return reflect.PtrTo(typ.Elem()).Implements(unmarshalerType) - } - return reflect.PtrTo(typ).Implements(unmarshalerType) -} - -func dereferencePointers(typ reflect.Type, val reflect.Value) (reflect.Type, reflect.Value) { - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - val = val.Elem() - } else { - return typ, val - } - - return dereferencePointers(typ, val) -} - -func dereferenceTypePointer(typ reflect.Type) reflect.Type { - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - } else { - return typ - } - - return dereferenceTypePointer(typ) -} - -func isComplexValueType(typ reflect.Type) bool { - switch typ.Kind() { - case reflect.Struct: - switch typ { - case reflect.TypeOf(time.Time{}): - fallthrough - case reflect.TypeOf(big.Int{}): - fallthrough - case reflect.TypeOf(types.Date{}): - return true - } - } - - return false -} - -func isModelType(typ reflect.Type) bool { - if isComplexValueType(typ) { - return false - } - - if typ.Kind() == reflect.Struct { - return true - } - - return false -} diff --git a/internal/membershipclient/internal/utils/pathparams.go b/internal/membershipclient/internal/utils/pathparams.go deleted file mode 100644 index 99d6ad44..00000000 --- a/internal/membershipclient/internal/utils/pathparams.go +++ /dev/null @@ -1,179 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "context" - "fmt" - "math/big" - "net/url" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/membershipclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/membershipclient/v3/types" -) - -func GenerateURL(_ context.Context, serverURL, path string, pathParams interface{}, globals interface{}) (string, error) { - uri := strings.TrimSuffix(serverURL, "/") + path - - parsedParameters := map[string]string{} - - globalsAlreadyPopulated, err := populateParsedParameters(pathParams, globals, parsedParameters, []string{}) - if err != nil { - return "", err - } - - if globals != nil { - _, err = populateParsedParameters(globals, nil, parsedParameters, globalsAlreadyPopulated) - if err != nil { - return "", err - } - } - - // TODO should we handle the case where there are no matching path params? - return ReplaceParameters(uri, parsedParameters), nil -} - -func populateParsedParameters(pathParams interface{}, globals interface{}, parsedParameters map[string]string, skipFields []string) ([]string, error) { - pathParamsStructType, pathParamsValType := dereferencePointers(reflect.TypeOf(pathParams), reflect.ValueOf(pathParams)) - - globalsAlreadyPopulated := []string{} - - for i := 0; i < pathParamsStructType.NumField(); i++ { - fieldType := pathParamsStructType.Field(i) - valType := pathParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - requestTag := getRequestTag(fieldType) - if requestTag != nil { - continue - } - - ppTag := parseParamTag(pathParamTagKey, fieldType, "simple", false) - if ppTag == nil { - continue - } - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, pathParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - if ppTag.Serialization != "" { - vals, err := populateSerializedParams(ppTag, fieldType.Type, valType) - if err != nil { - return nil, err - } - for k, v := range vals { - parsedParameters[k] = url.PathEscape(v) - } - } else { - // TODO: support other styles - switch ppTag.Style { - case "simple": - simpleParams := getSimplePathParams(ppTag.ParamName, fieldType.Type, valType, ppTag.Explode) - for k, v := range simpleParams { - parsedParameters[k] = v - } - } - } - } - - return globalsAlreadyPopulated, nil -} - -func getSimplePathParams(parentName string, objType reflect.Type, objValue reflect.Value, explode bool) map[string]string { - pathParams := make(map[string]string) - - if isNil(objType, objValue) { - return nil - } - - if objType.Kind() == reflect.Ptr { - objType = objType.Elem() - objValue = objValue.Elem() - } - - switch objType.Kind() { - case reflect.Array, reflect.Slice: - if objValue.Len() == 0 { - return nil - } - var ppVals []string - for i := 0; i < objValue.Len(); i++ { - ppVals = append(ppVals, valToString(objValue.Index(i).Interface())) - } - pathParams[parentName] = strings.Join(ppVals, ",") - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - pathParams[parentName] = valToString(value) - } - // If not set or explicitly null, return nil (skip parameter) - return pathParams - } - - if objValue.Len() == 0 { - return nil - } - var ppVals []string - objMap := objValue.MapRange() - for objMap.Next() { - if explode { - ppVals = append(ppVals, fmt.Sprintf("%s=%s", objMap.Key().String(), valToString(objMap.Value().Interface()))) - } else { - ppVals = append(ppVals, fmt.Sprintf("%s,%s", objMap.Key().String(), valToString(objMap.Value().Interface()))) - } - } - pathParams[parentName] = strings.Join(ppVals, ",") - case reflect.Struct: - switch objValue.Interface().(type) { - case time.Time: - pathParams[parentName] = valToString(objValue.Interface()) - case types.Date: - pathParams[parentName] = valToString(objValue.Interface()) - case big.Int: - pathParams[parentName] = valToString(objValue.Interface()) - default: - var ppVals []string - for i := 0; i < objType.NumField(); i++ { - fieldType := objType.Field(i) - valType := objValue.Field(i) - - ppTag := parseParamTag(pathParamTagKey, fieldType, "simple", explode) - if ppTag == nil { - continue - } - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - valType = valType.Elem() - } - - if explode { - ppVals = append(ppVals, fmt.Sprintf("%s=%s", ppTag.ParamName, valToString(valType.Interface()))) - } else { - ppVals = append(ppVals, fmt.Sprintf("%s,%s", ppTag.ParamName, valToString(valType.Interface()))) - } - } - pathParams[parentName] = strings.Join(ppVals, ",") - } - default: - pathParams[parentName] = valToString(objValue.Interface()) - } - - return pathParams -} diff --git a/internal/membershipclient/internal/utils/queryparams.go b/internal/membershipclient/internal/utils/queryparams.go deleted file mode 100644 index 42812c6e..00000000 --- a/internal/membershipclient/internal/utils/queryparams.go +++ /dev/null @@ -1,297 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "context" - "encoding/json" - "fmt" - "math/big" - "net/http" - "net/url" - "reflect" - "time" - - "github.com/formancehq/fctl/internal/membershipclient/v3/optionalnullable" - "github.com/formancehq/fctl/internal/membershipclient/v3/types" -) - -func PopulateQueryParams(_ context.Context, req *http.Request, queryParams interface{}, globals interface{}, allowEmptyValue map[string]struct{}) error { - // Query parameters may already be present from overriding URL - if req.URL.RawQuery != "" { - return nil - } - - values := url.Values{} - - globalsAlreadyPopulated, err := populateQueryParams(queryParams, globals, values, []string{}, allowEmptyValue) - if err != nil { - return err - } - - if globals != nil { - _, err = populateQueryParams(globals, nil, values, globalsAlreadyPopulated, allowEmptyValue) - if err != nil { - return err - } - } - - req.URL.RawQuery = values.Encode() - - return nil -} - -func populateQueryParams(queryParams interface{}, globals interface{}, values url.Values, skipFields []string, allowEmptyValue map[string]struct{}) ([]string, error) { - queryParamsVal := reflect.ValueOf(queryParams) - if queryParamsVal.Kind() == reflect.Pointer && queryParamsVal.IsNil() { - return nil, nil - } - queryParamsStructType, queryParamsValType := dereferencePointers(reflect.TypeOf(queryParams), queryParamsVal) - - globalsAlreadyPopulated := []string{} - for i := 0; i < queryParamsStructType.NumField(); i++ { - fieldType := queryParamsStructType.Field(i) - valType := queryParamsValType.Field(i) - - if contains(skipFields, fieldType.Name) { - continue - } - - requestTag := getRequestTag(fieldType) - if requestTag != nil { - continue - } - - qpTag := parseQueryParamTag(fieldType) - if qpTag == nil { - continue - } - - constValue := parseConstTag(fieldType) - if constValue != nil { - values.Add(qpTag.ParamName, *constValue) - continue - } - - defaultValue := parseDefaultTag(fieldType) - - if globals != nil { - var globalFound bool - fieldType, valType, globalFound = populateFromGlobals(fieldType, valType, queryParamTagKey, globals) - if globalFound { - globalsAlreadyPopulated = append(globalsAlreadyPopulated, fieldType.Name) - } - } - - if qpTag.Serialization != "" { - vals, err := populateSerializedParams(qpTag, fieldType.Type, valType) - if err != nil { - return nil, err - } - for k, v := range vals { - values.Add(k, v) - } - } else { - switch qpTag.Style { - case "deepObject": - vals := populateDeepObjectParams(qpTag, fieldType.Type, valType) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - case "form": - vals := populateFormParams(qpTag, fieldType.Type, valType, ",", defaultValue, allowEmptyValue) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - case "pipeDelimited": - vals := populateFormParams(qpTag, fieldType.Type, valType, "|", defaultValue, allowEmptyValue) - for k, v := range vals { - for _, vv := range v { - values.Add(k, vv) - } - } - default: - return nil, fmt.Errorf("unsupported style: %s", qpTag.Style) - } - } - } - - return globalsAlreadyPopulated, nil -} - -func populateSerializedParams(tag *paramTag, objType reflect.Type, objValue reflect.Value) (map[string]string, error) { - if isNil(objType, objValue) { - return nil, nil - } - - if objType.Kind() == reflect.Pointer { - objValue = objValue.Elem() - } - - values := map[string]string{} - - switch tag.Serialization { - case "json": - data, err := json.Marshal(objValue.Interface()) - if err != nil { - return nil, fmt.Errorf("error marshaling json: %v", err) - } - values[tag.ParamName] = string(data) - } - - return values, nil -} - -func populateDeepObjectParams(tag *paramTag, objType reflect.Type, objValue reflect.Value) url.Values { - values := url.Values{} - - if isNil(objType, objValue) { - return values - } - - if objValue.Kind() == reflect.Pointer { - objValue = objValue.Elem() - } - - switch objValue.Kind() { - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(objValue); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - values.Add(tag.ParamName, valToString(value)) - } - // If not set or explicitly null, skip adding to values - return values - } - - populateDeepObjectParamsMap(values, tag.ParamName, objValue) - case reflect.Struct: - populateDeepObjectParamsStruct(values, tag.ParamName, objValue) - } - - return values -} - -func populateDeepObjectParamsArray(qsValues url.Values, priorScope string, value reflect.Value) { - if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { - return - } - - for i := 0; i < value.Len(); i++ { - qsValues.Add(priorScope, valToString(value.Index(i).Interface())) - } -} - -func populateDeepObjectParamsMap(qsValues url.Values, priorScope string, mapValue reflect.Value) { - if mapValue.Kind() != reflect.Map { - return - } - - iter := mapValue.MapRange() - - for iter.Next() { - scope := priorScope + "[" + iter.Key().String() + "]" - iterValue := iter.Value() - - switch iterValue.Kind() { - case reflect.Array, reflect.Slice: - populateDeepObjectParamsArray(qsValues, scope, iterValue) - case reflect.Map: - populateDeepObjectParamsMap(qsValues, scope, iterValue) - default: - qsValues.Add(scope, valToString(iterValue.Interface())) - } - } -} - -func populateDeepObjectParamsStruct(qsValues url.Values, priorScope string, structValue reflect.Value) { - if structValue.Kind() != reflect.Struct { - return - } - - structType := structValue.Type() - - for i := 0; i < structType.NumField(); i++ { - field := structType.Field(i) - fieldValue := structValue.Field(i) - - if isNil(field.Type, fieldValue) { - continue - } - - if fieldValue.Kind() == reflect.Pointer { - fieldValue = fieldValue.Elem() - } - - qpTag := parseQueryParamTag(field) - - if qpTag == nil { - continue - } - - scope := priorScope - - if !qpTag.Inline { - scope = priorScope + "[" + qpTag.ParamName + "]" - } - - switch fieldValue.Kind() { - case reflect.Array, reflect.Slice: - populateDeepObjectParamsArray(qsValues, scope, fieldValue) - case reflect.Map: - populateDeepObjectParamsMap(qsValues, scope, fieldValue) - case reflect.Struct: - switch fieldValue.Type() { - case reflect.TypeOf(big.Int{}), reflect.TypeOf(time.Time{}), reflect.TypeOf(types.Date{}): - qsValues.Add(scope, valToString(fieldValue.Interface())) - - continue - } - - populateDeepObjectParamsStruct(qsValues, scope, fieldValue) - default: - qsValues.Add(scope, valToString(fieldValue.Interface())) - } - } -} - -func populateFormParams(tag *paramTag, objType reflect.Type, objValue reflect.Value, delimiter string, defaultValue *string, allowEmptyValue map[string]struct{}) url.Values { - return populateForm(tag.ParamName, tag.Explode, objType, objValue, delimiter, defaultValue, allowEmptyValue, func(fieldType reflect.StructField) string { - qpTag := parseQueryParamTag(fieldType) - if qpTag == nil { - return "" - } - - // When inline is true, use the parent's param name instead of the field's own name. - // This allows union/oneOf wrapper types to serialize their values directly under - // the parent's query parameter name. - if qpTag.Inline { - return tag.ParamName - } - - return qpTag.ParamName - }) -} - -type paramTag struct { - Style string - Explode bool - ParamName string - Serialization string - - // Inline is a special case for union/oneOf. When a wrapper struct type is - // used, each union/oneOf value field should be inlined (e.g. not appended - // in deepObject style with the name) as if the value was directly on the - // parent struct field. Without this annotation, the value would not be - // encoded by downstream logic that requires the struct field tag. - Inline bool -} - -func parseQueryParamTag(field reflect.StructField) *paramTag { - return parseParamTag(queryParamTagKey, field, "form", true) -} diff --git a/internal/membershipclient/internal/utils/requestbody.go b/internal/membershipclient/internal/utils/requestbody.go deleted file mode 100644 index e6eee43c..00000000 --- a/internal/membershipclient/internal/utils/requestbody.go +++ /dev/null @@ -1,460 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "bytes" - "context" - "fmt" - "io" - "mime" - "mime/multipart" - "net/textproto" - "net/url" - "path/filepath" - "reflect" - "regexp" - - "github.com/formancehq/fctl/internal/membershipclient/v3/optionalnullable" -) - -const ( - requestTagKey = "request" - multipartFormTagKey = "multipartForm" - formTagKey = "form" -) - -var ( - jsonEncodingRegex = regexp.MustCompile(`^(application|text)\/([^+]+\+)*json.*`) - multipartEncodingRegex = regexp.MustCompile(`^multipart\/.*`) - urlEncodedEncodingRegex = regexp.MustCompile(`^application\/x-www-form-urlencoded.*`) -) - -func SerializeRequestBody(_ context.Context, request interface{}, nullable, optional bool, requestFieldName, serializationMethod, tag string) (io.Reader, string, error) { - bodyReader, contentType, err := serializeRequestBody(request, nullable, optional, requestFieldName, serializationMethod, tag) - if err != nil { - return nil, "", fmt.Errorf("error serializing request body: %w", err) - } - - if bodyReader == nil && !optional { - return nil, "", fmt.Errorf("request body is required") - } - - return bodyReader, contentType, nil -} - -func serializeRequestBody(request interface{}, nullable, optional bool, requestFieldName, serializationMethod, tag string) (io.Reader, string, error) { - requestStructType := reflect.TypeOf(request) - requestValType := reflect.ValueOf(request) - - if isNil(requestStructType, requestValType) { - if !nullable && optional { - return nil, "", nil - } - - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], requestValType, tag) - } - - if requestStructType.Kind() == reflect.Pointer { - requestStructType = requestStructType.Elem() - requestValType = requestValType.Elem() - } - - if requestStructType.Kind() != reflect.Struct { - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], requestValType, tag) - } - - requestField, ok := requestStructType.FieldByName(requestFieldName) - - if ok { - tag := getRequestTag(requestField) - if tag != nil { - // request object (non-flattened) - requestVal := requestValType.FieldByName(requestFieldName) - val := reflect.ValueOf(requestVal.Interface()) - if isNil(requestField.Type, requestVal) { - if !nullable && optional { - return nil, "", nil - } - - return serializeContentType(requestFieldName, tag.MediaType, val, string(requestField.Tag)) - } - - return serializeContentType(requestFieldName, tag.MediaType, val, string(requestField.Tag)) - } - } - - // flattened request object - return serializeContentType(requestFieldName, SerializationMethodToContentType[serializationMethod], reflect.ValueOf(request), tag) -} - -func serializeContentType(fieldName string, mediaType string, val reflect.Value, tag string) (io.Reader, string, error) { - buf := &bytes.Buffer{} - - if isNil(val.Type(), val) { - // TODO: what does a null mean for other content types? Just returning an empty buffer for now - if jsonEncodingRegex.MatchString(mediaType) { - if _, err := buf.Write([]byte("null")); err != nil { - return nil, "", err - } - } - - return buf, mediaType, nil - } - - switch { - case jsonEncodingRegex.MatchString(mediaType): - data, err := MarshalJSON(val.Interface(), reflect.StructTag(tag), true) - if err != nil { - return nil, "", err - } - - if _, err := buf.Write(data); err != nil { - return nil, "", err - } - case multipartEncodingRegex.MatchString(mediaType): - var err error - mediaType, err = encodeMultipartFormData(buf, val.Interface()) - if err != nil { - return nil, "", err - } - case urlEncodedEncodingRegex.MatchString(mediaType): - if err := encodeFormData(fieldName, buf, val.Interface()); err != nil { - return nil, "", err - } - case val.Type().Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()): - return val.Interface().(io.Reader), mediaType, nil - default: - val = reflect.Indirect(val) - - switch { - case val.Type().Kind() == reflect.String: - if _, err := buf.WriteString(valToString(val.Interface())); err != nil { - return nil, "", err - } - case reflect.TypeOf(val.Interface()) == reflect.TypeOf([]byte(nil)): - if _, err := buf.Write(val.Interface().([]byte)); err != nil { - return nil, "", err - } - default: - return nil, "", fmt.Errorf("invalid request body type %s for mediaType %s", val.Type(), mediaType) - } - } - - return buf, mediaType, nil -} - -func encodeMultipartFormData(w io.Writer, data interface{}) (string, error) { - requestStructType := reflect.TypeOf(data) - requestValType := reflect.ValueOf(data) - - if requestStructType.Kind() == reflect.Pointer { - requestStructType = requestStructType.Elem() - requestValType = requestValType.Elem() - } - - writer := multipart.NewWriter(w) - - for i := 0; i < requestStructType.NumField(); i++ { - field := requestStructType.Field(i) - fieldType := field.Type - valType := requestValType.Field(i) - - if isNil(fieldType, valType) { - continue - } - - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - valType = valType.Elem() - } - - tag := parseMultipartFormTag(field) - if tag.File { - switch fieldType.Kind() { - case reflect.Slice, reflect.Array: - for i := 0; i < valType.Len(); i++ { - arrayVal := valType.Index(i) - - if err := encodeMultipartFormDataFile(writer, tag.Name, arrayVal.Type(), arrayVal); err != nil { - writer.Close() - return "", err - } - } - default: - if err := encodeMultipartFormDataFile(writer, tag.Name, fieldType, valType); err != nil { - writer.Close() - return "", err - } - } - } else if tag.JSON { - jw, err := writer.CreateFormField(tag.Name) - if err != nil { - writer.Close() - return "", err - } - d, err := MarshalJSON(valType.Interface(), field.Tag, true) - if err != nil { - writer.Close() - return "", err - } - if _, err := jw.Write(d); err != nil { - writer.Close() - return "", err - } - } else { - switch fieldType.Kind() { - case reflect.Slice, reflect.Array: - values := parseDelimitedArray(true, valType, ",") - for _, v := range values { - if err := writer.WriteField(tag.Name, v); err != nil { - writer.Close() - return "", err - } - } - default: - if err := writer.WriteField(tag.Name, valToString(valType.Interface())); err != nil { - writer.Close() - return "", err - } - } - } - } - - if err := writer.Close(); err != nil { - return "", err - } - - return writer.FormDataContentType(), nil -} - -func encodeMultipartFormDataFile(w *multipart.Writer, fieldName string, fieldType reflect.Type, valType reflect.Value) error { - if fieldType.Kind() != reflect.Struct { - return fmt.Errorf("invalid type %s for multipart/form-data file", valType.Type()) - } - - var fileName string - var reader io.Reader - - for i := 0; i < fieldType.NumField(); i++ { - field := fieldType.Field(i) - val := valType.Field(i) - - tag := parseMultipartFormTag(field) - if !tag.Content && tag.Name == "" { - continue - } - - if tag.Content && val.CanInterface() { - if reflect.TypeOf(val.Interface()) == reflect.TypeOf([]byte(nil)) { - reader = bytes.NewReader(val.Interface().([]byte)) - } else if reflect.TypeOf(val.Interface()).Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { - reader = val.Interface().(io.Reader) - } - } else { - fileName = val.String() - } - } - - if fileName == "" || reader == nil { - return fmt.Errorf("invalid multipart/form-data file") - } - - // Detect content type based on file extension - contentType := mime.TypeByExtension(filepath.Ext(fileName)) - if contentType == "" { - contentType = "application/octet-stream" - } - - // Create multipart header with proper content type - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileName)) - h.Set("Content-Type", contentType) - - fw, err := w.CreatePart(h) - if err != nil { - return err - } - if _, err := io.Copy(fw, reader); err != nil { - return err - } - - // Reset seek position to 0 if the reader supports seeking - if seeker, ok := reader.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - return err - } - } - - return nil -} - -func encodeFormData(fieldName string, w io.Writer, data interface{}) error { - requestType := reflect.TypeOf(data) - requestValType := reflect.ValueOf(data) - - if requestType.Kind() == reflect.Pointer { - requestType = requestType.Elem() - requestValType = requestValType.Elem() - } - - dataValues := url.Values{} - - switch requestType.Kind() { - case reflect.Struct: - for i := 0; i < requestType.NumField(); i++ { - field := requestType.Field(i) - fieldType := field.Type - valType := requestValType.Field(i) - - if isNil(fieldType, valType) { - continue - } - - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - valType = valType.Elem() - } - - tag := parseFormTag(field) - if tag.JSON { - data, err := MarshalJSON(valType.Interface(), field.Tag, true) - if err != nil { - return err - } - dataValues.Set(tag.Name, string(data)) - } else { - switch tag.Style { - // TODO: support other styles - case "form": - values := populateForm(tag.Name, tag.Explode, fieldType, valType, ",", nil, nil, func(sf reflect.StructField) string { - tag := parseFormTag(field) - if tag == nil { - return "" - } - - return tag.Name - }) - for k, v := range values { - for _, vv := range v { - dataValues.Add(k, vv) - } - } - } - } - } - case reflect.Map: - // check if optionalnullable.OptionalNullable[T] - if nullableValue, ok := optionalnullable.AsOptionalNullable(requestValType); ok { - // Handle optionalnullable.OptionalNullable[T] using GetUntyped method - if value, isSet := nullableValue.GetUntyped(); isSet && value != nil { - dataValues.Set(fieldName, valToString(value)) - } - // If not set or explicitly null, skip adding to form - break - } - - // Handle regular map - for _, k := range requestValType.MapKeys() { - v := requestValType.MapIndex(k) - dataValues.Set(fmt.Sprintf("%v", k.Interface()), valToString(v.Interface())) - } - case reflect.Slice, reflect.Array: - for i := 0; i < requestValType.Len(); i++ { - v := requestValType.Index(i) - dataValues.Set(fieldName, valToString(v.Interface())) - } - } - - if _, err := w.Write([]byte(dataValues.Encode())); err != nil { - return err - } - - return nil -} - -type requestTag struct { - MediaType string -} - -func getRequestTag(field reflect.StructField) *requestTag { - // example `request:"mediaType=multipart/form-data"` - values := parseStructTag(requestTagKey, field) - if values == nil { - return nil - } - - tag := &requestTag{ - MediaType: "application/octet-stream", - } - - for k, v := range values { - switch k { - case "mediaType": - tag.MediaType = v - } - } - - return tag -} - -type multipartFormTag struct { - File bool - Content bool - JSON bool - Name string -} - -func parseMultipartFormTag(field reflect.StructField) *multipartFormTag { - // example `multipartForm:"name=file"` - values := parseStructTag(multipartFormTagKey, field) - - tag := &multipartFormTag{} - - for k, v := range values { - switch k { - case "file": - tag.File = v == "true" - case "content": - tag.Content = v == "true" - case "name": - tag.Name = v - case "json": - tag.JSON = v == "true" - } - } - - return tag -} - -type formTag struct { - Name string - JSON bool - Style string - Explode bool -} - -func parseFormTag(field reflect.StructField) *formTag { - // example `form:"name=propName,style=spaceDelimited,explode"` - values := parseStructTag(formTagKey, field) - - tag := &formTag{ - Style: "form", - Explode: true, - } - - for k, v := range values { - switch k { - case "name": - tag.Name = v - case "json": - tag.JSON = v == "true" - case "style": - tag.Style = v - case "explode": - tag.Explode = v == "true" - } - } - - return tag -} diff --git a/internal/membershipclient/internal/utils/retries.go b/internal/membershipclient/internal/utils/retries.go deleted file mode 100644 index 8a587994..00000000 --- a/internal/membershipclient/internal/utils/retries.go +++ /dev/null @@ -1,271 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "context" - "errors" - "fmt" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "io" - "math" - "math/rand" - "net" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - "syscall" - "time" -) - -// Deprecated: Use retry.BackoffStrategy instead. -type BackoffStrategy = retry.BackoffStrategy - -// Deprecated: Use retry.Config instead. -type RetryConfig = retry.Config - -type Retries struct { - Config *retry.Config - StatusCodes []string -} - -var ( - // IETF RFC 7231 4.2 safe and idempotent HTTP methods for connection retries - idempotentHTTPMethods = []string{ - http.MethodDelete, - http.MethodGet, - http.MethodHead, - http.MethodOptions, - http.MethodPut, - } -) - -func Retry(ctx context.Context, r Retries, operation func() (*http.Response, error)) (*http.Response, error) { - switch r.Config.Strategy { - case "backoff": - if r.Config.Backoff == nil { - return operation() - } - - var resp *http.Response - - err := retryWithBackoff(ctx, r.Config.Backoff, func() error { - if resp != nil { - resp.Body.Close() - } - - select { - case <-ctx.Done(): - return retry.Permanent(ctx.Err()) - default: - } - - res, err := operation() - if err != nil { - if !r.Config.RetryConnectionErrors { - return retry.Permanent(err) - } - - var httpMethod string - - // Use http.Request method if available - if res != nil && res.Request != nil { - httpMethod = res.Request.Method - } - - isIdempotentHTTPMethod := slices.Contains(idempotentHTTPMethods, httpMethod) - urlError := new(url.Error) - - if errors.As(err, &urlError) { - if urlError.Temporary() || urlError.Timeout() { - return err - } - - // In certain error cases, the http.Request may not have - // been populated, so use url.Error.Op which only has its - // first character capitalized from the original request - // HTTP method. - if httpMethod == "" { - httpMethod = strings.ToUpper(urlError.Op) - } - - isIdempotentHTTPMethod = slices.Contains(idempotentHTTPMethods, httpMethod) - - // Connection closed - if errors.Is(urlError.Err, io.EOF) && isIdempotentHTTPMethod { - return err - } - } - - var networkOperationError *net.OpError - isBrokenPipeOrConnectionReset := errors.As(err, &networkOperationError) && - (errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET)) - - if isBrokenPipeOrConnectionReset && isIdempotentHTTPMethod { - return err - } - - return retry.Permanent(err) - } - resp = res - if res == nil { - return fmt.Errorf("no response") - } - - for _, code := range r.StatusCodes { - if strings.Contains(strings.ToUpper(code), "X") { - codeRange, err := strconv.Atoi(code[:1]) - if err != nil { - continue - } - - s := res.StatusCode / 100 - - if s >= codeRange && s < codeRange+1 { - return retry.TemporaryFromResponse("request failed", res) - } - } else { - parsedCode, err := strconv.Atoi(code) - if err != nil { - continue - } - - if res.StatusCode == parsedCode { - return retry.TemporaryFromResponse("request failed", res) - } - } - } - - resp = res - - return nil - }) - - var tempErr *retry.TemporaryError - if err != nil && !errors.As(err, &tempErr) { - return nil, err - } - - return resp, nil - default: - return operation() - } -} - -func retryWithBackoff(ctx context.Context, s *retry.BackoffStrategy, operation func() error) error { - var ( - err error - next time.Duration - attempt int - start = time.Now() - maxElapsedTime = time.Duration(s.MaxElapsedTime) * time.Millisecond - ) - - timer := &defaultTimer{} - defer func() { - timer.Stop() - }() - - for { - err = operation() - if err == nil { - return nil - } - - var permanent *retry.PermanentError - if errors.As(err, &permanent) { - return permanent.Unwrap() - } - - if time.Since(start) >= maxElapsedTime { - return err - } - - var temporary *retry.TemporaryError - if errors.As(err, &temporary) { - next = temporary.RetryAfter() - } - - if next <= 0 { - next = nextInterval(s, attempt) - } - - timer.Start(next) - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C(): - } - - attempt += 1 - } -} - -type Timer interface { - Start(duration time.Duration) - Stop() - C() <-chan time.Time -} - -// defaultTimer implements Timer interface using time.Timer -type defaultTimer struct { - timer *time.Timer -} - -// C returns the timers channel which receives the current time when the timer fires. -func (t *defaultTimer) C() <-chan time.Time { - return t.timer.C -} - -// Start starts the timer to fire after the given duration -func (t *defaultTimer) Start(duration time.Duration) { - if t.timer == nil { - t.timer = time.NewTimer(duration) - return - } - - if !t.timer.Stop() { - select { - case <-t.timer.C: - default: - } - } - - t.timer.Reset(duration) -} - -// Stop is called when the timer is not used anymore and resources may be freed. -func (t *defaultTimer) Stop() { - if t.timer != nil { - t.timer.Stop() - } -} - -func nextInterval(s *retry.BackoffStrategy, attempt int) time.Duration { - initialInterval := float64(time.Duration(s.InitialInterval) * time.Millisecond) - maxInterval := float64(time.Duration(s.MaxInterval) * time.Millisecond) - exponent := s.Exponent - jitterFactor := float64(0.25) - - interval := initialInterval * math.Pow(float64(attempt+1), exponent) - - jitter := rand.Float64() * jitterFactor * interval - if rand.Float64() < 0.5 { - jitter = -1 * jitter - } - - interval = interval + jitter - - if interval <= 0 { - interval = initialInterval - } - - if interval > maxInterval { - interval = maxInterval - } - - return time.Duration(interval) -} diff --git a/internal/membershipclient/internal/utils/security.go b/internal/membershipclient/internal/utils/security.go deleted file mode 100644 index 77566aa2..00000000 --- a/internal/membershipclient/internal/utils/security.go +++ /dev/null @@ -1,287 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "context" - "encoding/base64" - "fmt" - "net/http" - "reflect" - "strings" -) - -const ( - securityTagKey = "security" -) - -type securityTag struct { - Option bool - Scheme bool - Name string - Type string - SubType string - Env string -} - -func PopulateSecurity(ctx context.Context, req *http.Request, securitySource func(context.Context) (interface{}, error)) error { - if securitySource == nil { - return nil - } - - security, err := securitySource(ctx) - if err != nil { - return err - } - - headers := make(map[string]string) - queryParams := make(map[string]string) - - securityValType := trueReflectValue(reflect.ValueOf(security)) - securityStructType := securityValType.Type() - - if isNil(securityStructType, securityValType) { - return nil - } - - if securityStructType.Kind() == reflect.Ptr { - securityStructType = securityStructType.Elem() - securityValType = securityValType.Elem() - } - - for i := 0; i < securityStructType.NumField(); i++ { - fieldType := securityStructType.Field(i) - valType := securityValType.Field(i) - - kind := valType.Kind() - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Pointer { - kind = valType.Elem().Kind() - } - - secTag := parseSecurityTag(fieldType) - if secTag != nil { - if secTag.Option { - handleSecurityOption(headers, queryParams, valType.Interface()) - } else if secTag.Scheme { - // Special case for basic auth which could be a flattened struct - if secTag.SubType == "basic" && kind != reflect.Struct { - parseSecurityScheme(headers, queryParams, secTag, security) - } else { - parseSecurityScheme(headers, queryParams, secTag, valType.Interface()) - } - } - } - } - - for key, value := range headers { - req.Header.Add(key, value) - } - - query := req.URL.Query() - for key, value := range queryParams { - query.Add(key, value) - } - req.URL.RawQuery = query.Encode() - - return nil -} - -func handleSecurityOption(headers, queryParams map[string]string, option interface{}) { - optionValType := trueReflectValue(reflect.ValueOf(option)) - optionStructType := optionValType.Type() - - if isNil(optionStructType, optionValType) { - return - } - - for i := 0; i < optionStructType.NumField(); i++ { - fieldType := optionStructType.Field(i) - valType := optionValType.Field(i) - - secTag := parseSecurityTag(fieldType) - if secTag != nil && secTag.Scheme { - parseSecurityScheme(headers, queryParams, secTag, valType.Interface()) - } - } -} - -func parseSecurityScheme(headers, queryParams map[string]string, schemeTag *securityTag, scheme interface{}) { - schemeVal := trueReflectValue(reflect.ValueOf(scheme)) - schemeType := schemeVal.Type() - - if isNil(schemeType, schemeVal) { - return - } - - if schemeType.Kind() == reflect.Struct { - if schemeTag.Type == "http" { - switch schemeTag.SubType { - case "basic": - handleBasicAuthScheme(headers, schemeVal.Interface()) - return - case "custom": - return - } - } - - for i := 0; i < schemeType.NumField(); i++ { - fieldType := schemeType.Field(i) - valType := schemeVal.Field(i) - - if isNil(fieldType.Type, valType) { - continue - } - - if fieldType.Type.Kind() == reflect.Ptr { - valType = valType.Elem() - } - - secTag := parseSecurityTag(fieldType) - if secTag == nil || secTag.Name == "" { - return - } - - parseSecuritySchemeValue(headers, queryParams, schemeTag, secTag, valType.Interface()) - } - } else { - parseSecuritySchemeValue(headers, queryParams, schemeTag, schemeTag, schemeVal.Interface()) - } -} - -func parseSecuritySchemeValue(headers, queryParams map[string]string, schemeTag *securityTag, secTag *securityTag, val interface{}) { - switch schemeTag.Type { - case "apiKey": - switch schemeTag.SubType { - case "header": - headers[secTag.Name] = valToString(val) - case "query": - queryParams[secTag.Name] = valToString(val) - case "cookie": - headers["Cookie"] = fmt.Sprintf("%s=%s", secTag.Name, valToString(val)) - default: - panic("not supported") - } - case "openIdConnect": - headers[secTag.Name] = prefixBearer(valToString(val)) - case "oauth2": - if schemeTag.SubType != "client_credentials" { - headers[secTag.Name] = prefixBearer(valToString(val)) - } - case "http": - switch schemeTag.SubType { - case "bearer": - headers[secTag.Name] = prefixBearer(valToString(val)) - case "basic": - headers[secTag.Name] = valToString(val) - case "custom": - default: - panic("not supported") - } - default: - panic("not supported") - } -} - -func prefixBearer(authHeaderValue string) string { - if strings.HasPrefix(strings.ToLower(authHeaderValue), "bearer ") { - return authHeaderValue - } - - return fmt.Sprintf("Bearer %s", authHeaderValue) -} - -func handleBasicAuthScheme(headers map[string]string, scheme interface{}) { - schemeStructType := reflect.TypeOf(scheme) - schemeValType := reflect.ValueOf(scheme) - - var username, password string - - for i := 0; i < schemeStructType.NumField(); i++ { - fieldType := schemeStructType.Field(i) - valType := schemeValType.Field(i) - - if fieldType.Type.Kind() == reflect.Ptr { - valType = valType.Elem() - } - - secTag := parseSecurityTag(fieldType) - if secTag == nil || secTag.Name == "" { - continue - } - - switch secTag.Name { - case "username": - username = valType.String() - case "password": - password = valType.String() - } - } - - headers["Authorization"] = fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))) -} - -func parseSecurityTag(field reflect.StructField) *securityTag { - tag := field.Tag.Get(securityTagKey) - if tag == "" { - return nil - } - - option := false - scheme := false - name := "" - securityType := "" - securitySubType := "" - env := "" - - options := strings.Split(tag, ",") - for _, optionConf := range options { - parts := strings.Split(optionConf, "=") - if len(parts) < 1 || len(parts) > 2 { - continue - } - - switch parts[0] { - case "name": - name = parts[1] - case "type": - securityType = parts[1] - case "subtype": - securitySubType = parts[1] - case "option": - option = true - case "scheme": - scheme = true - case "env": - env = parts[1] - } - } - - // TODO: validate tag? - - return &securityTag{ - Option: option, - Scheme: scheme, - Name: name, - Type: securityType, - SubType: securitySubType, - Env: env, - } -} - -func trueReflectValue(val reflect.Value) reflect.Value { - kind := val.Type().Kind() - for kind == reflect.Interface || kind == reflect.Ptr { - innerVal := val.Elem() - if !innerVal.IsValid() { - break - } - val = innerVal - kind = val.Type().Kind() - } - return val -} diff --git a/internal/membershipclient/internal/utils/union.go b/internal/membershipclient/internal/utils/union.go deleted file mode 100644 index eb89df32..00000000 --- a/internal/membershipclient/internal/utils/union.go +++ /dev/null @@ -1,289 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "encoding/json" - "math/big" - "reflect" - "strings" - "time" - - "github.com/formancehq/fctl/internal/membershipclient/v3/types" -) - -// UnionCandidate represents a candidate type during union deserialization -type UnionCandidate struct { - Matched int // Count of matched fields (includes inexact) - Inexact int // Count of fields with unknown/unrecognized enum values - Unmatched int // Count of struct fields not found in raw JSON - AdditionalProperties int // Count of fields captured by additionalProperties - Type any // The union type enum value - Value any // The unmarshaled value -} - -// PickBestUnionCandidate selects the best union type candidate according to `betterCandidate` -func PickBestUnionCandidate(candidates []UnionCandidate, rawJSON []byte) *UnionCandidate { - if len(candidates) == 0 { - return nil - } - - if len(candidates) == 1 { - return &candidates[0] - } - - var raw any - _ = json.Unmarshal(rawJSON, &raw) - - var best *UnionCandidate - for i := range candidates { - countFields(&candidates[i], raw) - best = betterCandidate(best, &candidates[i]) - } - return best -} - -// betterCandidate returns the better of two candidates based on: -// 1. Matched count (higher is better) -// 2. Inexact count (lower is better) -// 3. Unmatched count (lower is better - fewer zero defaulted values) -// 4. AdditionalProperties count (higher is better - captures more extra fields) -// Returns a if tied (preserving spec order). -func betterCandidate(a, b *UnionCandidate) *UnionCandidate { - if a == nil { - return b - } - if b == nil { - return a - } - if a.Matched != b.Matched { - if a.Matched > b.Matched { - return a - } - return b - } - if a.Inexact != b.Inexact { - if a.Inexact > b.Inexact { - return b - } - return a - } - if a.Unmatched != b.Unmatched { - if a.Unmatched > b.Unmatched { - return b - } - return a - } - if a.AdditionalProperties != b.AdditionalProperties { - if a.AdditionalProperties > b.AdditionalProperties { - return a - } - return b - } - return a -} - -// countFields populates UnionCandidate.Matched, UnionCandidate.Inexact, and UnionCandidate.Unmatched fields -func countFields(candidate *UnionCandidate, raw any) { - typ := reflect.TypeOf(candidate.Value) - val := reflect.ValueOf(candidate.Value) - countFieldsRecursive(candidate, typ, val, raw) -} - -func countFieldsRecursive(candidate *UnionCandidate, typ reflect.Type, val reflect.Value, raw any) { - kind := typ.Kind() - - // Handle interface{}/any types - can hold any JSON value - if kind == reflect.Interface { - candidate.Matched++ - return - } - - if typ.Kind() == reflect.Ptr { - if raw == nil { - // Handle null JSON value match - candidate.Matched++ - return - } - typ, val = dereferencePointers(typ, val) - kind = typ.Kind() - } - - // Handle primitives - if kind == reflect.String || - kind == reflect.Bool || - kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64 || - kind == reflect.Uint || kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 || kind == reflect.Uint64 || - kind == reflect.Float32 || kind == reflect.Float64 || - typ == reflect.TypeOf(time.Time{}) || - typ == reflect.TypeOf(big.Int{}) || - typ == reflect.TypeOf(types.Date{}) || - typ == reflect.TypeOf([]byte{}) { - candidate.Matched++ - if !isExact(val) || raw == nil { - candidate.Inexact++ - } - return - } - - // Handle unions - if isUnion, activeVariant, variantVal := findActiveUnionVariant(typ, val); isUnion { - if activeVariant != nil && !variantVal.IsNil() { - countFieldsRecursive(candidate, activeVariant.Type.Elem(), variantVal.Elem(), raw) - } - return - } - - // Handle regular structs - if kind == reflect.Struct { - rawObj, ok := raw.(map[string]any) - if !ok { - return - } - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - fieldVal := val.Field(i) - - if field.Tag.Get("additionalProperties") == "true" { - if field.Type.Kind() == reflect.Map && !fieldVal.IsNil() { - candidate.AdditionalProperties += fieldVal.Len() - } - continue - } - - jsonName, ok := jsonFieldName(field) - if !ok { - continue - } - - rawField, exists := rawObj[jsonName] - if !exists { - candidate.Unmatched++ - continue - } - - countFieldsRecursive(candidate, field.Type, fieldVal, rawField) - } - return - } - - // Handle slices and arrays - if kind == reflect.Slice || kind == reflect.Array { - if val.IsNil() || val.Len() == 0 { - return - } - - rawArr, ok := raw.([]any) - if !ok { - return - } - - // Count each array/slice element - for i := 0; i < val.Len() && i < len(rawArr); i++ { - itemVal := val.Index(i) - countFieldsRecursive(candidate, itemVal.Type(), itemVal, rawArr[i]) - } - return - } - - // Handle maps - if kind == reflect.Map { - if val.IsNil() || val.Len() == 0 { - return - } - - rawObj, ok := raw.(map[string]any) - if !ok { - return - } - - for _, key := range val.MapKeys() { - keyStr := key.String() - rawVal, exists := rawObj[keyStr] - if exists { - mapVal := val.MapIndex(key) - countFieldsRecursive(candidate, mapVal.Type(), mapVal, rawVal) - } - } - return - } - // Anything else - candidate.Matched++ - return -} - -// jsonFieldName returns the JSON field name for a struct field. -// Returns ("", false) if the field should be skipped (json:"-"). -func jsonFieldName(field reflect.StructField) (string, bool) { - jsonTag := field.Tag.Get("json") - if jsonTag == "-" { - return "", false - } - if jsonTag != "" { - parts := strings.Split(jsonTag, ",") - if parts[0] != "" { - return parts[0], true - } - } - return field.Name, true -} - -// findActiveUnionVariant detects if a struct is a union type and returns the active variant. -// A union type is detected by having fields with the `union:"member"` tag. -// Returns (false, nil, invalid) if not a union type. -// Returns (true, nil, invalid) if union type but no active variant found (all nil). -// Returns (true, field, value) if union type with an active variant. -func findActiveUnionVariant(typ reflect.Type, val reflect.Value) (bool, *reflect.StructField, reflect.Value) { - if typ.Kind() != reflect.Struct { - return false, nil, reflect.Value{} - } - - var activeVariant *reflect.StructField - var activeValue reflect.Value - isUnion := false - - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - - // Look for fields tagged as union members - if field.Tag.Get("union") != "member" { - continue - } - - isUnion = true - - // All union variants are pointers - only set active if non-nil - fieldVal := val.Field(i) - if !fieldVal.IsNil() { - activeVariant = &field - activeValue = fieldVal - } - } - - return isUnion, activeVariant, activeValue -} - -func isExact(val reflect.Value) bool { - if !val.IsValid() { - return true - } - - // If not addressable, make an addressable copy - if !val.CanAddr() && val.CanInterface() { - ptr := reflect.New(val.Type()) - ptr.Elem().Set(val) - val = ptr.Elem() - } - - if val.CanInterface() && val.CanAddr() { - ptrVal := val.Addr() - if method := ptrVal.MethodByName("IsExact"); method.IsValid() { - results := method.Call(nil) - if len(results) == 1 && results[0].Kind() == reflect.Bool { - return results[0].Bool() - } - } - } - return true -} diff --git a/internal/membershipclient/internal/utils/union_test.go b/internal/membershipclient/internal/utils/union_test.go deleted file mode 100644 index f66814c7..00000000 --- a/internal/membershipclient/internal/utils/union_test.go +++ /dev/null @@ -1,587 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// makeCandidates unmarshals payload into each type and returns candidates -func makeCandidates(t *testing.T, payload string, types ...any) []UnionCandidate { - t.Helper() - candidates := make([]UnionCandidate, len(types)) - for i, typ := range types { - val := reflect.New(reflect.TypeOf(typ)).Interface() - require.NoError(t, UnmarshalJSON([]byte(payload), val, "", false, nil)) - candidates[i] = UnionCandidate{Type: typ, Value: reflect.ValueOf(val).Elem().Interface()} - } - return candidates -} - -func TestPickBestUnionCandidate_SelectsTypeWithMoreMatchedFields(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - Bar string `json:"bar"` - } - - payload := `{"foo": "", "bar": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_EmptyCandidates(t *testing.T) { - result := PickBestUnionCandidate([]UnionCandidate{}, []byte(`{"foo": "test"}`)) - assert.Nil(t, result) -} - -func TestPickBestUnionCandidate_PrefersFewerUnmatchedFields(t *testing.T) { - type A struct { - Foo string `json:"foo"` - Bar string `json:"bar"` // not in payload - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": "test"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) // fewer unmatched fields -} - -func TestPickBestUnionCandidate_NestedStructs(t *testing.T) { - type InnerA struct { - Value string `json:"value"` - } - type InnerB struct { - Value string `json:"value"` - Extra string `json:"extra"` - } - type A struct { - Nested InnerA `json:"nested"` - } - type B struct { - Nested InnerB `json:"nested"` - } - - payload := `{"nested": {"value": "test", "extra": "data"}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ArrayFields(t *testing.T) { - type ItemA struct { - Name string `json:"name"` - } - type ItemB struct { - Name string `json:"name"` - Value string `json:"value"` - } - type A = []ItemA - type B = []ItemB - - payload := `[{"name": "a", "value": "1"}, {"name": "b", "value": "2"}]` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_PreservesOrderOnTie(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": "test"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -func TestPickBestUnionCandidate_OptionalPointerFields(t *testing.T) { - type A struct { - Foo *string `json:"foo"` - } - type B struct { - Foo *string `json:"foo"` - Bar *string `json:"bar"` - } - - payload := `{"foo": "test", "bar": "value"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_OptionalPointerStructs(t *testing.T) { - type InnerA struct { - Name string `json:"name"` - } - type InnerB struct { - Name string `json:"name"` - Value string `json:"value"` - } - type A struct { - Nested *InnerA `json:"nested"` - } - type B struct { - Nested *InnerB `json:"nested"` - } - - payload := `{"nested": {"name": "test", "value": "data"}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullPointerField(t *testing.T) { - type A struct { - Bar *string `json:"bar"` - } - type B struct { - Foo *string `json:"foo"` - } - - payload := `{"foo": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedPointerField(t *testing.T) { - type InnerA struct { - Bar *string `json:"bar"` - } - type InnerB struct { - Bar *bool `json:"bar"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"bar": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -func TestPickBestUnionCandidate_NullNonPointerField(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B struct { - Foo string `json:"foo"` - Bar string `json:"bar"` - } - - payload := `{"foo": "", "bar": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedFieldDifferentStructs(t *testing.T) { - type InnerA struct { - Bar string `json:"bar"` - } - type InnerB struct { - Baz *string `json:"baz"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"baz": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_NullNestedFieldBothPresent(t *testing.T) { - type InnerA struct { - Bar *string `json:"bar"` - } - type InnerB struct { - Baz *string `json:"baz"` - } - type A struct { - Foo InnerA `json:"foo"` - } - type B struct { - Foo InnerB `json:"foo"` - } - - payload := `{"foo": {"bar": null, "baz": null}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) // first wins on tie -} - -// EnumA represents an enum with values 1 or 2 -type EnumA int - -func (e *EnumA) IsExact() bool { - return *e == 1 || *e == 2 -} - -func (e *EnumA) UnmarshalJSON(data []byte) error { - var v int - if err := json.Unmarshal(data, &v); err != nil { - return err - } - *e = EnumA(v) - return nil -} - -// EnumB represents an enum with values 3 or 4 -type EnumB int - -func (e *EnumB) IsExact() bool { - return *e == 3 || *e == 4 -} - -func (e *EnumB) UnmarshalJSON(data []byte) error { - var v int - if err := json.Unmarshal(data, &v); err != nil { - return err - } - *e = EnumB(v) - return nil -} - -func TestPickBestUnionCandidate_EnumDiscrimination(t *testing.T) { - type A struct { - Val EnumA `json:"a"` - } - type B struct { - Val EnumB `json:"a"` - } - - payload := `{"a": 4}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ThreeWayFieldDiscrimination(t *testing.T) { - type A struct { - FieldA string `json:"a"` - FieldB string `json:"b"` - } - type B struct { - FieldA string `json:"a"` - FieldC string `json:"c"` - } - type C struct { - FieldB string `json:"b"` - FieldC string `json:"c"` - } - - payload := `{"b": "", "c": ""}` - candidates := makeCandidates(t, payload, A{}, B{}, C{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, C{}, result.Type) -} - -func TestPickBestUnionCandidate_ConstFieldDiscrimination(t *testing.T) { - type A struct { - ConstA string `json:"a" const:"x"` - ConstB string `json:"b" const:"1"` - } - type B struct { - ConstA string `json:"a" const:"x"` - ConstC string `json:"c" const:"1"` - } - type C struct { - ConstB string `json:"b" const:"1"` - ConstC string `json:"c" const:"1"` - } - - payload := `{"b": "1", "c": "1"}` - candidates := makeCandidates(t, payload, A{}, B{}, C{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, C{}, result.Type) -} - -func TestPickBestUnionCandidate_NullPayload(t *testing.T) { - type A struct { - Foo string `json:"foo"` - } - type B *string // nullable type - - payload := `null` - - candidates := makeCandidates(t, payload, A{}, B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isB := result.Type.(B) - assert.True(t, isB, "expected B (nullable type) to win for null payload") -} - -func TestPickBestUnionCandidate_PrimitiveStringTypes(t *testing.T) { - // When both types are strings, first wins on tie - type A = string - type B = string - - payload := `"asdf"` - - candidates := makeCandidates(t, payload, A(""), B("")) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (first type) to win on tie") -} - -func TestPickBestUnionCandidate_NullPointerPrimitives(t *testing.T) { - // A: *string, B: *float64 - // payload: null - // Both can be null, first wins - type A = *string - type B = *float64 - - payload := `null` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (*string) to win for null payload (first wins on tie)") -} - -func TestPickBestUnionCandidate_NullPointersMatchOverMissingFields(t *testing.T) { - // A: { a: string } - // B: { b: *string, c: *string } - // payload: { "b": null, "c": null } - // B wins because it has 2 matched fields, A has 0 - type A struct { - A string `json:"a"` - } - type B struct { - B *string `json:"b"` - C *string `json:"c"` - } - - payload := `{"b": null, "c": null}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_MapOfStructs(t *testing.T) { - // A: map[string]{ a: string } - // B: map[string]{ b: string } - // payload: { "x": null, "y": null, "z": { "b": "b" } } - // B wins because it has a matched field in the nested struct - type InnerA struct { - A string `json:"a"` - } - type InnerB struct { - B string `json:"b"` - } - type A = map[string]*InnerA - type B = map[string]*InnerB - - payload := `{"x": null, "y": null, "z": {"b": "b"}}` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isB := result.Type.(B) - assert.True(t, isB, "expected B (map with matching struct field) to win") -} - -func TestPickBestUnionCandidate_NullPointerStructVsString(t *testing.T) { - // A: *{foo: string}, B: *string - // payload: null - type Inner struct { - Foo string `json:"foo"` - } - type A = *Inner - type B = *string - - payload := `null` - - candidates := makeCandidates(t, payload, A(nil), B(nil)) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - _, isA := result.Type.(A) - assert.True(t, isA, "expected A (*struct) to win for null payload (first wins on tie)") -} - -func TestPickBestUnionCandidate_MapWithNestedStructsVsSimpleField(t *testing.T) { - // A: { a: string } - // B: { b: map[string]{ id: string, name: string } } - // payload: { "a": "", "b": { "foo": { "id": "", "name": "" } } } - // B should win because it has more matched fields (id, name in nested struct) - type Inner struct { - ID string `json:"id"` - Name string `json:"name"` - } - type A struct { - A string `json:"a"` - } - type B struct { - B map[string]Inner `json:"b"` - } - - payload := `{"a": "", "b": {"foo": {"id": "", "name": ""}}}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_AdditionalPropertiesWins(t *testing.T) { - type A struct { - A string `json:"a"` - } - type B struct { - B string `json:"b"` - AdditionalProperties map[string]string `additionalProperties:"true"` - } - - payload := `{"a": "", "b": "", "c": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_StructVsMapOfStrings(t *testing.T) { - // A: map[string]string - // B: { id: string } - // payload: { "id": "", "foo": "" } - // B should win because struct fields are more specific than map - type A = map[string]string - type B struct { - ID string `json:"id"` - } - - payload := `{"id": "", "foo": ""}` - - candidates := makeCandidates(t, payload, A(nil), B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, A{}, result.Type) -} - -func TestPickBestUnionCandidate_AdditionalPropertiesVsExactField(t *testing.T) { - // A: { id: *string, additionalProperties: true } - // B: { foo: string } - // payload: { "foo": "" } - // B should win because it has an exact field match, while A only matches via additionalProperties - type A struct { - ID *string `json:"id"` - AdditionalProperties map[string]string `additionalProperties:"true"` - } - type B struct { - Foo string `json:"foo"` - } - - payload := `{"foo": ""}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_ArrayOfNullableStructs(t *testing.T) { - // A: Array<{ foo: *string } | null> - // B: Array<{ bar: *string } | null> - // payload: [null, null, { "bar": "" }] - // B should win because it has a matching field in the non-null element - type ItemA struct { - Foo *string `json:"foo"` - } - type ItemB struct { - Bar *string `json:"bar"` - } - type A = []*ItemA - type B = []*ItemB - - payload := `[null, null, {"bar": ""}]` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} - -func TestPickBestUnionCandidate_AnyFieldType(t *testing.T) { - // A: { a: string } - // B: { b: any } - // payload: { "b": "asdf" } - // B should win because it has a matching field - type A struct { - A string `json:"a"` - } - type B struct { - B any `json:"b"` - } - - payload := `{"b": "asdf"}` - candidates := makeCandidates(t, payload, A{}, B{}) - result := PickBestUnionCandidate(candidates, []byte(payload)) - - require.NotNil(t, result) - assert.IsType(t, B{}, result.Type) -} diff --git a/internal/membershipclient/internal/utils/utils.go b/internal/membershipclient/internal/utils/utils.go deleted file mode 100644 index a614e376..00000000 --- a/internal/membershipclient/internal/utils/utils.go +++ /dev/null @@ -1,293 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package utils - -import ( - "bytes" - "context" - "fmt" - "io" - "math/big" - "net/http" - "reflect" - "regexp" - "strconv" - "strings" - "time" -) - -const ( - queryParamTagKey = "queryParam" - headerParamTagKey = "header" - pathParamTagKey = "pathParam" -) - -var ( - paramRegex = regexp.MustCompile(`({.*?})`) - SerializationMethodToContentType = map[string]string{ - "json": "application/json", - "form": "application/x-www-form-urlencoded", - "multipart": "multipart/form-data", - "raw": "application/octet-stream", - "string": "text/plain", - } -) - -func UnmarshalJsonFromResponseBody(body io.Reader, out interface{}, tag string) error { - data, err := io.ReadAll(body) - if err != nil { - return fmt.Errorf("error reading response body: %w", err) - } - if err := UnmarshalJSON(data, out, reflect.StructTag(tag), true, nil); err != nil { - return fmt.Errorf("error unmarshaling json response body: %w", err) - } - - return nil -} - -func UnmarshalJsonFromString(json string, out interface{}, tag string) error { - if err := UnmarshalJSON([]byte(json), out, reflect.StructTag(tag), true, nil); err != nil { - return fmt.Errorf("error unmarshalling json response body: %w", err) - } - - return nil -} - -func ReplaceParameters(stringWithParams string, params map[string]string) string { - if len(params) == 0 { - return stringWithParams - } - - return paramRegex.ReplaceAllStringFunc(stringWithParams, func(match string) string { - match = match[1 : len(match)-1] - return params[match] - }) -} - -func Contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -func MatchStatusCodes(expectedCodes []string, statusCode int) bool { - for _, codeStr := range expectedCodes { - code, err := strconv.Atoi(codeStr) - if err == nil { - if code == statusCode { - return true - } - continue - } - - codeRange, err := strconv.Atoi(string(codeStr[0])) - if err != nil { - continue - } - - if statusCode >= (codeRange*100) && statusCode < ((codeRange+1)*100) { - return true - } - } - - return false -} - -func AsSecuritySource(security interface{}) func(context.Context) (interface{}, error) { - return func(context.Context) (interface{}, error) { - return security, nil - } -} - -func parseConstTag(field reflect.StructField) *string { - value := field.Tag.Get("const") - - if value == "" { - return nil - } - - return &value -} - -func parseDefaultTag(field reflect.StructField) *string { - value := field.Tag.Get("default") - - if value == "" { - return nil - } - - return &value -} - -func parseStructTag(tagKey string, field reflect.StructField) map[string]string { - tag := field.Tag.Get(tagKey) - if tag == "" { - return nil - } - - values := map[string]string{} - - options := strings.Split(tag, ",") - for _, optionConf := range options { - parts := strings.Split(optionConf, "=") - - switch len(parts) { - case 1: - // flag option - parts = append(parts, "true") - case 2: - // key=value option - default: - // invalid option - continue - } - - values[parts[0]] = parts[1] - } - - return values -} - -func parseParamTag(tagKey string, field reflect.StructField, defaultStyle string, defaultExplode bool) *paramTag { - // example `{tagKey}:"style=simple,explode=false,name=apiID"` - // example `{tagKey}:"inline"` - values := parseStructTag(tagKey, field) - if values == nil { - return nil - } - - tag := ¶mTag{ - Style: defaultStyle, - Explode: defaultExplode, - ParamName: strings.ToLower(field.Name), - } - - for k, v := range values { - switch k { - case "inline": - tag.Inline = v == "true" - case "style": - tag.Style = v - case "explode": - tag.Explode = v == "true" - case "name": - tag.ParamName = v - case "serialization": - tag.Serialization = v - } - } - - return tag -} - -func valToString(val interface{}) string { - switch v := val.(type) { - case time.Time: - return v.Format(time.RFC3339Nano) - case big.Int: - return v.String() - default: - return fmt.Sprintf("%v", v) - } -} - -func populateFromGlobals(fieldType reflect.StructField, valType reflect.Value, paramType string, globals interface{}) (reflect.StructField, reflect.Value, bool) { - if globals == nil { - return fieldType, valType, false - } - - globalsStruct := reflect.TypeOf(globals) - globalsStructVal := reflect.ValueOf(globals) - - globalsField, found := globalsStruct.FieldByName(fieldType.Name) - if !found { - return fieldType, valType, false - } - - if fieldType.Type.Kind() != reflect.Ptr || !valType.IsNil() { - return fieldType, valType, true - } - - globalsVal := globalsStructVal.FieldByName(fieldType.Name) - - if !globalsVal.IsValid() { - return fieldType, valType, false - } - - switch paramType { - case queryParamTagKey: - qpTag := parseQueryParamTag(globalsField) - if qpTag == nil { - return fieldType, valType, false - } - default: - tag := parseParamTag(paramType, fieldType, "simple", false) - if tag == nil { - return fieldType, valType, false - } - } - - return globalsField, globalsVal, true -} - -func isNil(typ reflect.Type, val reflect.Value) bool { - // `reflect.TypeOf(nil) == nil` so calling typ.Kind() will cause a nil pointer - // dereference panic. Catch it and return early. - // https://github.com/golang/go/issues/51649 - // https://github.com/golang/go/issues/54208 - if typ == nil { - return true - } - - if typ.Kind() == reflect.Ptr || typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice || typ.Kind() == reflect.Interface { - return val.IsNil() - } - - return false -} - -func isEmptyContainer(typ reflect.Type, val reflect.Value) bool { - if isNil(typ, val) { - return true - } - - switch typ.Kind() { - case reflect.Slice, reflect.Array: - return val.Len() == 0 - case reflect.Map: - return val.Len() == 0 - default: - return false - } -} - -func contains(arr []string, str string) bool { - for _, a := range arr { - if a == str { - return true - } - } - return false -} - -func DrainBody(res *http.Response) { - io.Copy(io.Discard, res.Body) - res.Body.Close() - res.Body = io.NopCloser(bytes.NewReader(nil)) -} - -func ConsumeRawBody(res *http.Response) ([]byte, error) { - defer res.Body.Close() - - rawBody, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - res.Body = io.NopCloser(bytes.NewBuffer(rawBody)) - - return rawBody, nil -} diff --git a/internal/membershipclient/models/apierrors/apierror.go b/internal/membershipclient/models/apierrors/apierror.go deleted file mode 100644 index 61a9628a..00000000 --- a/internal/membershipclient/models/apierrors/apierror.go +++ /dev/null @@ -1,35 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package apierrors - -import ( - "fmt" - "net/http" -) - -type APIError struct { - Message string - StatusCode int - Body string - RawResponse *http.Response -} - -var _ error = &APIError{} - -func NewAPIError(message string, statusCode int, body string, httpRes *http.Response) *APIError { - return &APIError{ - Message: message, - StatusCode: statusCode, - Body: body, - RawResponse: httpRes, - } -} - -func (e *APIError) Error() string { - body := "" - if len(e.Body) > 0 { - body = fmt.Sprintf("\n%s", e.Body) - } - - return fmt.Sprintf("%s: Status %d%s", e.Message, e.StatusCode, body) -} diff --git a/internal/membershipclient/models/apierrors/error.go b/internal/membershipclient/models/apierrors/error.go deleted file mode 100644 index 306f886c..00000000 --- a/internal/membershipclient/models/apierrors/error.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package apierrors - -import ( - "encoding/json" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type Error struct { - ErrorCode string `json:"errorCode"` - ErrorMessage *string `json:"errorMessage,omitzero"` - HTTPMeta components.HTTPMetadata `json:"-"` -} - -var _ error = &Error{} - -func (e *Error) Error() string { - data, _ := json.Marshal(e) - return string(data) -} diff --git a/internal/membershipclient/models/components/action.go b/internal/membershipclient/models/components/action.go deleted file mode 100644 index 87658859..00000000 --- a/internal/membershipclient/models/components/action.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "encoding/json" - "fmt" -) - -type Action string - -const ( - ActionAgentsConnected Action = "agents.connected" - ActionAgentsDisconnected Action = "agents.disconnected" - ActionInvitationsCreated Action = "invitations.created" - ActionInvitationsAccepted Action = "invitations.accepted" - ActionInvitationsRejected Action = "invitations.rejected" - ActionInvitationsCancelled Action = "invitations.cancelled" - ActionOrganizationsCreated Action = "organizations.created" - ActionOrganizationsUpdated Action = "organizations.updated" - ActionOrganizationsDeleted Action = "organizations.deleted" - ActionOrganizationsUserDeleted Action = "organizations.user.deleted" - ActionOrganizationsUserUpdated Action = "organizations.user.updated" - ActionRegionsCreated Action = "regions.created" - ActionRegionsDeleted Action = "regions.deleted" - ActionUsersCreated Action = "users.created" - ActionUsersDeleted Action = "users.deleted" - ActionStacksDisposal Action = "stacks.disposal" - ActionStacksDisposalReset Action = "stacks.disposal-reset" - ActionStacksWarned Action = "stacks.warned" - ActionStacksPruned Action = "stacks.pruned" - ActionStacksStatusUpdated Action = "stacks.status.updated" - ActionStacksCreated Action = "stacks.created" - ActionStacksUpdated Action = "stacks.updated" - ActionStacksDeleted Action = "stacks.deleted" - ActionStacksRestored Action = "stacks.restored" - ActionStacksDisabled Action = "stacks.disabled" - ActionStacksEnabled Action = "stacks.enabled" - ActionStacksUpgraded Action = "stacks.upgraded" - ActionStacksStargateEnabled Action = "stacks.stargate.enabled" - ActionStacksStargateDisabled Action = "stacks.stargate.disabled" - ActionStacksUserUpdated Action = "stacks.user.updated" - ActionStacksUserDeleted Action = "stacks.user.deleted" - ActionStacksReachnessUpdated Action = "stacks.reachness.updated" - ActionStacksModuleEnabled Action = "stacks.module.enabled" - ActionStacksModuleDisabled Action = "stacks.module.disabled" - ActionStacksModuleStatusUpdated Action = "stacks.module.status.updated" - ActionPoliciesCreated Action = "policies.created" - ActionPoliciesUpdated Action = "policies.updated" - ActionPoliciesDeleted Action = "policies.deleted" - ActionPoliciesScopeAdded Action = "policies.scope.added" - ActionPoliciesScopeRemoved Action = "policies.scope.removed" -) - -func (e Action) ToPointer() *Action { - return &e -} -func (e *Action) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "agents.connected": - fallthrough - case "agents.disconnected": - fallthrough - case "invitations.created": - fallthrough - case "invitations.accepted": - fallthrough - case "invitations.rejected": - fallthrough - case "invitations.cancelled": - fallthrough - case "organizations.created": - fallthrough - case "organizations.updated": - fallthrough - case "organizations.deleted": - fallthrough - case "organizations.user.deleted": - fallthrough - case "organizations.user.updated": - fallthrough - case "regions.created": - fallthrough - case "regions.deleted": - fallthrough - case "users.created": - fallthrough - case "users.deleted": - fallthrough - case "stacks.disposal": - fallthrough - case "stacks.disposal-reset": - fallthrough - case "stacks.warned": - fallthrough - case "stacks.pruned": - fallthrough - case "stacks.status.updated": - fallthrough - case "stacks.created": - fallthrough - case "stacks.updated": - fallthrough - case "stacks.deleted": - fallthrough - case "stacks.restored": - fallthrough - case "stacks.disabled": - fallthrough - case "stacks.enabled": - fallthrough - case "stacks.upgraded": - fallthrough - case "stacks.stargate.enabled": - fallthrough - case "stacks.stargate.disabled": - fallthrough - case "stacks.user.updated": - fallthrough - case "stacks.user.deleted": - fallthrough - case "stacks.reachness.updated": - fallthrough - case "stacks.module.enabled": - fallthrough - case "stacks.module.disabled": - fallthrough - case "stacks.module.status.updated": - fallthrough - case "policies.created": - fallthrough - case "policies.updated": - fallthrough - case "policies.deleted": - fallthrough - case "policies.scope.added": - fallthrough - case "policies.scope.removed": - *e = Action(v) - return nil - default: - return fmt.Errorf("invalid value for Action: %v", v) - } -} diff --git a/internal/membershipclient/models/components/anyregion.go b/internal/membershipclient/models/components/anyregion.go deleted file mode 100644 index 83b822f9..00000000 --- a/internal/membershipclient/models/components/anyregion.go +++ /dev/null @@ -1,158 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type AnyRegion struct { - ID string `json:"id"` - BaseURL string `json:"baseUrl"` - CreatedAt string `json:"createdAt"` - Active bool `json:"active"` - LastPing *time.Time `json:"lastPing,omitzero"` - Name string `json:"name"` - Capabilities RegionCapability `json:"capabilities"` - AgentID string `json:"agentID"` - Outdated bool `json:"outdated"` - CreatorID *string `json:"creatorId,omitzero"` - Version *string `json:"version,omitzero"` - ClientID *string `json:"clientID,omitzero"` - OrganizationID *string `json:"organizationID,omitzero"` - Creator *User `json:"creator,omitzero"` - Production *bool `json:"production,omitzero"` - Public bool `json:"public"` - Secret *PrivateRegionSecret `json:"secret,omitzero"` -} - -func (a AnyRegion) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AnyRegion) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AnyRegion) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *AnyRegion) GetBaseURL() string { - if a == nil { - return "" - } - return a.BaseURL -} - -func (a *AnyRegion) GetCreatedAt() string { - if a == nil { - return "" - } - return a.CreatedAt -} - -func (a *AnyRegion) GetActive() bool { - if a == nil { - return false - } - return a.Active -} - -func (a *AnyRegion) GetLastPing() *time.Time { - if a == nil { - return nil - } - return a.LastPing -} - -func (a *AnyRegion) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AnyRegion) GetCapabilities() RegionCapability { - if a == nil { - return RegionCapability{} - } - return a.Capabilities -} - -func (a *AnyRegion) GetAgentID() string { - if a == nil { - return "" - } - return a.AgentID -} - -func (a *AnyRegion) GetOutdated() bool { - if a == nil { - return false - } - return a.Outdated -} - -func (a *AnyRegion) GetCreatorID() *string { - if a == nil { - return nil - } - return a.CreatorID -} - -func (a *AnyRegion) GetVersion() *string { - if a == nil { - return nil - } - return a.Version -} - -func (a *AnyRegion) GetClientID() *string { - if a == nil { - return nil - } - return a.ClientID -} - -func (a *AnyRegion) GetOrganizationID() *string { - if a == nil { - return nil - } - return a.OrganizationID -} - -func (a *AnyRegion) GetCreator() *User { - if a == nil { - return nil - } - return a.Creator -} - -func (a *AnyRegion) GetProduction() *bool { - if a == nil { - return nil - } - return a.Production -} - -func (a *AnyRegion) GetPublic() bool { - if a == nil { - return false - } - return a.Public -} - -func (a *AnyRegion) GetSecret() *PrivateRegionSecret { - if a == nil { - return nil - } - return a.Secret -} diff --git a/internal/membershipclient/models/components/application.go b/internal/membershipclient/models/components/application.go deleted file mode 100644 index c0b4ab01..00000000 --- a/internal/membershipclient/models/components/application.go +++ /dev/null @@ -1,85 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type Application struct { - // Application name - Name string `json:"name"` - // Application description - Description *string `json:"description,omitzero"` - // Application URL (must be unique) - URL string `json:"url"` - // Application alias - Alias string `json:"alias"` - // Application ID - ID string `json:"id"` - // Creation date - CreatedAt time.Time `json:"createdAt"` - // Last update date - UpdatedAt time.Time `json:"updatedAt"` -} - -func (a Application) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *Application) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *Application) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *Application) GetDescription() *string { - if a == nil { - return nil - } - return a.Description -} - -func (a *Application) GetURL() string { - if a == nil { - return "" - } - return a.URL -} - -func (a *Application) GetAlias() string { - if a == nil { - return "" - } - return a.Alias -} - -func (a *Application) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *Application) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *Application) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} diff --git a/internal/membershipclient/models/components/applicationdata.go b/internal/membershipclient/models/components/applicationdata.go deleted file mode 100644 index 6e054254..00000000 --- a/internal/membershipclient/models/components/applicationdata.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type ApplicationData struct { - // Application name - Name string `json:"name"` - // Application description - Description *string `json:"description,omitzero"` - // Application URL (must be unique) - URL string `json:"url"` - // Application alias - Alias string `json:"alias"` -} - -func (a *ApplicationData) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *ApplicationData) GetDescription() *string { - if a == nil { - return nil - } - return a.Description -} - -func (a *ApplicationData) GetURL() string { - if a == nil { - return "" - } - return a.URL -} - -func (a *ApplicationData) GetAlias() string { - if a == nil { - return "" - } - return a.Alias -} diff --git a/internal/membershipclient/models/components/applicationwithscope.go b/internal/membershipclient/models/components/applicationwithscope.go deleted file mode 100644 index 5bd02b4c..00000000 --- a/internal/membershipclient/models/components/applicationwithscope.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type ApplicationWithScope struct { - // Application name - Name string `json:"name"` - // Application description - Description *string `json:"description,omitzero"` - // Application URL (must be unique) - URL string `json:"url"` - // Application alias - Alias string `json:"alias"` - // Application ID - ID string `json:"id"` - // Creation date - CreatedAt time.Time `json:"createdAt"` - // Last update date - UpdatedAt time.Time `json:"updatedAt"` - // List of scopes associated with this application - Scopes []Scope `json:"scopes"` -} - -func (a ApplicationWithScope) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *ApplicationWithScope) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *ApplicationWithScope) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *ApplicationWithScope) GetDescription() *string { - if a == nil { - return nil - } - return a.Description -} - -func (a *ApplicationWithScope) GetURL() string { - if a == nil { - return "" - } - return a.URL -} - -func (a *ApplicationWithScope) GetAlias() string { - if a == nil { - return "" - } - return a.Alias -} - -func (a *ApplicationWithScope) GetID() string { - if a == nil { - return "" - } - return a.ID -} - -func (a *ApplicationWithScope) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *ApplicationWithScope) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} - -func (a *ApplicationWithScope) GetScopes() []Scope { - if a == nil { - return []Scope{} - } - return a.Scopes -} diff --git a/internal/membershipclient/models/components/authenticationproviderresponse.go b/internal/membershipclient/models/components/authenticationproviderresponse.go deleted file mode 100644 index a73a6bcd..00000000 --- a/internal/membershipclient/models/components/authenticationproviderresponse.go +++ /dev/null @@ -1,716 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "errors" - "fmt" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -// AuthenticationProviderResponseOIDCConfigType - Type of the authentication provider -type AuthenticationProviderResponseOIDCConfigType string - -const ( - AuthenticationProviderResponseOIDCConfigTypeOidc AuthenticationProviderResponseOIDCConfigType = "oidc" - AuthenticationProviderResponseOIDCConfigTypeGoogle AuthenticationProviderResponseOIDCConfigType = "google" - AuthenticationProviderResponseOIDCConfigTypeGithub AuthenticationProviderResponseOIDCConfigType = "github" - AuthenticationProviderResponseOIDCConfigTypeMicrosoft AuthenticationProviderResponseOIDCConfigType = "microsoft" -) - -func (e AuthenticationProviderResponseOIDCConfigType) ToPointer() *AuthenticationProviderResponseOIDCConfigType { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *AuthenticationProviderResponseOIDCConfigType) IsExact() bool { - if e != nil { - switch *e { - case "oidc", "google", "github", "microsoft": - return true - } - } - return false -} - -type AuthenticationProviderResponseOIDCConfigConfig struct { - // OIDC issuer URL - Issuer string `json:"issuer"` - DiscoveryPath *string `default:"/.well-known/openid-configuration" json:"discoveryPath"` -} - -func (a AuthenticationProviderResponseOIDCConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseOIDCConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseOIDCConfigConfig) GetIssuer() string { - if a == nil { - return "" - } - return a.Issuer -} - -func (a *AuthenticationProviderResponseOIDCConfigConfig) GetDiscoveryPath() *string { - if a == nil { - return nil - } - return a.DiscoveryPath -} - -type AuthenticationProviderResponseOIDCConfig struct { - // Type of the authentication provider - Type AuthenticationProviderResponseOIDCConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config AuthenticationProviderResponseOIDCConfigConfig `json:"config"` - // Unique identifier for the authentication provider - OrganizationID string `json:"organizationId"` - // Creation date of the authentication provider - CreatedAt time.Time `json:"createdAt"` - // Last update date of the authentication provider - UpdatedAt time.Time `json:"updatedAt"` - RedirectURI string `json:"redirectURI"` -} - -func (a AuthenticationProviderResponseOIDCConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseOIDCConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetType() AuthenticationProviderResponseOIDCConfigType { - if a == nil { - return AuthenticationProviderResponseOIDCConfigType("") - } - return a.Type -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetClientID() string { - if a == nil { - return "" - } - return a.ClientID -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetClientSecret() string { - if a == nil { - return "" - } - return a.ClientSecret -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetConfig() AuthenticationProviderResponseOIDCConfigConfig { - if a == nil { - return AuthenticationProviderResponseOIDCConfigConfig{} - } - return a.Config -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} - -func (a *AuthenticationProviderResponseOIDCConfig) GetRedirectURI() string { - if a == nil { - return "" - } - return a.RedirectURI -} - -// AuthenticationProviderResponseGithubIDPConfigType - Type of the authentication provider -type AuthenticationProviderResponseGithubIDPConfigType string - -const ( - AuthenticationProviderResponseGithubIDPConfigTypeOidc AuthenticationProviderResponseGithubIDPConfigType = "oidc" - AuthenticationProviderResponseGithubIDPConfigTypeGoogle AuthenticationProviderResponseGithubIDPConfigType = "google" - AuthenticationProviderResponseGithubIDPConfigTypeGithub AuthenticationProviderResponseGithubIDPConfigType = "github" - AuthenticationProviderResponseGithubIDPConfigTypeMicrosoft AuthenticationProviderResponseGithubIDPConfigType = "microsoft" -) - -func (e AuthenticationProviderResponseGithubIDPConfigType) ToPointer() *AuthenticationProviderResponseGithubIDPConfigType { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *AuthenticationProviderResponseGithubIDPConfigType) IsExact() bool { - if e != nil { - switch *e { - case "oidc", "google", "github", "microsoft": - return true - } - } - return false -} - -type AuthenticationProviderResponseGithubIDPConfigConfig struct { -} - -func (a AuthenticationProviderResponseGithubIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseGithubIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -type AuthenticationProviderResponseGithubIDPConfig struct { - // Type of the authentication provider - Type AuthenticationProviderResponseGithubIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config AuthenticationProviderResponseGithubIDPConfigConfig `json:"config"` - // Unique identifier for the authentication provider - OrganizationID string `json:"organizationId"` - // Creation date of the authentication provider - CreatedAt time.Time `json:"createdAt"` - // Last update date of the authentication provider - UpdatedAt time.Time `json:"updatedAt"` - RedirectURI string `json:"redirectURI"` -} - -func (a AuthenticationProviderResponseGithubIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetType() AuthenticationProviderResponseGithubIDPConfigType { - if a == nil { - return AuthenticationProviderResponseGithubIDPConfigType("") - } - return a.Type -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetClientID() string { - if a == nil { - return "" - } - return a.ClientID -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetClientSecret() string { - if a == nil { - return "" - } - return a.ClientSecret -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetConfig() AuthenticationProviderResponseGithubIDPConfigConfig { - if a == nil { - return AuthenticationProviderResponseGithubIDPConfigConfig{} - } - return a.Config -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} - -func (a *AuthenticationProviderResponseGithubIDPConfig) GetRedirectURI() string { - if a == nil { - return "" - } - return a.RedirectURI -} - -// AuthenticationProviderResponseMicrosoftIDPConfigType - Type of the authentication provider -type AuthenticationProviderResponseMicrosoftIDPConfigType string - -const ( - AuthenticationProviderResponseMicrosoftIDPConfigTypeOidc AuthenticationProviderResponseMicrosoftIDPConfigType = "oidc" - AuthenticationProviderResponseMicrosoftIDPConfigTypeGoogle AuthenticationProviderResponseMicrosoftIDPConfigType = "google" - AuthenticationProviderResponseMicrosoftIDPConfigTypeGithub AuthenticationProviderResponseMicrosoftIDPConfigType = "github" - AuthenticationProviderResponseMicrosoftIDPConfigTypeMicrosoft AuthenticationProviderResponseMicrosoftIDPConfigType = "microsoft" -) - -func (e AuthenticationProviderResponseMicrosoftIDPConfigType) ToPointer() *AuthenticationProviderResponseMicrosoftIDPConfigType { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *AuthenticationProviderResponseMicrosoftIDPConfigType) IsExact() bool { - if e != nil { - switch *e { - case "oidc", "google", "github", "microsoft": - return true - } - } - return false -} - -type AuthenticationProviderResponseMicrosoftIDPConfigConfig struct { - // Tenant ID for Microsoft authentication - Tenant *string `default:"common" json:"tenant"` -} - -func (a AuthenticationProviderResponseMicrosoftIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfigConfig) GetTenant() *string { - if a == nil { - return nil - } - return a.Tenant -} - -type AuthenticationProviderResponseMicrosoftIDPConfig struct { - // Type of the authentication provider - Type AuthenticationProviderResponseMicrosoftIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config AuthenticationProviderResponseMicrosoftIDPConfigConfig `json:"config"` - // Unique identifier for the authentication provider - OrganizationID string `json:"organizationId"` - // Creation date of the authentication provider - CreatedAt time.Time `json:"createdAt"` - // Last update date of the authentication provider - UpdatedAt time.Time `json:"updatedAt"` - RedirectURI string `json:"redirectURI"` -} - -func (a AuthenticationProviderResponseMicrosoftIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetType() AuthenticationProviderResponseMicrosoftIDPConfigType { - if a == nil { - return AuthenticationProviderResponseMicrosoftIDPConfigType("") - } - return a.Type -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetClientID() string { - if a == nil { - return "" - } - return a.ClientID -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetClientSecret() string { - if a == nil { - return "" - } - return a.ClientSecret -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetConfig() AuthenticationProviderResponseMicrosoftIDPConfigConfig { - if a == nil { - return AuthenticationProviderResponseMicrosoftIDPConfigConfig{} - } - return a.Config -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} - -func (a *AuthenticationProviderResponseMicrosoftIDPConfig) GetRedirectURI() string { - if a == nil { - return "" - } - return a.RedirectURI -} - -// AuthenticationProviderResponseGoogleIDPConfigType - Type of the authentication provider -type AuthenticationProviderResponseGoogleIDPConfigType string - -const ( - AuthenticationProviderResponseGoogleIDPConfigTypeOidc AuthenticationProviderResponseGoogleIDPConfigType = "oidc" - AuthenticationProviderResponseGoogleIDPConfigTypeGoogle AuthenticationProviderResponseGoogleIDPConfigType = "google" - AuthenticationProviderResponseGoogleIDPConfigTypeGithub AuthenticationProviderResponseGoogleIDPConfigType = "github" - AuthenticationProviderResponseGoogleIDPConfigTypeMicrosoft AuthenticationProviderResponseGoogleIDPConfigType = "microsoft" -) - -func (e AuthenticationProviderResponseGoogleIDPConfigType) ToPointer() *AuthenticationProviderResponseGoogleIDPConfigType { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *AuthenticationProviderResponseGoogleIDPConfigType) IsExact() bool { - if e != nil { - switch *e { - case "oidc", "google", "github", "microsoft": - return true - } - } - return false -} - -type AuthenticationProviderResponseGoogleIDPConfigConfig struct { -} - -func (a AuthenticationProviderResponseGoogleIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseGoogleIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -type AuthenticationProviderResponseGoogleIDPConfig struct { - // Type of the authentication provider - Type AuthenticationProviderResponseGoogleIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config AuthenticationProviderResponseGoogleIDPConfigConfig `json:"config"` - // Unique identifier for the authentication provider - OrganizationID string `json:"organizationId"` - // Creation date of the authentication provider - CreatedAt time.Time `json:"createdAt"` - // Last update date of the authentication provider - UpdatedAt time.Time `json:"updatedAt"` - RedirectURI string `json:"redirectURI"` -} - -func (a AuthenticationProviderResponseGoogleIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetType() AuthenticationProviderResponseGoogleIDPConfigType { - if a == nil { - return AuthenticationProviderResponseGoogleIDPConfigType("") - } - return a.Type -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetName() string { - if a == nil { - return "" - } - return a.Name -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetClientID() string { - if a == nil { - return "" - } - return a.ClientID -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetClientSecret() string { - if a == nil { - return "" - } - return a.ClientSecret -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetConfig() AuthenticationProviderResponseGoogleIDPConfigConfig { - if a == nil { - return AuthenticationProviderResponseGoogleIDPConfigConfig{} - } - return a.Config -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetCreatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.CreatedAt -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetUpdatedAt() time.Time { - if a == nil { - return time.Time{} - } - return a.UpdatedAt -} - -func (a *AuthenticationProviderResponseGoogleIDPConfig) GetRedirectURI() string { - if a == nil { - return "" - } - return a.RedirectURI -} - -type DataType string - -const ( - DataTypeAuthenticationProviderResponseGoogleIDPConfig DataType = "AuthenticationProviderResponse_GoogleIDPConfig" - DataTypeAuthenticationProviderResponseMicrosoftIDPConfig DataType = "AuthenticationProviderResponse_MicrosoftIDPConfig" - DataTypeAuthenticationProviderResponseGithubIDPConfig DataType = "AuthenticationProviderResponse_GithubIDPConfig" - DataTypeAuthenticationProviderResponseOIDCConfig DataType = "AuthenticationProviderResponse_OIDCConfig" -) - -type Data struct { - AuthenticationProviderResponseGoogleIDPConfig *AuthenticationProviderResponseGoogleIDPConfig `queryParam:"inline" union:"member"` - AuthenticationProviderResponseMicrosoftIDPConfig *AuthenticationProviderResponseMicrosoftIDPConfig `queryParam:"inline" union:"member"` - AuthenticationProviderResponseGithubIDPConfig *AuthenticationProviderResponseGithubIDPConfig `queryParam:"inline" union:"member"` - AuthenticationProviderResponseOIDCConfig *AuthenticationProviderResponseOIDCConfig `queryParam:"inline" union:"member"` - - Type DataType -} - -func CreateDataAuthenticationProviderResponseGoogleIDPConfig(authenticationProviderResponseGoogleIDPConfig AuthenticationProviderResponseGoogleIDPConfig) Data { - typ := DataTypeAuthenticationProviderResponseGoogleIDPConfig - - return Data{ - AuthenticationProviderResponseGoogleIDPConfig: &authenticationProviderResponseGoogleIDPConfig, - Type: typ, - } -} - -func CreateDataAuthenticationProviderResponseMicrosoftIDPConfig(authenticationProviderResponseMicrosoftIDPConfig AuthenticationProviderResponseMicrosoftIDPConfig) Data { - typ := DataTypeAuthenticationProviderResponseMicrosoftIDPConfig - - return Data{ - AuthenticationProviderResponseMicrosoftIDPConfig: &authenticationProviderResponseMicrosoftIDPConfig, - Type: typ, - } -} - -func CreateDataAuthenticationProviderResponseGithubIDPConfig(authenticationProviderResponseGithubIDPConfig AuthenticationProviderResponseGithubIDPConfig) Data { - typ := DataTypeAuthenticationProviderResponseGithubIDPConfig - - return Data{ - AuthenticationProviderResponseGithubIDPConfig: &authenticationProviderResponseGithubIDPConfig, - Type: typ, - } -} - -func CreateDataAuthenticationProviderResponseOIDCConfig(authenticationProviderResponseOIDCConfig AuthenticationProviderResponseOIDCConfig) Data { - typ := DataTypeAuthenticationProviderResponseOIDCConfig - - return Data{ - AuthenticationProviderResponseOIDCConfig: &authenticationProviderResponseOIDCConfig, - Type: typ, - } -} - -func (u *Data) UnmarshalJSON(data []byte) error { - - var candidates []utils.UnionCandidate - - // Collect all valid candidates - var authenticationProviderResponseGoogleIDPConfig AuthenticationProviderResponseGoogleIDPConfig = AuthenticationProviderResponseGoogleIDPConfig{} - if err := utils.UnmarshalJSON(data, &authenticationProviderResponseGoogleIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: DataTypeAuthenticationProviderResponseGoogleIDPConfig, - Value: &authenticationProviderResponseGoogleIDPConfig, - }) - } - - var authenticationProviderResponseMicrosoftIDPConfig AuthenticationProviderResponseMicrosoftIDPConfig = AuthenticationProviderResponseMicrosoftIDPConfig{} - if err := utils.UnmarshalJSON(data, &authenticationProviderResponseMicrosoftIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: DataTypeAuthenticationProviderResponseMicrosoftIDPConfig, - Value: &authenticationProviderResponseMicrosoftIDPConfig, - }) - } - - var authenticationProviderResponseGithubIDPConfig AuthenticationProviderResponseGithubIDPConfig = AuthenticationProviderResponseGithubIDPConfig{} - if err := utils.UnmarshalJSON(data, &authenticationProviderResponseGithubIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: DataTypeAuthenticationProviderResponseGithubIDPConfig, - Value: &authenticationProviderResponseGithubIDPConfig, - }) - } - - var authenticationProviderResponseOIDCConfig AuthenticationProviderResponseOIDCConfig = AuthenticationProviderResponseOIDCConfig{} - if err := utils.UnmarshalJSON(data, &authenticationProviderResponseOIDCConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: DataTypeAuthenticationProviderResponseOIDCConfig, - Value: &authenticationProviderResponseOIDCConfig, - }) - } - - if len(candidates) == 0 { - return fmt.Errorf("could not unmarshal `%s` into any supported union types for Data", string(data)) - } - - // Pick the best candidate using multi-stage filtering - best := utils.PickBestUnionCandidate(candidates, data) - if best == nil { - return fmt.Errorf("could not unmarshal `%s` into any supported union types for Data", string(data)) - } - - // Set the union type and value based on the best candidate - u.Type = best.Type.(DataType) - switch best.Type { - case DataTypeAuthenticationProviderResponseGoogleIDPConfig: - u.AuthenticationProviderResponseGoogleIDPConfig = best.Value.(*AuthenticationProviderResponseGoogleIDPConfig) - return nil - case DataTypeAuthenticationProviderResponseMicrosoftIDPConfig: - u.AuthenticationProviderResponseMicrosoftIDPConfig = best.Value.(*AuthenticationProviderResponseMicrosoftIDPConfig) - return nil - case DataTypeAuthenticationProviderResponseGithubIDPConfig: - u.AuthenticationProviderResponseGithubIDPConfig = best.Value.(*AuthenticationProviderResponseGithubIDPConfig) - return nil - case DataTypeAuthenticationProviderResponseOIDCConfig: - u.AuthenticationProviderResponseOIDCConfig = best.Value.(*AuthenticationProviderResponseOIDCConfig) - return nil - } - - return fmt.Errorf("could not unmarshal `%s` into any supported union types for Data", string(data)) -} - -func (u Data) MarshalJSON() ([]byte, error) { - if u.AuthenticationProviderResponseGoogleIDPConfig != nil { - return utils.MarshalJSON(u.AuthenticationProviderResponseGoogleIDPConfig, "", true) - } - - if u.AuthenticationProviderResponseMicrosoftIDPConfig != nil { - return utils.MarshalJSON(u.AuthenticationProviderResponseMicrosoftIDPConfig, "", true) - } - - if u.AuthenticationProviderResponseGithubIDPConfig != nil { - return utils.MarshalJSON(u.AuthenticationProviderResponseGithubIDPConfig, "", true) - } - - if u.AuthenticationProviderResponseOIDCConfig != nil { - return utils.MarshalJSON(u.AuthenticationProviderResponseOIDCConfig, "", true) - } - - return nil, errors.New("could not marshal union type Data: all fields are null") -} - -type AuthenticationProviderResponse struct { - Data *Data `json:"data,omitzero"` -} - -func (a *AuthenticationProviderResponse) GetData() *Data { - if a == nil { - return nil - } - return a.Data -} diff --git a/internal/membershipclient/models/components/capability.go b/internal/membershipclient/models/components/capability.go deleted file mode 100644 index 3ebfc59f..00000000 --- a/internal/membershipclient/models/components/capability.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "encoding/json" - "fmt" -) - -type Capability string - -const ( - CapabilityModuleSelection Capability = "MODULE_SELECTION" -) - -func (e Capability) ToPointer() *Capability { - return &e -} -func (e *Capability) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "MODULE_SELECTION": - *e = Capability(v) - return nil - default: - return fmt.Errorf("invalid value for Capability: %v", v) - } -} diff --git a/internal/membershipclient/models/components/createapplicationresponse.go b/internal/membershipclient/models/components/createapplicationresponse.go deleted file mode 100644 index 7a986243..00000000 --- a/internal/membershipclient/models/components/createapplicationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateApplicationResponse struct { - Data *Application `json:"data,omitzero"` -} - -func (c CreateApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateApplicationResponse) GetData() *Application { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createapplicationscoperequest.go b/internal/membershipclient/models/components/createapplicationscoperequest.go deleted file mode 100644 index e2917d61..00000000 --- a/internal/membershipclient/models/components/createapplicationscoperequest.go +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreateApplicationScopeRequest struct { - // Scope description - Description *string `json:"description,omitzero"` - // The OAuth2 scope label (e.g., "custom:read") - Label string `json:"label"` -} - -func (c *CreateApplicationScopeRequest) GetDescription() *string { - if c == nil { - return nil - } - return c.Description -} - -func (c *CreateApplicationScopeRequest) GetLabel() string { - if c == nil { - return "" - } - return c.Label -} diff --git a/internal/membershipclient/models/components/createapplicationscoperesponse.go b/internal/membershipclient/models/components/createapplicationscoperesponse.go deleted file mode 100644 index e1ca8adf..00000000 --- a/internal/membershipclient/models/components/createapplicationscoperesponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateApplicationScopeResponse struct { - Data *Scope `json:"data,omitzero"` -} - -func (c CreateApplicationScopeResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateApplicationScopeResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateApplicationScopeResponse) GetData() *Scope { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createclientresponseresponse.go b/internal/membershipclient/models/components/createclientresponseresponse.go deleted file mode 100644 index 5509b402..00000000 --- a/internal/membershipclient/models/components/createclientresponseresponse.go +++ /dev/null @@ -1,123 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type CreateClientResponseResponseSecret struct { - LastDigits string `json:"lastDigits"` - Clear *string `json:"clear,omitzero"` -} - -func (c *CreateClientResponseResponseSecret) GetLastDigits() string { - if c == nil { - return "" - } - return c.LastDigits -} - -func (c *CreateClientResponseResponseSecret) GetClear() *string { - if c == nil { - return nil - } - return c.Clear -} - -type CreateClientResponseResponseData struct { - // Organization ID - ID string `json:"id"` - Secret CreateClientResponseResponseSecret `json:"secret"` - // Name of the client - Name string `json:"name"` - // Description of the client - Description string `json:"description"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - // The ID of the owner of the client (DEPRECATED will be empty) - OwnerID string `json:"ownerId"` -} - -func (c CreateClientResponseResponseData) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateClientResponseResponseData) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateClientResponseResponseData) GetID() string { - if c == nil { - return "" - } - return c.ID -} - -func (c *CreateClientResponseResponseData) GetSecret() CreateClientResponseResponseSecret { - if c == nil { - return CreateClientResponseResponseSecret{} - } - return c.Secret -} - -func (c *CreateClientResponseResponseData) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateClientResponseResponseData) GetDescription() string { - if c == nil { - return "" - } - return c.Description -} - -func (c *CreateClientResponseResponseData) GetCreatedAt() time.Time { - if c == nil { - return time.Time{} - } - return c.CreatedAt -} - -func (c *CreateClientResponseResponseData) GetUpdatedAt() time.Time { - if c == nil { - return time.Time{} - } - return c.UpdatedAt -} - -func (c *CreateClientResponseResponseData) GetOwnerID() string { - if c == nil { - return "" - } - return c.OwnerID -} - -type CreateClientResponseResponse struct { - Data *CreateClientResponseResponseData `json:"data,omitzero"` -} - -func (c CreateClientResponseResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateClientResponseResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateClientResponseResponse) GetData() *CreateClientResponseResponseData { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createdprivateregionresponse.go b/internal/membershipclient/models/components/createdprivateregionresponse.go deleted file mode 100644 index c678f954..00000000 --- a/internal/membershipclient/models/components/createdprivateregionresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreatedPrivateRegionResponse struct { - Data PrivateRegion `json:"data"` -} - -func (c *CreatedPrivateRegionResponse) GetData() PrivateRegion { - if c == nil { - return PrivateRegion{} - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createinvitationresponse.go b/internal/membershipclient/models/components/createinvitationresponse.go deleted file mode 100644 index 8454c990..00000000 --- a/internal/membershipclient/models/components/createinvitationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateInvitationResponse struct { - Data *Invitation `json:"data,omitzero"` -} - -func (c CreateInvitationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateInvitationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateInvitationResponse) GetData() *Invitation { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createorganizationclientrequest.go b/internal/membershipclient/models/components/createorganizationclientrequest.go deleted file mode 100644 index d301132c..00000000 --- a/internal/membershipclient/models/components/createorganizationclientrequest.go +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreateOrganizationClientRequest struct { - // Name of the client - Name *string `json:"name,omitzero"` - // Description of the client - Description *string `json:"description,omitzero"` -} - -func (c *CreateOrganizationClientRequest) GetName() *string { - if c == nil { - return nil - } - return c.Name -} - -func (c *CreateOrganizationClientRequest) GetDescription() *string { - if c == nil { - return nil - } - return c.Description -} diff --git a/internal/membershipclient/models/components/createorganizationclientresponse.go b/internal/membershipclient/models/components/createorganizationclientresponse.go deleted file mode 100644 index 0d82449f..00000000 --- a/internal/membershipclient/models/components/createorganizationclientresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreateOrganizationClientResponse struct { - Data OrganizationClient `json:"data"` -} - -func (c *CreateOrganizationClientResponse) GetData() OrganizationClient { - if c == nil { - return OrganizationClient{} - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createorganizationrequest.go b/internal/membershipclient/models/components/createorganizationrequest.go deleted file mode 100644 index 71acd60d..00000000 --- a/internal/membershipclient/models/components/createorganizationrequest.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreateOrganizationRequest struct { - // Organization name - Name string `json:"name"` - // Organization domain - Domain *string `json:"domain,omitzero"` - // Default policy ID applied to new users - DefaultPolicyID *int64 `json:"defaultPolicyID,omitzero"` - // User ID of the owner of the organization (only when used with a non user agent) - OwnerID *string `json:"ownerID,omitzero"` -} - -func (c *CreateOrganizationRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateOrganizationRequest) GetDomain() *string { - if c == nil { - return nil - } - return c.Domain -} - -func (c *CreateOrganizationRequest) GetDefaultPolicyID() *int64 { - if c == nil { - return nil - } - return c.DefaultPolicyID -} - -func (c *CreateOrganizationRequest) GetOwnerID() *string { - if c == nil { - return nil - } - return c.OwnerID -} diff --git a/internal/membershipclient/models/components/createorganizationresponse.go b/internal/membershipclient/models/components/createorganizationresponse.go deleted file mode 100644 index 5b684da5..00000000 --- a/internal/membershipclient/models/components/createorganizationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateOrganizationResponse struct { - Data *OrganizationExpanded `json:"data,omitzero"` -} - -func (c CreateOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateOrganizationResponse) GetData() *OrganizationExpanded { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createpolicyrequest.go b/internal/membershipclient/models/components/createpolicyrequest.go deleted file mode 100644 index f8719470..00000000 --- a/internal/membershipclient/models/components/createpolicyrequest.go +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreatePolicyRequest struct { - // Policy name - Name string `json:"name"` - // Policy description - Description *string `json:"description,omitzero"` -} - -func (c *CreatePolicyRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreatePolicyRequest) GetDescription() *string { - if c == nil { - return nil - } - return c.Description -} diff --git a/internal/membershipclient/models/components/createpolicyresponse.go b/internal/membershipclient/models/components/createpolicyresponse.go deleted file mode 100644 index a8c07d60..00000000 --- a/internal/membershipclient/models/components/createpolicyresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreatePolicyResponse struct { - Data *Policy `json:"data,omitzero"` -} - -func (c CreatePolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreatePolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreatePolicyResponse) GetData() *Policy { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/createprivateregionrequest.go b/internal/membershipclient/models/components/createprivateregionrequest.go deleted file mode 100644 index 3d3ab8b9..00000000 --- a/internal/membershipclient/models/components/createprivateregionrequest.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreatePrivateRegionRequest struct { - Name string `json:"name"` -} - -func (c *CreatePrivateRegionRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} diff --git a/internal/membershipclient/models/components/createstackrequest.go b/internal/membershipclient/models/components/createstackrequest.go deleted file mode 100644 index 53ec0520..00000000 --- a/internal/membershipclient/models/components/createstackrequest.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateStackRequest struct { - // Stack name - Name string `json:"name"` - Metadata map[string]string `json:"metadata,omitzero"` - // Supported only with agent version >= v0.7.0 - Version *string `json:"version,omitzero"` - RegionID string `json:"regionID"` -} - -func (c CreateStackRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateStackRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateStackRequest) GetName() string { - if c == nil { - return "" - } - return c.Name -} - -func (c *CreateStackRequest) GetMetadata() map[string]string { - if c == nil { - return nil - } - return c.Metadata -} - -func (c *CreateStackRequest) GetVersion() *string { - if c == nil { - return nil - } - return c.Version -} - -func (c *CreateStackRequest) GetRegionID() string { - if c == nil { - return "" - } - return c.RegionID -} diff --git a/internal/membershipclient/models/components/createuserrequest.go b/internal/membershipclient/models/components/createuserrequest.go deleted file mode 100644 index 82ad0b40..00000000 --- a/internal/membershipclient/models/components/createuserrequest.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type CreateUserRequest struct { - // Email address of the user to create - Email string `json:"email"` -} - -func (c *CreateUserRequest) GetEmail() string { - if c == nil { - return "" - } - return c.Email -} diff --git a/internal/membershipclient/models/components/createuserresponse.go b/internal/membershipclient/models/components/createuserresponse.go deleted file mode 100644 index 4f63509f..00000000 --- a/internal/membershipclient/models/components/createuserresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type CreateUserResponse struct { - Data *User `json:"data,omitzero"` -} - -func (c CreateUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateUserResponse) GetData() *User { - if c == nil { - return nil - } - return c.Data -} diff --git a/internal/membershipclient/models/components/enableapplicationfororganizationresponse.go b/internal/membershipclient/models/components/enableapplicationfororganizationresponse.go deleted file mode 100644 index 400401a0..00000000 --- a/internal/membershipclient/models/components/enableapplicationfororganizationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type EnableApplicationForOrganizationResponse struct { - Data *OrganizationApplication `json:"data,omitzero"` -} - -func (e EnableApplicationForOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(e, "", false) -} - -func (e *EnableApplicationForOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &e, "", false, nil); err != nil { - return err - } - return nil -} - -func (e *EnableApplicationForOrganizationResponse) GetData() *OrganizationApplication { - if e == nil { - return nil - } - return e.Data -} diff --git a/internal/membershipclient/models/components/error.go b/internal/membershipclient/models/components/error.go deleted file mode 100644 index 12883beb..00000000 --- a/internal/membershipclient/models/components/error.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type Error struct { - ErrorCode string `json:"errorCode"` - ErrorMessage *string `json:"errorMessage,omitzero"` -} - -func (e *Error) GetErrorCode() string { - if e == nil { - return "" - } - return e.ErrorCode -} - -func (e *Error) GetErrorMessage() *string { - if e == nil { - return nil - } - return e.ErrorMessage -} diff --git a/internal/membershipclient/models/components/getapplicationresponse.go b/internal/membershipclient/models/components/getapplicationresponse.go deleted file mode 100644 index 83a5e03e..00000000 --- a/internal/membershipclient/models/components/getapplicationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type GetApplicationResponse struct { - Data *ApplicationWithScope `json:"data,omitzero"` -} - -func (g GetApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetApplicationResponse) GetData() *ApplicationWithScope { - if g == nil { - return nil - } - return g.Data -} diff --git a/internal/membershipclient/models/components/getregionresponse.go b/internal/membershipclient/models/components/getregionresponse.go deleted file mode 100644 index 357ae586..00000000 --- a/internal/membershipclient/models/components/getregionresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type GetRegionResponse struct { - Data AnyRegion `json:"data"` -} - -func (g *GetRegionResponse) GetData() AnyRegion { - if g == nil { - return AnyRegion{} - } - return g.Data -} diff --git a/internal/membershipclient/models/components/getregionversionsresponse.go b/internal/membershipclient/models/components/getregionversionsresponse.go deleted file mode 100644 index ec7e1e9b..00000000 --- a/internal/membershipclient/models/components/getregionversionsresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type GetRegionVersionsResponse struct { - Data []Version `json:"data"` -} - -func (g *GetRegionVersionsResponse) GetData() []Version { - if g == nil { - return []Version{} - } - return g.Data -} diff --git a/internal/membershipclient/models/components/httpmetadata.go b/internal/membershipclient/models/components/httpmetadata.go deleted file mode 100644 index 148ec47f..00000000 --- a/internal/membershipclient/models/components/httpmetadata.go +++ /dev/null @@ -1,28 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "net/http" -) - -type HTTPMetadata struct { - // Raw HTTP response; suitable for custom response parsing - Response *http.Response `json:"-"` - // Raw HTTP request; suitable for debugging - Request *http.Request `json:"-"` -} - -func (h *HTTPMetadata) GetResponse() *http.Response { - if h == nil { - return nil - } - return h.Response -} - -func (h *HTTPMetadata) GetRequest() *http.Request { - if h == nil { - return nil - } - return h.Request -} diff --git a/internal/membershipclient/models/components/invitation.go b/internal/membershipclient/models/components/invitation.go deleted file mode 100644 index 663ad1bd..00000000 --- a/internal/membershipclient/models/components/invitation.go +++ /dev/null @@ -1,162 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type InvitationStatus string - -const ( - InvitationStatusPending InvitationStatus = "PENDING" - InvitationStatusAccepted InvitationStatus = "ACCEPTED" - InvitationStatusRejected InvitationStatus = "REJECTED" - InvitationStatusCancelled InvitationStatus = "CANCELLED" -) - -func (e InvitationStatus) ToPointer() *InvitationStatus { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *InvitationStatus) IsExact() bool { - if e != nil { - switch *e { - case "PENDING", "ACCEPTED", "REJECTED", "CANCELLED": - return true - } - } - return false -} - -type OrganizationAccess struct { - Email string `json:"email"` - ID string `json:"id"` - // Policy ID applied to the user - PolicyID int64 `json:"policyId"` -} - -func (o *OrganizationAccess) GetEmail() string { - if o == nil { - return "" - } - return o.Email -} - -func (o *OrganizationAccess) GetID() string { - if o == nil { - return "" - } - return o.ID -} - -func (o *OrganizationAccess) GetPolicyID() int64 { - if o == nil { - return 0 - } - return o.PolicyID -} - -type Invitation struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - UserEmail string `json:"userEmail"` - Status InvitationStatus `json:"status"` - CreationDate time.Time `json:"creationDate"` - UpdatedAt *string `json:"updatedAt,omitzero"` - UserID *string `json:"userId,omitzero"` - OrganizationAccess *OrganizationAccess `json:"organizationAccess,omitzero"` - ExpiresAt *time.Time `json:"expiresAt,omitzero"` - CreatorID *string `json:"creatorId,omitzero"` - LastUpdate *time.Time `json:"lastUpdate,omitzero"` -} - -func (i Invitation) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(i, "", false) -} - -func (i *Invitation) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &i, "", false, nil); err != nil { - return err - } - return nil -} - -func (i *Invitation) GetID() string { - if i == nil { - return "" - } - return i.ID -} - -func (i *Invitation) GetOrganizationID() string { - if i == nil { - return "" - } - return i.OrganizationID -} - -func (i *Invitation) GetUserEmail() string { - if i == nil { - return "" - } - return i.UserEmail -} - -func (i *Invitation) GetStatus() InvitationStatus { - if i == nil { - return InvitationStatus("") - } - return i.Status -} - -func (i *Invitation) GetCreationDate() time.Time { - if i == nil { - return time.Time{} - } - return i.CreationDate -} - -func (i *Invitation) GetUpdatedAt() *string { - if i == nil { - return nil - } - return i.UpdatedAt -} - -func (i *Invitation) GetUserID() *string { - if i == nil { - return nil - } - return i.UserID -} - -func (i *Invitation) GetOrganizationAccess() *OrganizationAccess { - if i == nil { - return nil - } - return i.OrganizationAccess -} - -func (i *Invitation) GetExpiresAt() *time.Time { - if i == nil { - return nil - } - return i.ExpiresAt -} - -func (i *Invitation) GetCreatorID() *string { - if i == nil { - return nil - } - return i.CreatorID -} - -func (i *Invitation) GetLastUpdate() *time.Time { - if i == nil { - return nil - } - return i.LastUpdate -} diff --git a/internal/membershipclient/models/components/listapplicationsresponse.go b/internal/membershipclient/models/components/listapplicationsresponse.go deleted file mode 100644 index 92ab99b9..00000000 --- a/internal/membershipclient/models/components/listapplicationsresponse.go +++ /dev/null @@ -1,72 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type Cursor struct { - PageSize int64 `json:"pageSize"` - HasMore bool `json:"hasMore"` - Previous *string `json:"previous,omitzero"` - Next *string `json:"next,omitzero"` - Data []Application `json:"data"` -} - -func (c *Cursor) GetPageSize() int64 { - if c == nil { - return 0 - } - return c.PageSize -} - -func (c *Cursor) GetHasMore() bool { - if c == nil { - return false - } - return c.HasMore -} - -func (c *Cursor) GetPrevious() *string { - if c == nil { - return nil - } - return c.Previous -} - -func (c *Cursor) GetNext() *string { - if c == nil { - return nil - } - return c.Next -} - -func (c *Cursor) GetData() []Application { - if c == nil { - return []Application{} - } - return c.Data -} - -type ListApplicationsResponse struct { - Cursor *Cursor `json:"cursor,omitzero"` -} - -func (l ListApplicationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListApplicationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListApplicationsResponse) GetCursor() *Cursor { - if l == nil { - return nil - } - return l.Cursor -} diff --git a/internal/membershipclient/models/components/listinvitationsresponse.go b/internal/membershipclient/models/components/listinvitationsresponse.go deleted file mode 100644 index ed5ab458..00000000 --- a/internal/membershipclient/models/components/listinvitationsresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ListInvitationsResponse struct { - Data []Invitation `json:"data,omitzero"` -} - -func (l ListInvitationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListInvitationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListInvitationsResponse) GetData() []Invitation { - if l == nil { - return nil - } - return l.Data -} diff --git a/internal/membershipclient/models/components/listmodulesresponse.go b/internal/membershipclient/models/components/listmodulesresponse.go deleted file mode 100644 index abea7bed..00000000 --- a/internal/membershipclient/models/components/listmodulesresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type ListModulesResponse struct { - Data []Module `json:"data"` -} - -func (l *ListModulesResponse) GetData() []Module { - if l == nil { - return []Module{} - } - return l.Data -} diff --git a/internal/membershipclient/models/components/listorganizationexpandedresponse.go b/internal/membershipclient/models/components/listorganizationexpandedresponse.go deleted file mode 100644 index fe89517c..00000000 --- a/internal/membershipclient/models/components/listorganizationexpandedresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ListOrganizationExpandedResponse struct { - Data []OrganizationExpanded `json:"data,omitzero"` -} - -func (l ListOrganizationExpandedResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationExpandedResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationExpandedResponse) GetData() []OrganizationExpanded { - if l == nil { - return nil - } - return l.Data -} diff --git a/internal/membershipclient/models/components/listpoliciesresponse.go b/internal/membershipclient/models/components/listpoliciesresponse.go deleted file mode 100644 index f28cf4d5..00000000 --- a/internal/membershipclient/models/components/listpoliciesresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ListPoliciesResponse struct { - Data []Policy `json:"data,omitzero"` -} - -func (l ListPoliciesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListPoliciesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListPoliciesResponse) GetData() []Policy { - if l == nil { - return nil - } - return l.Data -} diff --git a/internal/membershipclient/models/components/listregionsresponse.go b/internal/membershipclient/models/components/listregionsresponse.go deleted file mode 100644 index fd19e9a5..00000000 --- a/internal/membershipclient/models/components/listregionsresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type ListRegionsResponse struct { - Data []AnyRegion `json:"data"` -} - -func (l *ListRegionsResponse) GetData() []AnyRegion { - if l == nil { - return []AnyRegion{} - } - return l.Data -} diff --git a/internal/membershipclient/models/components/liststacksresponse.go b/internal/membershipclient/models/components/liststacksresponse.go deleted file mode 100644 index 44635fb1..00000000 --- a/internal/membershipclient/models/components/liststacksresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ListStacksResponse struct { - Data []Stack `json:"data,omitzero"` -} - -func (l ListStacksResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListStacksResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListStacksResponse) GetData() []Stack { - if l == nil { - return nil - } - return l.Data -} diff --git a/internal/membershipclient/models/components/listusersresponse.go b/internal/membershipclient/models/components/listusersresponse.go deleted file mode 100644 index a2e90bed..00000000 --- a/internal/membershipclient/models/components/listusersresponse.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type OrganizationUser struct { - Email string `json:"email"` - ID string `json:"id"` - // Policy ID applied to the user - PolicyID int64 `json:"policyId"` -} - -func (o *OrganizationUser) GetEmail() string { - if o == nil { - return "" - } - return o.Email -} - -func (o *OrganizationUser) GetID() string { - if o == nil { - return "" - } - return o.ID -} - -func (o *OrganizationUser) GetPolicyID() int64 { - if o == nil { - return 0 - } - return o.PolicyID -} - -type ListUsersResponse struct { - Data []OrganizationUser `json:"data,omitzero"` -} - -func (l ListUsersResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListUsersResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListUsersResponse) GetData() []OrganizationUser { - if l == nil { - return nil - } - return l.Data -} diff --git a/internal/membershipclient/models/components/log.go b/internal/membershipclient/models/components/log.go deleted file mode 100644 index de1a986c..00000000 --- a/internal/membershipclient/models/components/log.go +++ /dev/null @@ -1,73 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type LogData struct { -} - -type Log struct { - Seq string `json:"seq"` - OrganizationID string `json:"organizationId"` - UserID string `json:"userId"` - Action string `json:"action"` - Date time.Time `json:"date"` - Data LogData `json:"data"` -} - -func (l Log) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *Log) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *Log) GetSeq() string { - if l == nil { - return "" - } - return l.Seq -} - -func (l *Log) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *Log) GetUserID() string { - if l == nil { - return "" - } - return l.UserID -} - -func (l *Log) GetAction() string { - if l == nil { - return "" - } - return l.Action -} - -func (l *Log) GetDate() time.Time { - if l == nil { - return time.Time{} - } - return l.Date -} - -func (l *Log) GetData() LogData { - if l == nil { - return LogData{} - } - return l.Data -} diff --git a/internal/membershipclient/models/components/logcursor.go b/internal/membershipclient/models/components/logcursor.go deleted file mode 100644 index 1394bcd0..00000000 --- a/internal/membershipclient/models/components/logcursor.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type LogCursorData struct { - PageSize int64 `json:"pageSize"` - HasMore bool `json:"hasMore"` - Previous *string `json:"previous,omitzero"` - Next *string `json:"next,omitzero"` - Data []Log `json:"data"` -} - -func (l *LogCursorData) GetPageSize() int64 { - if l == nil { - return 0 - } - return l.PageSize -} - -func (l *LogCursorData) GetHasMore() bool { - if l == nil { - return false - } - return l.HasMore -} - -func (l *LogCursorData) GetPrevious() *string { - if l == nil { - return nil - } - return l.Previous -} - -func (l *LogCursorData) GetNext() *string { - if l == nil { - return nil - } - return l.Next -} - -func (l *LogCursorData) GetData() []Log { - if l == nil { - return []Log{} - } - return l.Data -} - -type LogCursor struct { - Data LogCursorData `json:"data"` -} - -func (l *LogCursor) GetData() LogCursorData { - if l == nil { - return LogCursorData{} - } - return l.Data -} diff --git a/internal/membershipclient/models/components/module.go b/internal/membershipclient/models/components/module.go deleted file mode 100644 index e31ac524..00000000 --- a/internal/membershipclient/models/components/module.go +++ /dev/null @@ -1,119 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type ModuleState string - -const ( - ModuleStateEnabled ModuleState = "ENABLED" - ModuleStateDisabled ModuleState = "DISABLED" -) - -func (e ModuleState) ToPointer() *ModuleState { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *ModuleState) IsExact() bool { - if e != nil { - switch *e { - case "ENABLED", "DISABLED": - return true - } - } - return false -} - -type ModuleStatus string - -const ( - ModuleStatusUnknown ModuleStatus = "UNKNOWN" - ModuleStatusProgressing ModuleStatus = "PROGRESSING" - ModuleStatusReady ModuleStatus = "READY" - ModuleStatusDeleted ModuleStatus = "DELETED" -) - -func (e ModuleStatus) ToPointer() *ModuleStatus { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *ModuleStatus) IsExact() bool { - if e != nil { - switch *e { - case "UNKNOWN", "PROGRESSING", "READY", "DELETED": - return true - } - } - return false -} - -type ClusterStatus struct { -} - -type Module struct { - Name string `json:"name"` - State ModuleState `json:"state"` - Status ModuleStatus `json:"status"` - LastStatusUpdate time.Time `json:"lastStatusUpdate"` - LastStateUpdate time.Time `json:"lastStateUpdate"` - ClusterStatus *ClusterStatus `json:"clusterStatus,omitzero"` -} - -func (m Module) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(m, "", false) -} - -func (m *Module) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &m, "", false, nil); err != nil { - return err - } - return nil -} - -func (m *Module) GetName() string { - if m == nil { - return "" - } - return m.Name -} - -func (m *Module) GetState() ModuleState { - if m == nil { - return ModuleState("") - } - return m.State -} - -func (m *Module) GetStatus() ModuleStatus { - if m == nil { - return ModuleStatus("") - } - return m.Status -} - -func (m *Module) GetLastStatusUpdate() time.Time { - if m == nil { - return time.Time{} - } - return m.LastStatusUpdate -} - -func (m *Module) GetLastStateUpdate() time.Time { - if m == nil { - return time.Time{} - } - return m.LastStateUpdate -} - -func (m *Module) GetClusterStatus() *ClusterStatus { - if m == nil { - return nil - } - return m.ClusterStatus -} diff --git a/internal/membershipclient/models/components/organizationapplication.go b/internal/membershipclient/models/components/organizationapplication.go deleted file mode 100644 index 2391b325..00000000 --- a/internal/membershipclient/models/components/organizationapplication.go +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type OrganizationApplication struct { - // Organization ID - OrganizationID string `json:"organizationId"` - // Application ID - ApplicationID string `json:"applicationId"` - // Creation date - CreatedAt time.Time `json:"createdAt"` -} - -func (o OrganizationApplication) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationApplication) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationApplication) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationApplication) GetApplicationID() string { - if o == nil { - return "" - } - return o.ApplicationID -} - -func (o *OrganizationApplication) GetCreatedAt() time.Time { - if o == nil { - return time.Time{} - } - return o.CreatedAt -} diff --git a/internal/membershipclient/models/components/organizationclient.go b/internal/membershipclient/models/components/organizationclient.go deleted file mode 100644 index 851405a0..00000000 --- a/internal/membershipclient/models/components/organizationclient.go +++ /dev/null @@ -1,92 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type OrganizationClientSecret struct { - LastDigits string `json:"lastDigits"` - Clear *string `json:"clear,omitzero"` -} - -func (o *OrganizationClientSecret) GetLastDigits() string { - if o == nil { - return "" - } - return o.LastDigits -} - -func (o *OrganizationClientSecret) GetClear() *string { - if o == nil { - return nil - } - return o.Clear -} - -type OrganizationClient struct { - // Organization ID - ID string `json:"id"` - Secret OrganizationClientSecret `json:"secret"` - // Name of the client - Name string `json:"name"` - // Description of the client - Description string `json:"description"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (o OrganizationClient) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClient) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClient) GetID() string { - if o == nil { - return "" - } - return o.ID -} - -func (o *OrganizationClient) GetSecret() OrganizationClientSecret { - if o == nil { - return OrganizationClientSecret{} - } - return o.Secret -} - -func (o *OrganizationClient) GetName() string { - if o == nil { - return "" - } - return o.Name -} - -func (o *OrganizationClient) GetDescription() string { - if o == nil { - return "" - } - return o.Description -} - -func (o *OrganizationClient) GetCreatedAt() time.Time { - if o == nil { - return time.Time{} - } - return o.CreatedAt -} - -func (o *OrganizationClient) GetUpdatedAt() time.Time { - if o == nil { - return time.Time{} - } - return o.UpdatedAt -} diff --git a/internal/membershipclient/models/components/organizationdata.go b/internal/membershipclient/models/components/organizationdata.go deleted file mode 100644 index f0b6b4bb..00000000 --- a/internal/membershipclient/models/components/organizationdata.go +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type OrganizationData struct { - // Organization name - Name string `json:"name"` - // Organization domain - Domain *string `json:"domain,omitzero"` - // Default policy ID applied to new users - DefaultPolicyID *int64 `json:"defaultPolicyID"` -} - -func (o *OrganizationData) GetName() string { - if o == nil { - return "" - } - return o.Name -} - -func (o *OrganizationData) GetDomain() *string { - if o == nil { - return nil - } - return o.Domain -} - -func (o *OrganizationData) GetDefaultPolicyID() *int64 { - if o == nil { - return nil - } - return o.DefaultPolicyID -} diff --git a/internal/membershipclient/models/components/organizationexpanded.go b/internal/membershipclient/models/components/organizationexpanded.go deleted file mode 100644 index a4f9af46..00000000 --- a/internal/membershipclient/models/components/organizationexpanded.go +++ /dev/null @@ -1,125 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type OrganizationExpanded struct { - // Organization name - Name string `json:"name"` - // Organization domain - Domain *string `json:"domain,omitzero"` - // Default policy ID applied to new users - DefaultPolicyID *int64 `json:"defaultPolicyID"` - // Organization ID - ID string `json:"id"` - // Owner ID - OwnerID string `json:"ownerId"` - // Number of available stacks - AvailableStacks *int64 `json:"availableStacks,omitzero"` - // Number of available sandboxes - AvailableSandboxes *int64 `json:"availableSandboxes,omitzero"` - CreatedAt *time.Time `json:"createdAt,omitzero"` - UpdatedAt *time.Time `json:"updatedAt,omitzero"` - TotalStacks *int64 `json:"totalStacks,omitzero"` - TotalUsers *int64 `json:"totalUsers,omitzero"` - Owner *User `json:"owner,omitzero"` -} - -func (o OrganizationExpanded) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationExpanded) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationExpanded) GetName() string { - if o == nil { - return "" - } - return o.Name -} - -func (o *OrganizationExpanded) GetDomain() *string { - if o == nil { - return nil - } - return o.Domain -} - -func (o *OrganizationExpanded) GetDefaultPolicyID() *int64 { - if o == nil { - return nil - } - return o.DefaultPolicyID -} - -func (o *OrganizationExpanded) GetID() string { - if o == nil { - return "" - } - return o.ID -} - -func (o *OrganizationExpanded) GetOwnerID() string { - if o == nil { - return "" - } - return o.OwnerID -} - -func (o *OrganizationExpanded) GetAvailableStacks() *int64 { - if o == nil { - return nil - } - return o.AvailableStacks -} - -func (o *OrganizationExpanded) GetAvailableSandboxes() *int64 { - if o == nil { - return nil - } - return o.AvailableSandboxes -} - -func (o *OrganizationExpanded) GetCreatedAt() *time.Time { - if o == nil { - return nil - } - return o.CreatedAt -} - -func (o *OrganizationExpanded) GetUpdatedAt() *time.Time { - if o == nil { - return nil - } - return o.UpdatedAt -} - -func (o *OrganizationExpanded) GetTotalStacks() *int64 { - if o == nil { - return nil - } - return o.TotalStacks -} - -func (o *OrganizationExpanded) GetTotalUsers() *int64 { - if o == nil { - return nil - } - return o.TotalUsers -} - -func (o *OrganizationExpanded) GetOwner() *User { - if o == nil { - return nil - } - return o.Owner -} diff --git a/internal/membershipclient/models/components/organizationfeature.go b/internal/membershipclient/models/components/organizationfeature.go deleted file mode 100644 index 25a5a2fb..00000000 --- a/internal/membershipclient/models/components/organizationfeature.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type OrganizationFeature struct { - OrganizationID string `json:"organizationID"` - Name string `json:"name"` - CreatedAt time.Time `json:"createdAt"` -} - -func (o OrganizationFeature) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationFeature) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationFeature) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationFeature) GetName() string { - if o == nil { - return "" - } - return o.Name -} - -func (o *OrganizationFeature) GetCreatedAt() time.Time { - if o == nil { - return time.Time{} - } - return o.CreatedAt -} diff --git a/internal/membershipclient/models/components/policy.go b/internal/membershipclient/models/components/policy.go deleted file mode 100644 index dd370386..00000000 --- a/internal/membershipclient/models/components/policy.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type Policy struct { - // Policy name - Name string `json:"name"` - // Policy description - Description *string `json:"description,omitzero"` - // Policy ID - ID int64 `json:"id"` - // Organization ID - OrganizationID *string `json:"organizationId,omitzero"` - // Whether the policy is protected from modification - Protected bool `json:"protected"` - // List of scopes assigned to the policy (only present when fetched via GET endpoint) - Scopes []Scope `json:"scopes,omitzero"` - // Creation date - CreatedAt time.Time `json:"createdAt"` - // Last update date - UpdatedAt time.Time `json:"updatedAt"` -} - -func (p Policy) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(p, "", false) -} - -func (p *Policy) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &p, "", false, nil); err != nil { - return err - } - return nil -} - -func (p *Policy) GetName() string { - if p == nil { - return "" - } - return p.Name -} - -func (p *Policy) GetDescription() *string { - if p == nil { - return nil - } - return p.Description -} - -func (p *Policy) GetID() int64 { - if p == nil { - return 0 - } - return p.ID -} - -func (p *Policy) GetOrganizationID() *string { - if p == nil { - return nil - } - return p.OrganizationID -} - -func (p *Policy) GetProtected() bool { - if p == nil { - return false - } - return p.Protected -} - -func (p *Policy) GetScopes() []Scope { - if p == nil { - return nil - } - return p.Scopes -} - -func (p *Policy) GetCreatedAt() time.Time { - if p == nil { - return time.Time{} - } - return p.CreatedAt -} - -func (p *Policy) GetUpdatedAt() time.Time { - if p == nil { - return time.Time{} - } - return p.UpdatedAt -} diff --git a/internal/membershipclient/models/components/privateregion.go b/internal/membershipclient/models/components/privateregion.go deleted file mode 100644 index 4f6a0413..00000000 --- a/internal/membershipclient/models/components/privateregion.go +++ /dev/null @@ -1,134 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type PrivateRegion struct { - ID string `json:"id"` - BaseURL string `json:"baseUrl"` - CreatedAt string `json:"createdAt"` - Active bool `json:"active"` - LastPing *time.Time `json:"lastPing,omitzero"` - Name string `json:"name"` - Capabilities RegionCapability `json:"capabilities"` - AgentID string `json:"agentID"` - Outdated bool `json:"outdated"` - CreatorID *string `json:"creatorId,omitzero"` - Version *string `json:"version,omitzero"` - OrganizationID string `json:"organizationID"` - CreatorId1 string `json:"creatorID"` - Secret *PrivateRegionSecret `json:"secret,omitzero"` -} - -func (p PrivateRegion) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(p, "", false) -} - -func (p *PrivateRegion) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &p, "", false, nil); err != nil { - return err - } - return nil -} - -func (p *PrivateRegion) GetID() string { - if p == nil { - return "" - } - return p.ID -} - -func (p *PrivateRegion) GetBaseURL() string { - if p == nil { - return "" - } - return p.BaseURL -} - -func (p *PrivateRegion) GetCreatedAt() string { - if p == nil { - return "" - } - return p.CreatedAt -} - -func (p *PrivateRegion) GetActive() bool { - if p == nil { - return false - } - return p.Active -} - -func (p *PrivateRegion) GetLastPing() *time.Time { - if p == nil { - return nil - } - return p.LastPing -} - -func (p *PrivateRegion) GetName() string { - if p == nil { - return "" - } - return p.Name -} - -func (p *PrivateRegion) GetCapabilities() RegionCapability { - if p == nil { - return RegionCapability{} - } - return p.Capabilities -} - -func (p *PrivateRegion) GetAgentID() string { - if p == nil { - return "" - } - return p.AgentID -} - -func (p *PrivateRegion) GetOutdated() bool { - if p == nil { - return false - } - return p.Outdated -} - -func (p *PrivateRegion) GetCreatorID() *string { - if p == nil { - return nil - } - return p.CreatorID -} - -func (p *PrivateRegion) GetVersion() *string { - if p == nil { - return nil - } - return p.Version -} - -func (p *PrivateRegion) GetOrganizationID() string { - if p == nil { - return "" - } - return p.OrganizationID -} - -func (p *PrivateRegion) GetCreatorId1() string { - if p == nil { - return "" - } - return p.CreatorId1 -} - -func (p *PrivateRegion) GetSecret() *PrivateRegionSecret { - if p == nil { - return nil - } - return p.Secret -} diff --git a/internal/membershipclient/models/components/privateregionsecret.go b/internal/membershipclient/models/components/privateregionsecret.go deleted file mode 100644 index bde1ff35..00000000 --- a/internal/membershipclient/models/components/privateregionsecret.go +++ /dev/null @@ -1,22 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type PrivateRegionSecret struct { - LastDigits string `json:"lastDigits"` - Clear *string `json:"clear,omitzero"` -} - -func (p *PrivateRegionSecret) GetLastDigits() string { - if p == nil { - return "" - } - return p.LastDigits -} - -func (p *PrivateRegionSecret) GetClear() *string { - if p == nil { - return nil - } - return p.Clear -} diff --git a/internal/membershipclient/models/components/readorganizationclientresponse.go b/internal/membershipclient/models/components/readorganizationclientresponse.go deleted file mode 100644 index 34042097..00000000 --- a/internal/membershipclient/models/components/readorganizationclientresponse.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type ReadOrganizationClientResponse struct { - Data OrganizationClient `json:"data"` -} - -func (r *ReadOrganizationClientResponse) GetData() OrganizationClient { - if r == nil { - return OrganizationClient{} - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readorganizationclientsresponse.go b/internal/membershipclient/models/components/readorganizationclientsresponse.go deleted file mode 100644 index 5a451ddc..00000000 --- a/internal/membershipclient/models/components/readorganizationclientsresponse.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type ReadOrganizationClientsResponseData struct { - PageSize int64 `json:"pageSize"` - HasMore bool `json:"hasMore"` - Previous *string `json:"previous,omitzero"` - Next *string `json:"next,omitzero"` - Data []OrganizationClient `json:"data"` -} - -func (r *ReadOrganizationClientsResponseData) GetPageSize() int64 { - if r == nil { - return 0 - } - return r.PageSize -} - -func (r *ReadOrganizationClientsResponseData) GetHasMore() bool { - if r == nil { - return false - } - return r.HasMore -} - -func (r *ReadOrganizationClientsResponseData) GetPrevious() *string { - if r == nil { - return nil - } - return r.Previous -} - -func (r *ReadOrganizationClientsResponseData) GetNext() *string { - if r == nil { - return nil - } - return r.Next -} - -func (r *ReadOrganizationClientsResponseData) GetData() []OrganizationClient { - if r == nil { - return []OrganizationClient{} - } - return r.Data -} - -type ReadOrganizationClientsResponse struct { - Data ReadOrganizationClientsResponseData `json:"data"` -} - -func (r *ReadOrganizationClientsResponse) GetData() ReadOrganizationClientsResponseData { - if r == nil { - return ReadOrganizationClientsResponseData{} - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readorganizationresponse.go b/internal/membershipclient/models/components/readorganizationresponse.go deleted file mode 100644 index faad21bf..00000000 --- a/internal/membershipclient/models/components/readorganizationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadOrganizationResponse struct { - Data *OrganizationExpanded `json:"data,omitzero"` -} - -func (r ReadOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadOrganizationResponse) GetData() *OrganizationExpanded { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readorganizationuserresponse.go b/internal/membershipclient/models/components/readorganizationuserresponse.go deleted file mode 100644 index d0f8411d..00000000 --- a/internal/membershipclient/models/components/readorganizationuserresponse.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadOrganizationUserResponseData struct { - Email string `json:"email"` - ID string `json:"id"` - // Policy ID applied to the user - PolicyID int64 `json:"policyId"` -} - -func (r *ReadOrganizationUserResponseData) GetEmail() string { - if r == nil { - return "" - } - return r.Email -} - -func (r *ReadOrganizationUserResponseData) GetID() string { - if r == nil { - return "" - } - return r.ID -} - -func (r *ReadOrganizationUserResponseData) GetPolicyID() int64 { - if r == nil { - return 0 - } - return r.PolicyID -} - -type ReadOrganizationUserResponse struct { - Data *ReadOrganizationUserResponseData `json:"data,omitzero"` -} - -func (r ReadOrganizationUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadOrganizationUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadOrganizationUserResponse) GetData() *ReadOrganizationUserResponseData { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readpolicyresponse.go b/internal/membershipclient/models/components/readpolicyresponse.go deleted file mode 100644 index 81404ae1..00000000 --- a/internal/membershipclient/models/components/readpolicyresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadPolicyResponse struct { - Data *Policy `json:"data,omitzero"` -} - -func (r ReadPolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadPolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadPolicyResponse) GetData() *Policy { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readstackresponse.go b/internal/membershipclient/models/components/readstackresponse.go deleted file mode 100644 index 93aed36d..00000000 --- a/internal/membershipclient/models/components/readstackresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadStackResponse struct { - Data *Stack `json:"data,omitzero"` -} - -func (r ReadStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadStackResponse) GetData() *Stack { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readstackuseraccess.go b/internal/membershipclient/models/components/readstackuseraccess.go deleted file mode 100644 index b9f6b076..00000000 --- a/internal/membershipclient/models/components/readstackuseraccess.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadStackUserAccessData struct { - // Stack ID - StackID string `json:"stackId"` - // User ID - UserID string `json:"userId"` - // User email - Email string `json:"email"` - // Policy ID applied to the user for the stack - PolicyID int64 `json:"policyId"` -} - -func (r *ReadStackUserAccessData) GetStackID() string { - if r == nil { - return "" - } - return r.StackID -} - -func (r *ReadStackUserAccessData) GetUserID() string { - if r == nil { - return "" - } - return r.UserID -} - -func (r *ReadStackUserAccessData) GetEmail() string { - if r == nil { - return "" - } - return r.Email -} - -func (r *ReadStackUserAccessData) GetPolicyID() int64 { - if r == nil { - return 0 - } - return r.PolicyID -} - -type ReadStackUserAccess struct { - Data *ReadStackUserAccessData `json:"data,omitzero"` -} - -func (r ReadStackUserAccess) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadStackUserAccess) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadStackUserAccess) GetData() *ReadStackUserAccessData { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/readuserresponse.go b/internal/membershipclient/models/components/readuserresponse.go deleted file mode 100644 index 470d6714..00000000 --- a/internal/membershipclient/models/components/readuserresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ReadUserResponse struct { - Data *User `json:"data,omitzero"` -} - -func (r ReadUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadUserResponse) GetData() *User { - if r == nil { - return nil - } - return r.Data -} diff --git a/internal/membershipclient/models/components/region.go b/internal/membershipclient/models/components/region.go deleted file mode 100644 index 29b71993..00000000 --- a/internal/membershipclient/models/components/region.go +++ /dev/null @@ -1,110 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type Region struct { - ID string `json:"id"` - BaseURL string `json:"baseUrl"` - CreatedAt string `json:"createdAt"` - Active bool `json:"active"` - LastPing *time.Time `json:"lastPing,omitzero"` - Name string `json:"name"` - Capabilities RegionCapability `json:"capabilities"` - AgentID string `json:"agentID"` - Outdated bool `json:"outdated"` - CreatorID *string `json:"creatorId,omitzero"` - Version *string `json:"version,omitzero"` -} - -func (r Region) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *Region) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *Region) GetID() string { - if r == nil { - return "" - } - return r.ID -} - -func (r *Region) GetBaseURL() string { - if r == nil { - return "" - } - return r.BaseURL -} - -func (r *Region) GetCreatedAt() string { - if r == nil { - return "" - } - return r.CreatedAt -} - -func (r *Region) GetActive() bool { - if r == nil { - return false - } - return r.Active -} - -func (r *Region) GetLastPing() *time.Time { - if r == nil { - return nil - } - return r.LastPing -} - -func (r *Region) GetName() string { - if r == nil { - return "" - } - return r.Name -} - -func (r *Region) GetCapabilities() RegionCapability { - if r == nil { - return RegionCapability{} - } - return r.Capabilities -} - -func (r *Region) GetAgentID() string { - if r == nil { - return "" - } - return r.AgentID -} - -func (r *Region) GetOutdated() bool { - if r == nil { - return false - } - return r.Outdated -} - -func (r *Region) GetCreatorID() *string { - if r == nil { - return nil - } - return r.CreatorID -} - -func (r *Region) GetVersion() *string { - if r == nil { - return nil - } - return r.Version -} diff --git a/internal/membershipclient/models/components/regioncapability.go b/internal/membershipclient/models/components/regioncapability.go deleted file mode 100644 index 81ec2c85..00000000 --- a/internal/membershipclient/models/components/regioncapability.go +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type RegionCapability struct { - ModuleList []string `json:"MODULE_LIST,omitzero"` - Ee []string `json:"EE,omitzero"` -} - -func (r RegionCapability) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *RegionCapability) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *RegionCapability) GetModuleList() []string { - if r == nil { - return nil - } - return r.ModuleList -} - -func (r *RegionCapability) GetEe() []string { - if r == nil { - return nil - } - return r.Ee -} diff --git a/internal/membershipclient/models/components/scope.go b/internal/membershipclient/models/components/scope.go deleted file mode 100644 index de760f22..00000000 --- a/internal/membershipclient/models/components/scope.go +++ /dev/null @@ -1,85 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type Scope struct { - // Scope ID - ID int64 `json:"id"` - // The OAuth2 scope label (e.g., "custom:read") - Label string `json:"label"` - // Scope description - Description *string `json:"description,omitzero"` - // Application ID (null for global scopes) - ApplicationID *string `json:"applicationId,omitzero"` - // Whether the scope is protected - Protected *bool `default:"false" json:"protected"` - // Creation timestamp - CreatedAt time.Time `json:"createdAt"` - // Last update timestamp - UpdatedAt time.Time `json:"updatedAt"` -} - -func (s Scope) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(s, "", false) -} - -func (s *Scope) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &s, "", false, nil); err != nil { - return err - } - return nil -} - -func (s *Scope) GetID() int64 { - if s == nil { - return 0 - } - return s.ID -} - -func (s *Scope) GetLabel() string { - if s == nil { - return "" - } - return s.Label -} - -func (s *Scope) GetDescription() *string { - if s == nil { - return nil - } - return s.Description -} - -func (s *Scope) GetApplicationID() *string { - if s == nil { - return nil - } - return s.ApplicationID -} - -func (s *Scope) GetProtected() *bool { - if s == nil { - return nil - } - return s.Protected -} - -func (s *Scope) GetCreatedAt() time.Time { - if s == nil { - return time.Time{} - } - return s.CreatedAt -} - -func (s *Scope) GetUpdatedAt() time.Time { - if s == nil { - return time.Time{} - } - return s.UpdatedAt -} diff --git a/internal/membershipclient/models/components/security.go b/internal/membershipclient/models/components/security.go deleted file mode 100644 index 86ed7557..00000000 --- a/internal/membershipclient/models/components/security.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type Security struct { - Oauth2 string `security:"scheme,type=oauth2,name=Authorization"` -} - -func (s *Security) GetOauth2() string { - if s == nil { - return "" - } - return s.Oauth2 -} diff --git a/internal/membershipclient/models/components/serverinfo.go b/internal/membershipclient/models/components/serverinfo.go deleted file mode 100644 index 043ef845..00000000 --- a/internal/membershipclient/models/components/serverinfo.go +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type ServerInfo struct { - Version string `json:"version"` - Capabilities []Capability `json:"capabilities,omitzero"` - ConsoleURL *string `json:"consoleURL,omitzero"` -} - -func (s ServerInfo) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(s, "", false) -} - -func (s *ServerInfo) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &s, "", false, nil); err != nil { - return err - } - return nil -} - -func (s *ServerInfo) GetVersion() string { - if s == nil { - return "" - } - return s.Version -} - -func (s *ServerInfo) GetCapabilities() []Capability { - if s == nil { - return nil - } - return s.Capabilities -} - -func (s *ServerInfo) GetConsoleURL() *string { - if s == nil { - return nil - } - return s.ConsoleURL -} diff --git a/internal/membershipclient/models/components/stack.go b/internal/membershipclient/models/components/stack.go deleted file mode 100644 index 291749dd..00000000 --- a/internal/membershipclient/models/components/stack.go +++ /dev/null @@ -1,309 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "time" -) - -type StackStatus string - -const ( - StackStatusUnknown StackStatus = "UNKNOWN" - StackStatusProgressing StackStatus = "PROGRESSING" - StackStatusReady StackStatus = "READY" - StackStatusDisabled StackStatus = "DISABLED" - StackStatusDeleted StackStatus = "DELETED" -) - -func (e StackStatus) ToPointer() *StackStatus { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *StackStatus) IsExact() bool { - if e != nil { - switch *e { - case "UNKNOWN", "PROGRESSING", "READY", "DISABLED", "DELETED": - return true - } - } - return false -} - -type StackState string - -const ( - StackStateActive StackState = "ACTIVE" - StackStateDisabled StackState = "DISABLED" - StackStateDeleted StackState = "DELETED" -) - -func (e StackState) ToPointer() *StackState { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *StackState) IsExact() bool { - if e != nil { - switch *e { - case "ACTIVE", "DISABLED", "DELETED": - return true - } - } - return false -} - -type ExpectedStatus string - -const ( - ExpectedStatusReady ExpectedStatus = "READY" - ExpectedStatusDisabled ExpectedStatus = "DISABLED" - ExpectedStatusDeleted ExpectedStatus = "DELETED" -) - -func (e ExpectedStatus) ToPointer() *ExpectedStatus { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *ExpectedStatus) IsExact() bool { - if e != nil { - switch *e { - case "READY", "DISABLED", "DELETED": - return true - } - } - return false -} - -type Stack struct { - // Stack name - Name string `json:"name"` - Metadata map[string]string `json:"metadata,omitzero"` - // Supported only with agent version >= v0.7.0 - Version *string `json:"version,omitzero"` - Status StackStatus `json:"status"` - State StackState `json:"state"` - ExpectedStatus ExpectedStatus `json:"expectedStatus"` - LastStateUpdate time.Time `json:"lastStateUpdate"` - LastExpectedStatusUpdate time.Time `json:"lastExpectedStatusUpdate"` - LastStatusUpdate time.Time `json:"lastStatusUpdate"` - WarnedAt *time.Time `json:"warnedAt,omitzero"` - DisposableSince *time.Time `json:"disposableSince,omitzero"` - // Stack is reachable through Stargate - Reachable bool `json:"reachable"` - // Last time the stack was reachable - LastReachableUpdate *time.Time `json:"lastReachableUpdate,omitzero"` - // Stack ID - ID string `json:"id"` - // Organization ID - OrganizationID string `json:"organizationId"` - // Base stack uri - URI string `json:"uri"` - // The region where the stack is installed - RegionID string `json:"regionID"` - Region *Region `json:"region,omitzero"` - StargateEnabled bool `json:"stargateEnabled"` - CreatedAt *time.Time `json:"createdAt,omitzero"` - DeletedAt *time.Time `json:"deletedAt,omitzero"` - DisabledAt *time.Time `json:"disabledAt,omitzero"` - AuditEnabled *bool `json:"auditEnabled,omitzero"` - Synchronised bool `json:"synchronised"` - UpdatedAt *time.Time `json:"updatedAt,omitzero"` - Modules []Module `json:"modules"` -} - -func (s Stack) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(s, "", false) -} - -func (s *Stack) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &s, "", false, nil); err != nil { - return err - } - return nil -} - -func (s *Stack) GetName() string { - if s == nil { - return "" - } - return s.Name -} - -func (s *Stack) GetMetadata() map[string]string { - if s == nil { - return nil - } - return s.Metadata -} - -func (s *Stack) GetVersion() *string { - if s == nil { - return nil - } - return s.Version -} - -func (s *Stack) GetStatus() StackStatus { - if s == nil { - return StackStatus("") - } - return s.Status -} - -func (s *Stack) GetState() StackState { - if s == nil { - return StackState("") - } - return s.State -} - -func (s *Stack) GetExpectedStatus() ExpectedStatus { - if s == nil { - return ExpectedStatus("") - } - return s.ExpectedStatus -} - -func (s *Stack) GetLastStateUpdate() time.Time { - if s == nil { - return time.Time{} - } - return s.LastStateUpdate -} - -func (s *Stack) GetLastExpectedStatusUpdate() time.Time { - if s == nil { - return time.Time{} - } - return s.LastExpectedStatusUpdate -} - -func (s *Stack) GetLastStatusUpdate() time.Time { - if s == nil { - return time.Time{} - } - return s.LastStatusUpdate -} - -func (s *Stack) GetWarnedAt() *time.Time { - if s == nil { - return nil - } - return s.WarnedAt -} - -func (s *Stack) GetDisposableSince() *time.Time { - if s == nil { - return nil - } - return s.DisposableSince -} - -func (s *Stack) GetReachable() bool { - if s == nil { - return false - } - return s.Reachable -} - -func (s *Stack) GetLastReachableUpdate() *time.Time { - if s == nil { - return nil - } - return s.LastReachableUpdate -} - -func (s *Stack) GetID() string { - if s == nil { - return "" - } - return s.ID -} - -func (s *Stack) GetOrganizationID() string { - if s == nil { - return "" - } - return s.OrganizationID -} - -func (s *Stack) GetURI() string { - if s == nil { - return "" - } - return s.URI -} - -func (s *Stack) GetRegionID() string { - if s == nil { - return "" - } - return s.RegionID -} - -func (s *Stack) GetRegion() *Region { - if s == nil { - return nil - } - return s.Region -} - -func (s *Stack) GetStargateEnabled() bool { - if s == nil { - return false - } - return s.StargateEnabled -} - -func (s *Stack) GetCreatedAt() *time.Time { - if s == nil { - return nil - } - return s.CreatedAt -} - -func (s *Stack) GetDeletedAt() *time.Time { - if s == nil { - return nil - } - return s.DeletedAt -} - -func (s *Stack) GetDisabledAt() *time.Time { - if s == nil { - return nil - } - return s.DisabledAt -} - -func (s *Stack) GetAuditEnabled() *bool { - if s == nil { - return nil - } - return s.AuditEnabled -} - -func (s *Stack) GetSynchronised() bool { - if s == nil { - return false - } - return s.Synchronised -} - -func (s *Stack) GetUpdatedAt() *time.Time { - if s == nil { - return nil - } - return s.UpdatedAt -} - -func (s *Stack) GetModules() []Module { - if s == nil { - return []Module{} - } - return s.Modules -} diff --git a/internal/membershipclient/models/components/stackdata.go b/internal/membershipclient/models/components/stackdata.go deleted file mode 100644 index 638c3755..00000000 --- a/internal/membershipclient/models/components/stackdata.go +++ /dev/null @@ -1,38 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type StackData struct { - // Stack name - Name string `json:"name"` - Metadata map[string]string `json:"metadata,omitzero"` -} - -func (s StackData) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(s, "", false) -} - -func (s *StackData) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &s, "", false, nil); err != nil { - return err - } - return nil -} - -func (s *StackData) GetName() string { - if s == nil { - return "" - } - return s.Name -} - -func (s *StackData) GetMetadata() map[string]string { - if s == nil { - return nil - } - return s.Metadata -} diff --git a/internal/membershipclient/models/components/stackuseraccessresponse.go b/internal/membershipclient/models/components/stackuseraccessresponse.go deleted file mode 100644 index 9ed46e31..00000000 --- a/internal/membershipclient/models/components/stackuseraccessresponse.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type StackUserAccessResponseData struct { - // Stack ID - StackID string `json:"stackId"` - // User ID - UserID string `json:"userId"` - // User email - Email string `json:"email"` - // Policy ID applied to the user for the stack - PolicyID int64 `json:"policyId"` -} - -func (s *StackUserAccessResponseData) GetStackID() string { - if s == nil { - return "" - } - return s.StackID -} - -func (s *StackUserAccessResponseData) GetUserID() string { - if s == nil { - return "" - } - return s.UserID -} - -func (s *StackUserAccessResponseData) GetEmail() string { - if s == nil { - return "" - } - return s.Email -} - -func (s *StackUserAccessResponseData) GetPolicyID() int64 { - if s == nil { - return 0 - } - return s.PolicyID -} - -type StackUserAccessResponse struct { - Data []StackUserAccessResponseData `json:"data,omitzero"` -} - -func (s StackUserAccessResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(s, "", false) -} - -func (s *StackUserAccessResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &s, "", false, nil); err != nil { - return err - } - return nil -} - -func (s *StackUserAccessResponse) GetData() []StackUserAccessResponseData { - if s == nil { - return nil - } - return s.Data -} diff --git a/internal/membershipclient/models/components/stackversion.go b/internal/membershipclient/models/components/stackversion.go deleted file mode 100644 index 2d9440df..00000000 --- a/internal/membershipclient/models/components/stackversion.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type StackVersion struct { - // Supported only with agent version >= v0.7.0 - Version *string `json:"version,omitzero"` -} - -func (s *StackVersion) GetVersion() *string { - if s == nil { - return nil - } - return s.Version -} diff --git a/internal/membershipclient/models/components/systemrole.go b/internal/membershipclient/models/components/systemrole.go deleted file mode 100644 index ca8251d1..00000000 --- a/internal/membershipclient/models/components/systemrole.go +++ /dev/null @@ -1,25 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type SystemRole string - -const ( - SystemRoleUser SystemRole = "USER" - SystemRoleSystem SystemRole = "SYSTEM" -) - -func (e SystemRole) ToPointer() *SystemRole { - return &e -} - -// IsExact returns true if the value matches a known enum value, false otherwise. -func (e *SystemRole) IsExact() bool { - if e != nil { - switch *e { - case "USER", "SYSTEM": - return true - } - } - return false -} diff --git a/internal/membershipclient/models/components/updateapplicationresponse.go b/internal/membershipclient/models/components/updateapplicationresponse.go deleted file mode 100644 index 05dd3c28..00000000 --- a/internal/membershipclient/models/components/updateapplicationresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type UpdateApplicationResponse struct { - Data *Application `json:"data,omitzero"` -} - -func (u UpdateApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateApplicationResponse) GetData() *Application { - if u == nil { - return nil - } - return u.Data -} diff --git a/internal/membershipclient/models/components/updateorganizationclientrequest.go b/internal/membershipclient/models/components/updateorganizationclientrequest.go deleted file mode 100644 index ab8aca55..00000000 --- a/internal/membershipclient/models/components/updateorganizationclientrequest.go +++ /dev/null @@ -1,24 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type UpdateOrganizationClientRequest struct { - // Name of the client - Name string `json:"name"` - // Description of the client - Description *string `json:"description,omitzero"` -} - -func (u *UpdateOrganizationClientRequest) GetName() string { - if u == nil { - return "" - } - return u.Name -} - -func (u *UpdateOrganizationClientRequest) GetDescription() *string { - if u == nil { - return nil - } - return u.Description -} diff --git a/internal/membershipclient/models/components/updateorganizationuserrequest.go b/internal/membershipclient/models/components/updateorganizationuserrequest.go deleted file mode 100644 index caa167b9..00000000 --- a/internal/membershipclient/models/components/updateorganizationuserrequest.go +++ /dev/null @@ -1,15 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type UpdateOrganizationUserRequest struct { - // Policy ID applied to the user - PolicyID *int64 `json:"policyId,omitzero"` -} - -func (u *UpdateOrganizationUserRequest) GetPolicyID() *int64 { - if u == nil { - return nil - } - return u.PolicyID -} diff --git a/internal/membershipclient/models/components/updatepolicyresponse.go b/internal/membershipclient/models/components/updatepolicyresponse.go deleted file mode 100644 index f61071c8..00000000 --- a/internal/membershipclient/models/components/updatepolicyresponse.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -type UpdatePolicyResponse struct { - Data *Policy `json:"data,omitzero"` -} - -func (u UpdatePolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdatePolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdatePolicyResponse) GetData() *Policy { - if u == nil { - return nil - } - return u.Data -} diff --git a/internal/membershipclient/models/components/updatestackuserrequest.go b/internal/membershipclient/models/components/updatestackuserrequest.go deleted file mode 100644 index 8bf7a622..00000000 --- a/internal/membershipclient/models/components/updatestackuserrequest.go +++ /dev/null @@ -1,14 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type UpdateStackUserRequest struct { - PolicyID int64 `json:"policyId"` -} - -func (u *UpdateStackUserRequest) GetPolicyID() int64 { - if u == nil { - return 0 - } - return u.PolicyID -} diff --git a/internal/membershipclient/models/components/upsertauthenticationproviderrequest.go b/internal/membershipclient/models/components/upsertauthenticationproviderrequest.go deleted file mode 100644 index d4b6ad3e..00000000 --- a/internal/membershipclient/models/components/upsertauthenticationproviderrequest.go +++ /dev/null @@ -1,597 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -import ( - "encoding/json" - "errors" - "fmt" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" -) - -// UpsertAuthenticationProviderRequestOIDCConfigType - Type of the authentication provider -type UpsertAuthenticationProviderRequestOIDCConfigType string - -const ( - UpsertAuthenticationProviderRequestOIDCConfigTypeOidc UpsertAuthenticationProviderRequestOIDCConfigType = "oidc" - UpsertAuthenticationProviderRequestOIDCConfigTypeGoogle UpsertAuthenticationProviderRequestOIDCConfigType = "google" - UpsertAuthenticationProviderRequestOIDCConfigTypeGithub UpsertAuthenticationProviderRequestOIDCConfigType = "github" - UpsertAuthenticationProviderRequestOIDCConfigTypeMicrosoft UpsertAuthenticationProviderRequestOIDCConfigType = "microsoft" -) - -func (e UpsertAuthenticationProviderRequestOIDCConfigType) ToPointer() *UpsertAuthenticationProviderRequestOIDCConfigType { - return &e -} -func (e *UpsertAuthenticationProviderRequestOIDCConfigType) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "oidc": - fallthrough - case "google": - fallthrough - case "github": - fallthrough - case "microsoft": - *e = UpsertAuthenticationProviderRequestOIDCConfigType(v) - return nil - default: - return fmt.Errorf("invalid value for UpsertAuthenticationProviderRequestOIDCConfigType: %v", v) - } -} - -type UpsertAuthenticationProviderRequestOIDCConfigConfig struct { - // OIDC issuer URL - Issuer string `json:"issuer"` - DiscoveryPath *string `default:"/.well-known/openid-configuration" json:"discoveryPath"` -} - -func (u UpsertAuthenticationProviderRequestOIDCConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfigConfig) GetIssuer() string { - if u == nil { - return "" - } - return u.Issuer -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfigConfig) GetDiscoveryPath() *string { - if u == nil { - return nil - } - return u.DiscoveryPath -} - -type UpsertAuthenticationProviderRequestOIDCConfig struct { - // Type of the authentication provider - Type UpsertAuthenticationProviderRequestOIDCConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config UpsertAuthenticationProviderRequestOIDCConfigConfig `json:"config"` -} - -func (u UpsertAuthenticationProviderRequestOIDCConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) GetType() UpsertAuthenticationProviderRequestOIDCConfigType { - if u == nil { - return UpsertAuthenticationProviderRequestOIDCConfigType("") - } - return u.Type -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) GetName() string { - if u == nil { - return "" - } - return u.Name -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) GetClientID() string { - if u == nil { - return "" - } - return u.ClientID -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) GetClientSecret() string { - if u == nil { - return "" - } - return u.ClientSecret -} - -func (u *UpsertAuthenticationProviderRequestOIDCConfig) GetConfig() UpsertAuthenticationProviderRequestOIDCConfigConfig { - if u == nil { - return UpsertAuthenticationProviderRequestOIDCConfigConfig{} - } - return u.Config -} - -// UpsertAuthenticationProviderRequestGithubIDPConfigType - Type of the authentication provider -type UpsertAuthenticationProviderRequestGithubIDPConfigType string - -const ( - UpsertAuthenticationProviderRequestGithubIDPConfigTypeOidc UpsertAuthenticationProviderRequestGithubIDPConfigType = "oidc" - UpsertAuthenticationProviderRequestGithubIDPConfigTypeGoogle UpsertAuthenticationProviderRequestGithubIDPConfigType = "google" - UpsertAuthenticationProviderRequestGithubIDPConfigTypeGithub UpsertAuthenticationProviderRequestGithubIDPConfigType = "github" - UpsertAuthenticationProviderRequestGithubIDPConfigTypeMicrosoft UpsertAuthenticationProviderRequestGithubIDPConfigType = "microsoft" -) - -func (e UpsertAuthenticationProviderRequestGithubIDPConfigType) ToPointer() *UpsertAuthenticationProviderRequestGithubIDPConfigType { - return &e -} -func (e *UpsertAuthenticationProviderRequestGithubIDPConfigType) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "oidc": - fallthrough - case "google": - fallthrough - case "github": - fallthrough - case "microsoft": - *e = UpsertAuthenticationProviderRequestGithubIDPConfigType(v) - return nil - default: - return fmt.Errorf("invalid value for UpsertAuthenticationProviderRequestGithubIDPConfigType: %v", v) - } -} - -type UpsertAuthenticationProviderRequestGithubIDPConfigConfig struct { -} - -func (u UpsertAuthenticationProviderRequestGithubIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -type UpsertAuthenticationProviderRequestGithubIDPConfig struct { - // Type of the authentication provider - Type UpsertAuthenticationProviderRequestGithubIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config UpsertAuthenticationProviderRequestGithubIDPConfigConfig `json:"config"` -} - -func (u UpsertAuthenticationProviderRequestGithubIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) GetType() UpsertAuthenticationProviderRequestGithubIDPConfigType { - if u == nil { - return UpsertAuthenticationProviderRequestGithubIDPConfigType("") - } - return u.Type -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) GetName() string { - if u == nil { - return "" - } - return u.Name -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) GetClientID() string { - if u == nil { - return "" - } - return u.ClientID -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) GetClientSecret() string { - if u == nil { - return "" - } - return u.ClientSecret -} - -func (u *UpsertAuthenticationProviderRequestGithubIDPConfig) GetConfig() UpsertAuthenticationProviderRequestGithubIDPConfigConfig { - if u == nil { - return UpsertAuthenticationProviderRequestGithubIDPConfigConfig{} - } - return u.Config -} - -// UpsertAuthenticationProviderRequestMicrosoftIDPConfigType - Type of the authentication provider -type UpsertAuthenticationProviderRequestMicrosoftIDPConfigType string - -const ( - UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeOidc UpsertAuthenticationProviderRequestMicrosoftIDPConfigType = "oidc" - UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeGoogle UpsertAuthenticationProviderRequestMicrosoftIDPConfigType = "google" - UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeGithub UpsertAuthenticationProviderRequestMicrosoftIDPConfigType = "github" - UpsertAuthenticationProviderRequestMicrosoftIDPConfigTypeMicrosoft UpsertAuthenticationProviderRequestMicrosoftIDPConfigType = "microsoft" -) - -func (e UpsertAuthenticationProviderRequestMicrosoftIDPConfigType) ToPointer() *UpsertAuthenticationProviderRequestMicrosoftIDPConfigType { - return &e -} -func (e *UpsertAuthenticationProviderRequestMicrosoftIDPConfigType) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "oidc": - fallthrough - case "google": - fallthrough - case "github": - fallthrough - case "microsoft": - *e = UpsertAuthenticationProviderRequestMicrosoftIDPConfigType(v) - return nil - default: - return fmt.Errorf("invalid value for UpsertAuthenticationProviderRequestMicrosoftIDPConfigType: %v", v) - } -} - -type UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig struct { - // Tenant ID for Microsoft authentication - Tenant *string `default:"common" json:"tenant"` -} - -func (u UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig) GetTenant() *string { - if u == nil { - return nil - } - return u.Tenant -} - -type UpsertAuthenticationProviderRequestMicrosoftIDPConfig struct { - // Type of the authentication provider - Type UpsertAuthenticationProviderRequestMicrosoftIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig `json:"config"` -} - -func (u UpsertAuthenticationProviderRequestMicrosoftIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) GetType() UpsertAuthenticationProviderRequestMicrosoftIDPConfigType { - if u == nil { - return UpsertAuthenticationProviderRequestMicrosoftIDPConfigType("") - } - return u.Type -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) GetName() string { - if u == nil { - return "" - } - return u.Name -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) GetClientID() string { - if u == nil { - return "" - } - return u.ClientID -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) GetClientSecret() string { - if u == nil { - return "" - } - return u.ClientSecret -} - -func (u *UpsertAuthenticationProviderRequestMicrosoftIDPConfig) GetConfig() UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig { - if u == nil { - return UpsertAuthenticationProviderRequestMicrosoftIDPConfigConfig{} - } - return u.Config -} - -// UpsertAuthenticationProviderRequestGoogleIDPConfigType - Type of the authentication provider -type UpsertAuthenticationProviderRequestGoogleIDPConfigType string - -const ( - UpsertAuthenticationProviderRequestGoogleIDPConfigTypeOidc UpsertAuthenticationProviderRequestGoogleIDPConfigType = "oidc" - UpsertAuthenticationProviderRequestGoogleIDPConfigTypeGoogle UpsertAuthenticationProviderRequestGoogleIDPConfigType = "google" - UpsertAuthenticationProviderRequestGoogleIDPConfigTypeGithub UpsertAuthenticationProviderRequestGoogleIDPConfigType = "github" - UpsertAuthenticationProviderRequestGoogleIDPConfigTypeMicrosoft UpsertAuthenticationProviderRequestGoogleIDPConfigType = "microsoft" -) - -func (e UpsertAuthenticationProviderRequestGoogleIDPConfigType) ToPointer() *UpsertAuthenticationProviderRequestGoogleIDPConfigType { - return &e -} -func (e *UpsertAuthenticationProviderRequestGoogleIDPConfigType) UnmarshalJSON(data []byte) error { - var v string - if err := json.Unmarshal(data, &v); err != nil { - return err - } - switch v { - case "oidc": - fallthrough - case "google": - fallthrough - case "github": - fallthrough - case "microsoft": - *e = UpsertAuthenticationProviderRequestGoogleIDPConfigType(v) - return nil - default: - return fmt.Errorf("invalid value for UpsertAuthenticationProviderRequestGoogleIDPConfigType: %v", v) - } -} - -type UpsertAuthenticationProviderRequestGoogleIDPConfigConfig struct { -} - -func (u UpsertAuthenticationProviderRequestGoogleIDPConfigConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfigConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -type UpsertAuthenticationProviderRequestGoogleIDPConfig struct { - // Type of the authentication provider - Type UpsertAuthenticationProviderRequestGoogleIDPConfigType `json:"type"` - // Name of the authentication provider - Name string `json:"name"` - // Client ID for the authentication provider - ClientID string `json:"clientID"` - // Client secret for the authentication provider - ClientSecret string `json:"clientSecret"` - Config UpsertAuthenticationProviderRequestGoogleIDPConfigConfig `json:"config"` -} - -func (u UpsertAuthenticationProviderRequestGoogleIDPConfig) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) GetType() UpsertAuthenticationProviderRequestGoogleIDPConfigType { - if u == nil { - return UpsertAuthenticationProviderRequestGoogleIDPConfigType("") - } - return u.Type -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) GetName() string { - if u == nil { - return "" - } - return u.Name -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) GetClientID() string { - if u == nil { - return "" - } - return u.ClientID -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) GetClientSecret() string { - if u == nil { - return "" - } - return u.ClientSecret -} - -func (u *UpsertAuthenticationProviderRequestGoogleIDPConfig) GetConfig() UpsertAuthenticationProviderRequestGoogleIDPConfigConfig { - if u == nil { - return UpsertAuthenticationProviderRequestGoogleIDPConfigConfig{} - } - return u.Config -} - -type UpsertAuthenticationProviderRequestType string - -const ( - UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGoogleIDPConfig UpsertAuthenticationProviderRequestType = "UpsertAuthenticationProviderRequest_GoogleIDPConfig" - UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestMicrosoftIDPConfig UpsertAuthenticationProviderRequestType = "UpsertAuthenticationProviderRequest_MicrosoftIDPConfig" - UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGithubIDPConfig UpsertAuthenticationProviderRequestType = "UpsertAuthenticationProviderRequest_GithubIDPConfig" - UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestOIDCConfig UpsertAuthenticationProviderRequestType = "UpsertAuthenticationProviderRequest_OIDCConfig" -) - -type UpsertAuthenticationProviderRequest struct { - UpsertAuthenticationProviderRequestGoogleIDPConfig *UpsertAuthenticationProviderRequestGoogleIDPConfig `queryParam:"inline" union:"member"` - UpsertAuthenticationProviderRequestMicrosoftIDPConfig *UpsertAuthenticationProviderRequestMicrosoftIDPConfig `queryParam:"inline" union:"member"` - UpsertAuthenticationProviderRequestGithubIDPConfig *UpsertAuthenticationProviderRequestGithubIDPConfig `queryParam:"inline" union:"member"` - UpsertAuthenticationProviderRequestOIDCConfig *UpsertAuthenticationProviderRequestOIDCConfig `queryParam:"inline" union:"member"` - - Type UpsertAuthenticationProviderRequestType -} - -func CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGoogleIDPConfig(upsertAuthenticationProviderRequestGoogleIDPConfig UpsertAuthenticationProviderRequestGoogleIDPConfig) UpsertAuthenticationProviderRequest { - typ := UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGoogleIDPConfig - - return UpsertAuthenticationProviderRequest{ - UpsertAuthenticationProviderRequestGoogleIDPConfig: &upsertAuthenticationProviderRequestGoogleIDPConfig, - Type: typ, - } -} - -func CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestMicrosoftIDPConfig(upsertAuthenticationProviderRequestMicrosoftIDPConfig UpsertAuthenticationProviderRequestMicrosoftIDPConfig) UpsertAuthenticationProviderRequest { - typ := UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestMicrosoftIDPConfig - - return UpsertAuthenticationProviderRequest{ - UpsertAuthenticationProviderRequestMicrosoftIDPConfig: &upsertAuthenticationProviderRequestMicrosoftIDPConfig, - Type: typ, - } -} - -func CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestGithubIDPConfig(upsertAuthenticationProviderRequestGithubIDPConfig UpsertAuthenticationProviderRequestGithubIDPConfig) UpsertAuthenticationProviderRequest { - typ := UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGithubIDPConfig - - return UpsertAuthenticationProviderRequest{ - UpsertAuthenticationProviderRequestGithubIDPConfig: &upsertAuthenticationProviderRequestGithubIDPConfig, - Type: typ, - } -} - -func CreateUpsertAuthenticationProviderRequestUpsertAuthenticationProviderRequestOIDCConfig(upsertAuthenticationProviderRequestOIDCConfig UpsertAuthenticationProviderRequestOIDCConfig) UpsertAuthenticationProviderRequest { - typ := UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestOIDCConfig - - return UpsertAuthenticationProviderRequest{ - UpsertAuthenticationProviderRequestOIDCConfig: &upsertAuthenticationProviderRequestOIDCConfig, - Type: typ, - } -} - -func (u *UpsertAuthenticationProviderRequest) UnmarshalJSON(data []byte) error { - - var candidates []utils.UnionCandidate - - // Collect all valid candidates - var upsertAuthenticationProviderRequestGoogleIDPConfig UpsertAuthenticationProviderRequestGoogleIDPConfig = UpsertAuthenticationProviderRequestGoogleIDPConfig{} - if err := utils.UnmarshalJSON(data, &upsertAuthenticationProviderRequestGoogleIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGoogleIDPConfig, - Value: &upsertAuthenticationProviderRequestGoogleIDPConfig, - }) - } - - var upsertAuthenticationProviderRequestMicrosoftIDPConfig UpsertAuthenticationProviderRequestMicrosoftIDPConfig = UpsertAuthenticationProviderRequestMicrosoftIDPConfig{} - if err := utils.UnmarshalJSON(data, &upsertAuthenticationProviderRequestMicrosoftIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestMicrosoftIDPConfig, - Value: &upsertAuthenticationProviderRequestMicrosoftIDPConfig, - }) - } - - var upsertAuthenticationProviderRequestGithubIDPConfig UpsertAuthenticationProviderRequestGithubIDPConfig = UpsertAuthenticationProviderRequestGithubIDPConfig{} - if err := utils.UnmarshalJSON(data, &upsertAuthenticationProviderRequestGithubIDPConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGithubIDPConfig, - Value: &upsertAuthenticationProviderRequestGithubIDPConfig, - }) - } - - var upsertAuthenticationProviderRequestOIDCConfig UpsertAuthenticationProviderRequestOIDCConfig = UpsertAuthenticationProviderRequestOIDCConfig{} - if err := utils.UnmarshalJSON(data, &upsertAuthenticationProviderRequestOIDCConfig, "", true, nil); err == nil { - candidates = append(candidates, utils.UnionCandidate{ - Type: UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestOIDCConfig, - Value: &upsertAuthenticationProviderRequestOIDCConfig, - }) - } - - if len(candidates) == 0 { - return fmt.Errorf("could not unmarshal `%s` into any supported union types for UpsertAuthenticationProviderRequest", string(data)) - } - - // Pick the best candidate using multi-stage filtering - best := utils.PickBestUnionCandidate(candidates, data) - if best == nil { - return fmt.Errorf("could not unmarshal `%s` into any supported union types for UpsertAuthenticationProviderRequest", string(data)) - } - - // Set the union type and value based on the best candidate - u.Type = best.Type.(UpsertAuthenticationProviderRequestType) - switch best.Type { - case UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGoogleIDPConfig: - u.UpsertAuthenticationProviderRequestGoogleIDPConfig = best.Value.(*UpsertAuthenticationProviderRequestGoogleIDPConfig) - return nil - case UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestMicrosoftIDPConfig: - u.UpsertAuthenticationProviderRequestMicrosoftIDPConfig = best.Value.(*UpsertAuthenticationProviderRequestMicrosoftIDPConfig) - return nil - case UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestGithubIDPConfig: - u.UpsertAuthenticationProviderRequestGithubIDPConfig = best.Value.(*UpsertAuthenticationProviderRequestGithubIDPConfig) - return nil - case UpsertAuthenticationProviderRequestTypeUpsertAuthenticationProviderRequestOIDCConfig: - u.UpsertAuthenticationProviderRequestOIDCConfig = best.Value.(*UpsertAuthenticationProviderRequestOIDCConfig) - return nil - } - - return fmt.Errorf("could not unmarshal `%s` into any supported union types for UpsertAuthenticationProviderRequest", string(data)) -} - -func (u UpsertAuthenticationProviderRequest) MarshalJSON() ([]byte, error) { - if u.UpsertAuthenticationProviderRequestGoogleIDPConfig != nil { - return utils.MarshalJSON(u.UpsertAuthenticationProviderRequestGoogleIDPConfig, "", true) - } - - if u.UpsertAuthenticationProviderRequestMicrosoftIDPConfig != nil { - return utils.MarshalJSON(u.UpsertAuthenticationProviderRequestMicrosoftIDPConfig, "", true) - } - - if u.UpsertAuthenticationProviderRequestGithubIDPConfig != nil { - return utils.MarshalJSON(u.UpsertAuthenticationProviderRequestGithubIDPConfig, "", true) - } - - if u.UpsertAuthenticationProviderRequestOIDCConfig != nil { - return utils.MarshalJSON(u.UpsertAuthenticationProviderRequestOIDCConfig, "", true) - } - - return nil, errors.New("could not marshal union type UpsertAuthenticationProviderRequest: all fields are null") -} diff --git a/internal/membershipclient/models/components/user.go b/internal/membershipclient/models/components/user.go deleted file mode 100644 index 44b07086..00000000 --- a/internal/membershipclient/models/components/user.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type User struct { - Email string `json:"email"` - // User ID - ID string `json:"id"` - Role *SystemRole `json:"role,omitzero"` -} - -func (u *User) GetEmail() string { - if u == nil { - return "" - } - return u.Email -} - -func (u *User) GetID() string { - if u == nil { - return "" - } - return u.ID -} - -func (u *User) GetRole() *SystemRole { - if u == nil { - return nil - } - return u.Role -} diff --git a/internal/membershipclient/models/components/version.go b/internal/membershipclient/models/components/version.go deleted file mode 100644 index 5fde6f1b..00000000 --- a/internal/membershipclient/models/components/version.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package components - -type Version struct { - Name string `json:"name"` - Versions map[string]string `json:"versions"` - RegionID string `json:"regionID"` - // Indicates if this version is deprecated and should not be used - Deprecated *bool `json:"deprecated,omitzero"` -} - -func (v *Version) GetName() string { - if v == nil { - return "" - } - return v.Name -} - -func (v *Version) GetVersions() map[string]string { - if v == nil { - return map[string]string{} - } - return v.Versions -} - -func (v *Version) GetRegionID() string { - if v == nil { - return "" - } - return v.RegionID -} - -func (v *Version) GetDeprecated() *bool { - if v == nil { - return nil - } - return v.Deprecated -} diff --git a/internal/membershipclient/models/operations/acceptinvitation.go b/internal/membershipclient/models/operations/acceptinvitation.go deleted file mode 100644 index b2a21752..00000000 --- a/internal/membershipclient/models/operations/acceptinvitation.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type AcceptInvitationRequest struct { - InvitationID string `pathParam:"style=simple,explode=false,name=invitationId"` -} - -func (a *AcceptInvitationRequest) GetInvitationID() string { - if a == nil { - return "" - } - return a.InvitationID -} - -type AcceptInvitationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (a AcceptInvitationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AcceptInvitationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AcceptInvitationResponse) GetHTTPMeta() components.HTTPMetadata { - if a == nil { - return components.HTTPMetadata{} - } - return a.HTTPMeta -} - -func (a *AcceptInvitationResponse) GetError() *components.Error { - if a == nil { - return nil - } - return a.Error -} diff --git a/internal/membershipclient/models/operations/addfeatures.go b/internal/membershipclient/models/operations/addfeatures.go deleted file mode 100644 index 5bc4270e..00000000 --- a/internal/membershipclient/models/operations/addfeatures.go +++ /dev/null @@ -1,80 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type AddFeaturesRequestBody struct { - Features []string `json:"features"` -} - -func (a *AddFeaturesRequestBody) GetFeatures() []string { - if a == nil { - return []string{} - } - return a.Features -} - -type AddFeaturesRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *AddFeaturesRequestBody `request:"mediaType=application/json"` -} - -func (a AddFeaturesRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AddFeaturesRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AddFeaturesRequest) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AddFeaturesRequest) GetBody() *AddFeaturesRequestBody { - if a == nil { - return nil - } - return a.Body -} - -type AddFeaturesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (a AddFeaturesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AddFeaturesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AddFeaturesResponse) GetHTTPMeta() components.HTTPMetadata { - if a == nil { - return components.HTTPMetadata{} - } - return a.HTTPMeta -} - -func (a *AddFeaturesResponse) GetError() *components.Error { - if a == nil { - return nil - } - return a.Error -} diff --git a/internal/membershipclient/models/operations/addscopetopolicy.go b/internal/membershipclient/models/operations/addscopetopolicy.go deleted file mode 100644 index e54a28fd..00000000 --- a/internal/membershipclient/models/operations/addscopetopolicy.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type AddScopeToPolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PolicyID int64 `pathParam:"style=simple,explode=false,name=policyId"` - ScopeID int64 `pathParam:"style=simple,explode=false,name=scopeId"` -} - -func (a *AddScopeToPolicyRequest) GetOrganizationID() string { - if a == nil { - return "" - } - return a.OrganizationID -} - -func (a *AddScopeToPolicyRequest) GetPolicyID() int64 { - if a == nil { - return 0 - } - return a.PolicyID -} - -func (a *AddScopeToPolicyRequest) GetScopeID() int64 { - if a == nil { - return 0 - } - return a.ScopeID -} - -type AddScopeToPolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (a AddScopeToPolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(a, "", false) -} - -func (a *AddScopeToPolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &a, "", false, nil); err != nil { - return err - } - return nil -} - -func (a *AddScopeToPolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if a == nil { - return components.HTTPMetadata{} - } - return a.HTTPMeta -} - -func (a *AddScopeToPolicyResponse) GetError() *components.Error { - if a == nil { - return nil - } - return a.Error -} diff --git a/internal/membershipclient/models/operations/createapplication.go b/internal/membershipclient/models/operations/createapplication.go deleted file mode 100644 index e5a5eac0..00000000 --- a/internal/membershipclient/models/operations/createapplication.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateApplicationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application created - CreateApplicationResponse *components.CreateApplicationResponse - // Error - Error *components.Error -} - -func (c CreateApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateApplicationResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateApplicationResponse) GetCreateApplicationResponse() *components.CreateApplicationResponse { - if c == nil { - return nil - } - return c.CreateApplicationResponse -} - -func (c *CreateApplicationResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createapplicationscope.go b/internal/membershipclient/models/operations/createapplicationscope.go deleted file mode 100644 index b4414431..00000000 --- a/internal/membershipclient/models/operations/createapplicationscope.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateApplicationScopeRequest struct { - // The unique identifier of the application (UUID format) - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` - Body *components.CreateApplicationScopeRequest `request:"mediaType=application/json"` -} - -func (c CreateApplicationScopeRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateApplicationScopeRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateApplicationScopeRequest) GetApplicationID() string { - if c == nil { - return "" - } - return c.ApplicationID -} - -func (c *CreateApplicationScopeRequest) GetBody() *components.CreateApplicationScopeRequest { - if c == nil { - return nil - } - return c.Body -} - -type CreateApplicationScopeResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application scope created - CreateApplicationScopeResponse *components.CreateApplicationScopeResponse - // Error - Error *components.Error -} - -func (c CreateApplicationScopeResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateApplicationScopeResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateApplicationScopeResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateApplicationScopeResponse) GetCreateApplicationScopeResponse() *components.CreateApplicationScopeResponse { - if c == nil { - return nil - } - return c.CreateApplicationScopeResponse -} - -func (c *CreateApplicationScopeResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createinvitation.go b/internal/membershipclient/models/operations/createinvitation.go deleted file mode 100644 index 2d7addb6..00000000 --- a/internal/membershipclient/models/operations/createinvitation.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateInvitationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Email string `queryParam:"style=form,explode=true,name=email"` -} - -func (c *CreateInvitationRequest) GetOrganizationID() string { - if c == nil { - return "" - } - return c.OrganizationID -} - -func (c *CreateInvitationRequest) GetEmail() string { - if c == nil { - return "" - } - return c.Email -} - -type CreateInvitationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Invitation already sent - CreateInvitationResponse *components.CreateInvitationResponse - // Error - Error *components.Error -} - -func (c CreateInvitationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateInvitationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateInvitationResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateInvitationResponse) GetCreateInvitationResponse() *components.CreateInvitationResponse { - if c == nil { - return nil - } - return c.CreateInvitationResponse -} - -func (c *CreateInvitationResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createorganization.go b/internal/membershipclient/models/operations/createorganization.go deleted file mode 100644 index 1a6153ee..00000000 --- a/internal/membershipclient/models/operations/createorganization.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization created - CreateOrganizationResponse *components.CreateOrganizationResponse - // Error - Error *components.Error -} - -func (c CreateOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateOrganizationResponse) GetCreateOrganizationResponse() *components.CreateOrganizationResponse { - if c == nil { - return nil - } - return c.CreateOrganizationResponse -} - -func (c *CreateOrganizationResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createorganizationclient.go b/internal/membershipclient/models/operations/createorganizationclient.go deleted file mode 100644 index 18b85e2d..00000000 --- a/internal/membershipclient/models/operations/createorganizationclient.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateOrganizationClientRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (c *CreateOrganizationClientRequest) GetOrganizationID() string { - if c == nil { - return "" - } - return c.OrganizationID -} - -type CreateOrganizationClientResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Client organization created - CreateClientResponseResponse *components.CreateClientResponseResponse - // Error - Error *components.Error -} - -func (c CreateOrganizationClientResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateOrganizationClientResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateOrganizationClientResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateOrganizationClientResponse) GetCreateClientResponseResponse() *components.CreateClientResponseResponse { - if c == nil { - return nil - } - return c.CreateClientResponseResponse -} - -func (c *CreateOrganizationClientResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createpolicy.go b/internal/membershipclient/models/operations/createpolicy.go deleted file mode 100644 index c6a72cdb..00000000 --- a/internal/membershipclient/models/operations/createpolicy.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreatePolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.CreatePolicyRequest `request:"mediaType=application/json"` -} - -func (c CreatePolicyRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreatePolicyRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreatePolicyRequest) GetOrganizationID() string { - if c == nil { - return "" - } - return c.OrganizationID -} - -func (c *CreatePolicyRequest) GetBody() *components.CreatePolicyRequest { - if c == nil { - return nil - } - return c.Body -} - -type CreatePolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Policy created - CreatePolicyResponse *components.CreatePolicyResponse - // Error - Error *components.Error -} - -func (c CreatePolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreatePolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreatePolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreatePolicyResponse) GetCreatePolicyResponse() *components.CreatePolicyResponse { - if c == nil { - return nil - } - return c.CreatePolicyResponse -} - -func (c *CreatePolicyResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createprivateregion.go b/internal/membershipclient/models/operations/createprivateregion.go deleted file mode 100644 index b357d6e7..00000000 --- a/internal/membershipclient/models/operations/createprivateregion.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreatePrivateRegionRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.CreatePrivateRegionRequest `request:"mediaType=application/json"` -} - -func (c CreatePrivateRegionRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreatePrivateRegionRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreatePrivateRegionRequest) GetOrganizationID() string { - if c == nil { - return "" - } - return c.OrganizationID -} - -func (c *CreatePrivateRegionRequest) GetBody() *components.CreatePrivateRegionRequest { - if c == nil { - return nil - } - return c.Body -} - -type CreatePrivateRegionResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Created region - CreatedPrivateRegionResponse *components.CreatedPrivateRegionResponse - // Error - Error *components.Error -} - -func (c CreatePrivateRegionResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreatePrivateRegionResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreatePrivateRegionResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreatePrivateRegionResponse) GetCreatedPrivateRegionResponse() *components.CreatedPrivateRegionResponse { - if c == nil { - return nil - } - return c.CreatedPrivateRegionResponse -} - -func (c *CreatePrivateRegionResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createstack.go b/internal/membershipclient/models/operations/createstack.go deleted file mode 100644 index c63b99e6..00000000 --- a/internal/membershipclient/models/operations/createstack.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.CreateStackRequest `request:"mediaType=application/json"` -} - -func (c CreateStackRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateStackRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateStackRequest) GetOrganizationID() string { - if c == nil { - return "" - } - return c.OrganizationID -} - -func (c *CreateStackRequest) GetBody() *components.CreateStackRequest { - if c == nil { - return nil - } - return c.Body -} - -type CreateStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Stack created - ReadStackResponse *components.ReadStackResponse - // Error - Error *components.Error -} - -func (c CreateStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateStackResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateStackResponse) GetReadStackResponse() *components.ReadStackResponse { - if c == nil { - return nil - } - return c.ReadStackResponse -} - -func (c *CreateStackResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/createuser.go b/internal/membershipclient/models/operations/createuser.go deleted file mode 100644 index 8f70eb95..00000000 --- a/internal/membershipclient/models/operations/createuser.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type CreateUserResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // User created successfully - CreateUserResponse *components.CreateUserResponse - // Invalid request (missing or invalid email) - Error *components.Error -} - -func (c CreateUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(c, "", false) -} - -func (c *CreateUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &c, "", false, nil); err != nil { - return err - } - return nil -} - -func (c *CreateUserResponse) GetHTTPMeta() components.HTTPMetadata { - if c == nil { - return components.HTTPMetadata{} - } - return c.HTTPMeta -} - -func (c *CreateUserResponse) GetCreateUserResponse() *components.CreateUserResponse { - if c == nil { - return nil - } - return c.CreateUserResponse -} - -func (c *CreateUserResponse) GetError() *components.Error { - if c == nil { - return nil - } - return c.Error -} diff --git a/internal/membershipclient/models/operations/declineinvitation.go b/internal/membershipclient/models/operations/declineinvitation.go deleted file mode 100644 index 57ecd677..00000000 --- a/internal/membershipclient/models/operations/declineinvitation.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeclineInvitationRequest struct { - InvitationID string `pathParam:"style=simple,explode=false,name=invitationId"` -} - -func (d *DeclineInvitationRequest) GetInvitationID() string { - if d == nil { - return "" - } - return d.InvitationID -} - -type DeclineInvitationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeclineInvitationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeclineInvitationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeclineInvitationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeclineInvitationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteapplication.go b/internal/membershipclient/models/operations/deleteapplication.go deleted file mode 100644 index 0f9abee4..00000000 --- a/internal/membershipclient/models/operations/deleteapplication.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteApplicationRequest struct { - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` -} - -func (d *DeleteApplicationRequest) GetApplicationID() string { - if d == nil { - return "" - } - return d.ApplicationID -} - -type DeleteApplicationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteApplicationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteApplicationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteapplicationscope.go b/internal/membershipclient/models/operations/deleteapplicationscope.go deleted file mode 100644 index 77a1478b..00000000 --- a/internal/membershipclient/models/operations/deleteapplicationscope.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteApplicationScopeRequest struct { - // The unique identifier of the application (UUID format) - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` - // The unique identifier of the scope to operate on - ScopeID int64 `pathParam:"style=simple,explode=false,name=scopeID"` -} - -func (d *DeleteApplicationScopeRequest) GetApplicationID() string { - if d == nil { - return "" - } - return d.ApplicationID -} - -func (d *DeleteApplicationScopeRequest) GetScopeID() int64 { - if d == nil { - return 0 - } - return d.ScopeID -} - -type DeleteApplicationScopeResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Invalid request (invalid UUID or empty scope ID) - Error *components.Error -} - -func (d DeleteApplicationScopeResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteApplicationScopeResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteApplicationScopeResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteApplicationScopeResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteauthenticationprovider.go b/internal/membershipclient/models/operations/deleteauthenticationprovider.go deleted file mode 100644 index 6d2dab84..00000000 --- a/internal/membershipclient/models/operations/deleteauthenticationprovider.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteAuthenticationProviderRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (d *DeleteAuthenticationProviderRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -type DeleteAuthenticationProviderResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteAuthenticationProviderResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteAuthenticationProviderResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteAuthenticationProviderResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteAuthenticationProviderResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deletefeature.go b/internal/membershipclient/models/operations/deletefeature.go deleted file mode 100644 index 216eb1af..00000000 --- a/internal/membershipclient/models/operations/deletefeature.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteFeatureRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Name string `pathParam:"style=simple,explode=false,name=name"` -} - -func (d *DeleteFeatureRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteFeatureRequest) GetName() string { - if d == nil { - return "" - } - return d.Name -} - -type DeleteFeatureResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteFeatureResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteFeatureResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteFeatureResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteFeatureResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteinvitation.go b/internal/membershipclient/models/operations/deleteinvitation.go deleted file mode 100644 index 33bc96f9..00000000 --- a/internal/membershipclient/models/operations/deleteinvitation.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteInvitationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - InvitationID string `pathParam:"style=simple,explode=false,name=invitationId"` -} - -func (d *DeleteInvitationRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteInvitationRequest) GetInvitationID() string { - if d == nil { - return "" - } - return d.InvitationID -} - -type DeleteInvitationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteInvitationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteInvitationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteInvitationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteInvitationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteorganization.go b/internal/membershipclient/models/operations/deleteorganization.go deleted file mode 100644 index 4501dbe9..00000000 --- a/internal/membershipclient/models/operations/deleteorganization.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (d *DeleteOrganizationRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -type DeleteOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteOrganizationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteorganizationclient.go b/internal/membershipclient/models/operations/deleteorganizationclient.go deleted file mode 100644 index 964a3b99..00000000 --- a/internal/membershipclient/models/operations/deleteorganizationclient.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteOrganizationClientRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (d *DeleteOrganizationClientRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -type DeleteOrganizationClientResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteOrganizationClientResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteOrganizationClientResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteOrganizationClientResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteOrganizationClientResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deletepolicy.go b/internal/membershipclient/models/operations/deletepolicy.go deleted file mode 100644 index a005d687..00000000 --- a/internal/membershipclient/models/operations/deletepolicy.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeletePolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PolicyID int64 `pathParam:"style=simple,explode=false,name=policyId"` -} - -func (d *DeletePolicyRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeletePolicyRequest) GetPolicyID() int64 { - if d == nil { - return 0 - } - return d.PolicyID -} - -type DeletePolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeletePolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeletePolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeletePolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeletePolicyResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteregion.go b/internal/membershipclient/models/operations/deleteregion.go deleted file mode 100644 index 240ce5b3..00000000 --- a/internal/membershipclient/models/operations/deleteregion.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteRegionRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - RegionID string `pathParam:"style=simple,explode=false,name=regionID"` -} - -func (d *DeleteRegionRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteRegionRequest) GetRegionID() string { - if d == nil { - return "" - } - return d.RegionID -} - -type DeleteRegionResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteRegionResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteRegionResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteRegionResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteRegionResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deletestack.go b/internal/membershipclient/models/operations/deletestack.go deleted file mode 100644 index 1c07fb34..00000000 --- a/internal/membershipclient/models/operations/deletestack.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - Force *bool `queryParam:"style=form,explode=true,name=force"` -} - -func (d *DeleteStackRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteStackRequest) GetStackID() string { - if d == nil { - return "" - } - return d.StackID -} - -func (d *DeleteStackRequest) GetForce() *bool { - if d == nil { - return nil - } - return d.Force -} - -type DeleteStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteStackResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteStackResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deletestackuseraccess.go b/internal/membershipclient/models/operations/deletestackuseraccess.go deleted file mode 100644 index a4f8614b..00000000 --- a/internal/membershipclient/models/operations/deletestackuseraccess.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteStackUserAccessRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` -} - -func (d *DeleteStackUserAccessRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteStackUserAccessRequest) GetStackID() string { - if d == nil { - return "" - } - return d.StackID -} - -func (d *DeleteStackUserAccessRequest) GetUserID() string { - if d == nil { - return "" - } - return d.UserID -} - -type DeleteStackUserAccessResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteStackUserAccessResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteStackUserAccessResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteStackUserAccessResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteStackUserAccessResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/deleteuserfromorganization.go b/internal/membershipclient/models/operations/deleteuserfromorganization.go deleted file mode 100644 index e1311eeb..00000000 --- a/internal/membershipclient/models/operations/deleteuserfromorganization.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DeleteUserFromOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` -} - -func (d *DeleteUserFromOrganizationRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DeleteUserFromOrganizationRequest) GetUserID() string { - if d == nil { - return "" - } - return d.UserID -} - -type DeleteUserFromOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DeleteUserFromOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DeleteUserFromOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DeleteUserFromOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DeleteUserFromOrganizationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/disableapplicationfororganization.go b/internal/membershipclient/models/operations/disableapplicationfororganization.go deleted file mode 100644 index 1556883c..00000000 --- a/internal/membershipclient/models/operations/disableapplicationfororganization.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DisableApplicationForOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` -} - -func (d *DisableApplicationForOrganizationRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DisableApplicationForOrganizationRequest) GetApplicationID() string { - if d == nil { - return "" - } - return d.ApplicationID -} - -type DisableApplicationForOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DisableApplicationForOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DisableApplicationForOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DisableApplicationForOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DisableApplicationForOrganizationResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/disablemodule.go b/internal/membershipclient/models/operations/disablemodule.go deleted file mode 100644 index 4be555ba..00000000 --- a/internal/membershipclient/models/operations/disablemodule.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DisableModuleRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - Name string `queryParam:"style=form,explode=true,name=name"` -} - -func (d *DisableModuleRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DisableModuleRequest) GetStackID() string { - if d == nil { - return "" - } - return d.StackID -} - -func (d *DisableModuleRequest) GetName() string { - if d == nil { - return "" - } - return d.Name -} - -type DisableModuleResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DisableModuleResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DisableModuleResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DisableModuleResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DisableModuleResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/disablestack.go b/internal/membershipclient/models/operations/disablestack.go deleted file mode 100644 index 244b6fdd..00000000 --- a/internal/membershipclient/models/operations/disablestack.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DisableStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (d *DisableStackRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DisableStackRequest) GetStackID() string { - if d == nil { - return "" - } - return d.StackID -} - -type DisableStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DisableStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DisableStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DisableStackResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DisableStackResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/disablestargate.go b/internal/membershipclient/models/operations/disablestargate.go deleted file mode 100644 index 7262df8e..00000000 --- a/internal/membershipclient/models/operations/disablestargate.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type DisableStargateRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (d *DisableStargateRequest) GetOrganizationID() string { - if d == nil { - return "" - } - return d.OrganizationID -} - -func (d *DisableStargateRequest) GetStackID() string { - if d == nil { - return "" - } - return d.StackID -} - -type DisableStargateResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (d DisableStargateResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(d, "", false) -} - -func (d *DisableStargateResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &d, "", false, nil); err != nil { - return err - } - return nil -} - -func (d *DisableStargateResponse) GetHTTPMeta() components.HTTPMetadata { - if d == nil { - return components.HTTPMetadata{} - } - return d.HTTPMeta -} - -func (d *DisableStargateResponse) GetError() *components.Error { - if d == nil { - return nil - } - return d.Error -} diff --git a/internal/membershipclient/models/operations/enableapplicationfororganization.go b/internal/membershipclient/models/operations/enableapplicationfororganization.go deleted file mode 100644 index 110fb8ad..00000000 --- a/internal/membershipclient/models/operations/enableapplicationfororganization.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type EnableApplicationForOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` -} - -func (e *EnableApplicationForOrganizationRequest) GetOrganizationID() string { - if e == nil { - return "" - } - return e.OrganizationID -} - -func (e *EnableApplicationForOrganizationRequest) GetApplicationID() string { - if e == nil { - return "" - } - return e.ApplicationID -} - -type EnableApplicationForOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application enabled for organization - EnableApplicationForOrganizationResponse *components.EnableApplicationForOrganizationResponse - // Error - Error *components.Error -} - -func (e EnableApplicationForOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(e, "", false) -} - -func (e *EnableApplicationForOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &e, "", false, nil); err != nil { - return err - } - return nil -} - -func (e *EnableApplicationForOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if e == nil { - return components.HTTPMetadata{} - } - return e.HTTPMeta -} - -func (e *EnableApplicationForOrganizationResponse) GetEnableApplicationForOrganizationResponse() *components.EnableApplicationForOrganizationResponse { - if e == nil { - return nil - } - return e.EnableApplicationForOrganizationResponse -} - -func (e *EnableApplicationForOrganizationResponse) GetError() *components.Error { - if e == nil { - return nil - } - return e.Error -} diff --git a/internal/membershipclient/models/operations/enablemodule.go b/internal/membershipclient/models/operations/enablemodule.go deleted file mode 100644 index 01d4863b..00000000 --- a/internal/membershipclient/models/operations/enablemodule.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type EnableModuleRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - Name string `queryParam:"style=form,explode=true,name=name"` -} - -func (e *EnableModuleRequest) GetOrganizationID() string { - if e == nil { - return "" - } - return e.OrganizationID -} - -func (e *EnableModuleRequest) GetStackID() string { - if e == nil { - return "" - } - return e.StackID -} - -func (e *EnableModuleRequest) GetName() string { - if e == nil { - return "" - } - return e.Name -} - -type EnableModuleResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (e EnableModuleResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(e, "", false) -} - -func (e *EnableModuleResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &e, "", false, nil); err != nil { - return err - } - return nil -} - -func (e *EnableModuleResponse) GetHTTPMeta() components.HTTPMetadata { - if e == nil { - return components.HTTPMetadata{} - } - return e.HTTPMeta -} - -func (e *EnableModuleResponse) GetError() *components.Error { - if e == nil { - return nil - } - return e.Error -} diff --git a/internal/membershipclient/models/operations/enablestack.go b/internal/membershipclient/models/operations/enablestack.go deleted file mode 100644 index 7b227ed9..00000000 --- a/internal/membershipclient/models/operations/enablestack.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type EnableStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (e *EnableStackRequest) GetOrganizationID() string { - if e == nil { - return "" - } - return e.OrganizationID -} - -func (e *EnableStackRequest) GetStackID() string { - if e == nil { - return "" - } - return e.StackID -} - -type EnableStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (e EnableStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(e, "", false) -} - -func (e *EnableStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &e, "", false, nil); err != nil { - return err - } - return nil -} - -func (e *EnableStackResponse) GetHTTPMeta() components.HTTPMetadata { - if e == nil { - return components.HTTPMetadata{} - } - return e.HTTPMeta -} - -func (e *EnableStackResponse) GetError() *components.Error { - if e == nil { - return nil - } - return e.Error -} diff --git a/internal/membershipclient/models/operations/enablestargate.go b/internal/membershipclient/models/operations/enablestargate.go deleted file mode 100644 index 7143179a..00000000 --- a/internal/membershipclient/models/operations/enablestargate.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type EnableStargateRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (e *EnableStargateRequest) GetOrganizationID() string { - if e == nil { - return "" - } - return e.OrganizationID -} - -func (e *EnableStargateRequest) GetStackID() string { - if e == nil { - return "" - } - return e.StackID -} - -type EnableStargateResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (e EnableStargateResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(e, "", false) -} - -func (e *EnableStargateResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &e, "", false, nil); err != nil { - return err - } - return nil -} - -func (e *EnableStargateResponse) GetHTTPMeta() components.HTTPMetadata { - if e == nil { - return components.HTTPMetadata{} - } - return e.HTTPMeta -} - -func (e *EnableStargateResponse) GetError() *components.Error { - if e == nil { - return nil - } - return e.Error -} diff --git a/internal/membershipclient/models/operations/getapplication.go b/internal/membershipclient/models/operations/getapplication.go deleted file mode 100644 index 5ec26d7e..00000000 --- a/internal/membershipclient/models/operations/getapplication.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetApplicationRequest struct { - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` -} - -func (g *GetApplicationRequest) GetApplicationID() string { - if g == nil { - return "" - } - return g.ApplicationID -} - -type GetApplicationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application details - GetApplicationResponse *components.GetApplicationResponse - // Error - Error *components.Error -} - -func (g GetApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetApplicationResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetApplicationResponse) GetGetApplicationResponse() *components.GetApplicationResponse { - if g == nil { - return nil - } - return g.GetApplicationResponse -} - -func (g *GetApplicationResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/getorganizationapplication.go b/internal/membershipclient/models/operations/getorganizationapplication.go deleted file mode 100644 index 5adaed79..00000000 --- a/internal/membershipclient/models/operations/getorganizationapplication.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetOrganizationApplicationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` -} - -func (g *GetOrganizationApplicationRequest) GetOrganizationID() string { - if g == nil { - return "" - } - return g.OrganizationID -} - -func (g *GetOrganizationApplicationRequest) GetApplicationID() string { - if g == nil { - return "" - } - return g.ApplicationID -} - -type GetOrganizationApplicationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application details - GetApplicationResponse *components.GetApplicationResponse - // Error - Error *components.Error -} - -func (g GetOrganizationApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetOrganizationApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetOrganizationApplicationResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetOrganizationApplicationResponse) GetGetApplicationResponse() *components.GetApplicationResponse { - if g == nil { - return nil - } - return g.GetApplicationResponse -} - -func (g *GetOrganizationApplicationResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/getregion.go b/internal/membershipclient/models/operations/getregion.go deleted file mode 100644 index d9e0fae5..00000000 --- a/internal/membershipclient/models/operations/getregion.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetRegionRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - RegionID string `pathParam:"style=simple,explode=false,name=regionID"` -} - -func (g *GetRegionRequest) GetOrganizationID() string { - if g == nil { - return "" - } - return g.OrganizationID -} - -func (g *GetRegionRequest) GetRegionID() string { - if g == nil { - return "" - } - return g.RegionID -} - -type GetRegionResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Get a specific region - GetRegionResponse *components.GetRegionResponse - // Error - Error *components.Error -} - -func (g GetRegionResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetRegionResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetRegionResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetRegionResponse) GetGetRegionResponse() *components.GetRegionResponse { - if g == nil { - return nil - } - return g.GetRegionResponse -} - -func (g *GetRegionResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/getregionversions.go b/internal/membershipclient/models/operations/getregionversions.go deleted file mode 100644 index 834223d8..00000000 --- a/internal/membershipclient/models/operations/getregionversions.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetRegionVersionsRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - RegionID string `pathParam:"style=simple,explode=false,name=regionID"` -} - -func (g *GetRegionVersionsRequest) GetOrganizationID() string { - if g == nil { - return "" - } - return g.OrganizationID -} - -func (g *GetRegionVersionsRequest) GetRegionID() string { - if g == nil { - return "" - } - return g.RegionID -} - -type GetRegionVersionsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Get a specific region - GetRegionVersionsResponse *components.GetRegionVersionsResponse - // Error - Error *components.Error -} - -func (g GetRegionVersionsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetRegionVersionsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetRegionVersionsResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetRegionVersionsResponse) GetGetRegionVersionsResponse() *components.GetRegionVersionsResponse { - if g == nil { - return nil - } - return g.GetRegionVersionsResponse -} - -func (g *GetRegionVersionsResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/getserverinfo.go b/internal/membershipclient/models/operations/getserverinfo.go deleted file mode 100644 index d08fb004..00000000 --- a/internal/membershipclient/models/operations/getserverinfo.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetServerInfoResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Server information - ServerInfo *components.ServerInfo - // Error - Error *components.Error -} - -func (g GetServerInfoResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetServerInfoResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetServerInfoResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetServerInfoResponse) GetServerInfo() *components.ServerInfo { - if g == nil { - return nil - } - return g.ServerInfo -} - -func (g *GetServerInfoResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/getstack.go b/internal/membershipclient/models/operations/getstack.go deleted file mode 100644 index 074a0f08..00000000 --- a/internal/membershipclient/models/operations/getstack.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type GetStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (g *GetStackRequest) GetOrganizationID() string { - if g == nil { - return "" - } - return g.OrganizationID -} - -func (g *GetStackRequest) GetStackID() string { - if g == nil { - return "" - } - return g.StackID -} - -type GetStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // OK - ReadStackResponse *components.ReadStackResponse - // Error - Error *components.Error -} - -func (g GetStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(g, "", false) -} - -func (g *GetStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &g, "", false, nil); err != nil { - return err - } - return nil -} - -func (g *GetStackResponse) GetHTTPMeta() components.HTTPMetadata { - if g == nil { - return components.HTTPMetadata{} - } - return g.HTTPMeta -} - -func (g *GetStackResponse) GetReadStackResponse() *components.ReadStackResponse { - if g == nil { - return nil - } - return g.ReadStackResponse -} - -func (g *GetStackResponse) GetError() *components.Error { - if g == nil { - return nil - } - return g.Error -} diff --git a/internal/membershipclient/models/operations/listapplications.go b/internal/membershipclient/models/operations/listapplications.go deleted file mode 100644 index e8b2d925..00000000 --- a/internal/membershipclient/models/operations/listapplications.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListApplicationsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of applications - ListApplicationsResponse *components.ListApplicationsResponse - // Error - Error *components.Error -} - -func (l ListApplicationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListApplicationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListApplicationsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListApplicationsResponse) GetListApplicationsResponse() *components.ListApplicationsResponse { - if l == nil { - return nil - } - return l.ListApplicationsResponse -} - -func (l *ListApplicationsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listfeatures.go b/internal/membershipclient/models/operations/listfeatures.go deleted file mode 100644 index 58355744..00000000 --- a/internal/membershipclient/models/operations/listfeatures.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListFeaturesRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (l *ListFeaturesRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -// ListFeaturesResponseBody - List of features -type ListFeaturesResponseBody struct { - Data []components.OrganizationFeature `json:"data"` -} - -func (l *ListFeaturesResponseBody) GetData() []components.OrganizationFeature { - if l == nil { - return []components.OrganizationFeature{} - } - return l.Data -} - -type ListFeaturesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of features - Object *ListFeaturesResponseBody - // Error - Error *components.Error -} - -func (l ListFeaturesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListFeaturesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListFeaturesResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListFeaturesResponse) GetObject() *ListFeaturesResponseBody { - if l == nil { - return nil - } - return l.Object -} - -func (l *ListFeaturesResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listinvitations.go b/internal/membershipclient/models/operations/listinvitations.go deleted file mode 100644 index 9b9f551b..00000000 --- a/internal/membershipclient/models/operations/listinvitations.go +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListInvitationsRequest struct { - // Status of organizations - Status *string `queryParam:"style=form,explode=true,name=status"` - // Status of organizations - Organization *string `queryParam:"style=form,explode=true,name=organization"` -} - -func (l *ListInvitationsRequest) GetStatus() *string { - if l == nil { - return nil - } - return l.Status -} - -func (l *ListInvitationsRequest) GetOrganization() *string { - if l == nil { - return nil - } - return l.Organization -} - -type ListInvitationsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of the invitations for the connected user - ListInvitationsResponse *components.ListInvitationsResponse - // Error - Error *components.Error -} - -func (l ListInvitationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListInvitationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListInvitationsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListInvitationsResponse) GetListInvitationsResponse() *components.ListInvitationsResponse { - if l == nil { - return nil - } - return l.ListInvitationsResponse -} - -func (l *ListInvitationsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listlogs.go b/internal/membershipclient/models/operations/listlogs.go deleted file mode 100644 index 65b93637..00000000 --- a/internal/membershipclient/models/operations/listlogs.go +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListLogsRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID *string `queryParam:"style=form,explode=true,name=stackId"` - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` - Action *components.Action `queryParam:"style=form,explode=true,name=action"` - UserID *string `queryParam:"style=form,explode=true,name=userId"` - Key *string `queryParam:"style=form,explode=true,name=key"` - Value *string `queryParam:"style=form,explode=true,name=value"` -} - -func (l *ListLogsRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListLogsRequest) GetStackID() *string { - if l == nil { - return nil - } - return l.StackID -} - -func (l *ListLogsRequest) GetCursor() *string { - if l == nil { - return nil - } - return l.Cursor -} - -func (l *ListLogsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListLogsRequest) GetAction() *components.Action { - if l == nil { - return nil - } - return l.Action -} - -func (l *ListLogsRequest) GetUserID() *string { - if l == nil { - return nil - } - return l.UserID -} - -func (l *ListLogsRequest) GetKey() *string { - if l == nil { - return nil - } - return l.Key -} - -func (l *ListLogsRequest) GetValue() *string { - if l == nil { - return nil - } - return l.Value -} - -type ListLogsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Cursor of logs - LogCursor *components.LogCursor - // Error - Error *components.Error -} - -func (l ListLogsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListLogsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListLogsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListLogsResponse) GetLogCursor() *components.LogCursor { - if l == nil { - return nil - } - return l.LogCursor -} - -func (l *ListLogsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listmodules.go b/internal/membershipclient/models/operations/listmodules.go deleted file mode 100644 index 71c275cb..00000000 --- a/internal/membershipclient/models/operations/listmodules.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListModulesRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (l *ListModulesRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListModulesRequest) GetStackID() string { - if l == nil { - return "" - } - return l.StackID -} - -type ListModulesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of modules - ListModulesResponse *components.ListModulesResponse - // Error - Error *components.Error -} - -func (l ListModulesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListModulesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListModulesResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListModulesResponse) GetListModulesResponse() *components.ListModulesResponse { - if l == nil { - return nil - } - return l.ListModulesResponse -} - -func (l *ListModulesResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listorganizationapplications.go b/internal/membershipclient/models/operations/listorganizationapplications.go deleted file mode 100644 index 48a72960..00000000 --- a/internal/membershipclient/models/operations/listorganizationapplications.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListOrganizationApplicationsRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PageSize *int64 `default:"15" queryParam:"style=form,explode=true,name=pageSize"` - Page *int64 `default:"0" queryParam:"style=form,explode=true,name=page"` -} - -func (l ListOrganizationApplicationsRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationApplicationsRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationApplicationsRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListOrganizationApplicationsRequest) GetPageSize() *int64 { - if l == nil { - return nil - } - return l.PageSize -} - -func (l *ListOrganizationApplicationsRequest) GetPage() *int64 { - if l == nil { - return nil - } - return l.Page -} - -type ListOrganizationApplicationsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of applications enabled for organization - ListApplicationsResponse *components.ListApplicationsResponse - // Error - Error *components.Error -} - -func (l ListOrganizationApplicationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationApplicationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationApplicationsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListOrganizationApplicationsResponse) GetListApplicationsResponse() *components.ListApplicationsResponse { - if l == nil { - return nil - } - return l.ListApplicationsResponse -} - -func (l *ListOrganizationApplicationsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listorganizationinvitations.go b/internal/membershipclient/models/operations/listorganizationinvitations.go deleted file mode 100644 index dc7cddbb..00000000 --- a/internal/membershipclient/models/operations/listorganizationinvitations.go +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListOrganizationInvitationsRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - // Status of organizations - Status *string `queryParam:"style=form,explode=true,name=status"` -} - -func (l *ListOrganizationInvitationsRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListOrganizationInvitationsRequest) GetStatus() *string { - if l == nil { - return nil - } - return l.Status -} - -type ListOrganizationInvitationsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of the invitations for the organization - ListInvitationsResponse *components.ListInvitationsResponse - // Error - Error *components.Error -} - -func (l ListOrganizationInvitationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationInvitationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationInvitationsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListOrganizationInvitationsResponse) GetListInvitationsResponse() *components.ListInvitationsResponse { - if l == nil { - return nil - } - return l.ListInvitationsResponse -} - -func (l *ListOrganizationInvitationsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listorganizations.go b/internal/membershipclient/models/operations/listorganizations.go deleted file mode 100644 index d17cca6f..00000000 --- a/internal/membershipclient/models/operations/listorganizations.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListOrganizationsRequest struct { - Expand *bool `queryParam:"style=form,explode=true,name=expand"` -} - -func (l *ListOrganizationsRequest) GetExpand() *bool { - if l == nil { - return nil - } - return l.Expand -} - -type ListOrganizationsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of organizations - ListOrganizationExpandedResponse *components.ListOrganizationExpandedResponse - // Error - Error *components.Error -} - -func (l ListOrganizationsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListOrganizationsResponse) GetListOrganizationExpandedResponse() *components.ListOrganizationExpandedResponse { - if l == nil { - return nil - } - return l.ListOrganizationExpandedResponse -} - -func (l *ListOrganizationsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listorganizationsexpanded.go b/internal/membershipclient/models/operations/listorganizationsexpanded.go deleted file mode 100644 index aec2e01b..00000000 --- a/internal/membershipclient/models/operations/listorganizationsexpanded.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListOrganizationsExpandedResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of organizations with expanded data - ListOrganizationExpandedResponse *components.ListOrganizationExpandedResponse -} - -func (l ListOrganizationsExpandedResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListOrganizationsExpandedResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListOrganizationsExpandedResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListOrganizationsExpandedResponse) GetListOrganizationExpandedResponse() *components.ListOrganizationExpandedResponse { - if l == nil { - return nil - } - return l.ListOrganizationExpandedResponse -} diff --git a/internal/membershipclient/models/operations/listpolicies.go b/internal/membershipclient/models/operations/listpolicies.go deleted file mode 100644 index f4c813f0..00000000 --- a/internal/membershipclient/models/operations/listpolicies.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListPoliciesRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (l *ListPoliciesRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -type ListPoliciesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of policies - ListPoliciesResponse *components.ListPoliciesResponse - // Error - Error *components.Error -} - -func (l ListPoliciesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListPoliciesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListPoliciesResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListPoliciesResponse) GetListPoliciesResponse() *components.ListPoliciesResponse { - if l == nil { - return nil - } - return l.ListPoliciesResponse -} - -func (l *ListPoliciesResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listregions.go b/internal/membershipclient/models/operations/listregions.go deleted file mode 100644 index c36c0ea0..00000000 --- a/internal/membershipclient/models/operations/listregions.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListRegionsRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (l *ListRegionsRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -type ListRegionsResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of the regions - ListRegionsResponse *components.ListRegionsResponse - // Error - Error *components.Error -} - -func (l ListRegionsResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListRegionsResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListRegionsResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListRegionsResponse) GetListRegionsResponse() *components.ListRegionsResponse { - if l == nil { - return nil - } - return l.ListRegionsResponse -} - -func (l *ListRegionsResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/liststacks.go b/internal/membershipclient/models/operations/liststacks.go deleted file mode 100644 index 7e893ab5..00000000 --- a/internal/membershipclient/models/operations/liststacks.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListStacksRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - // Include deleted and disabled stacks - All *bool `queryParam:"style=form,explode=true,name=all"` - // Include deleted stacks - // - // Deprecated: This will be removed in a future release, please migrate away from it as soon as possible. - Deleted *bool `queryParam:"style=form,explode=true,name=deleted"` -} - -func (l *ListStacksRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListStacksRequest) GetAll() *bool { - if l == nil { - return nil - } - return l.All -} - -func (l *ListStacksRequest) GetDeleted() *bool { - if l == nil { - return nil - } - return l.Deleted -} - -type ListStacksResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of stacks - ListStacksResponse *components.ListStacksResponse - // Error - Error *components.Error -} - -func (l ListStacksResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListStacksResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListStacksResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListStacksResponse) GetListStacksResponse() *components.ListStacksResponse { - if l == nil { - return nil - } - return l.ListStacksResponse -} - -func (l *ListStacksResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/liststackusersaccesses.go b/internal/membershipclient/models/operations/liststackusersaccesses.go deleted file mode 100644 index e9684bec..00000000 --- a/internal/membershipclient/models/operations/liststackusersaccesses.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListStackUsersAccessesRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (l *ListStackUsersAccessesRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -func (l *ListStackUsersAccessesRequest) GetStackID() string { - if l == nil { - return "" - } - return l.StackID -} - -type ListStackUsersAccessesResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Stack users access - StackUserAccessResponse *components.StackUserAccessResponse - // Error - Error *components.Error -} - -func (l ListStackUsersAccessesResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListStackUsersAccessesResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListStackUsersAccessesResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListStackUsersAccessesResponse) GetStackUserAccessResponse() *components.StackUserAccessResponse { - if l == nil { - return nil - } - return l.StackUserAccessResponse -} - -func (l *ListStackUsersAccessesResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/listusersoforganization.go b/internal/membershipclient/models/operations/listusersoforganization.go deleted file mode 100644 index 5be59764..00000000 --- a/internal/membershipclient/models/operations/listusersoforganization.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ListUsersOfOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (l *ListUsersOfOrganizationRequest) GetOrganizationID() string { - if l == nil { - return "" - } - return l.OrganizationID -} - -type ListUsersOfOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // List of users - ListUsersResponse *components.ListUsersResponse - // Error - Error *components.Error -} - -func (l ListUsersOfOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(l, "", false) -} - -func (l *ListUsersOfOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &l, "", false, nil); err != nil { - return err - } - return nil -} - -func (l *ListUsersOfOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if l == nil { - return components.HTTPMetadata{} - } - return l.HTTPMeta -} - -func (l *ListUsersOfOrganizationResponse) GetListUsersResponse() *components.ListUsersResponse { - if l == nil { - return nil - } - return l.ListUsersResponse -} - -func (l *ListUsersOfOrganizationResponse) GetError() *components.Error { - if l == nil { - return nil - } - return l.Error -} diff --git a/internal/membershipclient/models/operations/options.go b/internal/membershipclient/models/operations/options.go deleted file mode 100644 index 78b481e6..00000000 --- a/internal/membershipclient/models/operations/options.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "errors" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "time" -) - -var ErrUnsupportedOption = errors.New("unsupported option") - -const ( - SupportedOptionRetries = "retries" - SupportedOptionTimeout = "timeout" - SupportedOptionAcceptHeaderOverride = "acceptHeaderOverride" - SupportedOptionURLOverride = "urlOverride" -) - -type Options struct { - ServerURL *string - Retries *retry.Config - Timeout *time.Duration - URLOverride *string - SetHeaders map[string]string -} - -type Option func(*Options, ...string) error - -// WithServerURL allows providing an alternative server URL. -func WithServerURL(serverURL string) Option { - return func(opts *Options, supportedOptions ...string) error { - opts.ServerURL = &serverURL - return nil - } -} - -// WithTemplatedServerURL allows providing an alternative server URL with templated parameters. -func WithTemplatedServerURL(serverURL string, params map[string]string) Option { - return func(opts *Options, supportedOptions ...string) error { - if params != nil { - serverURL = utils.ReplaceParameters(serverURL, params) - } - - opts.ServerURL = &serverURL - return nil - } -} - -// WithRetries allows customizing the default retry configuration. -func WithRetries(config retry.Config) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionRetries) { - return ErrUnsupportedOption - } - - opts.Retries = &config - return nil - } -} - -// WithOperationTimeout allows setting the request timeout applied for an operation. -func WithOperationTimeout(timeout time.Duration) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionRetries) { - return ErrUnsupportedOption - } - - opts.Timeout = &timeout - return nil - } -} - -// WithURLOverride allows overriding the URL. -func WithURLOverride(urlOverride string) Option { - return func(opts *Options, supportedOptions ...string) error { - if !utils.Contains(supportedOptions, SupportedOptionURLOverride) { - return ErrUnsupportedOption - } - - opts.URLOverride = &urlOverride - return nil - } -} - -// WithSetHeaders takes a map of headers that will applied to a request. If the -// request contains headers that are in the map then they will be overwritten. -func WithSetHeaders(hdrs map[string]string) Option { - return func(opts *Options, supportedOptions ...string) error { - opts.SetHeaders = hdrs - return nil - } -} diff --git a/internal/membershipclient/models/operations/organizationclientcreate.go b/internal/membershipclient/models/operations/organizationclientcreate.go deleted file mode 100644 index c89b7bbf..00000000 --- a/internal/membershipclient/models/operations/organizationclientcreate.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type OrganizationClientCreateRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.CreateOrganizationClientRequest `request:"mediaType=application/json"` -} - -func (o OrganizationClientCreateRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientCreateRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientCreateRequest) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationClientCreateRequest) GetBody() *components.CreateOrganizationClientRequest { - if o == nil { - return nil - } - return o.Body -} - -type OrganizationClientCreateResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization client created - CreateOrganizationClientResponse *components.CreateOrganizationClientResponse - // Error - Error *components.Error -} - -func (o OrganizationClientCreateResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientCreateResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientCreateResponse) GetHTTPMeta() components.HTTPMetadata { - if o == nil { - return components.HTTPMetadata{} - } - return o.HTTPMeta -} - -func (o *OrganizationClientCreateResponse) GetCreateOrganizationClientResponse() *components.CreateOrganizationClientResponse { - if o == nil { - return nil - } - return o.CreateOrganizationClientResponse -} - -func (o *OrganizationClientCreateResponse) GetError() *components.Error { - if o == nil { - return nil - } - return o.Error -} diff --git a/internal/membershipclient/models/operations/organizationclientdelete.go b/internal/membershipclient/models/operations/organizationclientdelete.go deleted file mode 100644 index e7d93017..00000000 --- a/internal/membershipclient/models/operations/organizationclientdelete.go +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type OrganizationClientDeleteRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ClientID string `pathParam:"style=simple,explode=false,name=clientId"` -} - -func (o *OrganizationClientDeleteRequest) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationClientDeleteRequest) GetClientID() string { - if o == nil { - return "" - } - return o.ClientID -} - -type OrganizationClientDeleteResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (o OrganizationClientDeleteResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientDeleteResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientDeleteResponse) GetHTTPMeta() components.HTTPMetadata { - if o == nil { - return components.HTTPMetadata{} - } - return o.HTTPMeta -} - -func (o *OrganizationClientDeleteResponse) GetError() *components.Error { - if o == nil { - return nil - } - return o.Error -} diff --git a/internal/membershipclient/models/operations/organizationclientread.go b/internal/membershipclient/models/operations/organizationclientread.go deleted file mode 100644 index 1c73d2b5..00000000 --- a/internal/membershipclient/models/operations/organizationclientread.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type OrganizationClientReadRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ClientID string `pathParam:"style=simple,explode=false,name=clientId"` -} - -func (o *OrganizationClientReadRequest) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationClientReadRequest) GetClientID() string { - if o == nil { - return "" - } - return o.ClientID -} - -type OrganizationClientReadResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization client details - ReadOrganizationClientResponse *components.ReadOrganizationClientResponse - // Error - Error *components.Error -} - -func (o OrganizationClientReadResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientReadResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientReadResponse) GetHTTPMeta() components.HTTPMetadata { - if o == nil { - return components.HTTPMetadata{} - } - return o.HTTPMeta -} - -func (o *OrganizationClientReadResponse) GetReadOrganizationClientResponse() *components.ReadOrganizationClientResponse { - if o == nil { - return nil - } - return o.ReadOrganizationClientResponse -} - -func (o *OrganizationClientReadResponse) GetError() *components.Error { - if o == nil { - return nil - } - return o.Error -} diff --git a/internal/membershipclient/models/operations/organizationclientsread.go b/internal/membershipclient/models/operations/organizationclientsread.go deleted file mode 100644 index 76cd124c..00000000 --- a/internal/membershipclient/models/operations/organizationclientsread.go +++ /dev/null @@ -1,75 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type OrganizationClientsReadRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Cursor *string `queryParam:"style=form,explode=true,name=cursor"` - PageSize *int64 `queryParam:"style=form,explode=true,name=pageSize"` -} - -func (o *OrganizationClientsReadRequest) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationClientsReadRequest) GetCursor() *string { - if o == nil { - return nil - } - return o.Cursor -} - -func (o *OrganizationClientsReadRequest) GetPageSize() *int64 { - if o == nil { - return nil - } - return o.PageSize -} - -type OrganizationClientsReadResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization client - ReadOrganizationClientsResponse *components.ReadOrganizationClientsResponse - // Error - Error *components.Error -} - -func (o OrganizationClientsReadResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientsReadResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientsReadResponse) GetHTTPMeta() components.HTTPMetadata { - if o == nil { - return components.HTTPMetadata{} - } - return o.HTTPMeta -} - -func (o *OrganizationClientsReadResponse) GetReadOrganizationClientsResponse() *components.ReadOrganizationClientsResponse { - if o == nil { - return nil - } - return o.ReadOrganizationClientsResponse -} - -func (o *OrganizationClientsReadResponse) GetError() *components.Error { - if o == nil { - return nil - } - return o.Error -} diff --git a/internal/membershipclient/models/operations/organizationclientupdate.go b/internal/membershipclient/models/operations/organizationclientupdate.go deleted file mode 100644 index 639dc340..00000000 --- a/internal/membershipclient/models/operations/organizationclientupdate.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type OrganizationClientUpdateRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - ClientID string `pathParam:"style=simple,explode=false,name=clientId"` - Body *components.UpdateOrganizationClientRequest `request:"mediaType=application/json"` -} - -func (o OrganizationClientUpdateRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientUpdateRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientUpdateRequest) GetOrganizationID() string { - if o == nil { - return "" - } - return o.OrganizationID -} - -func (o *OrganizationClientUpdateRequest) GetClientID() string { - if o == nil { - return "" - } - return o.ClientID -} - -func (o *OrganizationClientUpdateRequest) GetBody() *components.UpdateOrganizationClientRequest { - if o == nil { - return nil - } - return o.Body -} - -type OrganizationClientUpdateResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (o OrganizationClientUpdateResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(o, "", false) -} - -func (o *OrganizationClientUpdateResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &o, "", false, nil); err != nil { - return err - } - return nil -} - -func (o *OrganizationClientUpdateResponse) GetHTTPMeta() components.HTTPMetadata { - if o == nil { - return components.HTTPMetadata{} - } - return o.HTTPMeta -} - -func (o *OrganizationClientUpdateResponse) GetError() *components.Error { - if o == nil { - return nil - } - return o.Error -} diff --git a/internal/membershipclient/models/operations/readauthenticationprovider.go b/internal/membershipclient/models/operations/readauthenticationprovider.go deleted file mode 100644 index 5df2cc6e..00000000 --- a/internal/membershipclient/models/operations/readauthenticationprovider.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadAuthenticationProviderRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (r *ReadAuthenticationProviderRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -type ReadAuthenticationProviderResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Authentication provider details - AuthenticationProviderResponse *components.AuthenticationProviderResponse - // Error - Error *components.Error -} - -func (r ReadAuthenticationProviderResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadAuthenticationProviderResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadAuthenticationProviderResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadAuthenticationProviderResponse) GetAuthenticationProviderResponse() *components.AuthenticationProviderResponse { - if r == nil { - return nil - } - return r.AuthenticationProviderResponse -} - -func (r *ReadAuthenticationProviderResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readconnecteduser.go b/internal/membershipclient/models/operations/readconnecteduser.go deleted file mode 100644 index cabf151f..00000000 --- a/internal/membershipclient/models/operations/readconnecteduser.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadConnectedUserResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Read a user - ReadUserResponse *components.ReadUserResponse - // Error - Error *components.Error -} - -func (r ReadConnectedUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadConnectedUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadConnectedUserResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadConnectedUserResponse) GetReadUserResponse() *components.ReadUserResponse { - if r == nil { - return nil - } - return r.ReadUserResponse -} - -func (r *ReadConnectedUserResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readorganization.go b/internal/membershipclient/models/operations/readorganization.go deleted file mode 100644 index 0054d4af..00000000 --- a/internal/membershipclient/models/operations/readorganization.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Expand *bool `queryParam:"style=form,explode=true,name=expand"` -} - -func (r *ReadOrganizationRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *ReadOrganizationRequest) GetExpand() *bool { - if r == nil { - return nil - } - return r.Expand -} - -type ReadOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // OK - ReadOrganizationResponse *components.ReadOrganizationResponse - // Error - Error *components.Error -} - -func (r ReadOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadOrganizationResponse) GetReadOrganizationResponse() *components.ReadOrganizationResponse { - if r == nil { - return nil - } - return r.ReadOrganizationResponse -} - -func (r *ReadOrganizationResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readorganizationclient.go b/internal/membershipclient/models/operations/readorganizationclient.go deleted file mode 100644 index d5daa746..00000000 --- a/internal/membershipclient/models/operations/readorganizationclient.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadOrganizationClientRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` -} - -func (r *ReadOrganizationClientRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -type ReadOrganizationClientResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization client - CreateClientResponseResponse *components.CreateClientResponseResponse - // Error - Error *components.Error -} - -func (r ReadOrganizationClientResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadOrganizationClientResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadOrganizationClientResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadOrganizationClientResponse) GetCreateClientResponseResponse() *components.CreateClientResponseResponse { - if r == nil { - return nil - } - return r.CreateClientResponseResponse -} - -func (r *ReadOrganizationClientResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readpolicy.go b/internal/membershipclient/models/operations/readpolicy.go deleted file mode 100644 index 63913945..00000000 --- a/internal/membershipclient/models/operations/readpolicy.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadPolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PolicyID int64 `pathParam:"style=simple,explode=false,name=policyId"` -} - -func (r *ReadPolicyRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *ReadPolicyRequest) GetPolicyID() int64 { - if r == nil { - return 0 - } - return r.PolicyID -} - -type ReadPolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Policy with scopes - ReadPolicyResponse *components.ReadPolicyResponse - // Error - Error *components.Error -} - -func (r ReadPolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadPolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadPolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadPolicyResponse) GetReadPolicyResponse() *components.ReadPolicyResponse { - if r == nil { - return nil - } - return r.ReadPolicyResponse -} - -func (r *ReadPolicyResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readstackuseraccess.go b/internal/membershipclient/models/operations/readstackuseraccess.go deleted file mode 100644 index 1c4c3218..00000000 --- a/internal/membershipclient/models/operations/readstackuseraccess.go +++ /dev/null @@ -1,75 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadStackUserAccessRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` -} - -func (r *ReadStackUserAccessRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *ReadStackUserAccessRequest) GetStackID() string { - if r == nil { - return "" - } - return r.StackID -} - -func (r *ReadStackUserAccessRequest) GetUserID() string { - if r == nil { - return "" - } - return r.UserID -} - -type ReadStackUserAccessResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Stack user access - ReadStackUserAccess *components.ReadStackUserAccess - // Error - Error *components.Error -} - -func (r ReadStackUserAccessResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadStackUserAccessResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadStackUserAccessResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadStackUserAccessResponse) GetReadStackUserAccess() *components.ReadStackUserAccess { - if r == nil { - return nil - } - return r.ReadStackUserAccess -} - -func (r *ReadStackUserAccessResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/readuseroforganization.go b/internal/membershipclient/models/operations/readuseroforganization.go deleted file mode 100644 index ae9f8ac9..00000000 --- a/internal/membershipclient/models/operations/readuseroforganization.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type ReadUserOfOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` -} - -func (r *ReadUserOfOrganizationRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *ReadUserOfOrganizationRequest) GetUserID() string { - if r == nil { - return "" - } - return r.UserID -} - -type ReadUserOfOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Read a user - ReadOrganizationUserResponse *components.ReadOrganizationUserResponse - // Error - Error *components.Error -} - -func (r ReadUserOfOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *ReadUserOfOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *ReadUserOfOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *ReadUserOfOrganizationResponse) GetReadOrganizationUserResponse() *components.ReadOrganizationUserResponse { - if r == nil { - return nil - } - return r.ReadOrganizationUserResponse -} - -func (r *ReadUserOfOrganizationResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/removescopefrompolicy.go b/internal/membershipclient/models/operations/removescopefrompolicy.go deleted file mode 100644 index f9045900..00000000 --- a/internal/membershipclient/models/operations/removescopefrompolicy.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type RemoveScopeFromPolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PolicyID int64 `pathParam:"style=simple,explode=false,name=policyId"` - ScopeID int64 `pathParam:"style=simple,explode=false,name=scopeId"` -} - -func (r *RemoveScopeFromPolicyRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *RemoveScopeFromPolicyRequest) GetPolicyID() int64 { - if r == nil { - return 0 - } - return r.PolicyID -} - -func (r *RemoveScopeFromPolicyRequest) GetScopeID() int64 { - if r == nil { - return 0 - } - return r.ScopeID -} - -type RemoveScopeFromPolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (r RemoveScopeFromPolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *RemoveScopeFromPolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *RemoveScopeFromPolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *RemoveScopeFromPolicyResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/restorestack.go b/internal/membershipclient/models/operations/restorestack.go deleted file mode 100644 index d2c33124..00000000 --- a/internal/membershipclient/models/operations/restorestack.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type RestoreStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` -} - -func (r *RestoreStackRequest) GetOrganizationID() string { - if r == nil { - return "" - } - return r.OrganizationID -} - -func (r *RestoreStackRequest) GetStackID() string { - if r == nil { - return "" - } - return r.StackID -} - -type RestoreStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // OK - ReadStackResponse *components.ReadStackResponse - // Error - Error *components.Error -} - -func (r RestoreStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(r, "", false) -} - -func (r *RestoreStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &r, "", false, nil); err != nil { - return err - } - return nil -} - -func (r *RestoreStackResponse) GetHTTPMeta() components.HTTPMetadata { - if r == nil { - return components.HTTPMetadata{} - } - return r.HTTPMeta -} - -func (r *RestoreStackResponse) GetReadStackResponse() *components.ReadStackResponse { - if r == nil { - return nil - } - return r.ReadStackResponse -} - -func (r *RestoreStackResponse) GetError() *components.Error { - if r == nil { - return nil - } - return r.Error -} diff --git a/internal/membershipclient/models/operations/updateapplication.go b/internal/membershipclient/models/operations/updateapplication.go deleted file mode 100644 index c4d621cf..00000000 --- a/internal/membershipclient/models/operations/updateapplication.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpdateApplicationRequest struct { - ApplicationID string `pathParam:"style=simple,explode=false,name=applicationId"` - Body *components.ApplicationData `request:"mediaType=application/json"` -} - -func (u UpdateApplicationRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateApplicationRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateApplicationRequest) GetApplicationID() string { - if u == nil { - return "" - } - return u.ApplicationID -} - -func (u *UpdateApplicationRequest) GetBody() *components.ApplicationData { - if u == nil { - return nil - } - return u.Body -} - -type UpdateApplicationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Application updated - UpdateApplicationResponse *components.UpdateApplicationResponse - // Error - Error *components.Error -} - -func (u UpdateApplicationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateApplicationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateApplicationResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdateApplicationResponse) GetUpdateApplicationResponse() *components.UpdateApplicationResponse { - if u == nil { - return nil - } - return u.UpdateApplicationResponse -} - -func (u *UpdateApplicationResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/updateorganization.go b/internal/membershipclient/models/operations/updateorganization.go deleted file mode 100644 index f737555c..00000000 --- a/internal/membershipclient/models/operations/updateorganization.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpdateOrganizationRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.OrganizationData `request:"mediaType=application/json"` -} - -func (u UpdateOrganizationRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateOrganizationRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateOrganizationRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpdateOrganizationRequest) GetBody() *components.OrganizationData { - if u == nil { - return nil - } - return u.Body -} - -type UpdateOrganizationResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Organization updated - ReadOrganizationResponse *components.ReadOrganizationResponse - // Error - Error *components.Error -} - -func (u UpdateOrganizationResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateOrganizationResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateOrganizationResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdateOrganizationResponse) GetReadOrganizationResponse() *components.ReadOrganizationResponse { - if u == nil { - return nil - } - return u.ReadOrganizationResponse -} - -func (u *UpdateOrganizationResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/updatepolicy.go b/internal/membershipclient/models/operations/updatepolicy.go deleted file mode 100644 index 1592f345..00000000 --- a/internal/membershipclient/models/operations/updatepolicy.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpdatePolicyRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - PolicyID int64 `pathParam:"style=simple,explode=false,name=policyId"` - Body *components.CreatePolicyRequest `request:"mediaType=application/json"` -} - -func (u UpdatePolicyRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdatePolicyRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdatePolicyRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpdatePolicyRequest) GetPolicyID() int64 { - if u == nil { - return 0 - } - return u.PolicyID -} - -func (u *UpdatePolicyRequest) GetBody() *components.CreatePolicyRequest { - if u == nil { - return nil - } - return u.Body -} - -type UpdatePolicyResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Policy updated - UpdatePolicyResponse *components.UpdatePolicyResponse - // Error - Error *components.Error -} - -func (u UpdatePolicyResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdatePolicyResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdatePolicyResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdatePolicyResponse) GetUpdatePolicyResponse() *components.UpdatePolicyResponse { - if u == nil { - return nil - } - return u.UpdatePolicyResponse -} - -func (u *UpdatePolicyResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/updatestack.go b/internal/membershipclient/models/operations/updatestack.go deleted file mode 100644 index 1603b6c1..00000000 --- a/internal/membershipclient/models/operations/updatestack.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpdateStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - Body *components.StackData `request:"mediaType=application/json"` -} - -func (u UpdateStackRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateStackRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateStackRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpdateStackRequest) GetStackID() string { - if u == nil { - return "" - } - return u.StackID -} - -func (u *UpdateStackRequest) GetBody() *components.StackData { - if u == nil { - return nil - } - return u.Body -} - -type UpdateStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Updated stack - ReadStackResponse *components.ReadStackResponse - // Error - Error *components.Error -} - -func (u UpdateStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpdateStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpdateStackResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpdateStackResponse) GetReadStackResponse() *components.ReadStackResponse { - if u == nil { - return nil - } - return u.ReadStackResponse -} - -func (u *UpdateStackResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/upgradestack.go b/internal/membershipclient/models/operations/upgradestack.go deleted file mode 100644 index dc9d36a6..00000000 --- a/internal/membershipclient/models/operations/upgradestack.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpgradeStackRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - Body *components.StackVersion `request:"mediaType=application/json"` -} - -func (u UpgradeStackRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpgradeStackRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpgradeStackRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpgradeStackRequest) GetStackID() string { - if u == nil { - return "" - } - return u.StackID -} - -func (u *UpgradeStackRequest) GetBody() *components.StackVersion { - if u == nil { - return nil - } - return u.Body -} - -type UpgradeStackResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (u UpgradeStackResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpgradeStackResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpgradeStackResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpgradeStackResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/upsertauthenticationprovider.go b/internal/membershipclient/models/operations/upsertauthenticationprovider.go deleted file mode 100644 index b3001a7a..00000000 --- a/internal/membershipclient/models/operations/upsertauthenticationprovider.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpsertAuthenticationProviderRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - Body *components.UpsertAuthenticationProviderRequest `request:"mediaType=application/json"` -} - -func (u *UpsertAuthenticationProviderRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpsertAuthenticationProviderRequest) GetBody() *components.UpsertAuthenticationProviderRequest { - if u == nil { - return nil - } - return u.Body -} - -type UpsertAuthenticationProviderResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Authentication provider created or updated - AuthenticationProviderResponse *components.AuthenticationProviderResponse - // Error - Error *components.Error -} - -func (u UpsertAuthenticationProviderResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertAuthenticationProviderResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertAuthenticationProviderResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpsertAuthenticationProviderResponse) GetAuthenticationProviderResponse() *components.AuthenticationProviderResponse { - if u == nil { - return nil - } - return u.AuthenticationProviderResponse -} - -func (u *UpsertAuthenticationProviderResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/upsertorganizationuser.go b/internal/membershipclient/models/operations/upsertorganizationuser.go deleted file mode 100644 index b4393c66..00000000 --- a/internal/membershipclient/models/operations/upsertorganizationuser.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpsertOrganizationUserRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` - Body *components.UpdateOrganizationUserRequest `request:"mediaType=application/json"` -} - -func (u UpsertOrganizationUserRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertOrganizationUserRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertOrganizationUserRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpsertOrganizationUserRequest) GetUserID() string { - if u == nil { - return "" - } - return u.UserID -} - -func (u *UpsertOrganizationUserRequest) GetBody() *components.UpdateOrganizationUserRequest { - if u == nil { - return nil - } - return u.Body -} - -type UpsertOrganizationUserResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (u UpsertOrganizationUserResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertOrganizationUserResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertOrganizationUserResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpsertOrganizationUserResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/models/operations/upsertstackuseraccess.go b/internal/membershipclient/models/operations/upsertstackuseraccess.go deleted file mode 100644 index 9e9d14dc..00000000 --- a/internal/membershipclient/models/operations/upsertstackuseraccess.go +++ /dev/null @@ -1,85 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package operations - -import ( - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" -) - -type UpsertStackUserAccessRequest struct { - OrganizationID string `pathParam:"style=simple,explode=false,name=organizationId"` - StackID string `pathParam:"style=simple,explode=false,name=stackId"` - UserID string `pathParam:"style=simple,explode=false,name=userId"` - Body *components.UpdateStackUserRequest `request:"mediaType=application/json"` -} - -func (u UpsertStackUserAccessRequest) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertStackUserAccessRequest) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertStackUserAccessRequest) GetOrganizationID() string { - if u == nil { - return "" - } - return u.OrganizationID -} - -func (u *UpsertStackUserAccessRequest) GetStackID() string { - if u == nil { - return "" - } - return u.StackID -} - -func (u *UpsertStackUserAccessRequest) GetUserID() string { - if u == nil { - return "" - } - return u.UserID -} - -func (u *UpsertStackUserAccessRequest) GetBody() *components.UpdateStackUserRequest { - if u == nil { - return nil - } - return u.Body -} - -type UpsertStackUserAccessResponse struct { - HTTPMeta components.HTTPMetadata `json:"-"` - // Error - Error *components.Error -} - -func (u UpsertStackUserAccessResponse) MarshalJSON() ([]byte, error) { - return utils.MarshalJSON(u, "", false) -} - -func (u *UpsertStackUserAccessResponse) UnmarshalJSON(data []byte) error { - if err := utils.UnmarshalJSON(data, &u, "", false, nil); err != nil { - return err - } - return nil -} - -func (u *UpsertStackUserAccessResponse) GetHTTPMeta() components.HTTPMetadata { - if u == nil { - return components.HTTPMetadata{} - } - return u.HTTPMeta -} - -func (u *UpsertStackUserAccessResponse) GetError() *components.Error { - if u == nil { - return nil - } - return u.Error -} diff --git a/internal/membershipclient/optionalnullable/optionalnullable.go b/internal/membershipclient/optionalnullable/optionalnullable.go deleted file mode 100644 index c6739be0..00000000 --- a/internal/membershipclient/optionalnullable/optionalnullable.go +++ /dev/null @@ -1,233 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package optionalnullable - -import ( - "bytes" - "encoding/json" - "reflect" -) - -// OptionalNullable represents a field that can distinguish between three states: -// 1. Set to a value: field is present with a non-nil value -// 2. Set to nil: field is present but explicitly set to null in JSON -// 3. Unset: field is omitted from JSON entirely -// -// This type is designed to work with JSON marshaling/unmarshaling and supports -// the `omitempty` struct tag to properly omit unset fields from JSON output. -// -// Usage: -// -// type User struct { -// Name OptionalNullable[string] `json:"name,omitempty"` -// Age OptionalNullable[int] `json:"age,omitempty"` -// Tags OptionalNullable[[]string] `json:"tags,omitempty"` -// } -// -// // Set to value -// name := "John" -// user.Name = From(&name) -// -// // Set to nil (will appear as "name": null in JSON) -// user.Name = From[string](nil) -// -// // Leave unset (will be omitted from JSON with omitempty) -// user := User{} -// -// WARNING: Do NOT use *OptionalNullable[T] as a field type. Always use OptionalNullable[T] directly. -// Using *OptionalNullable[T] will break the omitempty behavior and JSON marshaling. -// -// The type is implemented as a map[bool]*T where: -// - nil map represents unset state -// - Map with true key represents set state (value may be nil) -type OptionalNullable[T any] map[bool]*T - -// From creates a new OptionalNullable with the given value. -// Pass nil to create a OptionalNullable that is set to null. -// Pass a pointer to a value to create a OptionalNullable with that value. -// -// Examples: -// -// hello := "hello" -// From(&hello) // set to "hello" -// From[string](nil) // set to null -func From[T any](value *T) OptionalNullable[T] { - return map[bool]*T{ - true: value, - } -} - -// IsNull returns true if the OptionalNullable is explicitly set to nil. -// Returns false if the OptionalNullable is unset or has a value. -// -// Note: This differs from traditional null checks because unset fields -// return false, not true. Use IsSet() to check if a field was provided. -func (n OptionalNullable[T]) IsNull() bool { - v, ok := n[true] - return ok && v == nil -} - -// IsSet returns true if the OptionalNullable has been explicitly set (to either a value or nil). -// Returns false if the OptionalNullable is unset (omitted from JSON). -// -// This is the key method for distinguishing between: -// - Set to nil: IsSet() = true, IsNull() = true -// - Unset: IsSet() = false, IsNull() = false -func (n OptionalNullable[T]) IsSet() bool { - _, ok := n[true] - return ok -} - -// Get returns the internal pointer and whether the field was set. -// -// Return values: -// - (ptr, true): field was set (ptr may be nil if set to null) -// - (nil, false): field was unset/omitted -// -// This method provides direct access to the internal pointer representation. -func (n OptionalNullable[T]) Get() (*T, bool) { - v, ok := n[true] - return v, ok -} - -// GetOrZero returns the value and whether it was set. -// -// Return values: -// - (value, true): field was set to a non-nil value -// - (zero, true): field was explicitly set to nil -// - (zero, false): field was unset/omitted -// -// Examples: -// -// val, ok := nullable.GetOrZero() -// if !ok { -// // Field was unset/omitted -// } else if nullable.IsNull() { -// // Field was explicitly set to null -// } else { -// // Field has a value: val -// } -func (n OptionalNullable[T]) GetOrZero() (T, bool) { - var zero T - - if v, ok := n[true]; ok { - if v == nil { - return zero, true - } - return *v, true - } - return zero, false -} - -// GetUntyped returns the value as interface{} and whether it was set. -// This is useful for reflection-based code that needs to work with the value -// without knowing the specific type T. -// -// Return values: -// - (value, true): field was set to a non-nil value -// - (nil, true): field was explicitly set to nil -// - (nil, false): field was unset/omitted -func (n OptionalNullable[T]) GetUntyped() (interface{}, bool) { - if v, ok := n[true]; ok { - if v == nil { - return nil, true - } - return *v, true - } - return nil, false -} - -// Set sets the OptionalNullable to the given value pointer. -// Pass nil to set the field to null. -// Pass a pointer to a value to set the field to that value. -// -// Examples: -// -// nullable.Set(ptrFrom("hello")) // set to "hello" -// nullable.Set(nil) // set to null -func (n *OptionalNullable[T]) Set(value *T) { - *n = map[bool]*T{ - true: value, - } -} - -// Unset removes the value, making the field unset/omitted. -// After calling Unset(), IsSet() will return false and the field -// will be omitted from JSON output when using omitempty. -func (n *OptionalNullable[T]) Unset() { - *n = map[bool]*T{} -} - -// MarshalJSON implements json.Marshaler. -// -// Behavior: -// - Unset fields: omitted from JSON when struct field has omitempty tag -// - Null fields: serialized as "null" -// - Value fields: serialized as the actual value -// -// The omitempty behavior works because an empty map is considered -// a zero value by Go's JSON package. -func (n OptionalNullable[T]) MarshalJSON() ([]byte, error) { - if n.IsNull() { - return []byte("null"), nil - } - - return json.Marshal(n[true]) -} - -// UnmarshalJSON implements json.Unmarshaler. -// -// Behavior: -// - "null" in JSON: sets the field to null (IsSet=true, IsNull=true) -// - Any other value: sets the field to that value (IsSet=true, IsNull=false) -// - Missing from JSON: field remains unset (IsSet=false, IsNull=false) -func (n *OptionalNullable[T]) UnmarshalJSON(data []byte) error { - if bytes.Equal(data, []byte("null")) { - n.Set(nil) - return nil - } - var v T - if err := json.Unmarshal(data, &v); err != nil { - return err - } - n.Set(&v) - return nil -} - -// NullableInterface defines the interface that all OptionalNullable[T] types implement. -// This interface provides untyped access to optional nullable values for reflection-based code. -type OptionalNullableInterface interface { - GetUntyped() (interface{}, bool) -} - -// AsOptionalNullable attempts to convert a reflect.Value to a OptionalNullableInterface. -// This is a helper function for reflection-based code that needs to check -// if a value implements the optional nullable interface pattern. -// -// Returns: -// - (nullable, true): if the value implements OptionalNullableInterface -// - (nil, false): if the value does not implement OptionalNullableInterface -// -// Example usage: -// -// if nullable, ok := AsOptionalNullable(reflectValue); ok { -// if value, isSet := nullable.GetUntyped(); isSet { -// // Handle the nullable value -// } -// } -func AsOptionalNullable(v reflect.Value) (OptionalNullableInterface, bool) { - // Check if the value can be converted to an interface first - if !v.CanInterface() { - return nil, false - } - - // Check if the underlying value is a nil map (unset nullable) - if v.Kind() == reflect.Map && v.IsNil() { - return nil, false - } - - if nullable, ok := v.Interface().(OptionalNullableInterface); ok { - return nullable, true - } - return nil, false -} diff --git a/internal/membershipclient/optionalnullable/optionalnullable_test.go b/internal/membershipclient/optionalnullable/optionalnullable_test.go deleted file mode 100644 index e6e5a01c..00000000 --- a/internal/membershipclient/optionalnullable/optionalnullable_test.go +++ /dev/null @@ -1,1806 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package optionalnullable - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Test helper function to create pointers from values -func ptrFrom[T any](value T) *T { - return &value -} - -// Test helper types for comprehensive testing -type TestStruct struct { - Name string `json:"name"` - Age int `json:"age"` -} - -type TestContainer struct { - StringField OptionalNullable[string] `json:"string_field,omitempty"` - IntField OptionalNullable[int] `json:"int_field,omitempty"` - SliceField OptionalNullable[[]string] `json:"slice_field,omitempty"` - StructField OptionalNullable[TestStruct] `json:"struct_field,omitempty"` -} - -// TestNewNullable tests the From constructor -func TestNewNullable(t *testing.T) { - t.Parallel() - t.Run("with string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value for string - }) - - t.Run("with int value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("with slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - }) - - t.Run("with empty slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{})) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("with struct value", func(t *testing.T) { - t.Parallel() - val := TestStruct{Name: "John", Age: 30} - nullable := From(&val) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - v, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, val, v) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, got) - }) -} - -// TestNewNullableUnset tests the NewNullableUnset constructor -func TestNewNullableUnset(t *testing.T) { - t.Parallel() - t.Run("string type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value for string - }) - - t.Run("int type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, 0, got) // zero value for int - }) - - t.Run("slice type", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) // zero value for slice is nil - }) -} - -// TestIsNull tests the IsNull method -func TestIsNull(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - assert.False(t, nullable.IsNull()) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - assert.True(t, nullable.IsNull()) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - assert.False(t, nullable.IsNull()) - }) -} - -// TestIsSet tests the IsSet method -func TestIsSet(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - assert.True(t, nullable.IsSet()) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - assert.True(t, nullable.IsSet()) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - assert.False(t, nullable.IsSet()) - }) -} - -// TestGet tests the Get method -func TestGet(t *testing.T) { - t.Parallel() - t.Run("with string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("with slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b"})) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, got) - }) - - t.Run("with nil slice pointer", func(t *testing.T) { - t.Parallel() - nullable := From[[]string](nil) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) // zero value for slice is nil - }) -} - -// TestPointer tests the Pointer method -func TestPointer(t *testing.T) { - t.Parallel() - t.Run("with value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - ptr, ok := nullable.Get() - assert.True(t, ok) - assert.NotNil(t, ptr) - assert.Equal(t, "test", *ptr) - }) - - t.Run("with nil pointer", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - ptr, ok := nullable.Get() - assert.True(t, ok) - assert.Nil(t, ptr) - }) - - t.Run("unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - ptr, ok := nullable.Get() - assert.False(t, ok) - assert.Nil(t, ptr) - }) -} - -// TestSet tests the Set method -func TestSet(t *testing.T) { - t.Parallel() - t.Run("set string value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - // Initially unset - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Unset is not null - - // Set a value - nullable.Set(ptrFrom("test")) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("set int value", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[int]{} - - nullable.Set(ptrFrom(42)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("set slice value", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[[]string]{} - - slice := []string{"a", "b"} - nullable.Set(ptrFrom(slice)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, got) - }) - - t.Run("set empty slice", func(t *testing.T) { - t.Parallel() - nullable := OptionalNullable[[]string]{} - - slice := []string{} - nullable.Set(ptrFrom(slice)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("overwrite existing value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("original")) - - // Verify original value - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "original", got) - - // Set new value - nullable.Set(ptrFrom("new")) - - got, ok = nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "new", got) - }) -} - -// TestUnset tests the Unset method -func TestUnset(t *testing.T) { - t.Parallel() - t.Run("unset from value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - // Initially set - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // After unset is not null - // Value is now internal to the map implementation - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unset from nil", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - // Initially set to nil - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) // Set to nil should be null - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // After unset is not null - }) - - t.Run("unset already unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - - // Initially unset - assert.False(t, nullable.IsSet()) - - // Unset again - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) // Empty map is not null - }) -} - -// TestMarshalJSON tests JSON marshaling -func TestMarshalJSON(t *testing.T) { - t.Parallel() - t.Run("marshal string value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `"test"`, string(data)) - }) - - t.Run("marshal int value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `42`, string(data)) - }) - - t.Run("marshal nil value", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `null`, string(data)) - }) - - t.Run("marshal slice value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `["a","b","c"]`, string(data)) - }) - - t.Run("marshal empty slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `[]`, string(data)) - }) - - t.Run("marshal struct value", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(TestStruct{Name: "John", Age: 30})) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `{"name":"John","age":30}`, string(data)) - }) - - // Note: Unset values are not tested here because the current implementation - // doesn't handle unset fields in marshaling (see TODO in the code) -} - -// TestUnmarshalJSON tests JSON unmarshaling -func TestUnmarshalJSON(t *testing.T) { - t.Parallel() - t.Run("unmarshal string value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`"test"`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - }) - - t.Run("unmarshal int value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(`42`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - }) - - t.Run("unmarshal null value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`null`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - }) - - t.Run("unmarshal slice value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(`["a","b","c"]`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - }) - - t.Run("unmarshal empty slice", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(`[]`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - }) - - t.Run("unmarshal struct value", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(`{"name":"John","age":30}`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, got) - }) - - t.Run("unmarshal invalid JSON", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(`invalid`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) - - t.Run("unmarshal invalid JSON for int", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(`"not_a_number"`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) - - t.Run("unmarshal malformed JSON", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(`{invalid json`), &nullable) - assert.Error(t, err) - - // Ensure the nullable remains unset after error - assert.False(t, nullable.IsSet()) - }) -} - -// TestJSONRoundTrip tests marshaling and unmarshaling together -func TestJSONRoundTrip(t *testing.T) { - t.Parallel() - t.Run("string value round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From(ptrFrom("test value")) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) - - t.Run("nil value round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From[string](nil) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) - - t.Run("slice round trip", func(t *testing.T) { - t.Parallel() - nullable1 := From(ptrFrom([]string{"a", "b", "c"})) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[[]string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - assert.Equal(t, got1, got2) - }) -} - -// TestJSONToJSONRoundTrip tests starting with JSON and ensuring we can serialize back to the same JSON -func TestJSONToJSONRoundTrip(t *testing.T) { - t.Parallel() - t.Run("string value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `"hello world"` - - // Unmarshal from JSON - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "hello world", got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("null value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `null` - - // Unmarshal from JSON - var nullable OptionalNullable[string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", got) // zero value - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("int value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `42` - - // Unmarshal from JSON - var nullable OptionalNullable[int] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("slice value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `["a","b","c"]` - - // Unmarshal from JSON - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b", "c"}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("empty slice JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `[]` - - // Unmarshal from JSON - var nullable OptionalNullable[[]string] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) - - t.Run("struct value JSON round trip", func(t *testing.T) { - t.Parallel() - originalJSON := `{"name":"Alice","age":25}` - - // Unmarshal from JSON - var nullable OptionalNullable[TestStruct] - err := json.Unmarshal([]byte(originalJSON), &nullable) - require.NoError(t, err) - - // Verify state - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "Alice", Age: 25}, got) - - // Marshal back to JSON - resultJSON, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, originalJSON, string(resultJSON)) - }) -} - -// TestContainerStates tests comprehensive state detection and serialization with TestContainer -func TestContainerStates(t *testing.T) { - t.Parallel() - t.Run("all fields set to values", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From(ptrFrom("hello")), - IntField: From(ptrFrom(42)), - SliceField: From(ptrFrom([]string{"a", "b"})), - StructField: From(ptrFrom(TestStruct{Name: "John", Age: 30})), - } - - // Verify all fields are set and not null - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - assert.True(t, container.IntField.IsSet()) - assert.False(t, container.IntField.IsNull()) - assert.True(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Verify values - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "hello", stringVal) - - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 42, intVal) - - sliceVal, ok := container.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, sliceVal) - - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "John", Age: 30}, structVal) - - // Test JSON serialization - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Equal(t, "hello", result["string_field"]) - assert.Equal(t, float64(42), result["int_field"]) // JSON numbers are float64 - assert.Equal(t, []interface{}{"a", "b"}, result["slice_field"]) - structResult := result["struct_field"].(map[string]interface{}) - assert.Equal(t, "John", structResult["name"]) - assert.Equal(t, float64(30), structResult["age"]) - }) - - t.Run("all fields set to nil", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From[string](nil), - IntField: From[int](nil), - SliceField: From[[]string](nil), - StructField: From[TestStruct](nil), - } - - // Verify all fields are set but null - assert.True(t, container.StringField.IsSet()) - assert.True(t, container.StringField.IsNull()) - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - assert.True(t, container.SliceField.IsSet()) - assert.True(t, container.SliceField.IsNull()) - assert.True(t, container.StructField.IsSet()) - assert.True(t, container.StructField.IsNull()) - - // Verify GetOrZero() behavior for nil values - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) // set to nil still returns true - assert.Equal(t, "", stringVal) // zero value - - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, intVal) // zero value - - sliceVal, ok := container.SliceField.GetOrZero() - assert.True(t, ok) - assert.Nil(t, sliceVal) // zero value for slice is nil - - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{}, structVal) // zero value - - // Test JSON serialization - all should be null - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Nil(t, result["string_field"]) - assert.Nil(t, result["int_field"]) - assert.Nil(t, result["slice_field"]) - assert.Nil(t, result["struct_field"]) - }) - - t.Run("all fields unset", func(t *testing.T) { - t.Parallel() - container := TestContainer{} - - // Verify all fields are unset - assert.False(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) // unset is not null in new implementation - assert.False(t, container.IntField.IsSet()) - assert.False(t, container.IntField.IsNull()) - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) - assert.False(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Verify GetOrZero() behavior for unset values - stringVal, ok := container.StringField.GetOrZero() - assert.False(t, ok) // unset returns false - assert.Equal(t, "", stringVal) // zero value - - intVal, ok := container.IntField.GetOrZero() - assert.False(t, ok) - assert.Equal(t, 0, intVal) // zero value - - sliceVal, ok := container.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, sliceVal) // zero value - - structVal, ok := container.StructField.GetOrZero() - assert.False(t, ok) - assert.Equal(t, TestStruct{}, structVal) // zero value - - // Test JSON serialization - unset fields should be omitted due to omitempty - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - // With omitempty, unset fields should not appear in JSON - assert.NotContains(t, result, "string_field") - assert.NotContains(t, result, "int_field") - assert.NotContains(t, result, "slice_field") - assert.NotContains(t, result, "struct_field") - }) - - t.Run("slice field states: nil vs unset vs empty vs set", func(t *testing.T) { - t.Parallel() - // Test all possible slice states - nilSlice := TestContainer{ - SliceField: From[[]string](nil), // explicitly set to nil - } - unsetSlice := TestContainer{} // unset - emptySlice := TestContainer{ - SliceField: From(ptrFrom([]string{})), // empty slice - } - setSlice := TestContainer{ - SliceField: From(ptrFrom([]string{"a", "b"})), // slice with values - } - - // Verify nil slice - assert.True(t, nilSlice.SliceField.IsSet()) - assert.True(t, nilSlice.SliceField.IsNull()) - val, ok := nilSlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Nil(t, val) - - // Verify unset slice - assert.False(t, unsetSlice.SliceField.IsSet()) - assert.False(t, unsetSlice.SliceField.IsNull()) // Unset is not null - val, ok = unsetSlice.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, val) - - // Verify empty slice - assert.True(t, emptySlice.SliceField.IsSet()) - assert.False(t, emptySlice.SliceField.IsNull()) - val, ok = emptySlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{}, val) - - // Verify set slice - assert.True(t, setSlice.SliceField.IsSet()) - assert.False(t, setSlice.SliceField.IsNull()) - val, ok = setSlice.SliceField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, []string{"a", "b"}, val) - - // Test JSON serialization for each state - nilData, err := json.Marshal(nilSlice) - require.NoError(t, err) - assert.Contains(t, string(nilData), `"slice_field":null`) - - unsetData, err := json.Marshal(unsetSlice) - require.NoError(t, err) - assert.NotContains(t, string(unsetData), "slice_field") // omitted due to omitempty - - emptyData, err := json.Marshal(emptySlice) - require.NoError(t, err) - assert.Contains(t, string(emptyData), `"slice_field":[]`) - - setData, err := json.Marshal(setSlice) - require.NoError(t, err) - assert.Contains(t, string(setData), `"slice_field":["a","b"]`) - }) - - t.Run("mixed states container", func(t *testing.T) { - t.Parallel() - container := TestContainer{ - StringField: From(ptrFrom("hello")), // set to value - IntField: From[int](nil), // set to nil - StructField: From(ptrFrom(TestStruct{Name: "Alice", Age: 25})), // set to value - } - - // Verify states - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) // Unset is not null - - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - - // Test JSON serialization - data, err := json.Marshal(container) - require.NoError(t, err) - - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - assert.Equal(t, "hello", result["string_field"]) - assert.Nil(t, result["int_field"]) - assert.NotContains(t, result, "slice_field") // unset, so omitted - structResult := result["struct_field"].(map[string]interface{}) - assert.Equal(t, "Alice", structResult["name"]) - assert.Equal(t, float64(25), structResult["age"]) - }) - - t.Run("JSON unmarshaling preserves states", func(t *testing.T) { - t.Parallel() - // JSON with some fields missing, some null, some with values - jsonData := `{ - "string_field": "test", - "int_field": null, - "struct_field": {"name": "Bob", "age": 35} - }` - - var container TestContainer - err := json.Unmarshal([]byte(jsonData), &container) - require.NoError(t, err) - - // string_field: present with value - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - stringVal, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", stringVal) - - // int_field: present but null - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - intVal, ok := container.IntField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, intVal) // zero value - - // slice_field: missing from JSON, should remain unset - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.SliceField.IsNull()) // Unset is not null - sliceVal, ok := container.SliceField.GetOrZero() - assert.False(t, ok) - assert.Nil(t, sliceVal) - - // struct_field: present with value - assert.True(t, container.StructField.IsSet()) - assert.False(t, container.StructField.IsNull()) - structVal, ok := container.StructField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, TestStruct{Name: "Bob", Age: 35}, structVal) - }) -} - -// TestNilVsUnsetDistinction tests the key feature of distinguishing nil from unset -func TestNilVsUnsetDistinction(t *testing.T) { - t.Parallel() - t.Run("explicit nil vs unset", func(t *testing.T) { - t.Parallel() - // Explicitly set to nil - explicitNil := From[string](nil) - - // Unset - var unset OptionalNullable[string] - - // Both are null, but only one is set - assert.True(t, explicitNil.IsNull()) - assert.True(t, explicitNil.IsSet()) - - assert.False(t, unset.IsNull()) // Unset is not null - assert.False(t, unset.IsSet()) - - // Get behavior differs - got1, ok1 := explicitNil.GetOrZero() - got2, ok2 := unset.GetOrZero() - - assert.True(t, ok1) // explicitly set to nil returns true - assert.False(t, ok2) // unset returns false - assert.Equal(t, "", got1) // both return zero value - assert.Equal(t, "", got2) - - // Get behavior differs - ptr1, ok1 := explicitNil.Get() - ptr2, ok2 := unset.Get() - - assert.True(t, ok1) // explicitly set to nil returns true - assert.False(t, ok2) // unset returns false - assert.Nil(t, ptr1) // both return nil pointer - assert.Nil(t, ptr2) - }) - - t.Run("empty slice vs nil slice vs unset", func(t *testing.T) { - t.Parallel() - // Empty slice - emptyNullable := From(ptrFrom([]string{})) - - // Nil slice - nilNullable := From[[]string](nil) - - // Unset - var unsetNullable OptionalNullable[[]string] - - // All have different characteristics - assert.True(t, emptyNullable.IsSet()) - assert.False(t, emptyNullable.IsNull()) - - assert.True(t, nilNullable.IsSet()) - assert.True(t, nilNullable.IsNull()) - - assert.False(t, unsetNullable.IsSet()) - assert.False(t, unsetNullable.IsNull()) // Unset is not null - - // Get behavior - got1, ok1 := emptyNullable.GetOrZero() - got2, ok2 := nilNullable.GetOrZero() - got3, ok3 := unsetNullable.GetOrZero() - - assert.True(t, ok1) - assert.Equal(t, []string{}, got1) - - assert.True(t, ok2) - assert.Nil(t, got2) - - assert.False(t, ok3) - assert.Nil(t, got3) - }) -} - -// TestJSONOmitEmpty tests behavior with omitempty tag -func TestJSONOmitEmpty(t *testing.T) { - t.Parallel() - t.Run("marshal with omitempty", func(t *testing.T) { - t.Parallel() - // Test container with various nullable states - container := TestContainer{ - StringField: From(ptrFrom("test")), - IntField: From(ptrFrom(42)), - StructField: From[TestStruct](nil), // explicitly nil - } - - data, err := json.Marshal(container) - require.NoError(t, err) - - // Parse back to verify structure - var result map[string]interface{} - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - // Should contain set fields - assert.Contains(t, result, "string_field") - assert.Contains(t, result, "int_field") - assert.Contains(t, result, "struct_field") - - // Should not contain unset field (due to omitempty) - // Note: This depends on how the marshaling handles unset fields - // The current implementation doesn't handle this case properly (see TODO) - }) - - t.Run("unmarshal missing fields", func(t *testing.T) { - t.Parallel() - // JSON with some fields missing - jsonData := `{"string_field": "test", "int_field": null}` - - var container TestContainer - err := json.Unmarshal([]byte(jsonData), &container) - require.NoError(t, err) - - // Present fields should be set - assert.True(t, container.StringField.IsSet()) - assert.False(t, container.StringField.IsNull()) - got, ok := container.StringField.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "test", got) - - // Null field should be set to nil - assert.True(t, container.IntField.IsSet()) - assert.True(t, container.IntField.IsNull()) - - // Missing fields should remain unset - assert.False(t, container.SliceField.IsSet()) - assert.False(t, container.StructField.IsSet()) - }) -} - -// TestEdgeCases tests various edge cases -func TestEdgeCases(t *testing.T) { - t.Parallel() - t.Run("zero values", func(t *testing.T) { - t.Parallel() - // Test with zero values that are not nil - intNullable := From(ptrFrom(0)) - stringNullable := From(ptrFrom("")) - - assert.True(t, intNullable.IsSet()) - assert.False(t, intNullable.IsNull()) - got, ok := intNullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, 0, got) - - assert.True(t, stringNullable.IsSet()) - assert.False(t, stringNullable.IsNull()) - got2, ok2 := stringNullable.GetOrZero() - assert.True(t, ok2) - assert.Equal(t, "", got2) - }) - - t.Run("pointer to pointer", func(t *testing.T) { - t.Parallel() - // Test with pointer to pointer type - inner := "test" - nullable := From(ptrFrom(&inner)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, &inner, got) - assert.Equal(t, "test", *got) - }) - - t.Run("complex struct", func(t *testing.T) { - t.Parallel() - complexStruct := struct { - Name string - Values []int - Metadata map[string]string - }{ - Name: "complex", - Values: []int{1, 2, 3}, - Metadata: map[string]string{ - "key1": "value1", - "key2": "value2", - }, - } - - nullable := From(ptrFrom(complexStruct)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, complexStruct, got) - }) -} - -// TestDoublePointers tests comprehensive double pointer scenarios -func TestDoublePointers(t *testing.T) { - t.Parallel() - - t.Run("string double pointer with value", func(t *testing.T) { - t.Parallel() - inner := "hello world" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, &inner, got) - assert.Equal(t, "hello world", *got) - }) - - t.Run("int double pointer with value", func(t *testing.T) { - t.Parallel() - inner := 42 - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, &inner, got) - assert.Equal(t, 42, *got) - }) - - t.Run("double pointer to nil", func(t *testing.T) { - t.Parallel() - var ptr *string = nil - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Nil(t, got) - }) - - t.Run("nil double pointer", func(t *testing.T) { - t.Parallel() - nullable := From[*string](nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) // zero value for **string is nil - }) - - t.Run("unset double pointer", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) // zero value for **string is nil - }) - - t.Run("double pointer modification", func(t *testing.T) { - t.Parallel() - inner := "original" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - // Verify original value - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "original", *got) - - // Modify through double pointer - *got = "modified" - assert.Equal(t, "modified", inner) - assert.Equal(t, "modified", *got) - }) - - t.Run("double pointer to struct", func(t *testing.T) { - t.Parallel() - inner := TestStruct{Name: "Alice", Age: 30} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, TestStruct{Name: "Alice", Age: 30}, *got) - - // Modify through double pointer - (*got).Name = "Bob" - assert.Equal(t, "Bob", inner.Name) - assert.Equal(t, "Bob", (*got).Name) - }) - - t.Run("double pointer to slice", func(t *testing.T) { - t.Parallel() - inner := []string{"a", "b", "c"} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, []string{"a", "b", "c"}, *got) - - // Modify through double pointer - *got = append(*got, "d") - assert.Equal(t, []string{"a", "b", "c", "d"}, inner) - assert.Equal(t, []string{"a", "b", "c", "d"}, *got) - }) - - t.Run("double pointer to empty slice", func(t *testing.T) { - t.Parallel() - inner := []string{} - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Equal(t, []string{}, *got) - }) - - t.Run("double pointer to nil slice", func(t *testing.T) { - t.Parallel() - var inner []string = nil - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr, got) - assert.Nil(t, *got) - }) - - t.Run("double pointer JSON marshaling", func(t *testing.T) { - t.Parallel() - inner := "json test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `"json test"`, string(data)) - }) - - t.Run("double pointer JSON unmarshaling", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - err := json.Unmarshal([]byte(`"json test"`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.NotNil(t, got) - assert.Equal(t, "json test", *got) - }) - - t.Run("double pointer JSON null marshaling", func(t *testing.T) { - t.Parallel() - nullable := From[*string](nil) - - data, err := json.Marshal(nullable) - require.NoError(t, err) - assert.Equal(t, `null`, string(data)) - }) - - t.Run("double pointer JSON null unmarshaling", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - err := json.Unmarshal([]byte(`null`), &nullable) - require.NoError(t, err) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) - }) - - t.Run("double pointer round trip", func(t *testing.T) { - t.Parallel() - inner := "round trip test" - ptr := &inner - nullable1 := From(ptrFrom(ptr)) - - // Marshal - data, err := json.Marshal(nullable1) - require.NoError(t, err) - - // Unmarshal - var nullable2 OptionalNullable[*string] - err = json.Unmarshal(data, &nullable2) - require.NoError(t, err) - - // Compare states - assert.Equal(t, nullable1.IsSet(), nullable2.IsSet()) - assert.Equal(t, nullable1.IsNull(), nullable2.IsNull()) - - got1, ok1 := nullable1.GetOrZero() - got2, ok2 := nullable2.GetOrZero() - assert.Equal(t, ok1, ok2) - - // Values should be equal - assert.Equal(t, *got1, *got2) - }) - - t.Run("triple pointer", func(t *testing.T) { - t.Parallel() - inner := "triple" - ptr1 := &inner - ptr2 := &ptr1 - nullable := From(ptrFrom(ptr2)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, ptr2, got) - assert.Equal(t, ptr1, *got) - assert.Equal(t, "triple", **got) - }) - - t.Run("double pointer set and unset", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[*string] - - // Initially unset - assert.False(t, nullable.IsSet()) - - // Set to double pointer - inner := "set test" - ptr := &inner - nullable.Set(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "set test", *got) - - // Set to nil - nullable.Set(nil) - - assert.True(t, nullable.IsSet()) - assert.True(t, nullable.IsNull()) - - got, ok = nullable.GetOrZero() - assert.True(t, ok) - assert.Nil(t, got) - - // Unset - nullable.Unset() - - assert.False(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok = nullable.GetOrZero() - assert.False(t, ok) - assert.Nil(t, got) - }) - - t.Run("double pointer Get method", func(t *testing.T) { - t.Parallel() - inner := "get test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - // Test Get method - gotPtr, ok := nullable.Get() - assert.True(t, ok) - assert.NotNil(t, gotPtr) - assert.Equal(t, ptr, *gotPtr) - assert.Equal(t, "get test", **gotPtr) - - // Test with nil - nilNullable := From[*string](nil) - gotPtr, ok = nilNullable.Get() - assert.True(t, ok) - assert.Nil(t, gotPtr) - - // Test with unset - var unsetNullable OptionalNullable[*string] - gotPtr, ok = unsetNullable.Get() - assert.False(t, ok) - assert.Nil(t, gotPtr) - }) - - t.Run("double pointer zero values", func(t *testing.T) { - t.Parallel() - // Test with zero value string - inner := "" - ptr := &inner - nullable := From(ptrFrom(ptr)) - - assert.True(t, nullable.IsSet()) - assert.False(t, nullable.IsNull()) - - got, ok := nullable.GetOrZero() - assert.True(t, ok) - assert.Equal(t, "", *got) - - // Test with zero value int - innerInt := 0 - ptrInt := &innerInt - nullableInt := From(ptrFrom(ptrInt)) - - assert.True(t, nullableInt.IsSet()) - assert.False(t, nullableInt.IsNull()) - - gotInt, okInt := nullableInt.GetOrZero() - assert.True(t, okInt) - assert.Equal(t, 0, *gotInt) - }) -} - -// TestAsOptionalNullable tests the AsOptionalNullable helper function -func TestAsOptionalNullable(t *testing.T) { - t.Parallel() - - t.Run("with nullable string", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) - - t.Run("with nullable int", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom(42)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, 42, value) - }) - - t.Run("with nullable nil", func(t *testing.T) { - t.Parallel() - nullable := From[string](nil) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Nil(t, value) - }) - - t.Run("with unset nullable", func(t *testing.T) { - t.Parallel() - var nullable OptionalNullable[string] - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable string", func(t *testing.T) { - t.Parallel() - regularString := "not nullable" - reflectValue := reflect.ValueOf(regularString) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable int", func(t *testing.T) { - t.Parallel() - regularInt := 42 - reflectValue := reflect.ValueOf(regularInt) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable map", func(t *testing.T) { - t.Parallel() - regularMap := map[string]int{"key": 42} - reflectValue := reflect.ValueOf(regularMap) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with non-nullable struct", func(t *testing.T) { - t.Parallel() - regularStruct := TestStruct{Name: "test", Age: 30} - reflectValue := reflect.ValueOf(regularStruct) - - result, ok := AsOptionalNullable(reflectValue) - assert.False(t, ok) - assert.Nil(t, result) - }) - - t.Run("with nullable double pointer", func(t *testing.T) { - t.Parallel() - inner := "test" - ptr := &inner - nullable := From(ptrFrom(ptr)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, ptr, value) - assert.Equal(t, "test", *value.(*string)) - }) - - t.Run("with nullable slice", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom([]string{"a", "b", "c"})) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, []string{"a", "b", "c"}, value) - }) - - t.Run("with nullable struct", func(t *testing.T) { - t.Parallel() - testStruct := TestStruct{Name: "Alice", Age: 25} - nullable := From(ptrFrom(testStruct)) - reflectValue := reflect.ValueOf(nullable) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, testStruct, value) - }) - - t.Run("with pointer to nullable", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - ptrToNullable := &nullable - reflectValue := reflect.ValueOf(ptrToNullable) - - // This should work since the pointer to nullable still contains a nullable - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) - - t.Run("with interface containing nullable", func(t *testing.T) { - t.Parallel() - nullable := From(ptrFrom("test")) - var iface interface{} = nullable - reflectValue := reflect.ValueOf(iface) - - result, ok := AsOptionalNullable(reflectValue) - assert.True(t, ok) - assert.NotNil(t, result) - - value, isSet := result.GetUntyped() - assert.True(t, isSet) - assert.Equal(t, "test", value) - }) -} diff --git a/internal/membershipclient/retry/config.go b/internal/membershipclient/retry/config.go deleted file mode 100644 index 5b3dc7c1..00000000 --- a/internal/membershipclient/retry/config.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package retry - -import ( - "errors" - "net/http" - "strconv" - "time" -) - -// BackoffStrategy defines the parameters for exponential backoff. This can be -// used to drive a retry loop for example. -type BackoffStrategy struct { - InitialInterval int - MaxInterval int - Exponent float64 - MaxElapsedTime int -} - -// Config configures a retry policy. -type Config struct { - // Strategy sets the algorithm to use for a retry loop. It can be one of: - // - "backoff": retry with exponential backoff and random jitter. - // - "none" or "": disables retries. - Strategy string - Backoff *BackoffStrategy - RetryConnectionErrors bool -} - -// PermanentError is an error that signals that some operation has terminally -// failed and should not be retried. -type PermanentError struct { - cause error -} - -// Permanent creates a PermanentError that signals to a retry loop that it -// should stop retrying an operation and return the underlying error. -func Permanent(cause error) error { - if IsPermanentError(cause) { - return cause - } - - return &PermanentError{ - cause: cause, - } -} - -func (e *PermanentError) Error() string { - return e.cause.Error() -} - -func (e *PermanentError) Unwrap() error { - return e.cause -} - -// TemporaryError represents a retryable error and signals to a retry loop that -// an operation may be retried with an optional wait interval. -type TemporaryError struct { - wait time.Duration - message string -} - -// Temporary creates a TemporaryError that signals to a retry loop that an -// operation can be retried. The error may also carry details about how long to -// wait before retrying. This wait interval may be used to override the retry -// policy in use. -func Temporary(message string) error { - return &TemporaryError{ - message: message, - } -} - -// TemporaryFromResponse creates a TemporaryError similar to Temporary but -// additionally parses the Retry-After header from a response to determine the -// wait interval before the next retry attempt. -func TemporaryFromResponse(message string, res *http.Response) error { - return &TemporaryError{ - wait: retryIntervalFromResponse(res), - message: message, - } -} - -func (e *TemporaryError) Error() string { - return e.message -} - -// RetryAfter returns the time to wait before retrying the request. The zero -// value should be interpreted by retry loops to mean they should fallback on -// their default policy whether expenonential, constant backoff or something -// else. It does not mean that an operation should be retried immediately. -func (e *TemporaryError) RetryAfter() time.Duration { - return e.wait -} - -func retryIntervalFromResponse(res *http.Response) time.Duration { - if res == nil { - return 0 - } - - retryVal := res.Header.Get("retry-after") - if retryVal == "" { - return 0 - } - - parsedNumber, err := strconv.ParseInt(retryVal, 10, 64) - if err == nil { - if parsedNumber < 0 { - return 0 - } else { - return time.Duration(parsedNumber) * time.Second - } - } - - parsedDate, err := time.Parse(time.RFC1123, retryVal) - if err == nil { - delta := time.Until(parsedDate) - if delta < 0 { - return 0 - } else { - return delta - } - } - - return 0 -} - -// IsPermanentError returns true if an error value is or contains a -// PermanentError in its chain of errors. -func IsPermanentError(err error) bool { - if err == nil { - return false - } - - var pe *PermanentError - return errors.As(err, &pe) -} - -// IsTemporaryError returns true if an error value is or contains a -// TemporaryError in its chain of errors. -func IsTemporaryError(err error) bool { - if err == nil { - return false - } - - var pe *TemporaryError - return errors.As(err, &pe) -} diff --git a/internal/membershipclient/sdk.go b/internal/membershipclient/sdk.go deleted file mode 100644 index 1311a607..00000000 --- a/internal/membershipclient/sdk.go +++ /dev/null @@ -1,16375 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package membershipclient - -// Generated from OpenAPI doc version 0.1.0 and generator version 2.869.23 - -import ( - "bytes" - "context" - "fmt" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/config" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/hooks" - "github.com/formancehq/fctl/internal/membershipclient/v3/internal/utils" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/apierrors" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/operations" - "github.com/formancehq/fctl/internal/membershipclient/v3/retry" - "net/http" - "net/url" - "time" -) - -// ServerList contains the list of servers available to the SDK -var ServerList = []string{ - // Local server - "http://localhost:8080", -} - -// HTTPClient provides an interface for supplying the SDK with a custom HTTP client -type HTTPClient interface { - Do(req *http.Request) (*http.Response, error) -} - -// String provides a helper function to return a pointer to a string -func String(s string) *string { return &s } - -// Bool provides a helper function to return a pointer to a bool -func Bool(b bool) *bool { return &b } - -// Int provides a helper function to return a pointer to an int -func Int(i int) *int { return &i } - -// Int64 provides a helper function to return a pointer to an int64 -func Int64(i int64) *int64 { return &i } - -// Float32 provides a helper function to return a pointer to a float32 -func Float32(f float32) *float32 { return &f } - -// Float64 provides a helper function to return a pointer to a float64 -func Float64(f float64) *float64 { return &f } - -// Pointer provides a helper function to return a pointer to a type -func Pointer[T any](v T) *T { return &v } - -type SDK struct { - SDKVersion string - - sdkConfiguration config.SDKConfiguration - hooks *hooks.Hooks -} - -type SDKOption func(*SDK) - -// WithServerURL allows providing an alternative server URL -func WithServerURL(serverURL string) SDKOption { - return func(sdk *SDK) { - sdk.sdkConfiguration.ServerURL = serverURL - } -} - -// WithTemplatedServerURL allows the overriding of the default server URL with a templated URL populated with the provided parameters -func WithTemplatedServerURL(serverURL string, params map[string]string) SDKOption { - return func(sdk *SDK) { - if params != nil { - serverURL = utils.ReplaceParameters(serverURL, params) - } - - sdk.sdkConfiguration.ServerURL = serverURL - } -} - -// WithServerIndex allows the overriding of the default server by index -func WithServerIndex(serverIndex int) SDKOption { - return func(sdk *SDK) { - if serverIndex < 0 || serverIndex >= len(ServerList) { - panic(fmt.Errorf("server index %d out of range", serverIndex)) - } - - sdk.sdkConfiguration.ServerIndex = serverIndex - } -} - -// WithClient allows the overriding of the default HTTP client used by the SDK -func WithClient(client HTTPClient) SDKOption { - return func(sdk *SDK) { - sdk.sdkConfiguration.Client = client - } -} - -// WithSecurity configures the SDK to use the provided security details -func WithSecurity(oauth2 string) SDKOption { - return func(sdk *SDK) { - security := components.Security{Oauth2: oauth2} - sdk.sdkConfiguration.Security = utils.AsSecuritySource(&security) - } -} - -// WithSecuritySource configures the SDK to invoke the Security Source function on each method call to determine authentication -func WithSecuritySource(security func(context.Context) (components.Security, error)) SDKOption { - return func(sdk *SDK) { - sdk.sdkConfiguration.Security = func(ctx context.Context) (interface{}, error) { - return security(ctx) - } - } -} - -func WithRetryConfig(retryConfig retry.Config) SDKOption { - return func(sdk *SDK) { - sdk.sdkConfiguration.RetryConfig = &retryConfig - } -} - -// WithTimeout Optional request timeout applied to each operation -func WithTimeout(timeout time.Duration) SDKOption { - return func(sdk *SDK) { - sdk.sdkConfiguration.Timeout = &timeout - } -} - -// New creates a new instance of the SDK with the provided options -func New(opts ...SDKOption) *SDK { - sdk := &SDK{ - SDKVersion: "0.1.1", - sdkConfiguration: config.SDKConfiguration{ - UserAgent: "speakeasy-sdk/go 0.1.1 2.869.23 0.1.0 github.com/formancehq/fctl/internal/membershipclient/v3", - ServerList: ServerList, - }, - hooks: hooks.New(), - } - for _, opt := range opts { - opt(sdk) - } - - // Use WithClient to override the default client if you would like to customize the timeout - if sdk.sdkConfiguration.Client == nil { - sdk.sdkConfiguration.Client = &http.Client{Timeout: 60 * time.Second} - } - - sdk.sdkConfiguration = sdk.hooks.SDKInit(sdk.sdkConfiguration) - - return sdk -} - -// GetServerInfo - Get server info -func (s *SDK) GetServerInfo(ctx context.Context, opts ...operations.Option) (*operations.GetServerInfoResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/_info") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getServerInfo", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetServerInfoResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ServerInfo - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ServerInfo = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListOrganizations - List organizations of the connected user -func (s *SDK) ListOrganizations(ctx context.Context, request operations.ListOrganizationsRequest, opts ...operations.Option) (*operations.ListOrganizationsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/organizations") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listOrganizations", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListOrganizationsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListOrganizationExpandedResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListOrganizationExpandedResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateOrganization - Create organization -func (s *SDK) CreateOrganization(ctx context.Context, request *components.CreateOrganizationRequest, opts ...operations.Option) (*operations.CreateOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/organizations") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Request", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateOrganizationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateOrganizationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListOrganizationsExpanded - List organizations of the connected user with expanded data -// -// Deprecated: This will be removed in a future release, please migrate away from it as soon as possible. -func (s *SDK) ListOrganizationsExpanded(ctx context.Context, opts ...operations.Option) (*operations.ListOrganizationsExpandedResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/organizations/expanded") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listOrganizationsExpanded", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListOrganizationsExpandedResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListOrganizationExpandedResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListOrganizationExpandedResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("unknown status code returned", httpRes.StatusCode, string(rawBody), httpRes) - } - - return res, nil - -} - -// ReadOrganization - Read organization -func (s *SDK) ReadOrganization(ctx context.Context, request operations.ReadOrganizationRequest, opts ...operations.Option) (*operations.ReadOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadOrganizationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadOrganizationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdateOrganization - Update organization -func (s *SDK) UpdateOrganization(ctx context.Context, request operations.UpdateOrganizationRequest, opts ...operations.Option) (*operations.UpdateOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updateOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdateOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadOrganizationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadOrganizationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteOrganization - Delete organization -func (s *SDK) DeleteOrganization(ctx context.Context, request operations.DeleteOrganizationRequest, opts ...operations.Option) (*operations.DeleteOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadAuthenticationProvider - Read authentication provider -func (s *SDK) ReadAuthenticationProvider(ctx context.Context, request operations.ReadAuthenticationProviderRequest, opts ...operations.Option) (*operations.ReadAuthenticationProviderResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/authentication-provider", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readAuthenticationProvider", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadAuthenticationProviderResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.AuthenticationProviderResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.AuthenticationProviderResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpsertAuthenticationProvider - Upsert an authentication provider -func (s *SDK) UpsertAuthenticationProvider(ctx context.Context, request operations.UpsertAuthenticationProviderRequest, opts ...operations.Option) (*operations.UpsertAuthenticationProviderResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/authentication-provider", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "upsertAuthenticationProvider", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpsertAuthenticationProviderResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.AuthenticationProviderResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.AuthenticationProviderResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteAuthenticationProvider - Delete authentication provider -func (s *SDK) DeleteAuthenticationProvider(ctx context.Context, request operations.DeleteAuthenticationProviderRequest, opts ...operations.Option) (*operations.DeleteAuthenticationProviderResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/authentication-provider", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteAuthenticationProvider", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteAuthenticationProviderResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListFeatures - List features -func (s *SDK) ListFeatures(ctx context.Context, request operations.ListFeaturesRequest, opts ...operations.Option) (*operations.ListFeaturesResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/features", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listFeatures", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListFeaturesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out operations.ListFeaturesResponseBody - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Object = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// AddFeatures - Add Features -func (s *SDK) AddFeatures(ctx context.Context, request operations.AddFeaturesRequest, opts ...operations.Option) (*operations.AddFeaturesResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/features", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "addFeatures", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.AddFeaturesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteFeature - Delete feature -func (s *SDK) DeleteFeature(ctx context.Context, request operations.DeleteFeatureRequest, opts ...operations.Option) (*operations.DeleteFeatureResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/features/{name}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteFeature", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteFeatureResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadOrganizationClient - Read organization client (DEPRECATED) (until 12/31/2025) -// -// Deprecated: This will be removed in a future release, please migrate away from it as soon as possible. -func (s *SDK) ReadOrganizationClient(ctx context.Context, request operations.ReadOrganizationClientRequest, opts ...operations.Option) (*operations.ReadOrganizationClientResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/client", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readOrganizationClient", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadOrganizationClientResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateClientResponseResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateClientResponseResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateOrganizationClient - Create organization client (DEPRECATED) (until 12/31/2025) -// -// Deprecated: This will be removed in a future release, please migrate away from it as soon as possible. -func (s *SDK) CreateOrganizationClient(ctx context.Context, request operations.CreateOrganizationClientRequest, opts ...operations.Option) (*operations.CreateOrganizationClientResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/client", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createOrganizationClient", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateOrganizationClientResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateClientResponseResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateClientResponseResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteOrganizationClient - Delete organization client (DEPRECATED) (until 12/31/2025) -// -// Deprecated: This will be removed in a future release, please migrate away from it as soon as possible. -func (s *SDK) DeleteOrganizationClient(ctx context.Context, request operations.DeleteOrganizationClientRequest, opts ...operations.Option) (*operations.DeleteOrganizationClientResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/client", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteOrganizationClient", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteOrganizationClientResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// OrganizationClientsRead - Read organization clients -func (s *SDK) OrganizationClientsRead(ctx context.Context, request operations.OrganizationClientsReadRequest, opts ...operations.Option) (*operations.OrganizationClientsReadResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/clients", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "organizationClientsRead", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.OrganizationClientsReadResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadOrganizationClientsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadOrganizationClientsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// OrganizationClientCreate - Create organization client -func (s *SDK) OrganizationClientCreate(ctx context.Context, request operations.OrganizationClientCreateRequest, opts ...operations.Option) (*operations.OrganizationClientCreateResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/clients", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "organizationClientCreate", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.OrganizationClientCreateResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateOrganizationClientResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateOrganizationClientResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// OrganizationClientRead - Read organization client -func (s *SDK) OrganizationClientRead(ctx context.Context, request operations.OrganizationClientReadRequest, opts ...operations.Option) (*operations.OrganizationClientReadResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/clients/{clientId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "organizationClientRead", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.OrganizationClientReadResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadOrganizationClientResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadOrganizationClientResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// OrganizationClientDelete - Delete organization client -func (s *SDK) OrganizationClientDelete(ctx context.Context, request operations.OrganizationClientDeleteRequest, opts ...operations.Option) (*operations.OrganizationClientDeleteResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/clients/{clientId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "organizationClientDelete", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.OrganizationClientDeleteResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// OrganizationClientUpdate - Update organization client -func (s *SDK) OrganizationClientUpdate(ctx context.Context, request operations.OrganizationClientUpdateRequest, opts ...operations.Option) (*operations.OrganizationClientUpdateResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/clients/{clientId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "organizationClientUpdate", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.OrganizationClientUpdateResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListLogs - List logs -func (s *SDK) ListLogs(ctx context.Context, request operations.ListLogsRequest, opts ...operations.Option) (*operations.ListLogsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/logs", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listLogs", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListLogsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.LogCursor - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.LogCursor = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListUsersOfOrganization - List users of organization -func (s *SDK) ListUsersOfOrganization(ctx context.Context, request operations.ListUsersOfOrganizationRequest, opts ...operations.Option) (*operations.ListUsersOfOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/users", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listUsersOfOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListUsersOfOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListUsersResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListUsersResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadUserOfOrganization - Read user of organization -func (s *SDK) ReadUserOfOrganization(ctx context.Context, request operations.ReadUserOfOrganizationRequest, opts ...operations.Option) (*operations.ReadUserOfOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readUserOfOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadUserOfOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadOrganizationUserResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadOrganizationUserResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpsertOrganizationUser - Update user within an organization -func (s *SDK) UpsertOrganizationUser(ctx context.Context, request operations.UpsertOrganizationUserRequest, opts ...operations.Option) (*operations.UpsertOrganizationUserResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "upsertOrganizationUser", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpsertOrganizationUserResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteUserFromOrganization - delete user from organization -// The owner of the organization can remove anyone while each user can leave any organization where it is not owner. -func (s *SDK) DeleteUserFromOrganization(ctx context.Context, request operations.DeleteUserFromOrganizationRequest, opts ...operations.Option) (*operations.DeleteUserFromOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteUserFromOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteUserFromOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListPolicies - List policies of organization -func (s *SDK) ListPolicies(ctx context.Context, request operations.ListPoliciesRequest, opts ...operations.Option) (*operations.ListPoliciesResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listPolicies", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListPoliciesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListPoliciesResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListPoliciesResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreatePolicy - Create policy -func (s *SDK) CreatePolicy(ctx context.Context, request operations.CreatePolicyRequest, opts ...operations.Option) (*operations.CreatePolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createPolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreatePolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreatePolicyResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreatePolicyResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadPolicy - Read policy with scopes -func (s *SDK) ReadPolicy(ctx context.Context, request operations.ReadPolicyRequest, opts ...operations.Option) (*operations.ReadPolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies/{policyId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readPolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadPolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadPolicyResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadPolicyResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdatePolicy - Update policy -func (s *SDK) UpdatePolicy(ctx context.Context, request operations.UpdatePolicyRequest, opts ...operations.Option) (*operations.UpdatePolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies/{policyId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updatePolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdatePolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.UpdatePolicyResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.UpdatePolicyResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeletePolicy - Delete policy -func (s *SDK) DeletePolicy(ctx context.Context, request operations.DeletePolicyRequest, opts ...operations.Option) (*operations.DeletePolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies/{policyId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deletePolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeletePolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// AddScopeToPolicy - Add scope to policy -func (s *SDK) AddScopeToPolicy(ctx context.Context, request operations.AddScopeToPolicyRequest, opts ...operations.Option) (*operations.AddScopeToPolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies/{policyId}/scopes/{scopeId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "addScopeToPolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.AddScopeToPolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// RemoveScopeFromPolicy - Remove scope from policy -func (s *SDK) RemoveScopeFromPolicy(ctx context.Context, request operations.RemoveScopeFromPolicyRequest, opts ...operations.Option) (*operations.RemoveScopeFromPolicyResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/policies/{policyId}/scopes/{scopeId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "removeScopeFromPolicy", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.RemoveScopeFromPolicyResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListStacks - List stacks -func (s *SDK) ListStacks(ctx context.Context, request operations.ListStacksRequest, opts ...operations.Option) (*operations.ListStacksResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listStacks", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListStacksResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListStacksResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListStacksResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateStack - Create stack -func (s *SDK) CreateStack(ctx context.Context, request operations.CreateStackRequest, opts ...operations.Option) (*operations.CreateStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadStackResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadStackResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListModules - List modules of a stack -func (s *SDK) ListModules(ctx context.Context, request operations.ListModulesRequest, opts ...operations.Option) (*operations.ListModulesResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/modules", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listModules", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListModulesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListModulesResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListModulesResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// EnableModule - enable module -func (s *SDK) EnableModule(ctx context.Context, request operations.EnableModuleRequest, opts ...operations.Option) (*operations.EnableModuleResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/modules", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "enableModule", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.EnableModuleResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DisableModule - disable module -func (s *SDK) DisableModule(ctx context.Context, request operations.DisableModuleRequest, opts ...operations.Option) (*operations.DisableModuleResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/modules", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "disableModule", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DisableModuleResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpgradeStack - Upgrade stack -func (s *SDK) UpgradeStack(ctx context.Context, request operations.UpgradeStackRequest, opts ...operations.Option) (*operations.UpgradeStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/upgrade", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "upgradeStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpgradeStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// GetStack - Find stack -func (s *SDK) GetStack(ctx context.Context, request operations.GetStackRequest, opts ...operations.Option) (*operations.GetStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadStackResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadStackResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdateStack - Update stack -func (s *SDK) UpdateStack(ctx context.Context, request operations.UpdateStackRequest, opts ...operations.Option) (*operations.UpdateStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updateStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdateStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadStackResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadStackResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteStack - Delete stack -func (s *SDK) DeleteStack(ctx context.Context, request operations.DeleteStackRequest, opts ...operations.Option) (*operations.DeleteStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListStackUsersAccesses - List stack users accesses within an organization -func (s *SDK) ListStackUsersAccesses(ctx context.Context, request operations.ListStackUsersAccessesRequest, opts ...operations.Option) (*operations.ListStackUsersAccessesResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/users", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listStackUsersAccesses", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListStackUsersAccessesResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.StackUserAccessResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.StackUserAccessResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadStackUserAccess - Read stack user access within an organization -func (s *SDK) ReadStackUserAccess(ctx context.Context, request operations.ReadStackUserAccessRequest, opts ...operations.Option) (*operations.ReadStackUserAccessResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readStackUserAccess", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadStackUserAccessResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadStackUserAccess - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadStackUserAccess = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteStackUserAccess - Delete stack user access within an organization -func (s *SDK) DeleteStackUserAccess(ctx context.Context, request operations.DeleteStackUserAccessRequest, opts ...operations.Option) (*operations.DeleteStackUserAccessResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteStackUserAccess", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteStackUserAccessResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpsertStackUserAccess - Update stack user access within an organization -func (s *SDK) UpsertStackUserAccess(ctx context.Context, request operations.UpsertStackUserAccessRequest, opts ...operations.Option) (*operations.UpsertStackUserAccessResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/users/{userId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "upsertStackUserAccess", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpsertStackUserAccessResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DisableStack - Disable stack -func (s *SDK) DisableStack(ctx context.Context, request operations.DisableStackRequest, opts ...operations.Option) (*operations.DisableStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/disable", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "disableStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DisableStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// EnableStack - Enable stack -func (s *SDK) EnableStack(ctx context.Context, request operations.EnableStackRequest, opts ...operations.Option) (*operations.EnableStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/enable", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "enableStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.EnableStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// RestoreStack - Restore stack -func (s *SDK) RestoreStack(ctx context.Context, request operations.RestoreStackRequest, opts ...operations.Option) (*operations.RestoreStackResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/restore", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "restoreStack", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.RestoreStackResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadStackResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadStackResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// EnableStargate - Enable stargate on a stack -func (s *SDK) EnableStargate(ctx context.Context, request operations.EnableStargateRequest, opts ...operations.Option) (*operations.EnableStargateResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/stargate/enable", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "enableStargate", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.EnableStargateResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DisableStargate - Disable stargate on a stack -func (s *SDK) DisableStargate(ctx context.Context, request operations.DisableStargateRequest, opts ...operations.Option) (*operations.DisableStargateResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/stacks/{stackId}/stargate/disable", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "disableStargate", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DisableStargateResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 202: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListInvitations - List invitations of the user -func (s *SDK) ListInvitations(ctx context.Context, request operations.ListInvitationsRequest, opts ...operations.Option) (*operations.ListInvitationsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/me/invitations") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listInvitations", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListInvitationsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListInvitationsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListInvitationsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// AcceptInvitation - Accept invitation -func (s *SDK) AcceptInvitation(ctx context.Context, request operations.AcceptInvitationRequest, opts ...operations.Option) (*operations.AcceptInvitationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/me/invitations/{invitationId}/accept", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "acceptInvitation", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.AcceptInvitationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeclineInvitation - Decline invitation -func (s *SDK) DeclineInvitation(ctx context.Context, request operations.DeclineInvitationRequest, opts ...operations.Option) (*operations.DeclineInvitationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/me/invitations/{invitationId}/reject", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "declineInvitation", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeclineInvitationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListOrganizationInvitations - List invitations of the organization -func (s *SDK) ListOrganizationInvitations(ctx context.Context, request operations.ListOrganizationInvitationsRequest, opts ...operations.Option) (*operations.ListOrganizationInvitationsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/invitations", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listOrganizationInvitations", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListOrganizationInvitationsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListInvitationsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListInvitationsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateInvitation - Create invitation -func (s *SDK) CreateInvitation(ctx context.Context, request operations.CreateInvitationRequest, opts ...operations.Option) (*operations.CreateInvitationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/invitations", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createInvitation", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateInvitationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - fallthrough - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateInvitationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateInvitationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteInvitation - Delete invitation -func (s *SDK) DeleteInvitation(ctx context.Context, request operations.DeleteInvitationRequest, opts ...operations.Option) (*operations.DeleteInvitationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/invitations/{invitationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteInvitation", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteInvitationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListRegions - List regions -func (s *SDK) ListRegions(ctx context.Context, request operations.ListRegionsRequest, opts ...operations.Option) (*operations.ListRegionsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/regions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listRegions", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListRegionsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListRegionsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListRegionsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreatePrivateRegion - Create a private region -func (s *SDK) CreatePrivateRegion(ctx context.Context, request operations.CreatePrivateRegionRequest, opts ...operations.Option) (*operations.CreatePrivateRegionResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/regions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createPrivateRegion", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreatePrivateRegionResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreatedPrivateRegionResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreatedPrivateRegionResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// GetRegion - Get region -func (s *SDK) GetRegion(ctx context.Context, request operations.GetRegionRequest, opts ...operations.Option) (*operations.GetRegionResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/regions/{regionID}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getRegion", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetRegionResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.GetRegionResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.GetRegionResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteRegion - Delete region -func (s *SDK) DeleteRegion(ctx context.Context, request operations.DeleteRegionRequest, opts ...operations.Option) (*operations.DeleteRegionResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/regions/{regionID}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteRegion", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteRegionResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// GetRegionVersions - Get region versions -func (s *SDK) GetRegionVersions(ctx context.Context, request operations.GetRegionVersionsRequest, opts ...operations.Option) (*operations.GetRegionVersionsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/regions/{regionID}/versions", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getRegionVersions", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetRegionVersionsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.GetRegionVersionsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.GetRegionVersionsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListOrganizationApplications - List applications enabled for organization -func (s *SDK) ListOrganizationApplications(ctx context.Context, request operations.ListOrganizationApplicationsRequest, opts ...operations.Option) (*operations.ListOrganizationApplicationsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/applications", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listOrganizationApplications", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateQueryParams(ctx, req, request, nil, nil); err != nil { - return nil, fmt.Errorf("error populating query params: %w", err) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListOrganizationApplicationsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListApplicationsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListApplicationsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// GetOrganizationApplication - Get application for organization -func (s *SDK) GetOrganizationApplication(ctx context.Context, request operations.GetOrganizationApplicationRequest, opts ...operations.Option) (*operations.GetOrganizationApplicationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getOrganizationApplication", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetOrganizationApplicationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.GetApplicationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.GetApplicationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// EnableApplicationForOrganization - Enable application for organization -func (s *SDK) EnableApplicationForOrganization(ctx context.Context, request operations.EnableApplicationForOrganizationRequest, opts ...operations.Option) (*operations.EnableApplicationForOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "enableApplicationForOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.EnableApplicationForOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.EnableApplicationForOrganizationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.EnableApplicationForOrganizationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DisableApplicationForOrganization - Disable application for organization -func (s *SDK) DisableApplicationForOrganization(ctx context.Context, request operations.DisableApplicationForOrganizationRequest, opts ...operations.Option) (*operations.DisableApplicationForOrganizationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/organizations/{organizationId}/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "disableApplicationForOrganization", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DisableApplicationForOrganizationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ListApplications - List applications -func (s *SDK) ListApplications(ctx context.Context, opts ...operations.Option) (*operations.ListApplicationsResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/applications") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "listApplications", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ListApplicationsResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ListApplicationsResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ListApplicationsResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateApplication - Create application -func (s *SDK) CreateApplication(ctx context.Context, request *components.ApplicationData, opts ...operations.Option) (*operations.CreateApplicationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/applications") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createApplication", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Request", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateApplicationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateApplicationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateApplicationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// GetApplication - Get application -func (s *SDK) GetApplication(ctx context.Context, request operations.GetApplicationRequest, opts ...operations.Option) (*operations.GetApplicationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "getApplication", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.GetApplicationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.GetApplicationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.GetApplicationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// UpdateApplication - Update application -func (s *SDK) UpdateApplication(ctx context.Context, request operations.UpdateApplicationRequest, opts ...operations.Option) (*operations.UpdateApplicationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "updateApplication", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "PUT", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.UpdateApplicationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.UpdateApplicationResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.UpdateApplicationResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteApplication - Delete application -func (s *SDK) DeleteApplication(ctx context.Context, request operations.DeleteApplicationRequest, opts ...operations.Option) (*operations.DeleteApplicationResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/applications/{applicationId}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteApplication", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteApplicationResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateApplicationScope - Create application scope -func (s *SDK) CreateApplicationScope(ctx context.Context, request operations.CreateApplicationScopeRequest, opts ...operations.Option) (*operations.CreateApplicationScopeResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/applications/{applicationId}/scopes", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createApplicationScope", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, true, "Body", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateApplicationScopeResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateApplicationScopeResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateApplicationScopeResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// DeleteApplicationScope - Delete application scope -// Delete a specific scope from an application. This operation requires system administrator privileges. -func (s *SDK) DeleteApplicationScope(ctx context.Context, request operations.DeleteApplicationScopeRequest, opts ...operations.Option) (*operations.DeleteApplicationScopeResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := utils.GenerateURL(ctx, baseURL, "/applications/{applicationId}/scopes/{scopeID}", request, nil) - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "deleteApplicationScope", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "DELETE", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"400", "404", "4XX", "500", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.DeleteApplicationScopeResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 204: - utils.DrainBody(httpRes) - case httpRes.StatusCode == 400: - fallthrough - case httpRes.StatusCode == 404: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - out.HTTPMeta = components.HTTPMetadata{ - Request: req, - Response: httpRes, - } - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode == 500: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - out.HTTPMeta = components.HTTPMetadata{ - Request: req, - Response: httpRes, - } - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// CreateUser - Create user -// Create a new user in the system. This operation requires system administrator privileges. -func (s *SDK) CreateUser(ctx context.Context, request components.CreateUserRequest, opts ...operations.Option) (*operations.CreateUserResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/users") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "createUser", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - bodyReader, reqContentType, err := utils.SerializeRequestBody(ctx, request, false, false, "Request", "json", `request:"mediaType=application/json"`) - if err != nil { - return nil, err - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "POST", opURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - if reqContentType != "" { - req.Header.Set("Content-Type", reqContentType) - } - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"400", "4XX", "500", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.CreateUserResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 201: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.CreateUserResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.CreateUserResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode == 400: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - out.HTTPMeta = components.HTTPMetadata{ - Request: req, - Response: httpRes, - } - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode == 500: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out apierrors.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - out.HTTPMeta = components.HTTPMetadata{ - Request: req, - Response: httpRes, - } - return nil, &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} - -// ReadConnectedUser - Read user -func (s *SDK) ReadConnectedUser(ctx context.Context, opts ...operations.Option) (*operations.ReadConnectedUserResponse, error) { - o := operations.Options{} - supportedOptions := []string{ - operations.SupportedOptionRetries, - operations.SupportedOptionTimeout, - } - - for _, opt := range opts { - if err := opt(&o, supportedOptions...); err != nil { - return nil, fmt.Errorf("error applying option: %w", err) - } - } - - var baseURL string - if o.ServerURL == nil { - baseURL = utils.ReplaceParameters(s.sdkConfiguration.GetServerDetails()) - } else { - baseURL = *o.ServerURL - } - opURL, err := url.JoinPath(baseURL, "/me") - if err != nil { - return nil, fmt.Errorf("error generating URL: %w", err) - } - - hookCtx := hooks.HookContext{ - SDK: s, - SDKConfiguration: s.sdkConfiguration, - BaseURL: baseURL, - Context: ctx, - OperationID: "readConnectedUser", - OAuth2Scopes: nil, - SecuritySource: s.sdkConfiguration.Security, - } - - timeout := o.Timeout - if timeout == nil { - timeout = s.sdkConfiguration.Timeout - } - - if timeout != nil { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, *timeout) - defer cancel() - } - - req, err := http.NewRequestWithContext(ctx, "GET", opURL, nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", s.sdkConfiguration.UserAgent) - - if err := utils.PopulateSecurity(ctx, req, s.sdkConfiguration.Security); err != nil { - return nil, err - } - - for k, v := range o.SetHeaders { - req.Header.Set(k, v) - } - - globalRetryConfig := s.sdkConfiguration.RetryConfig - retryConfig := o.Retries - if retryConfig == nil { - if globalRetryConfig != nil { - retryConfig = globalRetryConfig - } - } - - var httpRes *http.Response - if retryConfig != nil { - httpRes, err = utils.Retry(ctx, utils.Retries{ - Config: retryConfig, - StatusCodes: []string{ - "429", - "500", - "502", - "503", - "504", - }, - }, func() (*http.Response, error) { - if req.Body != nil && req.Body != http.NoBody && req.GetBody != nil { - copyBody, err := req.GetBody() - - if err != nil { - return nil, err - } - - req.Body = copyBody - } - - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - if retry.IsPermanentError(err) || retry.IsTemporaryError(err) { - return nil, err - } - - return nil, retry.Permanent(err) - } - - httpRes, err := s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - } - return httpRes, err - }) - - if err != nil { - return nil, err - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } else { - req, err = s.hooks.BeforeRequest(hooks.BeforeRequestContext{HookContext: hookCtx}, req) - if err != nil { - return nil, err - } - - httpRes, err = s.sdkConfiguration.Client.Do(req) - if err != nil || httpRes == nil { - if err != nil { - err = fmt.Errorf("error sending request: %w", err) - } else { - err = fmt.Errorf("error sending request: no response") - } - - _, err = s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, nil, err) - return nil, err - } else if utils.MatchStatusCodes([]string{"4XX", "5XX"}, httpRes.StatusCode) { - _httpRes, err := s.hooks.AfterError(hooks.AfterErrorContext{HookContext: hookCtx}, httpRes, nil) - if err != nil { - return nil, err - } else if _httpRes != nil { - httpRes = _httpRes - } - } else { - httpRes, err = s.hooks.AfterSuccess(hooks.AfterSuccessContext{HookContext: hookCtx}, httpRes) - if err != nil { - return nil, err - } - } - } - - res := &operations.ReadConnectedUserResponse{ - HTTPMeta: components.HTTPMetadata{ - Request: req, - Response: httpRes, - }, - } - - switch { - case httpRes.StatusCode == 200: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.ReadUserResponse - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.ReadUserResponse = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - case httpRes.StatusCode >= 400 && httpRes.StatusCode < 500: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - case httpRes.StatusCode >= 500 && httpRes.StatusCode < 600: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError("API error occurred", httpRes.StatusCode, string(rawBody), httpRes) - default: - switch { - case utils.MatchContentType(httpRes.Header.Get("Content-Type"), `application/json`): - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - - var out components.Error - if err := utils.UnmarshalJsonFromResponseBody(bytes.NewBuffer(rawBody), &out, ""); err != nil { - return nil, err - } - - res.Error = &out - default: - rawBody, err := utils.ConsumeRawBody(httpRes) - if err != nil { - return nil, err - } - return nil, apierrors.NewAPIError(fmt.Sprintf("unknown content-type received: %s", httpRes.Header.Get("Content-Type")), httpRes.StatusCode, string(rawBody), httpRes) - } - } - - return res, nil - -} diff --git a/internal/membershipclient/types/bigint.go b/internal/membershipclient/types/bigint.go deleted file mode 100644 index 9c6a086d..00000000 --- a/internal/membershipclient/types/bigint.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package types - -import ( - "fmt" - "math/big" -) - -// MustNewBigIntFromString returns an instance of big.Int from a string -// The string is assumed to be base 10 and if it is not a valid big.Int -// then the function panics. -// Avoid using this function in production code. -func MustNewBigIntFromString(s string) *big.Int { - i, ok := new(big.Int).SetString(s, 10) - if !ok { - panic(fmt.Errorf("failed to parse string as big.Int")) - } - - return i -} diff --git a/internal/membershipclient/types/date.go b/internal/membershipclient/types/date.go deleted file mode 100644 index 5b2782f2..00000000 --- a/internal/membershipclient/types/date.go +++ /dev/null @@ -1,90 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package types - -import ( - "encoding/json" - "fmt" - "strings" - "time" -) - -// Date is a wrapper around time.Time that allows for JSON marshaling a date string formatted as "2006-01-02". -type Date struct { - time.Time -} - -var ( - _ json.Marshaler = &Date{} - _ json.Unmarshaler = &Date{} - _ fmt.Stringer = &Date{} -) - -// NewDate returns an instance of Date from a time.Time. -func NewDate(t time.Time) *Date { - d := DateFromTime(t) - return &d -} - -// DateFromTime returns a Date from a time.Time. -func DateFromTime(t time.Time) Date { - return Date{t} -} - -// NewDateFromString returns an instance of Date from a string formatted as "2006-01-02". -func NewDateFromString(str string) (*Date, error) { - d, err := DateFromString(str) - if err != nil { - return nil, err - } - - return &d, nil -} - -// DateFromString returns a Date from a string formatted as "2006-01-02". -func DateFromString(str string) (Date, error) { - var d Date - var err error - - d.Time, err = time.Parse("2006-01-02", str) - return d, err -} - -// MustNewDateFromString returns an instance of Date from a string formatted as "2006-01-02" or panics. -// Avoid using this function in production code. -func MustNewDateFromString(str string) *Date { - d := MustDateFromString(str) - return &d -} - -// MustDateFromString returns a Date from a string formatted as "2006-01-02" or panics. -// Avoid using this function in production code. -func MustDateFromString(str string) Date { - d, err := DateFromString(str) - if err != nil { - panic(err) - } - return d -} - -func (d Date) GetTime() time.Time { - return d.Time -} - -func (d Date) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf(`"%s"`, d.Time.Format("2006-01-02"))), nil -} - -func (d *Date) UnmarshalJSON(data []byte) error { - var err error - - str := string(data) - str = strings.Trim(str, `"`) - - d.Time, err = time.Parse("2006-01-02", str) - return err -} - -func (d Date) String() string { - return d.Time.Format("2006-01-02") -} diff --git a/internal/membershipclient/types/datetime.go b/internal/membershipclient/types/datetime.go deleted file mode 100644 index 3eff332d..00000000 --- a/internal/membershipclient/types/datetime.go +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package types - -import "time" - -// MustTimeFromString returns a time.Time from a string formatted as "2006-01-02T15:04:05Z07:00" or panics. -// Avoid using this function in production code. -func MustTimeFromString(str string) time.Time { - t, err := time.Parse(time.RFC3339, str) - if err != nil { - panic(err) - } - - return t -} - -// MustNewTimeFromString returns an instance of time.Time from a string formatted as "2006-01-02T15:04:05Z07:00" or panics. -// Avoid using this function in production code. -func MustNewTimeFromString(str string) *time.Time { - t := MustTimeFromString(str) - return &t -} diff --git a/internal/membershipclient/types/pointers.go b/internal/membershipclient/types/pointers.go deleted file mode 100644 index 35c439d2..00000000 --- a/internal/membershipclient/types/pointers.go +++ /dev/null @@ -1,11 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package types - -func String(s string) *string { return &s } -func Bool(b bool) *bool { return &b } -func Int(i int) *int { return &i } -func Int64(i int64) *int64 { return &i } -func Float32(f float32) *float32 { return &f } -func Float64(f float64) *float64 { return &f } -func Pointer[T any](v T) *T { return &v } diff --git a/internal/plugin/adapter.go b/internal/plugin/adapter.go new file mode 100644 index 00000000..a6ca9f69 --- /dev/null +++ b/internal/plugin/adapter.go @@ -0,0 +1,335 @@ +package plugin + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/interactive" +) + +// Resolver supplies the selected service endpoint and authenticated HTTP client. +type Resolver func(context.Context, string) (*api.Client, error) + +// RequestResolver can select a named profile using already validated flags. +type RequestResolver func(context.Context, string, pluginsdk.ExecuteRequest) (*api.Client, error) + +// Adapter installs commands from a frozen registry into the host root. +type Adapter struct { + registry *Registry + resolve Resolver + resolveRequest RequestResolver +} + +func NewCommand(registry *Registry, resolver Resolver) *Adapter { + return &Adapter{registry: registry, resolve: resolver} +} + +func NewCommandWithRequest(registry *Registry, resolver RequestResolver) *Adapter { + return &Adapter{registry: registry, resolveRequest: resolver} +} + +// AddTo validates every tree before attaching any command. Help and completion +// use only manifest metadata; runtime client resolution happens inside RunE. +func (a *Adapter) AddTo(root *cobra.Command) error { + if root == nil || a.registry == nil || (a.resolve == nil && a.resolveRequest == nil) { + return fmt.Errorf("plugin adapter requires root, registry and resolver") + } + var commands []*cobra.Command + for _, entry := range a.registry.snapshot() { + if err := checkHostCollisions(root, entry.manifest.Root); err != nil { + return err + } + for _, existing := range root.Commands() { + if existing.Name() == pluginsdk.CommandName(entry.manifest.Root) || existing.HasAlias(pluginsdk.CommandName(entry.manifest.Root)) { + return fmt.Errorf("plugin root collides with host command %q", existing.Name()) + } + } + tree, err := a.build(entry, entry.manifest.Root, nil, nil, "") + if err != nil { + return err + } + commands = append(commands, tree) + } + root.AddCommand(commands...) + return nil +} + +func checkHostCollisions(root *cobra.Command, spec pluginsdk.CommandSpec) error { + for _, flag := range spec.Flags { + if root.PersistentFlags().Lookup(flag.Name) != nil || root.InheritedFlags().Lookup(flag.Name) != nil { + return fmt.Errorf("plugin flag %q collides with host", flag.Name) + } + if flag.Shorthand != "" && (root.PersistentFlags().ShorthandLookup(flag.Shorthand) != nil || root.InheritedFlags().ShorthandLookup(flag.Shorthand) != nil) { + return fmt.Errorf("plugin shorthand %q collides with host", flag.Shorthand) + } + } + for _, child := range spec.Subcommands { + if err := checkHostCollisions(root, child); err != nil { + return err + } + } + return nil +} + +func (a *Adapter) build(entry registration, spec pluginsdk.CommandSpec, parentPath []string, inherited []pluginsdk.FlagSpec, target string) (*cobra.Command, error) { + path := append(append([]string{}, parentPath...), pluginsdk.CommandName(spec)) + flags := append(append([]pluginsdk.FlagSpec{}, inherited...), spec.Flags...) + if spec.Target != "" { + target = spec.Target + } + cmd := &cobra.Command{Use: spec.Use, Aliases: spec.Aliases, Short: spec.Short, Long: spec.Long, Example: spec.Example, Annotations: map[string]string{"fctl.target": target}} + cmd.Args = commandArgs(spec) + for _, flag := range spec.Flags { + if canSupplyFlag(spec.Inputs, flag) || canSupplyFileBody(spec.Files, flag) { + flag.Required = false // The SDK still enforces this after collection. + } + if err := addFlag(cmd, flag); err != nil { + return nil, err + } + } + if spec.Runnable { + cmd.RunE = func(cmd *cobra.Command, args []string) error { return a.run(cmd, entry, path, flags, args) } + } + next := append([]pluginsdk.FlagSpec{}, inherited...) + for _, flag := range spec.Flags { + if flag.Persistent { + next = append(next, flag) + } + } + for _, child := range spec.Subcommands { + tree, err := a.build(entry, child, path, next, target) + if err != nil { + return nil, err + } + cmd.AddCommand(tree) + } + return cmd, nil +} + +func addFlag(cmd *cobra.Command, flag pluginsdk.FlagSpec) error { + set := cmd.Flags() + if flag.Persistent { + set = cmd.PersistentFlags() + } + switch flag.Type { + case "string": + set.StringP(flag.Name, flag.Shorthand, flag.Default, flag.Usage) + case "bool": + value, err := strconv.ParseBool(flag.Default) + if err != nil { + return err + } + set.BoolP(flag.Name, flag.Shorthand, value, flag.Usage) + case "uint32": + value, err := strconv.ParseUint(flag.Default, 10, 32) + if err != nil { + return err + } + set.Uint32P(flag.Name, flag.Shorthand, uint32(value), flag.Usage) + default: + return fmt.Errorf("unsupported flag type %q", flag.Type) + } + if flag.Required { + if flag.Persistent { + return cmd.MarkPersistentFlagRequired(flag.Name) + } + return cmd.MarkFlagRequired(flag.Name) + } + return nil +} + +func (a *Adapter) run(cmd *cobra.Command, entry registration, path []string, flags []pluginsdk.FlagSpec, args []string) error { + if interactive.Enabled(cmd) { + return a.runInteractive(cmd, entry, path, flags, args) + } + normalized, err := prepareRequest(cmd, entry.manifest, path, flags, args) + if err != nil { + return err + } + service, err := pluginsdk.CommandService(entry.manifest, path) + if err != nil { + return err + } + client, err := a.resolveClient(cmd.Context(), service, normalized) + if err != nil { + return err + } + if client == nil || client.HTTPClient() == nil { + return fmt.Errorf("plugin resolver returned no HTTP client") + } + normalized.Endpoint = client.Endpoint() + normalized.Context = client.Context() + instance := entry.factory(client.HTTPClient()) + if instance == nil { + return fmt.Errorf("plugin factory returned nil") + } + spec, err := pluginsdk.FindCommand(entry.manifest, path) + if err != nil { + return err + } + return executePluginCommand(cmd, instance, spec, normalized) +} + +func (a *Adapter) resolveClient(ctx context.Context, service string, request pluginsdk.ExecuteRequest) (*api.Client, error) { + if a.resolveRequest != nil { + return a.resolveRequest(ctx, service, request) + } + return a.resolve(ctx, service) +} + +func prepareRequest(cmd *cobra.Command, manifest pluginsdk.Manifest, path []string, flags []pluginsdk.FlagSpec, args []string) (pluginsdk.ExecuteRequest, error) { + normalized, err := normalizeRequest(cmd, manifest, path, flags, args) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + spec, err := pluginsdk.FindCommand(manifest, path) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + return readPluginRequest(cmd, manifest, spec.Files, normalized, flags) +} + +// Normalize the request before opening a body or plugin input file. A declared +// file source can satisfy a required body flag once the host has read it. +func normalizeRequest(cmd *cobra.Command, manifest pluginsdk.Manifest, path []string, flags []pluginsdk.FlagSpec, args []string) (pluginsdk.ExecuteRequest, error) { + request, err := requestFromFlags(cmd, path, flags, args) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + spec, err := pluginsdk.FindCommand(manifest, path) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + source, err := pluginInputSource(spec.Files, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if source != "" { + manifest = fileInputManifest(manifest) + } + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if err := checkPluginInput(spec.Files, normalized, flags); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if _, err := pluginOutputFormat(spec.Files, normalized); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if _, err := pluginOutputDestination(spec.Files, normalized); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if err := checkPluginStreamFormat(spec, normalized); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + return normalized, nil +} + +func fileInputManifest(manifest pluginsdk.Manifest) pluginsdk.Manifest { + manifest = cloneManifest(manifest) + var relax func(*pluginsdk.CommandSpec) + relax = func(spec *pluginsdk.CommandSpec) { + for i := range spec.Flags { + if spec.Flags[i].Body { + spec.Flags[i].Required = false + } + } + for i := range spec.Subcommands { + relax(&spec.Subcommands[i]) + } + } + relax(&manifest.Root) + return manifest +} + +func readPluginRequest(cmd *cobra.Command, manifest pluginsdk.Manifest, files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest, flags []pluginsdk.FlagSpec) (pluginsdk.ExecuteRequest, error) { + spec, err := pluginsdk.FindCommand(manifest, request.CommandPath) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if err := checkPluginStreamFormat(spec, request); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if err := checkPluginInput(files, request, flags); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if _, err := pluginOutputFormat(files, request); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if _, err := pluginOutputDestination(files, request); err != nil { + return pluginsdk.ExecuteRequest{}, err + } + request, err = readRequestBody(cmd, request, flags) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if streamInput(spec) { + source, err := pluginInputSource(files, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if source != "" { + manifest = fileInputManifest(manifest) + } + return pluginsdk.NormalizeRequest(manifest, request) + } + request, err = ReadPluginInput(cmd, files, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + return pluginsdk.NormalizeRequest(manifest, request) +} + +func requestFromFlags(cmd *cobra.Command, path []string, flags []pluginsdk.FlagSpec, args []string) (pluginsdk.ExecuteRequest, error) { + request := pluginsdk.ExecuteRequest{CommandPath: append([]string{}, path...), Args: append([]string{}, args...), Flags: map[string]string{}, ChangedFlags: map[string]bool{}} + for _, spec := range flags { + flag := lookupFlag(cmd, spec.Name) + if flag == nil { + return pluginsdk.ExecuteRequest{}, fmt.Errorf("plugin flag %q is missing", spec.Name) + } + request.Flags[spec.Name] = flag.Value.String() + request.ChangedFlags[spec.Name] = flag.Changed + } + return request, nil +} + +func readRequestBody(cmd *cobra.Command, normalized pluginsdk.ExecuteRequest, flags []pluginsdk.FlagSpec) (pluginsdk.ExecuteRequest, error) { + var err error + for _, spec := range flags { + if spec.Body && (normalized.Flags[spec.Name] != "" || normalized.ChangedFlags[spec.Name]) { + normalized.Body, err = command.ReadBody(cmd, normalized.Flags[spec.Name]) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + } + } + return normalized, nil +} + +func lookupFlag(cmd *cobra.Command, name string) *pflag.Flag { + if flag := cmd.Flags().Lookup(name); flag != nil { + return flag + } + if flag := cmd.PersistentFlags().Lookup(name); flag != nil { + return flag + } + return cmd.InheritedFlags().Lookup(name) +} + +func commandArgs(spec pluginsdk.CommandSpec) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if interactive.Enabled(cmd) && len(spec.Inputs) != 0 { + return cobra.MaximumNArgs(spec.Args.Max)(cmd, args) + } + return cobra.RangeArgs(spec.Args.Min, spec.Args.Max)(cmd, args) + } +} diff --git a/internal/plugin/adapter_test.go b/internal/plugin/adapter_test.go new file mode 100644 index 00000000..fa43f051 --- /dev/null +++ b/internal/plugin/adapter_test.go @@ -0,0 +1,344 @@ +package plugin_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +type fakePlugin struct { + manifest pluginsdk.Manifest + manifestErr error + execute func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) +} + +func (f *fakePlugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { + return f.manifest, f.manifestErr +} +func (f *fakePlugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + return f.execute(ctx, req) +} + +func testManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "ledger", Version: "test", Service: "ledger", ProtocolVersion: 1, Root: pluginsdk.CommandSpec{ + Use: "ledger", Short: "Ledger fixture", Flags: []pluginsdk.FlagSpec{{Name: "tenant", Type: "string", Required: true, Persistent: true}, {Name: "enabled", Type: "bool", Default: "true", Persistent: true}}, + Subcommands: []pluginsdk.CommandSpec{{Use: "write NAME", Runnable: true, Confirm: true, Args: pluginsdk.ArgsSpec{Min: 1, Max: 1}, Flags: []pluginsdk.FlagSpec{{Name: "confirm", Type: "bool", Default: "false"}, {Name: "data", Type: "string", Body: true}, {Name: "limit", Type: "uint32", Default: "10"}}}}, + }} +} + +func attach(t *testing.T, m pluginsdk.Manifest, resolve plugin.Resolver, factory plugin.Factory) *cobra.Command { + t.Helper() + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, factory); err != nil { + t.Fatal(err) + } + root := &cobra.Command{Use: "fctl", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().String("profile", "", "host setting") + if err := plugin.NewCommand(registry, resolve).AddTo(root); err != nil { + t.Fatal(err) + } + return root +} + +func TestAdapterBodyAndAuthenticatedClient(t *testing.T) { + t.Parallel() + for _, kind := range []string{"inline", "file", "stdin"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); testBodyCase(t, kind) }) + } +} + +func bodyInput(t *testing.T, kind, body string) string { + t.Helper() + switch kind { + case "stdin": + return "-" + case "file": + path := filepath.Join(t.TempDir(), "body.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return "@" + path + default: + return body + } +} + +func bodyServer(t *testing.T, body string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/prefix/api/ledger/invoke" || r.Method != http.MethodPost { + t.Error("wrong service route") + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("injected authentication missing") + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + if string(data) != body { + t.Error("request JSON number changed") + } + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, body); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + return server +} + +func recordingFactory(t *testing.T, expected *http.Client, got *pluginsdk.ExecuteRequest) plugin.Factory { + t.Helper() + return func(injected *http.Client) pluginsdk.Plugin { + if injected != expected { + t.Error("factory did not receive resolved HTTP client") + } + return &fakePlugin{execute: func(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + *got = req + client, err := api.New(req.Endpoint, injected) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + data, err := client.Do(ctx, http.MethodPost, "/invoke", nil, req.Body, nil) + return pluginsdk.ExecuteResponse{Data: data}, err + }} + } +} + +func testBodyCase(t *testing.T, kind string) { + t.Helper() + body := `{"amount":9007199254740993}` + value := bodyInput(t, kind, body) + server := bodyServer(t, body) + httpClient := &http.Client{Transport: bearerTransport{server.Client().Transport}} + calls := 0 + var got pluginsdk.ExecuteRequest + endpoint := server.URL + "/prefix/api/ledger" + root := attach(t, testManifest(), func(_ context.Context, service string) (*api.Client, error) { + calls++ + if service != "ledger" { + t.Error("wrong service") + } + return api.New(endpoint, httpClient) + }, recordingFactory(t, httpClient, &got)) + root.SetIn(strings.NewReader(body)) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"--profile", "host", "ledger", "--tenant", "org", "write", "demo", "--confirm", "--enabled=false", "--limit", "42", "--data", value}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if calls != 1 || got.Endpoint != endpoint || string(got.Body) != body { + t.Fatal("wrong resolved request") + } + assertBodyRequest(t, got, value) + if out.String() != "{\n \"amount\": 9007199254740993\n}\n" { + t.Fatalf("JSON output = %q", out.String()) + } +} + +func assertBodyRequest(t *testing.T, got pluginsdk.ExecuteRequest, value string) { + t.Helper() + if !reflect.DeepEqual(got.CommandPath, []string{"ledger", "write"}) || !reflect.DeepEqual(got.Args, []string{"demo"}) { + t.Fatal("wrong command path or positional arguments") + } + wantFlags := map[string]string{"tenant": "org", "enabled": "false", "confirm": "true", "limit": "42", "data": value} + if !reflect.DeepEqual(got.Flags, wantFlags) { + t.Fatalf("plugin flags = %v", got.Flags) + } + for name := range wantFlags { + if !got.ChangedFlags[name] { + t.Errorf("missing changed flag %s", name) + } + } +} + +type bearerTransport struct{ base http.RoundTripper } + +func (b bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer test-token") + return b.base.RoundTrip(req) +} + +func TestAdapterValidationBeforeResolution(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + args []string + }{ + {"missing confirm", []string{"ledger", "--tenant", "org", "write", "demo", "--data", "-"}}, + {"missing required persistent flag", []string{"ledger", "write", "demo", "--confirm"}}, + {"missing arg", []string{"ledger", "--tenant", "org", "write", "--confirm"}}, + {"extra arg", []string{"ledger", "--tenant", "org", "write", "one", "two", "--confirm"}}, + {"invalid uint32", []string{"ledger", "--tenant", "org", "write", "demo", "--confirm", "--limit", "4294967296"}}, + {"invalid bool", []string{"ledger", "--tenant", "org", "write", "demo", "--confirm=invalid"}}, + {"explicit empty body", []string{"ledger", "--tenant", "org", "write", "demo", "--confirm", "--data="}}, + {"invalid JSON", []string{"ledger", "--tenant", "org", "write", "demo", "--confirm", "--data", "{"}}, + {"missing file", []string{"ledger", "--tenant", "org", "write", "demo", "--confirm", "--data", "@/nonexistent-fctl-plugin-body"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + root := attach(t, testManifest(), func(context.Context, string) (*api.Client, error) { + t.Error("invalid command resolved HTTP client") + return nil, errors.New("unexpected resolver") + }, func(*http.Client) pluginsdk.Plugin { t.Error("invalid command constructed plugin"); return nil }) + root.SetIn(forbiddenReader{t}) + root.SetArgs(tc.args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.ExecuteContext(t.Context()); err == nil { + t.Fatal("invalid command accepted") + } + }) + } +} + +type forbiddenReader struct{ t *testing.T } + +func (r forbiddenReader) Read([]byte) (int, error) { + r.t.Error("body read before command validation") + return 0, io.EOF +} + +func TestAdapterHelpWithoutHTTP(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"ledger", "--help"}, {"ledger", "write", "--help"}, {"__complete", "ledger", ""}} { + root := attach(t, testManifest(), func(context.Context, string) (*api.Client, error) { + t.Error("help resolved HTTP client") + return nil, nil + }, func(*http.Client) pluginsdk.Plugin { t.Error("help constructed plugin"); return nil }) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(io.Discard) + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if out.Len() == 0 { + t.Fatal("help or completion output missing") + } + } +} + +func TestAdapterDefaultsAndPartialError(t *testing.T) { + t.Parallel() + executionErr := errors.New("bulk partially failed") + writerErr := errors.New("stdout failed") + for _, failWrite := range []bool{false, true} { + m := testManifest() + // Exercise MaxArgs and an inherited body with a nonempty default. + m.Root.Flags = append(m.Root.Flags, pluginsdk.FlagSpec{Name: "payload", Type: "string", Default: `{"x":1}`, Body: true, Persistent: true}) + m.Root.Subcommands[0].Flags = m.Root.Subcommands[0].Flags[:1] + m.Root.Subcommands[0].Args = pluginsdk.ArgsSpec{Max: 2} + root := attach(t, m, func(context.Context, string) (*api.Client, error) { + return api.New("http://localhost:9000", http.DefaultClient) + }, func(*http.Client) pluginsdk.Plugin { + return &fakePlugin{execute: func(_ context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if req.Flags["enabled"] != "true" || req.ChangedFlags["enabled"] || req.ChangedFlags["payload"] { + t.Error("unchanged defaults marked as changed") + } + if string(req.Body) != `{"x":1}` { + t.Error("inherited default body not read") + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"ok":1,"failed":1}`)}, executionErr + }} + }) + var out bytes.Buffer + root.SetOut(&out) + if failWrite { + root.SetOut(errorWriter{writerErr}) + } + root.SetArgs([]string{"ledger", "write", "--tenant", "org", "--confirm"}) + err := root.ExecuteContext(t.Context()) + assertPartialResult(t, err, executionErr, writerErr, failWrite, out.String()) + } +} + +func assertPartialResult(t *testing.T, err, executionErr, writerErr error, failWrite bool, out string) { + t.Helper() + if !errors.Is(err, executionErr) { + t.Fatalf("lost execution error: %v", err) + } + if failWrite && !errors.Is(err, writerErr) { + t.Fatalf("lost writer error: %v", err) + } + if !failWrite && out != "{\n \"ok\": 1,\n \"failed\": 1\n}\n" { + t.Fatalf("partial result missing: %q", out) + } +} + +type errorWriter struct{ err error } + +func (w errorWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestAdapterPassesIsolatedHostContext(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + metadata map[string]string + }{ + {"default nil", nil}, + {"host metadata", map[string]string{"organization": "org-1", "stack": "stack-1", "organizationIDs": `["org-1","org-2"]`}}, + } { + t.Run(tc.name, func(t *testing.T) { testAdapterContext(t, tc.metadata) }) + } +} + +func testAdapterContext(t *testing.T, metadata map[string]string) { + t.Helper() + want := maps.Clone(metadata) + httpClient := &http.Client{} + base, err := api.New("https://ledger.example/prefix", httpClient) + if err != nil { + t.Fatal(err) + } + resolved := base.WithContext(metadata) + var received map[string]string + root := attach(t, testManifest(), func(context.Context, string) (*api.Client, error) { + return resolved, nil + }, func(injected *http.Client) pluginsdk.Plugin { + if injected != httpClient { + t.Error("metadata replaced the injected HTTP client") + } + return &fakePlugin{execute: func(_ context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + received = req.Context + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }} + }) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"ledger", "--tenant", "cli-tenant", "write", "demo", "--confirm"}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(received, want) || out.String() != "{\n \"ok\": true\n}\n" { + t.Fatal("adapter lost host context or printed it as command output") + } + if received != nil { + received["organization"] = "plugin-change" + } + if !reflect.DeepEqual(resolved.Context(), want) || !reflect.DeepEqual(metadata, want) { + t.Fatal("plugin mutation reached the resolver client or host metadata") + } +} diff --git a/internal/plugin/aliases_test.go b/internal/plugin/aliases_test.go new file mode 100644 index 00000000..a3460b23 --- /dev/null +++ b/internal/plugin/aliases_test.go @@ -0,0 +1,167 @@ +package plugin_test + +import ( + "context" + "io" + "net/http" + "slices" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +func aliasManifest() pluginsdk.Manifest { + manifest := testManifest() + manifest.Root.Aliases = []string{"legacy_ledger"} + manifest.Root.Subcommands[0].Use = "bank-accounts NAME" + manifest.Root.Subcommands[0].Aliases = []string{"bank_accounts", "old-accounts"} + return manifest +} + +func TestRegistryRejectsInvalidAliases(t *testing.T) { + t.Parallel() + for _, alias := range []string{"", " ", "bank accounts", " bank_accounts", "bank_accounts\n", "BANK_ACCOUNTS", "bank/accounts", "--bank-accounts", "help", "bank-accounts"} { + t.Run(alias, func(t *testing.T) { + t.Parallel() + manifest := aliasManifest() + manifest.Root.Subcommands[0].Aliases = []string{alias} + assertInvalidAliasManifest(t, manifest) + }) + } + for _, alias := range []string{"help", "completion", "version", "profiles", "login", "logout", "ledger"} { + manifest := aliasManifest() + manifest.Root.Aliases = []string{alias} + assertInvalidAliasManifest(t, manifest) + } + manifest := aliasManifest() + manifest.Root.Subcommands[0].Use = "bank_accounts NAME" + assertInvalidAliasManifest(t, manifest) +} + +func assertInvalidAliasManifest(t *testing.T, manifest pluginsdk.Manifest) { + t.Helper() + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory); err == nil { + t.Fatal("invalid alias metadata accepted") + } +} + +func TestRegistryRejectsSiblingAliasConflicts(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + edit func(*pluginsdk.Manifest) + }{ + {"duplicate leaf alias", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Aliases = []string{"old", "old"} }}, + {"duplicate root alias", func(m *pluginsdk.Manifest) { m.Root.Aliases = []string{"old", "old"} }}, + {"alias to canonical", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Aliases = []string{"list"} }}, + {"canonical to alias", func(m *pluginsdk.Manifest) { m.Root.Subcommands[1].Aliases = []string{"bank-accounts"} }}, + {"alias to alias", func(m *pluginsdk.Manifest) { m.Root.Subcommands[1].Aliases = []string{"bank_accounts"} }}, + {"historical h collision", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Aliases = []string{"h"} + m.Root.Subcommands[1].Aliases = []string{"h"} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := aliasManifest() + manifest.Root.Subcommands = append(manifest.Root.Subcommands, pluginsdk.CommandSpec{Use: "list", Runnable: true}) + tc.edit(&manifest) + assertInvalidAliasManifest(t, manifest) + }) + } +} + +func TestRegistryRejectsRootAliasConflicts(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + root, alias string + existingAlias string + }{ + {"alias to root", "payments", "ledger", "legacy_ledger"}, + {"root to alias", "payments", "old_payments", "payments"}, + {"alias to alias", "payments", "legacy_ledger", "legacy_ledger"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + registry := &plugin.Registry{} + existing := aliasManifest() + existing.Root.Aliases = []string{tc.existingAlias} + if err := registry.Register(t.Context(), &fakePlugin{manifest: existing}, inertFactory); err != nil { + t.Fatal(err) + } + manifest := aliasManifest() + manifest.Name, manifest.Root.Use, manifest.Root.Aliases = tc.root, tc.root, []string{tc.alias} + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory); err == nil { + t.Fatal("root alias conflict accepted") + } + if len(registry.List()) != 1 { + t.Fatal("failed registration modified the registry") + } + }) + } +} + +func TestRegistryAliasesAreIsolated(t *testing.T) { + t.Parallel() + manifest := aliasManifest() + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory); err != nil { + t.Fatal(err) + } + manifest.Root.Aliases[0] = "provider_change" + manifest.Root.Subcommands[0].Aliases[0] = "provider_child_change" + listed := registry.List() + listed[0].Root.Aliases[0] = "list_change" + listed[0].Root.Subcommands[0].Aliases[0] = "list_child_change" + again := registry.List()[0] + if again.Root.Aliases[0] != "legacy_ledger" || again.Root.Subcommands[0].Aliases[0] != "bank_accounts" { + t.Fatal("alias slices share provider or list storage") + } +} + +func TestAdapterAliasRoutesRemainCanonical(t *testing.T) { + t.Parallel() + called := false + root := attach(t, aliasManifest(), func(context.Context, string) (*api.Client, error) { + return api.New("https://ledger.example", &http.Client{}) + }, func(*http.Client) pluginsdk.Plugin { + return &fakePlugin{execute: func(_ context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + called = true + if !slices.Equal(request.CommandPath, []string{"ledger", "bank-accounts"}) || !request.ChangedFlags["confirm"] || request.ChangedFlags["limit"] { + t.Fatalf("alias invocation changed canonical routing or flags: %+v", request) + } + return pluginsdk.ExecuteResponse{}, nil + }} + }) + root.SetArgs([]string{"legacy_ledger", "bank_accounts", "fixture", "--tenant=org", "--confirm"}) + root.SetOut(io.Discard) + if err := root.ExecuteContext(t.Context()); err != nil || !called { + t.Fatalf("alias command did not execute: %v", err) + } +} + +func TestHistoricalHAliasPreservesHelpFlag(t *testing.T) { + t.Parallel() + manifest := aliasManifest() + manifest.Root.Subcommands[0].Aliases = []string{"h"} + called := false + root := attach(t, manifest, func(context.Context, string) (*api.Client, error) { + called = true + return api.New("https://ledger.example", &http.Client{}) + }, inertFactory) + root.SetOut(io.Discard) + root.SetArgs([]string{"ledger", "h", "-h"}) + if err := root.ExecuteContext(t.Context()); err != nil || called { + t.Fatalf("historical h alias did not preserve -h: %v", err) + } + request := pluginsdk.ExecuteRequest{CommandPath: []string{"ledger", "h"}, Args: []string{"fixture"}, Flags: map[string]string{"tenant": "org", "confirm": "true"}} + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil || !slices.Equal(normalized.CommandPath, []string{"ledger", "bank-accounts"}) { + t.Fatalf("historical h alias normalization = %v (%v)", normalized.CommandPath, err) + } +} diff --git a/internal/plugin/choice_paths_test.go b/internal/plugin/choice_paths_test.go new file mode 100644 index 00000000..82e4f8a8 --- /dev/null +++ b/internal/plugin/choice_paths_test.go @@ -0,0 +1,25 @@ +package plugin + +import ( + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestNestedChoiceFieldsAndLiteralPrecedence(t *testing.T) { + t.Parallel() + object := map[string]any{"metadata": map[string]any{"name": "nested"}, "metadata.name": "literal", "status": map[string]any{"phase": "Running"}} + source := pluginsdk.ChoiceSource{ValueField: "metadata.name", LabelFields: []string{"status.phase"}} + option, err := choiceOption(object, source) + if err != nil || option.Value != "literal" || option.Label != "Running (literal)" { + t.Fatalf("option=%+v err=%v", option, err) + } + delete(object, "metadata.name") + option, err = choiceOption(object, source) + if err != nil || option.Value != "nested" { + t.Fatalf("nested option=%+v err=%v", option, err) + } + if choiceField(object, "metadata.missing.name") != nil { + t.Fatal("missing nested field must be nil") + } +} diff --git a/internal/plugin/external.go b/internal/plugin/external.go new file mode 100644 index 00000000..d4c8403e --- /dev/null +++ b/internal/plugin/external.go @@ -0,0 +1,105 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "net/http" + "reflect" + "sync" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) + +// ExternalFactory uses cached metadata for help and forms. Processes exist only +// during Execute, including interactive choice queries, and are always closed. +// Authentication is injected through the same host HTTP boundary as embedded +// plugins; credentials are never passed in the executable's arguments. +func ExternalFactory(binary string, manifest pluginsdk.Manifest) Factory { + return ExternalFactoryWithVerifier(manifest, func(context.Context) (string, error) { return binary, nil }) +} + +// ExternalFactoryWithVerifier keeps metadata independent of executable +// availability. The verifier runs before every execution, including choice +// requests, and may verify integrity or locate a cached binary. +func ExternalFactoryWithVerifier(manifest pluginsdk.Manifest, verify func(context.Context) (string, error)) Factory { + return func(client *http.Client) pluginsdk.Plugin { + return &external{verify: verify, manifest: cloneManifest(manifest), http: client} + } +} + +type external struct { + verify func(context.Context) (string, error) + manifest pluginsdk.Manifest + http *http.Client + mu sync.Mutex + checked string +} + +func (p *external) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.Manifest{}, err + } + return cloneManifest(p.manifest), nil +} + +func (p *external) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (response pluginsdk.ExecuteResponse, err error) { + return p.execute(ctx, request, nil) +} + +func (p *external) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + return p.execute(ctx, request, &streams) +} + +func (p *external) execute(ctx context.Context, request pluginsdk.ExecuteRequest, streams *pluginsdk.ExecutionStreams) (response pluginsdk.ExecuteResponse, err error) { + binary, err := p.verify(ctx) + if err != nil { + return response, fmt.Errorf("verify plugin executable: %w", err) + } + if err := p.checkVersion(ctx, request.Endpoint); err != nil { + return response, err + } + client, err := transport.Open(ctx, binary, p.http, request.Endpoint) + if err != nil { + return response, err + } + defer func() { err = errors.Join(err, client.Close()) }() + manifest, err := client.GetManifest(ctx) + if err != nil { + return response, err + } + if !reflect.DeepEqual(manifest, p.manifest) { + return response, fmt.Errorf("plugin manifest differs from its installed catalogue; run fctl plugins sync") + } + if streams != nil { + return client.ExecuteStream(ctx, request, *streams) + } + return client.Execute(ctx, request) +} + +func (p *external) checkVersion(ctx context.Context, endpoint string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.checked != "" && p.checked == endpoint { + return nil + } + client, err := httpclient.New(endpoint, p.http) + if err != nil { + return err + } + data, err := client.Do(ctx, http.MethodGet, "/_info", nil, nil, nil) + if err != nil { + return fmt.Errorf("check %s plugin version: %w", p.manifest.Service, err) + } + version, err := pluginsdk.ServiceVersion(data) + if err != nil { + return fmt.Errorf("decode %s version: %w", p.manifest.Service, err) + } + if version != p.manifest.Version { + return fmt.Errorf("installed plugin targets %s %s, service reports %s; run fctl plugins sync or install the matching binary", p.manifest.Service, p.manifest.Version, version) + } + p.checked = endpoint + return nil +} diff --git a/internal/plugin/files.go b/internal/plugin/files.go new file mode 100644 index 00000000..3b2dfe6d --- /dev/null +++ b/internal/plugin/files.go @@ -0,0 +1,357 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/muesli/cancelreader" + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/command" +) + +const maxPluginInput = 4 << 20 + +func canSupplyFileBody(files *pluginsdk.FileSpec, flag pluginsdk.FlagSpec) bool { + return flag.Body && files != nil && (files.ReadArgument != nil || files.ReadFlag != "") +} + +func pluginFileInputs(spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest, bodyFlag string) ([]pluginsdk.InputSpec, error) { + source, err := pluginInputSource(spec.Files, request) + if err != nil || source == "" { + return spec.Inputs, err + } + // The file will provide the body after confirmation, so do not ask the + // user to enter the same content in a form while the file is still unread. + return slices.DeleteFunc(slices.Clone(spec.Inputs), func(input pluginsdk.InputSpec) bool { + return input.BodyPointer != "" || (bodyFlag != "" && input.Flag == bodyFlag) + }), nil +} + +func pluginInputSource(files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest) (string, error) { + if files == nil { + return "", nil + } + source := "" + if files.ReadArgument != nil && *files.ReadArgument >= 0 && len(request.Args) > *files.ReadArgument { + source = request.Args[*files.ReadArgument] + if source == "" { + return "", fmt.Errorf("file input argument must not be empty") + } + } + if files.ReadFlag != "" { + value := request.Flags[files.ReadFlag] + if request.ChangedFlags[files.ReadFlag] && value == "" { + return "", fmt.Errorf("--%s file input must not be empty", files.ReadFlag) + } + if value != "" { + if source != "" { + return "", fmt.Errorf("file argument and --%s cannot be combined", files.ReadFlag) + } + source = value + } + } + return source, nil +} + +func checkPluginInput(files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest, flags []pluginsdk.FlagSpec) error { + source, err := pluginInputSource(files, request) + if err != nil || source == "" { + return err + } + if request.Body != nil { + return fmt.Errorf("file input cannot be combined with an explicit body") + } + for _, flag := range flags { + if flag.Body && (request.ChangedFlags[flag.Name] || request.Flags[flag.Name] != "") { + return fmt.Errorf("file input cannot be combined with --%s", flag.Name) + } + } + return nil +} + +// ReadPluginInput reads an optional declared source into Body. The caller must +// enforce SDK gates and confirmation before invoking this host-only boundary. +func ReadPluginInput(cmd *cobra.Command, files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + source, err := pluginInputSource(files, request) + if err != nil || source == "" { + return request, err + } + if request.Body != nil { + return request, fmt.Errorf("file input cannot be combined with an explicit body") + } + data, err := readPluginSource(cmd, source) + if err != nil { + return request, fmt.Errorf("read plugin input: %w", err) + } + request.Body, err = decodePluginInput(files.ReadFormat, data) + return request, err +} + +func readPluginSource(cmd *cobra.Command, source string) (data []byte, err error) { + if err := cmd.Context().Err(); err != nil { + return nil, err + } + if source == "-" { + return readPluginStdin(cmd.Context(), cmd.InOrStdin()) + } + if scheme := strings.ToLower(source); strings.HasPrefix(scheme, "https:") || strings.HasPrefix(scheme, "http:") { + client := newPluginURLClient() + defer client.CloseIdleConnections() + return readPluginURL(cmd.Context(), source, client) + } + info, err := os.Stat(source) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > maxPluginInput { + return nil, fmt.Errorf("input must be a regular file within 4 MiB") + } + file, err := os.Open(source) //nolint:gosec // The source is an explicit user-selected local file, validated as regular and bounded above. + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, file.Close()) }() + return readBoundedPluginInput(cmd.Context(), file) +} + +func readPluginStdin(ctx context.Context, input io.Reader) (data []byte, err error) { + if file, ok := input.(*os.File); ok { + info, err := file.Stat() + if err != nil { + return nil, err + } + // kqueue-based cancellation readers can wait indefinitely for regular + // files on macOS. A bounded file read does not need readiness polling. + if info.Mode().IsRegular() { + return readBoundedPluginInput(ctx, file) + } + } + reader, err := cancelreader.NewReader(input) + if err != nil { + return nil, err + } + canceled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + reader.Cancel() + close(canceled) + }) + defer func() { + if !stop() { + <-canceled + } + err = errors.Join(err, reader.Close()) + }() + data, err = readBoundedPluginInput(ctx, reader) + if ctx.Err() != nil { + return nil, ctx.Err() + } + return data, err +} + +type pluginContextReader struct { + ctx context.Context + io.Reader +} + +func (r pluginContextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.Reader.Read(data) +} + +func readBoundedPluginInput(ctx context.Context, reader io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(pluginContextReader{ctx, reader}, maxPluginInput+1)) + if err != nil { + return nil, err + } + if len(data) > maxPluginInput { + return nil, fmt.Errorf("input exceeds 4 MiB") + } + return data, ctx.Err() +} + +func decodePluginInput(format string, data []byte) (json.RawMessage, error) { + var err error + switch format { + case "string": + data, err = json.Marshal(string(data)) + case "yaml": + data, err = yaml.YAMLToJSONStrict(data) + case "", "json": + default: + return nil, fmt.Errorf("unsupported input format %q", format) + } + if err != nil { + return nil, fmt.Errorf("decode plugin input: %w", err) + } + if len(data) > maxPluginInput || !json.Valid(data) { + return nil, fmt.Errorf("decoded plugin input must be valid JSON within 4 MiB") + } + return data, nil +} + +func pluginOutputFormat(files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest) (string, error) { + format := "" + if files != nil { + format = files.WriteFormat + if files.FormatFlag != "" && request.Flags[files.FormatFlag] != "" { + format = request.Flags[files.FormatFlag] + } + } + if format == "yml" { + format = "yaml" + } + if !slices.Contains([]string{"", "json", "ndjson", "yaml"}, format) { + return "", fmt.Errorf("unsupported output format %q", format) + } + return format, nil +} + +func pluginOutputDestination(files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest) (string, error) { + if files == nil || files.WriteFlag == "" { + return "", nil + } + destination := request.Flags[files.WriteFlag] + if request.ChangedFlags[files.WriteFlag] && destination == "" { + return "", fmt.Errorf("--%s output file must not be empty", files.WriteFlag) + } + return destination, nil +} + +func renderPluginOutput(cmd *cobra.Command, files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest, response pluginsdk.ExecuteResponse, execErr error) error { + if len(response.Data) == 0 { + return execErr + } + // Preserve partial-result reporting, but never turn failed execution into + // a successful export or replace the destination with partial data. + if execErr != nil { + return errors.Join(execErr, command.WriteJSON(cmd.OutOrStdout(), response.Data)) + } + if err := cmd.Context().Err(); err != nil { + return err + } + format, err := pluginOutputFormat(files, request) + if err != nil { + return err + } + destination, err := pluginOutputDestination(files, request) + if err != nil { + return err + } + if destination == "" || destination == "-" { + return writePluginOutput(cmd.OutOrStdout(), format, response.Data) + } + var output bytes.Buffer + if err := writePluginOutput(&output, format, response.Data); err != nil { + return err + } + return writePluginFile(cmd.Context(), destination, output.Bytes()) +} + +func writePluginOutput(writer io.Writer, format string, data json.RawMessage) error { + switch format { + case "", "json": + return command.WriteJSON(writer, data) + case "yaml": + if !json.Valid(data) { + return fmt.Errorf("plugin output must be valid JSON") + } + output, err := yaml.JSONToYAML(data) + if err != nil { + return err + } + _, err = writer.Write(output) + return err + case "ndjson": + return writePluginNDJSON(writer, data) + default: + return fmt.Errorf("unsupported output format %q", format) + } +} + +func writePluginNDJSON(writer io.Writer, data json.RawMessage) error { + if !json.Valid(data) { + return fmt.Errorf("plugin output must be valid JSON") + } + data, err := pluginNDJSONRecords(data) + if err != nil { + return err + } + var records []json.RawMessage + if err := json.Unmarshal(data, &records); err != nil || bytes.Equal(data, []byte("null")) { + return fmt.Errorf("NDJSON output requires an array or a data/logs array envelope") + } + var output bytes.Buffer + for _, record := range records { + if err := json.Compact(&output, record); err != nil { + return err + } + if err := output.WriteByte('\n'); err != nil { + return err + } + } + _, err = writer.Write(output.Bytes()) + return err +} + +func pluginNDJSONRecords(data json.RawMessage) (json.RawMessage, error) { + data = bytes.TrimSpace(data) + if len(data) == 0 || data[0] != '{' { + return data, nil + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err + } + for _, name := range []string{"logs", "data"} { + candidate := bytes.TrimSpace(envelope[name]) + if len(candidate) > 0 && candidate[0] == '[' { + return candidate, nil + } + } + return data, nil +} + +func writePluginFile(ctx context.Context, destination string, data []byte) (err error) { + if err := ctx.Err(); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(destination), ".fctl-export-*") + if err != nil { + return fmt.Errorf("create plugin export: %w", err) + } + closed := false + defer func() { + if !closed { + err = errors.Join(err, file.Close()) + } + if removeErr := os.Remove(file.Name()); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, removeErr) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Close(); err != nil { + closed = true + return err + } + closed = true + if err := ctx.Err(); err != nil { + return err + } + return os.Rename(file.Name(), destination) +} diff --git a/internal/plugin/files_io_test.go b/internal/plugin/files_io_test.go new file mode 100644 index 00000000..d83208fc --- /dev/null +++ b/internal/plugin/files_io_test.go @@ -0,0 +1,254 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type pluginInputCase struct{ name, format, input, want string } + +func TestPluginInputFormats(t *testing.T) { + t.Parallel() + for _, tc := range []pluginInputCase{ + {"numscript", "string", "send [USD 10] (\n source = @world\n destination = @alice\n)\n", ""}, + {"ndjson", "string", "{\"amount\":90071992547409931234567890}\n{\"amount\":1e+19}\n", ""}, + {"json", "json", `{"amount":90071992547409931234567890}`, `{"amount":90071992547409931234567890}`}, + {"default json", "", `[1,2]`, `[1,2]`}, + {"yaml", "yaml", "name: schema\namount: 9007199254740993\n", `{"amount":9007199254740993,"name":"schema"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, source := range []string{"file", "stdin"} { + testPluginInputFormat(t, tc, source) + } + }) + } +} + +func pluginInputCommand(t *testing.T, input, source string) (*cobra.Command, string) { + t.Helper() + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + cmd.SetIn(strings.NewReader(input)) + if source == "stdin" { + return cmd, "-" + } + path := filepath.Join(t.TempDir(), "input") + if err := os.WriteFile(path, []byte(input), 0o600); err != nil { + t.Fatal(err) + } + return cmd, path +} + +func testPluginInputFormat(t *testing.T, tc pluginInputCase, source string) { + t.Helper() + cmd, path := pluginInputCommand(t, tc.input, source) + request := pluginsdk.ExecuteRequest{Args: []string{"ledger", path}} + got, err := ReadPluginInput(cmd, &pluginsdk.FileSpec{ReadArgument: new(1), ReadFormat: tc.format}, request) + if err != nil { + t.Fatal(err) + } + if tc.format == "string" { + var decoded string + if err := json.Unmarshal(got.Body, &decoded); err != nil || decoded != tc.input { + t.Fatalf("text changed: %s (%v)", got.Body, err) + } + } else if string(got.Body) != tc.want { + t.Fatalf("body = %s, want %s", got.Body, tc.want) + } + if got.Args[1] != path || request.Body != nil { + t.Fatal("host changed the source request") + } +} + +func TestPluginInputBoundsAndValidation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, format, input string + }{ + {"oversized", "string", strings.Repeat("x", maxPluginInput+1)}, + {"encoded bound", "string", strings.Repeat("\n", maxPluginInput/2+1)}, + {"invalid JSON", "json", "{"}, + {"multiple JSON", "json", "{} {}"}, + {"duplicate YAML", "yaml", "name: one\nname: two\n"}, + {"invalid YAML", "yaml", "name: ["}, + {"unsupported format", "xml", "{}"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + for _, source := range []string{"file", "stdin"} { + cmd, path := pluginInputCommand(t, tc.input, source) + _, err := ReadPluginInput(cmd, &pluginsdk.FileSpec{ReadFlag: "file", ReadFormat: tc.format}, pluginsdk.ExecuteRequest{Flags: map[string]string{"file": path}}) + if err == nil { + t.Fatal("invalid file input accepted") + } + } + }) + } +} + +func TestPluginLocalInputAndExactLimit(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + for _, path := range []string{t.TempDir(), filepath.Join(t.TempDir(), "missing")} { + if _, err := ReadPluginInput(cmd, &pluginsdk.FileSpec{ReadArgument: new(0)}, pluginsdk.ExecuteRequest{Args: []string{path}}); err == nil { + t.Fatal("non-file source accepted") + } + } + // Exactly 4 MiB of valid JSON is accepted without decoding its numbers. + raw := []byte(`{"amount":90071992547409931234567890}`) + raw = append(raw, bytes.Repeat([]byte(" "), maxPluginInput-len(raw))...) + got, err := decodePluginInput("json", raw) + if err != nil || !bytes.Equal(got, raw) { + t.Fatalf("input at the limit changed: %v", err) + } +} + +func TestPluginStdinCancellationKeepsInputOpen(t *testing.T) { + t.Parallel() + input, output, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := input.Close(); err != nil { + t.Error(err) + } + if err := output.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + finished := make(chan error, 1) + started := make(chan struct{}) + go func() { + close(started) + _, err := readPluginStdin(ctx, input) + finished <- err + }() + <-started + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("stdin read did not stop on cancellation") + } + if _, err := output.Write([]byte("still open")); err != nil { + t.Fatal(err) + } + data := make([]byte, len("still open")) + if _, err := io.ReadFull(input, data); err != nil || string(data) != "still open" { + t.Fatalf("stdin was closed or consumed: %q (%v)", data, err) + } +} + +func TestPluginNDJSONRoundTrip(t *testing.T) { + t.Parallel() + array := `[{"amount":90071992547409931234567890,"ratio":1.234567890123456789e+30}, {"amount":18446744073709551616}]` + want := "{\"amount\":90071992547409931234567890,\"ratio\":1.234567890123456789e+30}\n{\"amount\":18446744073709551616}\n" + for _, data := range []string{array, `{"logs":` + array + `}`, `{"data":` + array + `}`} { + var output bytes.Buffer + if err := writePluginNDJSON(&output, json.RawMessage(data)); err != nil { + t.Fatal(err) + } + if output.String() != want { + t.Fatalf("NDJSON changed: %s", output.Bytes()) + } + body, err := decodePluginInput("string", output.Bytes()) + var input string + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &input); err != nil || input != want { + t.Fatalf("import changed exported logs: %v", err) + } + } +} + +func TestPluginInvalidNDJSONOutput(t *testing.T) { + t.Parallel() + for _, data := range []string{`null`, `{}`, `{"logs":{}}`, `[`, `1`} { + var output bytes.Buffer + if err := writePluginNDJSON(&output, json.RawMessage(data)); err == nil || output.Len() != 0 { + t.Fatalf("invalid export %q wrote output or succeeded", data) + } + } + var empty bytes.Buffer + if err := writePluginNDJSON(&empty, json.RawMessage(`[]`)); err != nil || empty.Len() != 0 { + t.Fatalf("empty export failed: %v", err) + } +} + +func TestPluginYAMLOutput(t *testing.T) { + t.Parallel() + var output bytes.Buffer + raw := json.RawMessage(`{"name":"schema","amount":9007199254740993,"properties":{"id":{"type":"string"}}}`) + if err := writePluginOutput(&output, "yaml", raw); err != nil { + t.Fatal(err) + } + converted, err := yaml.YAMLToJSON(output.Bytes()) + if err != nil || !bytes.Contains(converted, []byte(`9007199254740993`)) || !bytes.Contains(output.Bytes(), []byte("name: schema")) { + t.Fatalf("YAML output = %s (%v)", output.Bytes(), err) + } +} + +func TestPluginAtomicWrite(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "export.ndjson") + if err := os.WriteFile(path, []byte("old content"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { //nolint:gosec // Seed permissive existing output to verify the atomic replacement uses private permissions. + t.Fatal(err) + } + for range 2 { + if err := writePluginFile(t.Context(), path, []byte("new content\n")); err != nil { + t.Fatal(err) + } + assertPrivatePluginExport(t, path) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := writePluginFile(ctx, path, []byte("canceled")); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled write = %v", err) + } + if err := writePluginFile(t.Context(), dir, []byte("rename fails")); err == nil { + t.Fatal("export replaced a directory") + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 || entries[0].Name() != "export.ndjson" { + t.Fatalf("temporary exports leaked: %v (%v)", entries, err) + } + assertPrivatePluginExport(t, path) +} + +func assertPrivatePluginExport(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) //nolint:gosec // The path is the test-owned temporary export file. + if err != nil || string(data) != "new content\n" { + t.Fatal("failed write replaced previous export") + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("export permission: %v (%v)", info, err) + } +} diff --git a/internal/plugin/files_stdin_test.go b/internal/plugin/files_stdin_test.go new file mode 100644 index 00000000..988e2989 --- /dev/null +++ b/internal/plugin/files_stdin_test.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func regularStdinFile(t *testing.T, contents string) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "stdin-*.num") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Error(err) + } + }) + if _, err := io.WriteString(file, contents); err != nil { + t.Fatal(err) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + return file +} + +func TestPluginStdinRegularFileRedirectEOF(t *testing.T) { + t.Parallel() + for _, contents := range []string{"", "send [USD 10] (source = @world destination = @alice)\n"} { + t.Run(contents, func(t *testing.T) { + t.Parallel() + file := regularStdinFile(t, contents) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.SetIn(file) + request, err := ReadPluginInput(cmd, &pluginsdk.FileSpec{ReadArgument: new(0), ReadFormat: "string"}, pluginsdk.ExecuteRequest{Args: []string{"-"}}) + if err != nil { + t.Fatalf("redirected regular stdin did not finish at EOF: %v", err) + } + var script string + if err := json.Unmarshal(request.Body, &script); err != nil || script != contents { + t.Fatalf("redirected Numscript changed: %q (%v)", script, err) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + t.Fatalf("the host closed redirected stdin: %v", err) + } + }) + } +} + +func TestRegularPluginStdinBoundsAndCancellation(t *testing.T) { + t.Parallel() + file := regularStdinFile(t, "send [USD 10]") + data, err := readPluginStdin(t.Context(), file) + if err != nil || string(data) != "send [USD 10]" { + t.Fatalf("regular input = %q (%v)", data, err) + } + large := regularStdinFile(t, strings.Repeat("x", maxPluginInput+1)) + if _, err := readPluginStdin(t.Context(), large); err == nil { + t.Fatal("regular stdin bypassed the input bound") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := readPluginStdin(ctx, file); !errors.Is(err, context.Canceled) { + t.Fatalf("regular stdin lost cancellation: %v", err) + } + if data, err := readPluginStdin(t.Context(), strings.NewReader("stream")); err != nil || string(data) != "stream" { + t.Fatalf("non-file stream changed: %q (%v)", data, err) + } +} diff --git a/internal/plugin/files_stream.go b/internal/plugin/files_stream.go new file mode 100644 index 00000000..6763f96e --- /dev/null +++ b/internal/plugin/files_stream.go @@ -0,0 +1,277 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/muesli/cancelreader" + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/command" +) + +type pluginStreamInput struct { + io.Reader + close func() error + once sync.Once + err error +} + +func (r *pluginStreamInput) Close() error { + r.once.Do(func() { r.err = r.close() }) + return r.err +} + +func openPluginStreamInput(cmd *cobra.Command, spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest) (*pluginStreamInput, *int64, error) { + if !streamInput(spec) { + return nil, nil, nil + } + source, err := pluginInputSource(spec.Files, request) + if err != nil || source == "" { + return nil, nil, err + } + if request.Body != nil { + return nil, nil, fmt.Errorf("file input cannot be combined with an explicit body") + } + if err := cmd.Context().Err(); err != nil { + return nil, nil, err + } + if source == "-" { + return openPluginStreamStdin(cmd.Context(), cmd.InOrStdin()) + } + if scheme := strings.ToLower(source); strings.HasPrefix(scheme, "https:") || strings.HasPrefix(scheme, "http:") { + client := newPluginURLClient() + input, size, err := openPluginStreamURL(cmd.Context(), source, client) + if err != nil { + client.CloseIdleConnections() + return nil, nil, err + } + closeBody := input.close + input.close = func() error { + err := closeBody() + client.CloseIdleConnections() + return err + } + return input, size, nil + } + return openPluginStreamFile(source) +} + +func openPluginStreamFile(source string) (*pluginStreamInput, *int64, error) { + info, err := os.Stat(source) + if err != nil { + return nil, nil, err + } + if !info.Mode().IsRegular() { + return nil, nil, fmt.Errorf("input must be a regular file") + } + file, err := os.Open(source) //nolint:gosec // The source is an explicit user-selected regular file. + if err != nil { + return nil, nil, err + } + info, err = file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, nil, errors.Join(fmt.Errorf("input must be a regular file"), err, file.Close()) + } + return &pluginStreamInput{Reader: file, close: file.Close}, new(info.Size()), nil +} + +func openPluginStreamStdin(ctx context.Context, input io.Reader) (*pluginStreamInput, *int64, error) { + if file, ok := input.(*os.File); ok { + info, err := file.Stat() + if err != nil { + return nil, nil, err + } + // Regular stdin needs no readiness polling and remains caller-owned. + if info.Mode().IsRegular() { + return &pluginStreamInput{Reader: file, close: func() error { return nil }}, nil, nil + } + } + reader, err := cancelreader.NewReader(input) + if err != nil { + return nil, nil, err + } + stdin := &pluginStreamStdinReader{reader: reader} + var cancelOnce sync.Once + var cancelErr error + cancelInput := func() { + cancelOnce.Do(func() { + if !reader.Cancel() { + if closer, ok := input.(io.Closer); ok { + cancelErr = closer.Close() + } + } + }) + } + canceled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + cancelInput() + close(canceled) + }) + return &pluginStreamInput{Reader: stdin, close: func() error { + if !stop() { + <-canceled + } + cancelInput() + // Cancel first, then wait for a pending read before closing the + // readiness descriptors. stdin itself remains caller-owned. + stdin.mu.Lock() + defer stdin.mu.Unlock() + return errors.Join(cancelErr, reader.Close()) + }}, nil, nil +} + +type pluginStreamStdinReader struct { + reader cancelreader.CancelReader + mu sync.Mutex +} + +func (r *pluginStreamStdinReader) Read(data []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.reader.Read(data) +} + +// Use the same public client and redirect validation as bounded file input. +// A failed source fetch is returned directly; the host never retries it. +func openPluginStreamURL(ctx context.Context, source string, client *http.Client) (*pluginStreamInput, *int64, error) { + u, err := url.Parse(source) + if err != nil { + return nil, nil, fmt.Errorf("invalid HTTPS input URL") + } + if err := validatePluginURL(u); err != nil { + return nil, nil, err + } + if strings.Contains(source, "#") { + return nil, nil, fmt.Errorf("HTTPS input URL must not contain a fragment") + } + u.Scheme = "https" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, nil, fmt.Errorf("create HTTPS input request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + if failure, ok := errors.AsType[*url.Error](err); ok { + err = failure.Err + } + return nil, nil, fmt.Errorf("fetch HTTPS input: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil, errors.Join(fmt.Errorf("HTTPS input returned HTTP status %d", resp.StatusCode), resp.Body.Close()) + } + var size *int64 + if resp.ContentLength >= 0 { + size = new(resp.ContentLength) + } + return &pluginStreamInput{Reader: resp.Body, close: resp.Body.Close}, size, nil +} + +type pluginStreamOutput struct { + writer io.Writer + file *os.File + destination string + closeFile func() error + closeSink func() error + stop func() +} + +func openPluginStreamOutput(cmd *cobra.Command, spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest, failures *pluginStreamErrors) (*pluginStreamOutput, error) { + output := &pluginStreamOutput{} + if !spec.Streams.Output { + return output, nil + } + destination, err := pluginOutputDestination(spec.Files, request) + if err != nil { + return nil, err + } + output.destination = destination + writer := command.RawOutput(cmd) + if destination != "" && destination != "-" { + output.file, err = os.CreateTemp(filepath.Dir(destination), ".fctl-export-*") + if err != nil { + return nil, fmt.Errorf("create plugin export: %w", err) + } + writer = output.file + } else if file, ok := writer.(*os.File); ok { + writer, output.closeSink, err = openPluginStreamFileSink(file) + if err != nil { + return nil, fmt.Errorf("open interruptible output: %w", err) + } + } + output.interruptOnCancel(cmd.Context(), writer, failures) + output.writer = &pluginStreamWriter{ctx: cmd.Context(), writer: writer, failures: failures} + return output, nil +} + +func (o *pluginStreamOutput) interruptOnCancel(ctx context.Context, writer io.Writer, failures *pluginStreamErrors) { + closer, ok := writer.(io.Closer) + if !ok { + return + } + closeWriter := sync.OnceValue(closer.Close) + if o.file != nil { + o.closeFile = closeWriter + } + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + //nolint:errcheck // Record retains this close error for the invocation result. + _ = failures.Record(closeWriter()) + close(done) + }) + o.stop = func() { + if !stop() { + <-done + } + } +} + +func (o *pluginStreamOutput) Commit(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if o.file == nil { + if o.closeSink != nil { + return o.closeSink() + } + return nil + } + if err := o.file.Sync(); err != nil { + return err + } + err := o.closeFile() + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return os.Rename(o.file.Name(), o.destination) +} + +func (o *pluginStreamOutput) Close() (err error) { + if o.stop != nil { + o.stop() + } + if o.file == nil { + if o.closeSink != nil { + return o.closeSink() + } + return nil + } + err = o.closeFile() + if removeErr := os.Remove(o.file.Name()); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, removeErr) + } + return err +} diff --git a/internal/plugin/files_stream_other.go b/internal/plugin/files_stream_other.go new file mode 100644 index 00000000..af94134e --- /dev/null +++ b/internal/plugin/files_stream_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux + +package plugin + +import ( + "io" + "os" +) + +func openPluginStreamFileSink(file *os.File) (io.Writer, func() error, error) { + return file, nil, nil +} diff --git a/internal/plugin/files_stream_test.go b/internal/plugin/files_stream_test.go new file mode 100644 index 00000000..b8a52eb6 --- /dev/null +++ b/internal/plugin/files_stream_test.go @@ -0,0 +1,290 @@ +package plugin + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type pluginStreamZeros struct{} + +func (pluginStreamZeros) Read(data []byte) (int, error) { + clear(data) + return len(data), nil +} + +type pluginStreamTestBody struct { + io.Reader + closed bool +} + +func (b *pluginStreamTestBody) Close() error { + b.closed = true + return nil +} + +func TestPluginStreamURLLargeBodyAndSize(t *testing.T) { + t.Parallel() + for _, known := range []bool{false, true} { + const size int64 = 65 << 20 + body := &pluginStreamTestBody{Reader: io.LimitReader(pluginStreamZeros{}, size)} + client := newPluginURLClient() + calls := 0 + client.Transport = pluginURLTestTransport(func(request *http.Request) (*http.Response, error) { + calls++ + if request.Method != http.MethodGet || request.Header.Get("Authorization") != "" || request.Header.Get("Cookie") != "" { + t.Fatal("source fetch inherited credentials or changed method") + } + length := int64(-1) + if known { + length = size + } + return &http.Response{StatusCode: 200, Body: body, ContentLength: length, Request: request}, nil + }) + input, gotSize, err := openPluginStreamURL(t.Context(), "https://example.test/input", client) + if err != nil || (gotSize != nil) != known || (gotSize != nil && *gotSize != size) { + t.Fatalf("URL size lost: %v (%v)", gotSize, err) + } + n, err := io.Copy(io.Discard, input) + if err := errors.Join(err, input.Close()); err != nil || n != size || !body.closed || calls != 1 { + t.Fatalf("unbounded URL input failed: bytes=%d, calls=%d, closed=%t (%v)", n, calls, body.closed, err) + } + } +} + +func TestPluginStreamURLPolicyAndNoRetries(t *testing.T) { + t.Parallel() + client := newPluginURLClient() + calls := 0 + client.Transport = pluginURLTestTransport(func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("fetch failed") + }) + for _, source := range []string{ + "http://example.test/input", "https:opaque", "https:///input", "https://%zz/input", + "https://private-user:private-pass@example.test/input", "https://example.test/input#private-fragment", "https://example.test/input#", + } { + if _, _, err := openPluginStreamURL(t.Context(), source, client); err == nil || strings.Contains(err.Error(), "private-") { + t.Fatalf("invalid URL accepted or exposed: %v", err) + } + } + if calls != 0 { + t.Fatal("invalid URL reached transport") + } + if _, _, err := openPluginStreamURL(t.Context(), "https://example.test/input?private-secret", client); err == nil || strings.Contains(err.Error(), "private-secret") || calls != 1 { + t.Fatalf("failed URL exposed query or retried: calls=%d (%v)", calls, err) + } +} + +func TestPluginStreamURLRedirectAndCancellation(t *testing.T) { + t.Parallel() + var calls atomic.Int64 + started := make(chan struct{}) + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + if request.URL.Path == "/redirect" { + writer.Header().Set("Location", "http://example.test/private") + writer.WriteHeader(http.StatusFound) + return + } + writer.WriteHeader(http.StatusOK) + if flusher, ok := writer.(http.Flusher); ok { + flusher.Flush() + } + close(started) + <-request.Context().Done() + })) + defer server.Close() + client := pluginURLTestClient(t, server) + if _, _, err := openPluginStreamURL(t.Context(), server.URL+"/redirect", client); err == nil || calls.Load() != 1 { + t.Fatalf("stream followed insecure redirect: calls=%d (%v)", calls.Load(), err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + input, _, err := openPluginStreamURL(ctx, server.URL+"/blocking", client) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := input.Close(); err != nil { + t.Error(err) + } + }() + <-started + done := make(chan error, 1) + go func() { + _, err := io.Copy(io.Discard, &pluginStreamReader{ctx: ctx, reader: input, failures: &pluginStreamErrors{}}) + done <- err + }() + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("HTTP stream lost cancellation: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("HTTP read stayed blocked") + } + if calls.Load() != 2 { + t.Fatal("host retried source HTTP") + } +} + +func TestPluginStreamRegularStdinRemainsOpen(t *testing.T) { + t.Parallel() + file := regularStdinFile(t, "{\"record\":1}\n") + input, size, err := openPluginStreamStdin(t.Context(), file) + if err != nil || size != nil { + t.Fatalf("regular stdin opening failed: %v", err) + } + data, err := io.ReadAll(input) + if err := errors.Join(err, input.Close()); err != nil || string(data) != "{\"record\":1}\n" { + t.Fatalf("regular stdin changed: %q (%v)", data, err) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + t.Fatalf("host closed stdin: %v", err) + } +} + +func TestPluginStreamReaderCloseInterruptsPendingStdin(t *testing.T) { + t.Parallel() + input, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := errors.Join(input.Close(), writer.Close()); err != nil { + t.Error(err) + } + }) + source, _, err := openPluginStreamStdin(t.Context(), input) + if err != nil { + t.Fatal(err) + } + var reader io.ReadCloser = &pluginStreamReader{ctx: t.Context(), reader: source, failures: &pluginStreamErrors{}} + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := io.Copy(io.Discard, reader) + done <- err + }() + <-started + if err := reader.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("closing the interruptible input did not abort its read") + } + case <-time.After(5 * time.Second): + t.Fatal("wrapper Close left a blocked reader") + } + if err := reader.Close(); err != nil { + t.Fatalf("source Close was not idempotent: %v", err) + } + if _, err := input.Stat(); err != nil { + t.Fatalf("interrupt closed the caller's stdin: %v", err) + } +} + +type pluginStreamShortWriter struct{} + +func (pluginStreamShortWriter) Write(data []byte) (int, error) { return len(data) - 1, nil } + +func TestPluginStreamOutputWriteAndCommitErrors(t *testing.T) { + t.Parallel() + var failures pluginStreamErrors + writer := &pluginStreamWriter{ctx: t.Context(), writer: pluginStreamShortWriter{}, failures: &failures} + if _, err := writer.Write([]byte("record")); !errors.Is(err, io.ErrShortWrite) || !errors.Is(failures.Err(), io.ErrShortWrite) { + t.Fatalf("short write was ignored: %v", err) + } + path := filepath.Join(t.TempDir(), "destination") + if err := os.WriteFile(path, []byte("previous\n"), 0o600); err != nil { + t.Fatal(err) + } + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + spec := pluginsdk.CommandSpec{Streams: &pluginsdk.StreamSpec{Output: true}, Files: &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"}} + output, err := openPluginStreamOutput(cmd, spec, pluginsdk.ExecuteRequest{Flags: map[string]string{"file": path}}, &pluginStreamErrors{}) + if err != nil { + t.Fatal(err) + } + // A real file write failure and failed sync must not publish the temp file. + if err := output.file.Close(); err != nil { + t.Fatal(err) + } + if _, err := output.writer.Write([]byte("replacement\n")); err == nil { + t.Fatal("closed export accepted a write") + } + if err := output.Commit(t.Context()); err == nil { + t.Fatal("failed sync published the export") + } + if err := output.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + t.Fatal(err) + } + data, err := os.ReadFile(path) //nolint:gosec // Test-owned destination. + if err != nil || string(data) != "previous\n" { + t.Fatalf("write failure replaced destination: %q (%v)", data, err) + } + temps, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fctl-export-*")) + if err != nil || len(temps) != 0 { + t.Fatal("failed export left a temporary file") + } +} + +type finalProgressFailureWriter struct{ writes int } + +func (w *finalProgressFailureWriter) Write(data []byte) (int, error) { + w.writes++ + if w.writes == 1 { + return len(data), nil + } + return 0, io.ErrClosedPipe +} + +func TestFinalProgressFailurePreservesExport(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "destination") + if err := os.WriteFile(path, []byte("previous\n"), 0o600); err != nil { + t.Fatal(err) + } + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + var failures pluginStreamErrors + spec := pluginsdk.CommandSpec{Streams: &pluginsdk.StreamSpec{Output: true}, Files: &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"}} + output, err := openPluginStreamOutput(cmd, spec, pluginsdk.ExecuteRequest{Flags: map[string]string{"file": path}}, &failures) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(output.writer, "replacement\n"); err != nil { + t.Fatal(err) + } + progress := &pluginProgress{ctx: t.Context(), writer: &finalProgressFailureWriter{}, enabled: true, failures: &failures, now: time.Now} + if err := progress.Report(pluginsdk.ProgressEvent{Phase: "export", Completed: 1, Unit: "records"}); err != nil { + t.Fatal(err) + } + if err := completePluginStream(t.Context(), output, progress, nil); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("final progress failure lost: %v", err) + } + if err := output.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) //nolint:gosec // Test-owned destination. + if err != nil || string(data) != "previous\n" { + t.Fatalf("final progress failure published the export: %q (%v)", data, err) + } +} diff --git a/internal/plugin/files_stream_unix.go b/internal/plugin/files_stream_unix.go new file mode 100644 index 00000000..df5b4619 --- /dev/null +++ b/internal/plugin/files_stream_unix.go @@ -0,0 +1,54 @@ +//go:build darwin || linux + +package plugin + +import ( + "errors" + "io" + "math" + "os" + "runtime" + "sync" + + "golang.org/x/sys/unix" +) + +// Inherited stdout pipes are blocking os.Files and Close alone cannot interrupt +// their Write syscall. An owned nonblocking duplicate uses Go's poller instead. +// The descriptor shares its flags with the original, which we restore on Close. +func openPluginStreamFileSink(original *os.File) (io.Writer, func() error, error) { + info, err := original.Stat() + if err != nil || info.Mode().IsRegular() { + return original, nil, err + } + fd := original.Fd() + if fd > math.MaxInt { + return nil, nil, os.ErrInvalid + } + flags, err := unix.FcntlInt(fd, unix.F_GETFL, 0) + if err != nil { + return nil, nil, err + } + duplicate, err := unix.FcntlInt(fd, unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + return nil, nil, err + } + if err := unix.SetNonblock(duplicate, true); err != nil { + return nil, nil, errors.Join(err, unix.Close(duplicate)) + } + file := os.NewFile(uintptr(duplicate), original.Name()) //nolint:gosec // F_DUPFD_CLOEXEC returns a nonnegative file descriptor. + closeSink := sync.OnceValue(func() error { + err := file.Close() + _, restoreErr := unix.FcntlInt(fd, unix.F_SETFL, flags) + runtime.KeepAlive(original) + return errors.Join(err, restoreErr) + }) + return &pluginStreamFileSink{Writer: file, close: closeSink}, closeSink, nil +} + +type pluginStreamFileSink struct { + io.Writer + close func() error +} + +func (w *pluginStreamFileSink) Close() error { return w.close() } diff --git a/internal/plugin/files_stream_unix_test.go b/internal/plugin/files_stream_unix_test.go new file mode 100644 index 00000000..efe5e0ff --- /dev/null +++ b/internal/plugin/files_stream_unix_test.go @@ -0,0 +1,126 @@ +//go:build darwin || linux + +package plugin + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/spf13/cobra" + "golang.org/x/sys/unix" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/command" +) + +func TestInheritedStdoutPipeCancellation(t *testing.T) { + t.Parallel() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + fd := writer.Fd() // Match an inherited blocking stdout descriptor. + duplicate, err := unix.FcntlInt(fd, unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + original := os.NewFile(uintptr(duplicate), "stdout") //nolint:gosec // F_DUPFD_CLOEXEC returns a nonnegative descriptor. + t.Cleanup(func() { + if err := errors.Join(reader.Close(), writer.Close(), original.Close()); err != nil { + t.Error(err) + } + }) + flags, err := unix.FcntlInt(original.Fd(), unix.F_GETFL, 0) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + cmd := &cobra.Command{} + cmd.SetContext(ctx) + cmd.SetOut(original) + if err := command.ConfigureOutput(cmd, "json", "never"); err != nil { + t.Fatal(err) + } + spec := pluginsdk.CommandSpec{Streams: &pluginsdk.StreamSpec{Output: true}, Files: &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"}} + output, err := openPluginStreamOutput(cmd, spec, pluginsdk.ExecuteRequest{}, &pluginStreamErrors{}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := output.writer.Write(bytes.Repeat([]byte("x"), 1<<20)) + done <- err + }() + // Receiving a prefix proves Write started and exceeded the pipe capacity; + // the remaining payload cannot finish while this reader stops consuming. + if _, err := reader.Read(make([]byte, 1)); err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("inherited pipe cancellation lost: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("inherited pipe Write stayed blocked") + } + if err := output.Close(); err != nil { + t.Fatal(err) + } + restored, err := unix.FcntlInt(original.Fd(), unix.F_GETFL, 0) + // Darwin may add private pipe flags after IO; check the public modes. + const modes = unix.O_ACCMODE | unix.O_NONBLOCK | unix.O_APPEND | unix.O_ASYNC + if err != nil || restored&modes != flags&modes { + t.Fatalf("original stdout closed or flags changed: %d -> %d (%v)", flags, restored, err) + } +} + +func TestFailedStdoutExportClosesOwnedDescriptor(t *testing.T) { + t.Parallel() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := reader.Close(); err != nil { + t.Error(err) + } + }) + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + cmd.SetOut(writer) + spec := pluginsdk.CommandSpec{Streams: &pluginsdk.StreamSpec{Output: true}, Files: &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"}} + output, err := openPluginStreamOutput(cmd, spec, pluginsdk.ExecuteRequest{}, &pluginStreamErrors{}) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(output.writer, "partial\n"); err != nil { + t.Fatal(err) + } + // Failed invocations skip Commit; their deferred Close must release the + // duplicate so stdout can subsequently reach EOF. + if err := output.Close(); err != nil { + t.Fatal(err) + } + if _, err := writer.Stat(); err != nil { + t.Fatalf("host closed caller-owned stdout: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := reader.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(reader) + if err != nil || string(data) != "partial\n" { + t.Fatalf("failed export leaked its descriptor: %q (%v)", data, err) + } +} diff --git a/internal/plugin/files_test.go b/internal/plugin/files_test.go new file mode 100644 index 00000000..3b57680c --- /dev/null +++ b/internal/plugin/files_test.go @@ -0,0 +1,323 @@ +package plugin_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/interactive" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +func fileManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "ledger", Version: "test", Service: "ledger", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{ + Use: "ledger", Subcommands: []pluginsdk.CommandSpec{{ + Use: "invoke [FILE]", Runnable: true, Args: pluginsdk.ArgsSpec{Max: 1}, Files: &pluginsdk.FileSpec{ + ReadArgument: new(0), ReadFlag: "input", ReadFormat: "string", WriteFlag: "file", FormatFlag: "format", + }, Flags: []pluginsdk.FlagSpec{ + {Name: "data", Type: "string", Body: true}, {Name: "input", Type: "string"}, + {Name: "file", Type: "string"}, {Name: "format", Type: "string", Default: "json"}, + {Name: "confirm", Type: "bool", Default: "false"}, {Name: "experimental", Type: "bool", Default: "false"}, + }, + }}, + }} +} + +func fileAdapter(t *testing.T, manifest pluginsdk.Manifest, execute func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error)) *cobra.Command { + t.Helper() + return attach(t, manifest, func(context.Context, string) (*api.Client, error) { + return api.New("https://ledger.example", &http.Client{}) + }, func(*http.Client) pluginsdk.Plugin { return &fakePlugin{execute: execute} }) +} + +func TestFileAdapterInputAndRequiredBody(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"-"}, {"--input=-"}} { + m := fileManifest() + m.Root.Subcommands[0].Flags[0].Required = true + root := fileAdapter(t, m, func(_ context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + var script string + if err := json.Unmarshal(request.Body, &script); err != nil || script != "send [USD 10]" { + t.Fatalf("plugin did not receive Numscript: %s (%v)", request.Body, err) + } + if _, err := pluginsdk.NormalizeRequest(m, request); err != nil { + t.Fatal(err) + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"ok":true}`)}, nil + }) + root.SetIn(strings.NewReader("send [USD 10]")) + root.SetOut(io.Discard) + root.SetArgs(append([]string{"ledger", "invoke"}, args...)) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + } +} + +func TestFileAdapterGatesAndConflictsBeforeIO(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + args []string + edit func(*pluginsdk.CommandSpec) + }{ + {"confirmation", []string{"-"}, func(s *pluginsdk.CommandSpec) { s.Confirm = true }}, + {"SDK gate", []string{"-"}, func(s *pluginsdk.CommandSpec) { s.Flags[5].RequireTrue = true }}, + {"data conflict", []string{"-", "--data=-"}, nil}, + {"empty data conflict", []string{"-", "--data="}, nil}, + {"sources conflict", []string{"-", "--input=-"}, nil}, + {"empty input", []string{"--input="}, nil}, + {"empty output", []string{"--file="}, nil}, + {"invalid format", []string{"-", "--format=xml"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := fileManifest() + if tc.edit != nil { + tc.edit(&m.Root.Subcommands[0]) + } + root := attach(t, m, func(context.Context, string) (*api.Client, error) { + t.Fatal("invalid file command resolved client") + return nil, nil + }, func(*http.Client) pluginsdk.Plugin { + t.Fatal("invalid file command constructed plugin") + return nil + }) + root.SetIn(forbiddenReader{t}) + root.SetOut(io.Discard) + root.SetArgs(append([]string{"ledger", "invoke"}, tc.args...)) + if err := root.ExecuteContext(t.Context()); err == nil { + t.Fatal("invalid command accepted") + } + }) + } +} + +func TestFileAdapterOptionalSourceAndExplicitBody(t *testing.T) { + t.Parallel() + for _, body := range []string{"", `{"amount":90071992547409931234567890}`} { + called := false + root := fileAdapter(t, fileManifest(), func(_ context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + called = true + if string(request.Body) != body { + t.Fatalf("body = %s, want %s", request.Body, body) + } + return pluginsdk.ExecuteResponse{}, nil + }) + root.SetIn(forbiddenReader{t}) + args := []string{"ledger", "invoke"} + if body != "" { + args = append(args, "--data="+body) + } + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil || !called { + t.Fatalf("optional source command failed: %v", err) + } + } +} + +func TestFileAdapterExport(t *testing.T) { + t.Parallel() + for _, format := range []string{"json", "ndjson", "yaml", "yml"} { + t.Run(format, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "export") + root := fileAdapter(t, fileManifest(), func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`[{"amount":9007199254740993}]`)}, nil + }) + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"ledger", "invoke", "--file=" + path, "--format=" + format}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) //nolint:gosec // The path is the test-owned temporary export file. + if err != nil || !bytes.Contains(data, []byte("9007199254740993")) || output.Len() != 0 { + t.Fatalf("export failed: %q, stdout=%q (%v)", data, output.String(), err) + } + }) + } + for _, args := range [][]string{{"--format=ndjson"}, {"--format=ndjson", "--file=-"}} { + root := fileAdapter(t, fileManifest(), func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"logs":[{"amount":90071992547409931234567890}]}`)}, nil + }) + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs(append([]string{"ledger", "invoke"}, args...)) + if err := root.ExecuteContext(t.Context()); err != nil || output.String() != "{\"amount\":90071992547409931234567890}\n" { + t.Fatalf("stdout export = %q (%v)", output.String(), err) + } + } +} + +type pluginExportErrorCase struct { + name, data string + failed bool + cancel bool +} + +func TestFileAdapterNeverExportsErrors(t *testing.T) { + t.Parallel() + for _, tc := range []pluginExportErrorCase{ + {"partial failure", `[{"id":1}]`, true, false}, + {"invalid response", `{`, false, false}, + {"wrong export shape", `{}`, false, false}, + {"canceled execution", `[{"id":1}]`, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + testFileAdapterExportError(t, tc) + }) + } +} + +func testFileAdapterExportError(t *testing.T, tc pluginExportErrorCase) { + t.Helper() + path := filepath.Join(t.TempDir(), "export") + if err := os.WriteFile(path, []byte("previous export"), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + execErr := errors.New("plugin failed") + root := fileAdapter(t, fileManifest(), func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if tc.cancel { + cancel() + } + response := pluginsdk.ExecuteResponse{Data: json.RawMessage(tc.data)} + if tc.failed { + return response, execErr + } + return response, nil + }) + root.SetOut(io.Discard) + root.SetArgs([]string{"ledger", "invoke", "--file=" + path, "--format=ndjson"}) + err := root.ExecuteContext(ctx) + if err == nil || (tc.failed && !errors.Is(err, execErr)) || (tc.cancel && !errors.Is(err, context.Canceled)) { + t.Fatalf("export lost error: %v", err) + } + data, err := os.ReadFile(path) //nolint:gosec // The path is the test-owned temporary export file. + if err != nil || string(data) != "previous export" { + t.Fatalf("failed export replaced destination: %q (%v)", data, err) + } +} + +type fileConfirmRunner struct{ accepted bool } + +func (r fileConfirmRunner) Run(context.Context, *cobra.Command, []interactive.Field) ([]string, error) { + if r.accepted { + return []string{"true"}, nil + } + return []string{"false"}, nil +} + +func TestInteractiveFileConfirmationBeforeIO(t *testing.T) { + // Interactive enablement checks process-level environment settings. + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + for _, accepted := range []bool{false, true} { + testInteractiveFileConfirmation(t, accepted) + } +} + +func testInteractiveFileConfirmation(t *testing.T, accepted bool) { + t.Helper() + m := fileManifest() + m.Root.Subcommands[0].Confirm = true + m.Root.Subcommands[0].Flags[0].Required = true + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Script", Kind: "text", BodyPointer: "/script", Required: true}} + path := filepath.Join(t.TempDir(), "export") + called := false + root := fileAdapter(t, m, func(_ context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + called = true + if string(request.Body) != `"send [USD 10]"` || request.Flags["confirm"] != "true" { + t.Fatalf("incorrect interactive request: %+v", request) + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`[]`)}, nil + }) + root.SetOut(io.Discard) + root.SetIn(forbiddenReader{t}) + if accepted { + root.SetIn(strings.NewReader("send [USD 10]")) + } + root.SetArgs([]string{"ledger", "invoke", "-", "--file=" + path, "--format=ndjson"}) + err := root.ExecuteContext(interactive.WithRunner(t.Context(), fileConfirmRunner{accepted})) + if !accepted { + if !errors.Is(err, interactive.ErrCanceled) || called { + t.Fatalf("declined confirmation = %v, executed=%v", err, called) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("declined confirmation created a file") + } + } else if err != nil || !called { + t.Fatalf("accepted confirmation failed: %v", err) + } +} + +func TestFileMetadataValidationAndClone(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + edit func(*pluginsdk.Manifest) + }{ + {"group", func(m *pluginsdk.Manifest) { m.Root.Files = m.Root.Subcommands[0].Files }}, + {"runnable group", func(m *pluginsdk.Manifest) { + m.Root.Runnable = true + m.Root.Files = &pluginsdk.FileSpec{WriteFormat: "yaml"} + }}, + {"negative index", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.ReadArgument = new(-1) }}, + {"out of bounds", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.ReadArgument = new(1) }}, + {"read format", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.ReadFormat = "xml" }}, + {"write format", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.WriteFormat = "xml" }}, + {"missing read flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.ReadFlag = "missing" }}, + {"non-string flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.WriteFlag = "confirm" }}, + {"body flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.ReadFlag = "data" }}, + {"same file flags", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.WriteFlag = "input" }}, + {"same format flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Files.FormatFlag = "input" }}, + {"bad format default", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags[3].Default = "xml" }}, + {"format without source", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Files.ReadArgument = nil + m.Root.Subcommands[0].Files.ReadFlag = "" + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := fileManifest() + tc.edit(&m) + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, inertFactory); err == nil { + t.Fatal("invalid file metadata accepted") + } + }) + } + m := fileManifest() + // Persistent string flags can be bound by a leaf. Optional source args + // remain optional, and output-only metadata can format stdout. + m.Root.Flags = []pluginsdk.FlagSpec{{Name: "output-file", Type: "string", Persistent: true}} + m.Root.Subcommands[0].Files.WriteFlag = "output-file" + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, inertFactory); err != nil { + t.Fatal(err) + } + *m.Root.Subcommands[0].Files.ReadArgument = 10 + m.Root.Subcommands[0].Files.WriteFormat = "invalid" + listed := registry.List() + listed[0].Root.Subcommands[0].Files.ReadArgument = new(20) + listed[0].Root.Subcommands[0].Files.WriteFlag = "changed" + again := registry.List()[0].Root.Subcommands[0].Files + if *again.ReadArgument != 0 || again.WriteFlag != "output-file" || again.WriteFormat != "" { + t.Fatal("file metadata was not deeply cloned") + } +} diff --git a/internal/plugin/files_url.go b/internal/plugin/files_url.go new file mode 100644 index 00000000..66c685b8 --- /dev/null +++ b/internal/plugin/files_url.go @@ -0,0 +1,94 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + pluginURLTimeout = 30 * time.Second + maxPluginURLRedirects = 5 +) + +// URL sources use a separate public transport with no stack authentication, +// cookie jar, or inherited default-client middleware. +func newPluginURLClient() *http.Client { + return &http.Client{ + Timeout: pluginURLTimeout, + CheckRedirect: checkPluginURLRedirect, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: pluginURLTimeout, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 10 * time.Second, + }, + } +} + +func readPluginURL(ctx context.Context, source string, client *http.Client) (data []byte, err error) { + u, err := url.Parse(source) + if err != nil { + return nil, fmt.Errorf("invalid HTTPS input URL") + } + if err := validatePluginURL(u); err != nil { + return nil, err + } + if strings.Contains(source, "#") { + return nil, fmt.Errorf("HTTPS input URL must not contain a fragment") + } + u.Scheme = "https" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("create HTTPS input request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + // net/http includes the URL in its outer error. Keep the cause for + // cancellation checks without exposing credentials or query values. + if failure, ok := errors.AsType[*url.Error](err); ok { + err = failure.Err + } + return nil, fmt.Errorf("fetch HTTPS input: %w", err) + } + defer func() { err = errors.Join(err, resp.Body.Close()) }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("HTTPS input returned HTTP status %d", resp.StatusCode) + } + return readBoundedPluginInput(ctx, resp.Body) +} + +func validatePluginURL(u *url.URL) error { + if !strings.EqualFold(u.Scheme, "https") || u.Hostname() == "" || u.Opaque != "" { + return fmt.Errorf("input URL must be an absolute HTTPS URL") + } + if u.User != nil { + return fmt.Errorf("HTTPS input URL must not contain credentials") + } + if u.Fragment != "" || u.RawFragment != "" { + return fmt.Errorf("HTTPS input URL must not contain a fragment") + } + return nil +} + +func checkPluginURLRedirect(req *http.Request, via []*http.Request) error { + if err := validatePluginURL(req.URL); err != nil { + return err + } + if req.Response != nil && strings.Contains(req.Response.Header.Get("Location"), "#") { + return fmt.Errorf("HTTPS input URL must not contain a fragment") + } + if len(via) > maxPluginURLRedirects { + return fmt.Errorf("HTTPS input exceeded %d redirects", maxPluginURLRedirects) + } + req.URL.Scheme = "https" + return nil +} diff --git a/internal/plugin/files_url_test.go b/internal/plugin/files_url_test.go new file mode 100644 index 00000000..9012afea --- /dev/null +++ b/internal/plugin/files_url_test.go @@ -0,0 +1,380 @@ +package plugin + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func pluginURLTestClient(t *testing.T, server *httptest.Server) *http.Client { + t.Helper() + client := newPluginURLClient() + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("URL reader did not create a dedicated HTTP transport") + } + trusted, ok := server.Client().Transport.(*http.Transport) + if !ok { + t.Fatal("TLS fixture has no HTTP transport") + } + transport.TLSClientConfig = trusted.TLSClientConfig.Clone() + t.Cleanup(client.CloseIdleConnections) + return client +} + +func TestPluginURLPublicTransport(t *testing.T) { + t.Parallel() + const contents = "chart: {}\namount: 9007199254740993\n" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/schema.yaml" || r.URL.RawQuery != "revision=legacy" { + t.Errorf("unexpected schema request: method=%s, path=%s", r.Method, r.URL.Path) + } + for _, name := range []string{"Authorization", "Proxy-Authorization", "Cookie"} { + if r.Header.Get(name) != "" { + t.Errorf("public source request received %s", name) + } + } + if _, err := io.WriteString(w, contents); err != nil { + t.Error(err) + } + })) + defer server.Close() + client := pluginURLTestClient(t, server) + if client.Transport == http.DefaultTransport || client.Jar != nil || client.Timeout != 30*time.Second { + t.Fatal("URL client inherited shared transport, cookies, or an unbounded timeout") + } + data, err := readPluginURL(t.Context(), server.URL+"/schema.yaml?revision=legacy", client) + if err != nil || string(data) != contents { + t.Fatalf("HTTPS input changed: %q (%v)", data, err) + } +} + +func TestPluginURLRejectsInvalidSourceBeforeTransport(t *testing.T) { + t.Parallel() + for _, source := range []string{ + "http://example.test/schema", "https:opaque", "https:///schema", "https://%zz/schema", + "https://private-user:private-password@example.test/schema", "https://@example.test/schema", + "https://example.test/schema#private-fragment", "https://example.test/schema#", + } { + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + if _, err := readPluginSource(cmd, source); err == nil { + t.Fatal("invalid URL source accepted") + } else if strings.Contains(err.Error(), "private-") { + t.Fatal("URL rejection exposed credentials or a fragment") + } + } +} + +func TestPluginURLStatusesAndResponseClosure(t *testing.T) { + t.Parallel() + for _, status := range []int{200, 201, 204, 206, 301, 404, 500} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + t.Parallel() + body := &pluginURLTestBody{Reader: strings.NewReader(`{"chart":{}}`)} + client := newPluginURLClient() + client.Transport = pluginURLTestTransport(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{}, Body: body, Request: r}, nil + }) + data, err := readPluginURL(t.Context(), "https://example.test/schema", client) + accepted := status >= 200 && status < 300 + if (err == nil) != accepted || !body.closed { + t.Fatalf("status %d: accepted=%t, closed=%t, error=%v", status, err == nil, body.closed, err) + } + if accepted && string(data) != `{"chart":{}}` { + t.Fatal("successful source body changed") + } + }) + } + closeErr := errors.New("response close failed") + body := &pluginURLTestBody{Reader: strings.NewReader(`{}`), closeErr: closeErr} + client := newPluginURLClient() + client.Transport = pluginURLTestTransport(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: body, Request: r}, nil + }) + if _, err := readPluginURL(t.Context(), "https://example.test/schema", client); !errors.Is(err, closeErr) { + t.Fatalf("response close error lost: %v", err) + } +} + +func TestPluginURLBodyLimits(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + size int + chunked bool + }{ + {"exact", maxPluginInput, false}, + {"oversized", maxPluginInput + 1, false}, + {"oversized chunked", maxPluginInput + 1, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + testPluginURLBodyLimit(t, tc.size, tc.chunked) + }) + } +} + +func testPluginURLBodyLimit(t *testing.T, size int, chunked bool) { + t.Helper() + contents := bytes.Repeat([]byte("x"), size) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if chunked { + if err := http.NewResponseController(w).Flush(); err != nil { + t.Error(err) + return + } + } else { + w.Header().Set("Content-Length", strconv.Itoa(size)) + } + if _, err := w.Write(contents); err != nil { + t.Error(err) + } + })) + defer server.Close() + data, err := readPluginURL(t.Context(), server.URL, pluginURLTestClient(t, server)) + if size <= maxPluginInput { + if err != nil || !bytes.Equal(data, contents) { + t.Fatalf("exact-limit body rejected or changed: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), "4 MiB") { + t.Fatalf("oversized response accepted: %v", err) + } +} + +func TestPluginURLRedirectRefusals(t *testing.T) { + t.Parallel() + for _, destination := range []string{ + "http://example.test/schema", "ftp://example.test/schema", + "https://private-user:private-password@example.test/schema?private-query=value", + "https://@example.test/schema", "https://example.test/schema#private-fragment", "/next#", + } { + var hits atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.Header().Set("Location", destination) + w.WriteHeader(http.StatusFound) + })) + client := pluginURLTestClient(t, server) + _, err := readPluginURL(t.Context(), server.URL+"?private-query=value", client) + server.Close() + if err == nil || hits.Load() != 1 { + t.Fatalf("unsafe redirect was followed: hits=%d, error=%v", hits.Load(), err) + } + if strings.Contains(err.Error(), "private-") { + t.Fatal("redirect rejection exposed credentials or URL query values") + } + } +} + +func TestPluginURLHTTPSRedirectAndCookieIsolation(t *testing.T) { + t.Parallel() + const contents = `{"chart":{}}` + destination := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { + t.Error("public redirect carried authentication") + } + if _, err := io.WriteString(w, contents); err != nil { + t.Error(err) + } + })) + defer destination.Close() + origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Set-Cookie", "session=private-cookie; Secure") + w.Header().Set("Location", destination.URL+"/schema") + w.WriteHeader(http.StatusFound) + })) + defer origin.Close() + data, err := readPluginURL(t.Context(), origin.URL, pluginURLTestClient(t, origin)) + if err != nil || string(data) != contents { + t.Fatalf("safe HTTPS redirect failed: %q (%v)", data, err) + } +} + +func TestPluginURLRedirectLimit(t *testing.T) { + t.Parallel() + for _, redirects := range []int{5, 6} { + t.Run(strconv.Itoa(redirects), func(t *testing.T) { + t.Parallel() + testPluginURLRedirectLimit(t, redirects) + }) + } +} + +func testPluginURLRedirectLimit(t *testing.T, redirects int) { + t.Helper() + var hits atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + hop, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/hop/")) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + if hop < redirects { + w.Header().Set("Location", "/hop/"+strconv.Itoa(hop+1)) + w.WriteHeader(http.StatusFound) + return + } + if _, err := io.WriteString(w, `{"chart":{}}`); err != nil { + t.Error(err) + } + })) + defer server.Close() + _, err := readPluginURL(t.Context(), server.URL+"/hop/0", pluginURLTestClient(t, server)) + if (err == nil) != (redirects == 5) || hits.Load() != 6 { + t.Fatalf("redirect count %d: requests=%d, error=%v", redirects, hits.Load(), err) + } +} + +func TestPluginURLCancellationAndTimeout(t *testing.T) { + t.Parallel() + for _, phase := range []string{"headers", "body", "timeout"} { + t.Run(phase, func(t *testing.T) { + t.Parallel() + testPluginURLCancellation(t, phase) + }) + } +} + +func testPluginURLCancellation(t *testing.T, phase string) { + t.Helper() + started := make(chan struct{}) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if phase != "headers" { + w.WriteHeader(http.StatusOK) + if err := http.NewResponseController(w).Flush(); err != nil { + t.Error(err) + return + } + } + close(started) + <-r.Context().Done() + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + client := pluginURLTestClient(t, server) + want := context.Canceled + if phase == "timeout" { + client.Timeout = 200 * time.Millisecond + want = context.DeadlineExceeded + } + finished := make(chan error, 1) + go func() { + _, err := readPluginURL(ctx, server.URL, client) + finished <- err + }() + if phase != "timeout" { + waitPluginURLStarted(t, started) + cancel() + } + waitPluginURLError(t, finished, want) +} + +func waitPluginURLStarted(t *testing.T, started <-chan struct{}) { + t.Helper() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("HTTPS request did not reach the fixture") + } +} + +func waitPluginURLError(t *testing.T, finished <-chan error, want error) { + t.Helper() + select { + case err := <-finished: + if !errors.Is(err, want) { + t.Fatalf("HTTPS error = %v, want %v", err, want) + } + case <-time.After(5 * time.Second): + t.Fatal("HTTPS read did not stop") + } +} + +func TestPluginURLTransportErrorOmitsURL(t *testing.T) { + t.Parallel() + cause := errors.New("transport failed") + client := newPluginURLClient() + client.Transport = pluginURLTestTransport(func(*http.Request) (*http.Response, error) { return nil, cause }) + _, err := readPluginURL(t.Context(), "https://example.test/schema?private-token=value", client) + if !errors.Is(err, cause) || strings.Contains(err.Error(), "private-token") { + t.Fatalf("transport error lost its cause or exposed the URL: %v", err) + } +} + +type pluginURLTestTransport func(*http.Request) (*http.Response, error) + +func (f pluginURLTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type pluginURLTestBody struct { + *strings.Reader + closed bool + closeErr error +} + +func (b *pluginURLTestBody) Close() error { + b.closed = true + return b.closeErr +} + +func TestPluginRedirectedStdinFile(t *testing.T) { + t.Parallel() + const contents = "send [USD/2 1] (\n source = @world\n destination = @users:qa:main\n)\n" + source := filepath.Join(t.TempDir(), "transfer.num") + if err := os.WriteFile(source, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + input, err := os.Open(source) //nolint:gosec // The input is the test-owned regular file used to reproduce redirected stdin. + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := input.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + data, err := readPluginStdin(ctx, input) + if err != nil || string(data) != contents { + t.Fatalf("redirected stdin = %q (%v)", data, err) + } + if _, err := input.Seek(0, io.SeekStart); err != nil { + t.Fatalf("host closed redirected stdin: %v", err) + } +} + +func TestPluginRedirectedStdinFileCanceled(t *testing.T) { + t.Parallel() + input, err := os.CreateTemp(t.TempDir(), "stdin") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := input.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := readPluginStdin(ctx, input); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled redirected stdin = %v", err) + } +} diff --git a/internal/plugin/interaction.go b/internal/plugin/interaction.go new file mode 100644 index 00000000..b3c67f2a --- /dev/null +++ b/internal/plugin/interaction.go @@ -0,0 +1,405 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/interactive" +) + +func canSupplyFlag(inputs []pluginsdk.InputSpec, flag pluginsdk.FlagSpec) bool { + for _, input := range inputs { + if input.Flag == flag.Name || (flag.Body && input.BodyPointer != "") { + return true + } + } + return false +} + +func relaxedManifest(manifest pluginsdk.Manifest, path []string) pluginsdk.Manifest { + manifest = cloneManifest(manifest) + current := &manifest.Root + for _, name := range path[1:] { + for i := range current.Subcommands { + if pluginsdk.CommandName(current.Subcommands[i]) == name { + current = ¤t.Subcommands[i] + break + } + } + } + current.Args.Min = 0 + current.Confirm = false + for i := range current.Flags { + if canSupplyFlag(current.Inputs, current.Flags[i]) { + current.Flags[i].Required = false + } + } + return manifest +} + +func (a *Adapter) runInteractive(cmd *cobra.Command, entry registration, path []string, flags []pluginsdk.FlagSpec, args []string) error { + spec, err := pluginsdk.FindCommand(entry.manifest, path) + if err != nil { + return err + } + request, err := prepareInteractiveRequest(cmd, entry.manifest, spec, path, flags, args) + if err != nil { + return err // Gates and explicit body errors precede auth and discovery. + } + request = seedInputContext(spec.Inputs, request) + instance, choices, err := a.interactiveInstances(cmd, entry, path, &request) + if err != nil { + return err + } + bodyFlag := bodyFlagName(flags) + inputs, err := pluginFileInputs(spec, request, bodyFlag) + if err != nil { + return err + } + request, err = collectInputs(cmd, choices, inputs, request, bodyFlag) + if err != nil { + return err + } + if err := confirmRequest(cmd, spec, path, &request, bodyFlag); err != nil { + return err + } + request, err = finishInteractiveRequest(cmd, entry.manifest, spec.Files, request, flags) + if err != nil { + return err + } + return executePluginCommand(cmd, instance, spec, request) +} + +func prepareInteractiveRequest(cmd *cobra.Command, manifest pluginsdk.Manifest, spec pluginsdk.CommandSpec, path []string, flags []pluginsdk.FlagSpec, args []string) (pluginsdk.ExecuteRequest, error) { + if spec.Confirm { + flag := lookupFlag(cmd, "confirm") + if flag != nil && flag.Changed && flag.Value.String() != "true" { + return pluginsdk.ExecuteRequest{}, fmt.Errorf("%s requires --confirm", strings.Join(path, " ")) + } + } + if spec.Files != nil { + // File reads wait until the interactive confirmation is accepted. + return normalizeRequest(cmd, relaxedManifest(manifest, path), path, flags, args) + } + return prepareRequest(cmd, relaxedManifest(manifest, path), path, flags, args) +} + +func finishInteractiveRequest(cmd *cobra.Command, manifest pluginsdk.Manifest, files *pluginsdk.FileSpec, request pluginsdk.ExecuteRequest, flags []pluginsdk.FlagSpec) (pluginsdk.ExecuteRequest, error) { + validation := manifest + source, err := pluginInputSource(files, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if source != "" { + validation = fileInputManifest(validation) + } + request, err = pluginsdk.NormalizeRequest(validation, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if files != nil { + return readPluginRequest(cmd, manifest, files, request, flags) + } + return request, nil +} + +func (a *Adapter) interactiveInstances(cmd *cobra.Command, entry registration, path []string, request *pluginsdk.ExecuteRequest) (pluginsdk.Plugin, pluginsdk.Plugin, error) { + service, err := pluginsdk.CommandService(entry.manifest, path) + if err != nil { + return nil, nil, err + } + client, err := a.resolveClient(cmd.Context(), service, *request) + if err != nil { + return nil, nil, err + } + if client == nil || client.HTTPClient() == nil { + return nil, nil, fmt.Errorf("plugin resolver returned no HTTP client") + } + request.Endpoint, request.Context = client.Endpoint(), client.Context() + instance, choices := entry.factory(client.HTTPClient()), entry.factory(choiceClient(client.HTTPClient())) + if instance == nil || choices == nil { + return nil, nil, fmt.Errorf("plugin factory returned nil") + } + return instance, choices, nil +} + +func confirmRequest(cmd *cobra.Command, spec pluginsdk.CommandSpec, path []string, request *pluginsdk.ExecuteRequest, bodyFlag string) error { + if !spec.Confirm || request.Flags["confirm"] == "true" { + return nil + } + if request.ChangedFlags["confirm"] { + return fmt.Errorf("%s requires --confirm", strings.Join(path, " ")) + } + accepted, err := interactive.Confirm(cmd.Context(), cmd, "Confirm "+confirmationLabel(path, spec.Inputs, *request, bodyFlag)) + if err != nil { + return err + } + if !accepted { + return interactive.ErrCanceled + } + request.Flags["confirm"], request.ChangedFlags["confirm"] = "true", true + return nil +} + +func confirmationLabel(path []string, inputs []pluginsdk.InputSpec, request pluginsdk.ExecuteRequest, bodyFlag string) string { + parts := append([]string{}, path...) + values := confirmationValues(inputs, request, bodyFlag) + parts = append(parts, values...) + return interactive.SafeLabel(strings.Join(parts, " ")) + "?" +} + +func confirmationValues(inputs []pluginsdk.InputSpec, request pluginsdk.ExecuteRequest, bodyFlag string) []string { + values := append([]string{}, request.Args...) + values = redactArguments(inputs, values) + for _, input := range inputs { + if input.Secret || (bodyFlag != "" && input.Flag == bodyFlag) { + continue + } + value := request.Flags[input.Flag] + if input.Context != "" { + value = request.Context[input.Context] + } + if value != "" && !slices.Contains(values, value) { + values = append(values, value) + } + } + return values +} + +func collectInputs(cmd *cobra.Command, instance pluginsdk.Plugin, inputs []pluginsdk.InputSpec, request pluginsdk.ExecuteRequest, bodyFlag string) (pluginsdk.ExecuteRequest, error) { + request.Flags, request.ChangedFlags, request.Context = maps.Clone(request.Flags), maps.Clone(request.ChangedFlags), maps.Clone(request.Context) + if request.Context == nil { + request.Context = make(map[string]string) + } + request = seedInputContext(inputs, request) + if !needsInputForm(inputs, request, bodyFlag) { + return request, nil + } + for index := 0; index < len(inputs); { + group, next := inputGroup(inputs, index, request, bodyFlag) + index = next + if len(group) == 0 { + continue + } + if err := collectGroup(cmd, instance, group, &request, bodyFlag); err != nil { + return request, err + } + } + return request, nil +} + +func needsInputForm(inputs []pluginsdk.InputSpec, request pluginsdk.ExecuteRequest, bodyFlag string) bool { + required, missing := false, false + for _, input := range inputs { + required = required || input.Required + if !suppliedInput(input, request, bodyFlag) { + if input.Required { + return true + } + missing = true + } + } + return !required && missing +} + +func inputGroup(inputs []pluginsdk.InputSpec, index int, request pluginsdk.ExecuteRequest, bodyFlag string) ([]pluginsdk.InputSpec, int) { + if suppliedInput(inputs[index], request, bodyFlag) { + return nil, index + 1 + } + group := []pluginsdk.InputSpec{inputs[index]} + index++ + // Resolve dependent choice lists after the preceding form is accepted. + for index < len(inputs) && inputs[index].Source == nil { + if !suppliedInput(inputs[index], request, bodyFlag) { + group = append(group, inputs[index]) + } + index++ + } + return group, index +} + +func collectGroup(cmd *cobra.Command, instance pluginsdk.Plugin, inputs []pluginsdk.InputSpec, request *pluginsdk.ExecuteRequest, bodyFlag string) error { + fields := make([]interactive.Field, 0, len(inputs)) + for _, input := range inputs { + field, err := inputField(cmd.Context(), instance, input, *request) + if err != nil { + return err + } + fields = append(fields, field) + } + values, err := interactive.Run(cmd.Context(), cmd, fields) + if err != nil { + return err + } + for i, input := range inputs { + if values[i] == "" && !input.Required { + continue + } + if err := bindInput(input, values[i], request, bodyFlag); err != nil { + return err + } + } + return nil +} + +func suppliedInput(input pluginsdk.InputSpec, request pluginsdk.ExecuteRequest, bodyFlag string) bool { + if input.AlternativeFlag != "" && request.ChangedFlags[input.AlternativeFlag] { + return true + } + if input.AlternativeArgument != nil && len(request.Args) > *input.AlternativeArgument { + return true + } + switch { + case input.Flag != "": + return request.ChangedFlags[input.Flag] || (request.Flags[input.Flag] != "" && input.Source == nil) + case input.Argument != nil: + return len(request.Args) > *input.Argument + case input.Context != "": + return request.Context[input.Context] != "" + case input.BodyPointer != "": + if request.ChangedFlags[bodyFlag] { + return true + } + _, exists := bodyValue(request.Body, input.BodyPointer) + return exists + } + return true +} + +func inputField(ctx context.Context, instance pluginsdk.Plugin, input pluginsdk.InputSpec, request pluginsdk.ExecuteRequest) (interactive.Field, error) { + field := interactive.Field{Title: input.Title, Description: input.Description, Kind: input.Kind, Default: input.Default, Required: input.Required, Secret: input.Secret} + if input.Flag != "" && request.Flags[input.Flag] != "" { + field.Default = request.Flags[input.Flag] + } + for _, option := range input.Options { + field.Options = append(field.Options, interactive.Option{Label: option.Label, Value: option.Value}) + } + if input.Source != nil { + options, err := discoverChoices(ctx, instance, *input.Source, request) + if err != nil { + return field, err + } + field.Options = options + if len(options) == 0 { + message := input.Source.EmptyMessage + if message == "" { + message = "no resources available for " + input.Title + } + return field, errors.New(message) + } + } + field.Validate = func(value string) error { + if value == "" && !input.Required { + return nil + } + _, err := inputJSON(input.ValueType, value) + return err + } + return field, nil +} + +func inputJSON(kind, value string) (json.RawMessage, error) { + if kind == "json" || kind == "number" || kind == "bool" { + if !json.Valid([]byte(value)) { + return nil, fmt.Errorf("enter valid %s", kind) + } + if kind == "number" { + number, err := decodeJSON(json.RawMessage(value)) + if _, ok := number.(json.Number); err != nil || !ok { + return nil, fmt.Errorf("enter a JSON number") + } + } + if kind == "bool" && value != "true" && value != "false" { + return nil, fmt.Errorf("choose true or false") + } + return json.RawMessage(value), nil + } + return json.Marshal(value) +} + +func bindInput(input pluginsdk.InputSpec, value string, request *pluginsdk.ExecuteRequest, bodyFlag string) error { + switch { + case input.Flag != "": + request.Flags[input.Flag], request.ChangedFlags[input.Flag] = value, true + if input.Flag == bodyFlag { + request.Body = json.RawMessage(value) + } + case input.Argument != nil: + if len(request.Args) != *input.Argument { + return fmt.Errorf("interactive arguments must be supplied in order") + } + request.Args = append(request.Args, value) + case input.Context != "": + request.Context[input.Context] = value + case input.BodyPointer != "": + raw, err := inputJSON(input.ValueType, value) + if err != nil { + return err + } + body, err := setBodyValue(request.Body, input.BodyPointer, raw) + if err != nil { + return err + } + request.Body = body + } + return nil +} + +func decodeJSON(raw json.RawMessage) (any, error) { + if len(raw) == 0 { + return map[string]any{}, nil + } + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil +} + +func seedInputContext(inputs []pluginsdk.InputSpec, request pluginsdk.ExecuteRequest) pluginsdk.ExecuteRequest { + request.Context = maps.Clone(request.Context) + if request.Context == nil { + request.Context = make(map[string]string) + } + for _, input := range inputs { + if input.Context != "" && input.AlternativeArgument != nil && len(request.Args) > *input.AlternativeArgument { + request.Context[input.Context] = request.Args[*input.AlternativeArgument] + } + } + return request +} + +func redactArguments(inputs []pluginsdk.InputSpec, values []string) []string { + for _, input := range inputs { + if !input.Secret { + continue + } + for _, index := range []*int{input.Argument, input.AlternativeArgument} { + if index != nil && *index < len(values) { + values[*index] = "[redacted]" + } + } + } + return values +} + +func bodyFlagName(flags []pluginsdk.FlagSpec) string { + for _, flag := range flags { + if flag.Body { + return flag.Name + } + } + return "" +} diff --git a/internal/plugin/interaction_body.go b/internal/plugin/interaction_body.go new file mode 100644 index 00000000..9928d3b8 --- /dev/null +++ b/internal/plugin/interaction_body.go @@ -0,0 +1,108 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +func pointerParts(pointer string) ([]string, error) { + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("body input requires a JSON pointer") + } + parts := strings.Split(pointer[1:], "/") + for i, part := range parts { + parts[i] = strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~") + } + return parts, nil +} + +func bodyValue(body json.RawMessage, pointer string) (any, bool) { + parts, err := pointerParts(pointer) + if err != nil { + return nil, false + } + value, err := decodeJSON(body) + if err != nil { + return nil, false + } + for _, part := range parts { + switch container := value.(type) { + case map[string]any: + var exists bool + value, exists = container[part] + if !exists { + return nil, false + } + case []any: + index, err := strconv.Atoi(part) + if err != nil || index < 0 || index >= len(container) { + return nil, false + } + value = container[index] + default: + return nil, false + } + } + return value, true +} + +func setBodyValue(body json.RawMessage, pointer string, value json.RawMessage) (json.RawMessage, error) { + parts, err := pointerParts(pointer) + if err != nil { + return nil, err + } + root, err := decodeJSON(body) + if err != nil { + return nil, err + } + root, err = setJSONValue(root, parts, value) + if err != nil { + return nil, err + } + return json.Marshal(root) +} + +func setJSONValue(node any, parts []string, value json.RawMessage) (any, error) { + if len(parts) == 0 { + return value, nil + } + key := parts[0] + if node == nil { + if _, err := strconv.Atoi(key); err == nil { + node = []any{} + } else { + node = map[string]any{} + } + } + switch container := node.(type) { + case map[string]any: + child, err := setJSONValue(container[key], parts[1:], value) + if err != nil { + return nil, err + } + container[key] = child + return container, nil + case []any: + return setArrayValue(container, key, parts[1:], value) + default: + return nil, fmt.Errorf("form input conflicts with an existing JSON value") + } +} + +func setArrayValue(container []any, key string, parts []string, value json.RawMessage) (any, error) { + index, err := strconv.Atoi(key) + if err != nil || index < 0 || index > 1000 { + return nil, fmt.Errorf("invalid form array index") + } + for len(container) <= index { + container = append(container, nil) + } + child, err := setJSONValue(container[index], parts, value) + if err != nil { + return nil, err + } + container[index] = child + return container, nil +} diff --git a/internal/plugin/interaction_choices.go b/internal/plugin/interaction_choices.go new file mode 100644 index 00000000..d8548eb7 --- /dev/null +++ b/internal/plugin/interaction_choices.go @@ -0,0 +1,262 @@ +package plugin + +import ( + "context" + "fmt" + "maps" + "slices" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/interactive" +) + +func discoverChoices(ctx context.Context, instance pluginsdk.Plugin, source pluginsdk.ChoiceSource, request pluginsdk.ExecuteRequest) ([]interactive.Option, error) { + query, err := choiceQuery(source, request) + if err != nil { + return nil, err + } + options, err := enumerateChoices(ctx, instance, source, query) + if err != nil { + return nil, err + } + if source.PreferredPrefix != "" { + slices.SortStableFunc(options, func(a, b interactive.Option) int { + ap, bp := strings.HasPrefix(a.Value, source.PreferredPrefix), strings.HasPrefix(b.Value, source.PreferredPrefix) + if ap == bp { + return 0 + } + if ap { + return -1 + } + return 1 + }) + } + return options, nil +} + +func choiceQuery(source pluginsdk.ChoiceSource, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + query := pluginsdk.ExecuteRequest{CommandPath: source.CommandPath, Endpoint: request.Endpoint, Context: maps.Clone(request.Context), Flags: map[string]string{}, ChangedFlags: map[string]bool{}} + for _, arg := range source.Args { + value, err := choiceReference(arg, request) + if err != nil { + return query, err + } + query.Args = append(query.Args, value) + } + for flag, reference := range source.Flags { + value, err := choiceReference(reference, request) + if err != nil { + return query, err + } + query.Flags[flag], query.ChangedFlags[flag] = value, true + } + if source.AfterField != "" { + query.Flags["page-size"], query.ChangedFlags["page-size"] = "100", true + } + return query, nil +} + +func enumerateChoices(ctx context.Context, instance pluginsdk.Plugin, source pluginsdk.ChoiceSource, query pluginsdk.ExecuteRequest) ([]interactive.Option, error) { + var options []interactive.Option + seen, cursors := map[string]bool{}, map[string]bool{} + for range 1000 { + items, more, next, err := readChoicePage(ctx, instance, query, source) + if err != nil { + return nil, err + } + options, err = appendChoices(options, items, source, seen) + if err != nil { + return nil, err + } + if !more { + return options, nil + } + if next == "" || cursors[next] { + return nil, fmt.Errorf("cannot enumerate choices; specify the resource explicitly") + } + cursors[next] = true + flag := "cursor" + if source.AfterField != "" { + flag = "after" + } + query.Flags[flag], query.ChangedFlags[flag] = next, true + } + return nil, fmt.Errorf("choice pagination exceeded its limit; specify the resource explicitly") +} + +func readChoicePage(ctx context.Context, instance pluginsdk.Plugin, query pluginsdk.ExecuteRequest, source pluginsdk.ChoiceSource) ([]any, bool, string, error) { + response, err := instance.Execute(ctx, query) + if err != nil { + return nil, false, "", fmt.Errorf("list choices: %w", err) + } + value, err := decodeJSON(response.Data) + if err != nil { + return nil, false, "", fmt.Errorf("read choices: %w", err) + } + items, more, next, err := choiceItems(value) + if err != nil { + return nil, false, "", err + } + if source.AfterField != "" { + return keysetChoicePage(items, source.AfterField) + } + return items, more, next, nil +} + +func appendChoices(options []interactive.Option, items []any, source pluginsdk.ChoiceSource, seen map[string]bool) ([]interactive.Option, error) { + for _, item := range items { + if excludedChoice(item, source.ExcludeTrueFields) || !matchingChoice(item, source.MatchFields) { + continue + } + option, err := choiceOption(item, source) + if err != nil { + return nil, err + } + if !seen[option.Value] { + options = append(options, option) + seen[option.Value] = true + } + } + if len(options) > 10000 { + return nil, fmt.Errorf("too many choices; specify the resource explicitly") + } + return options, nil +} + +func excludedChoice(item any, fields []string) bool { + object, ok := item.(map[string]any) + if !ok { + return false + } + for _, field := range fields { + if value, ok := choiceField(object, field).(bool); ok && value { + return true + } + } + return false +} + +func matchingChoice(item any, fields map[string][]string) bool { + object, ok := item.(map[string]any) + if !ok { + return len(fields) == 0 + } + for field, values := range fields { + if !slices.Contains(values, choiceText(choiceField(object, field))) { + return false + } + } + return true +} + +func choiceReference(reference string, request pluginsdk.ExecuteRequest) (string, error) { + if !strings.HasPrefix(reference, "$") { + return reference, nil + } + name := reference[1:] + if suffix, ok := strings.CutPrefix(name, "arg"); ok { + index, err := strconv.Atoi(suffix) + if err == nil && index >= 0 && index < len(request.Args) { + return request.Args[index], nil + } + } + if value := request.Flags[name]; value != "" { + return value, nil + } + if value := request.Context[name]; value != "" { + return value, nil + } + return "", fmt.Errorf("select %s before listing dependent choices", name) +} + +func choiceItems(value any) ([]any, bool, string, error) { + if items, ok := value.([]any); ok { + return items, false, "", nil + } + object, ok := value.(map[string]any) + if !ok { + return nil, false, "", fmt.Errorf("resource list is not an object or array") + } + if cursor, ok := object["cursor"].(map[string]any); ok { + object = cursor + } + items, ok := object["data"].([]any) + if !ok { + return nil, false, "", fmt.Errorf("resource list has no data array") + } + more := false + if value, exists := object["hasMore"]; exists { + parsed, ok := value.(bool) + if !ok { + return nil, false, "", fmt.Errorf("resource list has invalid hasMore value") + } + more = parsed + } + return items, more, choiceText(object["next"]), nil +} + +func choiceOption(item any, source pluginsdk.ChoiceSource) (interactive.Option, error) { + object, ok := item.(map[string]any) + if !ok { + return interactive.Option{}, fmt.Errorf("resource choice is not an object") + } + value := choiceText(choiceField(object, source.ValueField)) + if value == "" { + return interactive.Option{}, fmt.Errorf("resource choice lacks %s", source.ValueField) + } + var labels []string + for _, field := range source.LabelFields { + if label := choiceText(choiceField(object, field)); label != "" && label != value { + labels = append(labels, label) + } + } + label := value + if len(labels) > 0 { + label = strings.Join(labels, " · ") + " (" + value + ")" + } + return interactive.Option{Label: label, Value: value}, nil +} + +func choiceField(object map[string]any, field string) any { + if value, exists := object[field]; exists { + return value + } + var value any = object + for part := range strings.SplitSeq(field, ".") { + current, ok := value.(map[string]any) + if !ok { + return nil + } + value = current[part] + } + return value +} + +func choiceText(value any) string { + switch value := value.(type) { + case nil: + return "" + case string: + return value + default: + return fmt.Sprint(value) + } +} + +func keysetChoicePage(items []any, field string) ([]any, bool, string, error) { + if len(items) < 100 { + return items, false, "", nil + } + last, ok := items[len(items)-1].(map[string]any) + if !ok { + return nil, false, "", fmt.Errorf("resource choice is not an object") + } + value := choiceText(choiceField(last, field)) + if value == "" { + return nil, false, "", fmt.Errorf("resource choice lacks pagination field %s", field) + } + return items, true, value, nil +} diff --git a/internal/plugin/interaction_test.go b/internal/plugin/interaction_test.go new file mode 100644 index 00000000..d31bf228 --- /dev/null +++ b/internal/plugin/interaction_test.go @@ -0,0 +1,1067 @@ +package plugin_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/interactive" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +const interactionBody = `{"postings":[{"amount":900719925474099312345678901234567890}],"metadata":{"exact":18446744073709551616}}` + +type interactionAnswer struct { + title, kind, value string + options []interactive.Option +} + +// Match the schema, independent of whether the host groups static fields. +type interactionRunner struct { + t *testing.T + answers []interactionAnswer + next int + err error + fields int +} + +func (r *interactionRunner) Run(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + r.t.Helper() + values := make([]string, len(fields)) + for i, field := range fields { + r.fields++ + if r.next >= len(r.answers) { + r.t.Fatalf("unexpected form field %q (%s)", field.Title, field.Kind) + } + answer := r.answers[r.next] + r.next++ + if field.Title != answer.title || field.Kind != answer.kind { + r.t.Fatalf("field = %q (%s), want %q (%s)", field.Title, field.Kind, answer.title, answer.kind) + } + if answer.options != nil && !reflect.DeepEqual(field.Options, answer.options) { + r.t.Fatalf("choices = %+v, want %+v", field.Options, answer.options) + } + values[i] = answer.value + } + if r.err != nil { + return nil, r.err + } + return values, nil +} + +type interactionHTTPCall struct { + method, path string + query url.Values + body []byte +} + +type interactionFixture struct { + t *testing.T + manifest pluginsdk.Manifest + server *httptest.Server + metadata map[string]string + resolutions int + input, out, stderr bytes.Buffer + mu sync.Mutex + requests []pluginsdk.ExecuteRequest + calls []interactionHTTPCall + list func(*http.Request) string + onResolve func() +} + +type interactionPlugin struct { + fixture *interactionFixture + client *http.Client +} + +func (p *interactionPlugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { + return p.fixture.manifest, nil +} + +func (p *interactionPlugin) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + request, err := pluginsdk.NormalizeRequest(p.fixture.manifest, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + copyRequest := request + copyRequest.Flags = maps.Clone(request.Flags) + copyRequest.ChangedFlags = maps.Clone(request.ChangedFlags) + copyRequest.Context = maps.Clone(request.Context) + copyRequest.Body = slices.Clone(request.Body) + p.fixture.mu.Lock() + p.fixture.requests = append(p.fixture.requests, copyRequest) + p.fixture.mu.Unlock() + client, err := api.New(request.Endpoint, p.client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + method, path := http.MethodGet, "/choices" + switch request.CommandPath[len(request.CommandPath)-1] { + case "create": + method, path = http.MethodPost, "/ledgers" + case "delete": + method, path = http.MethodDelete, api.Path("ledgers", request.Args[0]) + case "post-choices": + method = http.MethodPost + } + query := url.Values{} + for _, name := range []string{"cursor", "after", "page-size", "stack-id", "organization-id", "bucket"} { + if value := request.Flags[name]; value != "" { + query.Set(name, value) + } + } + data, err := client.Do(ctx, method, path, query, request.Body, http.Header{"Authorization": {"Bearer fixture-auth"}}) + return pluginsdk.ExecuteResponse{Data: data}, err +} + +func interactionFixtureManifest() pluginsdk.Manifest { + source := &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "list"}, ValueField: "id", LabelFields: []string{"name"}, Flags: map[string]string{"stack-id": "$stack"}} + return pluginsdk.Manifest{Name: "ledger", Version: "test", Service: "ledger", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{ + Use: "ledger", Subcommands: []pluginsdk.CommandSpec{ + {Use: "create NAME", Runnable: true, Args: pluginsdk.ArgsSpec{Min: 1, Max: 1}, Flags: []pluginsdk.FlagSpec{ + {Name: "bucket", Type: "string", Required: true}, + {Name: "data", Type: "string", Body: true, Required: true}, + }, Inputs: []pluginsdk.InputSpec{ + {Title: "Bucket", Kind: "select", Flag: "bucket", Required: true, Source: source}, + {Title: "Ledger name", Kind: "input", Argument: new(0), Required: true}, + {Title: "Amount", Kind: "input", BodyPointer: "/postings/0/amount", ValueType: "number", Required: true}, + }}, + {Use: "list", Runnable: true, Flags: []pluginsdk.FlagSpec{ + {Name: "cursor", Type: "string"}, {Name: "stack-id", Type: "string"}, {Name: "organization-id", Type: "string"}, + {Name: "after", Type: "string"}, {Name: "page-size", Type: "uint32", Default: "10"}, + }}, + {Use: "delete NAME", Runnable: true, Args: pluginsdk.ArgsSpec{Min: 1, Max: 1}, Confirm: true, Flags: []pluginsdk.FlagSpec{{Name: "confirm", Type: "bool", Default: "false"}}, Inputs: []pluginsdk.InputSpec{ + {Title: "Ledger", Kind: "select", Argument: new(0), Required: true, Source: source}, + }}, + {Use: "post-choices", Runnable: true}, + }, + }} +} + +func newInteractionFixture(t *testing.T) *interactionFixture { + t.Helper() + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + f := &interactionFixture{t: t, manifest: interactionFixtureManifest(), metadata: map[string]string{"stack": "stack-1"}} + f.list = func(*http.Request) string { return `{"data":[{"id":"bucket-main","name":"Main"}],"hasMore":false}` } + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20+1)) + if err != nil { + t.Error(err) + http.Error(w, "read body", http.StatusBadRequest) + return + } + if r.Header.Get("Authorization") != "Bearer fixture-auth" { + t.Error("plugin request lost its authenticated HTTP client") + } + f.mu.Lock() + f.calls = append(f.calls, interactionHTTPCall{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.Query(), body: body}) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + writeInteractionJSON(t, w, f.list(r)) + return + } + writeInteractionJSON(t, w, `{"ok":true}`) + })) + t.Cleanup(f.server.Close) + return f +} + +func writeInteractionJSON(t *testing.T, w io.Writer, data string) { + t.Helper() + if _, err := io.WriteString(w, data); err != nil { + t.Error(err) + } +} + +// An isolated host root with a real registry and adapter; no product imports. +func (f *interactionFixture) execRoot(runner interactive.Runner, args ...string) error { + registry := new(plugin.Registry) + factory := func(client *http.Client) pluginsdk.Plugin { return &interactionPlugin{fixture: f, client: client} } + if err := registry.Register(f.t.Context(), factory(nil), factory); err != nil { + f.t.Fatal(err) + } + registry.Freeze() + root := &cobra.Command{Use: "fctl", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("no-input", false, "disable forms") + root.PersistentFlags().String("color", "never", "terminal color") + root.SetIn(&f.input) + root.SetOut(&f.out) + root.SetErr(&f.stderr) + adapter := plugin.NewCommand(registry, func(_ context.Context, service string) (*api.Client, error) { + f.resolutions++ + if f.onResolve != nil { + f.onResolve() + } + if service != f.manifest.Service { + f.t.Fatalf("resolved unexpected service %q", service) + } + client := f.server.Client() + client.Timeout = time.Second + resolved, err := api.New(f.server.URL, client) + if err != nil { + return nil, err + } + return resolved.WithContext(f.metadata), nil + }) + if err := adapter.AddTo(root); err != nil { + f.t.Fatal(err) + } + ctx := f.t.Context() + if runner != nil { + ctx = interactive.WithRunner(ctx, runner) + } + root.SetContext(ctx) + root.SetArgs(args) + return root.Execute() +} + +func (f *interactionFixture) httpCalls() []interactionHTTPCall { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.calls) +} + +func (f *interactionFixture) assertWrites(want int) { + f.t.Helper() + writes := 0 + for _, call := range f.httpCalls() { + if call.method != http.MethodGet { + writes++ + } + } + if writes != want { + f.t.Fatalf("HTTP writes = %d, want %d", writes, want) + } +} + +func TestInteractionCanceledLedgerCreateDoesNotWrite(t *testing.T) { + f := newInteractionFixture(t) + runner := &interactionRunner{t: t, answers: []interactionAnswer{ + {title: "Bucket", kind: "select", value: "bucket-main"}, + {title: "Ledger name", kind: "input"}, + {title: "Amount", kind: "input"}, + }, err: interactive.ErrCanceled} + err := f.execRoot(runner, "ledger", "create") + if !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("create error = %v, want canceled", err) + } + f.assertWrites(0) + if calls := f.httpCalls(); len(calls) != 1 || calls[0].method != http.MethodGet { + t.Fatalf("cancellation should permit only discovery; got %+v", calls) + } + if f.out.Len() != 0 { + t.Fatal("canceled creation printed a successful result") + } +} + +func TestInteractionBindsAllInputKindsWithoutRoundingNumbers(t *testing.T) { + f := newInteractionFixture(t) + create := &f.manifest.Root.Subcommands[0] + create.Flags[1].Default = `{"postings":[{"asset":"USD/2","existing":9007199254740993123456789}],"metadata":{"keep":18446744073709551616}}` + create.Inputs = append([]pluginsdk.InputSpec{{Title: "Organization", Kind: "select", Context: "organization", Required: true, Options: []pluginsdk.InputOption{{Label: "Example", Value: "org-new"}}}}, create.Inputs...) + create.Inputs[1].Source.Flags["organization-id"] = "$organization" + create.Inputs = append(create.Inputs, pluginsdk.InputSpec{Title: "Metadata id", Kind: "input", BodyPointer: "/metadata/a~1b/~0id", ValueType: "number", Required: true}) + runner := &interactionRunner{t: t, answers: []interactionAnswer{ + {title: "Organization", kind: "select", value: "org-new"}, + {title: "Bucket", kind: "select", value: "bucket-main"}, + {title: "Ledger name", kind: "input", value: "demo"}, + {title: "Amount", kind: "input", value: "900719925474099312345678901234567890"}, + {title: "Metadata id", kind: "input", value: "18446744073709551617"}, + }} + if err := f.execRoot(runner, "ledger", "create"); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 2 || calls[0].query.Get("organization-id") != "org-new" || calls[0].query.Get("stack-id") != "stack-1" { + t.Fatalf("choice source did not receive previously resolved context: %+v", calls) + } + const wantBody = `{"metadata":{"a/b":{"~id":18446744073709551617},"keep":18446744073709551616},"postings":[{"amount":900719925474099312345678901234567890,"asset":"USD/2","existing":9007199254740993123456789}]}` + if string(calls[1].body) != wantBody { + t.Fatal("body pointer binding rounded numbers or lost existing JSON") + } + request := f.requests[len(f.requests)-1] + if !reflect.DeepEqual(request.Args, []string{"demo"}) || request.Flags["bucket"] != "bucket-main" || !request.ChangedFlags["bucket"] || request.Context["organization"] != "org-new" || string(request.Body) != wantBody { + t.Fatal("interactive values did not survive the normalized SDK request") + } + if runner.next != len(runner.answers) || f.out.String() != "{\n \"ok\": true\n}\n" { + t.Fatal("wizard did not collect all fields and print the service response") + } +} + +func TestInteractionExplicitInputsSkipEveryForm(t *testing.T) { + for _, kind := range []string{"inline", "file", "stdin"} { + t.Run(kind, func(t *testing.T) { testInteractionExplicitInputs(t, kind) }) + } +} + +func testInteractionExplicitInputs(t *testing.T, kind string) { + t.Helper() + f := newInteractionFixture(t) + input := f.bodyArgument(kind) + runner := &interactionRunner{t: t} + if err := f.execRoot(runner, "ledger", "create", "demo", "--bucket", "bucket-main", "--data", input); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 1 || calls[0].method != http.MethodPost || string(calls[0].body) != interactionBody || runner.fields != 0 { + t.Fatal("explicit input prompted, discovered choices, or changed raw JSON") + } +} + +func (f *interactionFixture) bodyArgument(kind string) string { + f.t.Helper() + switch kind { + case "file": + path := filepath.Join(f.t.TempDir(), "request.json") + if err := os.WriteFile(path, []byte(interactionBody), 0o600); err != nil { + f.t.Fatal(err) + } + return "@" + path + case "stdin": + if _, err := f.input.WriteString(interactionBody); err != nil { + f.t.Fatal(err) + } + return "-" + default: + return interactionBody + } +} + +func TestInteractionDisabledMissingInputsFailBeforeAuthentication(t *testing.T) { + for _, mode := range []string{"no-input", "CI", "FCTL_NO_INPUT", "nonTTY"} { + for _, tc := range []struct { + name string + args []string + }{ + {"argument", []string{"ledger", "create", "--bucket", "bucket-main", "--data", interactionBody}}, + {"flag", []string{"ledger", "create", "demo", "--data", interactionBody}}, + {"body", []string{"ledger", "create", "demo", "--bucket", "bucket-main"}}, + {"confirmation", []string{"ledger", "delete", "demo"}}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + testInteractionMissingInput(t, mode, tc.args) + }) + } + } +} + +func testInteractionMissingInput(t *testing.T, mode string, args []string) { + t.Helper() + f := newInteractionFixture(t) + runner := &interactionRunner{t: t} + injected, prefix := disabledInteractionRunner(t, mode, runner) + if err := f.execRoot(injected, append(prefix, args...)...); err == nil { + t.Fatal("missing input was accepted with forms disabled") + } + if f.resolutions != 0 || runner.fields != 0 || len(f.httpCalls()) != 0 || len(f.requests) != 0 { + t.Fatal("missing input invoked authentication, discovery, a form, or the plugin") + } +} + +func disabledInteractionRunner(t *testing.T, mode string, runner interactive.Runner) (interactive.Runner, []string) { + t.Helper() + switch mode { + case "no-input": + return runner, []string{"--no-input"} + case "CI": + t.Setenv("CI", "true") + case "FCTL_NO_INPUT": + t.Setenv("FCTL_NO_INPUT", "true") + case "nonTTY": + return nil, nil + } + return runner, nil +} + +func TestInteractionChoicesPaginateAndDeduplicate(t *testing.T) { + f := newInteractionFixture(t) + f.list = func(r *http.Request) string { + switch r.URL.Query().Get("cursor") { + case "": + return `{"cursor":{"data":[{"id":"first","name":"First"}],"hasMore":true,"next":"page-2"}}` + case "page-2": + return `{"cursor":{"data":[{"id":"first","name":"Duplicate"},{"id":9007199254740993123456789,"name":"Large\nledger"}],"hasMore":true,"next":"page-3"}}` + case "page-3": + return `{"cursor":{"data":[{"id":"first"}],"hasMore":false}}` + default: + t.Error("unexpected pagination cursor") + return `{"data":[]}` + } + } + runner := &interactionRunner{t: t, answers: []interactionAnswer{{title: "Bucket", kind: "select", value: "9007199254740993123456789", options: []interactive.Option{ + {Label: "First (first)", Value: "first"}, + {Label: `Large\u000aledger (9007199254740993123456789)`, Value: "9007199254740993123456789"}, + }}}} + if err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 4 { + t.Fatalf("HTTP calls = %d, want three pages and one create", len(calls)) + } + for i, cursor := range []string{"", "page-2", "page-3"} { + if calls[i].method != http.MethodGet || calls[i].query.Get("cursor") != cursor || calls[i].query.Get("stack-id") != "stack-1" { + t.Fatalf("discovery page %d lost cursor or host context", i) + } + } + if calls[3].query.Get("bucket") != "9007199254740993123456789" { + t.Fatal("selected numeric identifier lost precision before creation") + } +} + +func TestInteractionInvalidChoiceDiscoveryCannotWrite(t *testing.T) { + for _, tc := range []struct { + name, response, errorText string + getCalls int + }{ + {"repeated cursor", `{"data":[{"id":"first"}],"hasMore":true,"next":"same"}`, "cannot enumerate choices", 2}, + {"missing next cursor", `{"data":[{"id":"first"}],"hasMore":true}`, "cannot enumerate choices", 1}, + {"missing array", `{"cursor":{"hasMore":false}}`, "no data array", 1}, + {"missing identifier", `{"data":[{"name":"nameless"}]}`, "lacks id", 1}, + {"invalid resource", `{"data":["wrong"]}`, "not an object", 1}, + {"empty list", `{"data":[]}`, "no resources available", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newInteractionFixture(t) + f.list = func(*http.Request) string { return tc.response } + runner := &interactionRunner{t: t} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if err == nil || !strings.Contains(err.Error(), tc.errorText) { + t.Fatalf("discovery error = %v, want %q", err, tc.errorText) + } + f.assertWrites(0) + if len(f.httpCalls()) != tc.getCalls || runner.fields != 0 { + t.Fatal("failed discovery did not stop before forms and writes") + } + }) + } +} + +func TestInteractionReadOnlyDiscoveryRefusesPOST(t *testing.T) { + f := newInteractionFixture(t) + input := &f.manifest.Root.Subcommands[0].Inputs[0] + input.Source = &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "post-choices"}, ValueField: "id"} + runner := &interactionRunner{t: t} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if err == nil || !strings.Contains(err.Error(), "refused a mutating request") { + t.Fatalf("discovery error = %v, want read-only rejection", err) + } + f.assertWrites(0) + if len(f.httpCalls()) != 0 || runner.fields != 0 { + t.Fatal("mutating choice discovery reached HTTP or the form") + } +} + +func TestInteractionConfirmationControlsDELETE(t *testing.T) { + for _, accepted := range []bool{false, true} { + t.Run(fmt.Sprint(accepted), func(t *testing.T) { testInteractionDeletion(t, accepted) }) + } +} + +func testInteractionDeletion(t *testing.T, accepted bool) { + t.Helper() + f := newInteractionFixture(t) + runner := &interactionRunner{t: t, answers: []interactionAnswer{ + {title: "Ledger", kind: "select", value: "bucket-main"}, + {title: "Confirm ledger delete bucket-main?", kind: "confirm", value: fmt.Sprint(accepted)}, + }} + err := f.execRoot(runner, "ledger", "delete") + if !accepted { + if !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("declined delete error = %v, want canceled", err) + } + f.assertWrites(0) + if f.out.Len() != 0 { + t.Fatal("declined deletion printed success") + } + return + } + if err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 2 || calls[1].method != http.MethodDelete || calls[1].path != "/ledgers/bucket-main" { + t.Fatal("accepted confirmation did not delete the selected resource exactly once") + } + request := f.requests[len(f.requests)-1] + if request.Flags["confirm"] != "true" || !request.ChangedFlags["confirm"] { + t.Fatal("accepted confirmation was not enforced by the SDK") + } +} + +func TestInteractionNumberInputsRejectNonNumbers(t *testing.T) { + for _, value := range []string{`"9007199254740993"`, "true", "null", `{"amount":1}`, "[1]", "fixture-secret-not-a-number"} { + t.Run(value, func(t *testing.T) { + f := newInteractionFixture(t) + runner := &interactionRunner{t: t, answers: []interactionAnswer{{title: "Amount", kind: "input", value: value}}} + err := f.execRoot(runner, "ledger", "create", "demo", "--bucket", "bucket-main") + if err == nil { + t.Fatal("non-number was accepted as a JSON number") + } + f.assertWrites(0) + if strings.Contains(err.Error()+f.out.String()+f.stderr.String(), value) { + t.Fatal("validation printed a secret input") + } + }) + } +} + +func TestInteractionAlternativeFlagSkipsPathOnlyWhenExplicit(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(fmt.Sprint(explicit), func(t *testing.T) { testInteractionAlternativeFlag(t, explicit) }) + } +} + +func testInteractionAlternativeFlag(t *testing.T, explicit bool) { + t.Helper() + f := newInteractionFixture(t) + create := &f.manifest.Root.Subcommands[0] + create.Args.Min = 0 + create.Inputs[1].AlternativeFlag = "data" + args := []string{"ledger", "create", "--bucket", "bucket-main"} + runner := &interactionRunner{t: t} + if explicit { + args = append(args, "--data", interactionBody) + } else { + create.Flags[1].Default = interactionBody + runner.answers = []interactionAnswer{{title: "Ledger name", kind: "input", value: "demo"}} + } + if err := f.execRoot(runner, args...); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + if calls := f.httpCalls(); len(calls) != 1 || string(calls[0].body) != interactionBody { + t.Fatal("alternative flag changed the explicit JSON or triggered discovery") + } + request := f.requests[0] + if explicit && (len(request.Args) != 0 || runner.fields != 0) { + t.Fatal("explicit alternative flag did not bypass the path form") + } + if !explicit && (!reflect.DeepEqual(request.Args, []string{"demo"}) || runner.fields != 1) { + t.Fatal("an unchanged body default incorrectly bypassed the path form") + } +} + +func TestInteractionPreferredPrefixSuggestsWithoutSelecting(t *testing.T) { + f := newInteractionFixture(t) + f.manifest.Root.Subcommands[0].Inputs[0].Source.PreferredPrefix = "v4." + f.list = func(*http.Request) string { + return `{"data":[{"id":"v3.2.0"},{"id":"v4.0.2"},{"id":"v4.0.1"},{"id":"v3.1.0"}]}` + } + runner := &interactionRunner{t: t, answers: []interactionAnswer{{title: "Bucket", kind: "select", value: "v3.2.0", options: []interactive.Option{ + {Label: "v4.0.2", Value: "v4.0.2"}, {Label: "v4.0.1", Value: "v4.0.1"}, + {Label: "v3.2.0", Value: "v3.2.0"}, {Label: "v3.1.0", Value: "v3.1.0"}, + }}}} + if err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 2 || runner.fields != 1 || calls[1].query.Get("bucket") != "v3.2.0" { + t.Fatal("catalog suggestion replaced the user's explicit selection") + } +} + +func TestInteractionBodyFileClosesBeforeAuthAndOnValidationFailure(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("file descriptor inspection requires Linux /proc/self/fd; macOS /dev/fd does not expose matching file inodes") + } + for _, valid := range []bool{false, true} { + t.Run(fmt.Sprint(valid), func(t *testing.T) { testInteractionBodyFile(t, valid) }) + } +} + +func testInteractionBodyFile(t *testing.T, valid bool) { + t.Helper() + f := newInteractionFixture(t) + file, err := os.CreateTemp(t.TempDir(), "body-*.json") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeInteractionFile(t, file) }) + path := file.Name() + body := "{ invalid JSON" + if valid { + body = interactionBody + } + if _, err := file.WriteString(body); err != nil { + t.Fatal(err) + } + if !interactionFileOpen(t, path) { + t.Fatal("descriptor inspection cannot detect an open body file") + } + closeInteractionFile(t, file) + f.onResolve = func() { assertInteractionFileClosed(t, path) } + err = f.execRoot(&interactionRunner{t: t}, "ledger", "create", "demo", "--bucket", "bucket-main", "--data", "@"+path) + assertInteractionFileClosed(t, path) + if valid { + if err != nil { + t.Fatal(err) + } + f.assertWrites(1) + return + } + if err == nil || f.resolutions != 0 { + t.Fatal("invalid body reached authentication") + } + f.assertWrites(0) +} + +func assertInteractionFileClosed(t *testing.T, path string) { + t.Helper() + if interactionFileOpen(t, path) { + t.Fatal("request body file is still open") + } +} + +func interactionFileOpen(t *testing.T, path string) bool { + t.Helper() + want, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + for _, name := range interactionDescriptorNames(t) { + info, err := os.Stat(filepath.Join("/proc/self/fd", name)) + if err == nil && os.SameFile(info, want) { + return true + } + } + return false +} + +func TestInteractionExplicitFalseConfirmationFailsBeforeAuth(t *testing.T) { + f := newInteractionFixture(t) + runner := &interactionRunner{t: t} + err := f.execRoot(runner, "ledger", "delete", "demo", "--confirm=false") + if err == nil || !strings.Contains(err.Error(), "requires --confirm") { + t.Fatalf("explicit false confirmation error = %v", err) + } + f.assertWrites(0) + if f.resolutions != 0 || runner.fields != 0 || len(f.httpCalls()) != 0 || len(f.requests) != 0 { + t.Fatal("explicitly declined deletion reached authentication, discovery, a form, or the plugin") + } +} + +func interactionDescriptorNames(t *testing.T) []string { + t.Helper() + dir, err := os.Open("/proc/self/fd") + if err != nil { + t.Fatal(err) + } + names, readErr := dir.Readdirnames(-1) + if err := errors.Join(readErr, dir.Close()); err != nil { + t.Fatal(err) + } + return names +} + +func closeInteractionFile(t *testing.T, file *os.File) { + t.Helper() + if err := file.Close(); err != nil && !errors.Is(err, os.ErrClosed) { + t.Fatal(err) + } +} + +func TestInteractionSecretStringNeverAppearsInOutput(t *testing.T) { + f := newInteractionFixture(t) + create := &f.manifest.Root.Subcommands[0] + create.Flags = append(create.Flags, pluginsdk.FlagSpec{Name: "credential", Type: "string", Required: true}) + create.Inputs = append(create.Inputs, pluginsdk.InputSpec{Title: "Credential", Kind: "input", Flag: "credential", Required: true, Secret: true}) + const secret = "fixture-secret-do-not-print" + runner := &interactionRunner{t: t, answers: []interactionAnswer{{title: "Credential", kind: "input", value: secret}}} + if err := f.execRoot(runner, "ledger", "create", "demo", "--bucket", "bucket-main", "--data", interactionBody); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + if f.requests[0].Flags["credential"] != secret || !f.requests[0].ChangedFlags["credential"] { + t.Fatal("secret value did not reach the plugin") + } + if strings.Contains(f.out.String()+f.stderr.String(), secret) { + t.Fatal("command printed a secret string") + } +} + +func TestInteractionAfterFieldLoadsAllRowsWithExactIDs(t *testing.T) { + f := newInteractionFixture(t) + source := f.manifest.Root.Subcommands[0].Inputs[0].Source + source.AfterField = "id" + source.Flags["page-size"] = "2" + firstPage, options := interactionKeysetPage(t) + const lastID = "9007199254740993099" + const selectedID = "18446744073709551617" + options = append(options, interactive.Option{Label: selectedID, Value: selectedID}) + f.list = func(r *http.Request) string { + if r.URL.Query().Get("after") == lastID { + return `{"data":[{"id":18446744073709551617}]}` + } + return firstPage + } + runner := &interactionRunner{t: t, answers: []interactionAnswer{{title: "Bucket", kind: "select", value: selectedID, options: options}}} + if err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody); err != nil { + t.Fatal(err) + } + f.assertWrites(1) + calls := f.httpCalls() + if len(calls) != 3 || calls[0].query.Get("after") != "" || calls[1].query.Get("after") != lastID || calls[2].query.Get("bucket") != selectedID { + t.Fatal("keyset pagination lost its exact after value or the final selection") + } + for _, call := range calls[:2] { + if call.method != http.MethodGet || call.query.Get("page-size") != "100" || call.query.Has("cursor") { + t.Fatal("keyset discovery did not use GET, page-size 100, and --after") + } + } +} + +func TestInteractionAfterFieldStopsOnRepeatedPage(t *testing.T) { + f := newInteractionFixture(t) + f.manifest.Root.Subcommands[0].Inputs[0].Source.AfterField = "id" + page, _ := interactionKeysetPage(t) + f.list = func(*http.Request) string { return page } + runner := &interactionRunner{t: t} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if err == nil || !strings.Contains(err.Error(), "cannot enumerate choices") { + t.Fatalf("repeated keyset page error = %v", err) + } + f.assertWrites(0) + if len(f.httpCalls()) != 2 || runner.fields != 0 { + t.Fatal("repeated keyset page continued enumerating or invoked the form") + } +} + +func interactionKeysetPage(t *testing.T) (string, []interactive.Option) { + t.Helper() + rows := make([]map[string]json.Number, 100) + options := make([]interactive.Option, 100) + for i := range 100 { + id := fmt.Sprintf("9007199254740993%03d", i) + rows[i] = map[string]json.Number{"id": json.Number(id)} + options[i] = interactive.Option{Label: id, Value: id} + } + page, err := json.Marshal(map[string]any{"data": rows}) + if err != nil { + t.Fatal(err) + } + return string(page), options +} + +type confirmationProbeRunner struct { + t *testing.T + hidden []string + titles []string +} + +func (r *confirmationProbeRunner) Run(_ context.Context, cmd *cobra.Command, fields []interactive.Field) ([]string, error) { + r.t.Helper() + values := make([]string, len(fields)) + for i, field := range fields { + if field.Kind != "confirm" || field.Default != "false" { + r.t.Fatal("fully supplied command did not reach a confirmation with a safe default") + } + assertConfirmationHidden(r.t, field.Title+field.Description, r.hidden) + r.titles = append(r.titles, field.Title) + // Exercise the host's rendered confirmation text and stderr boundary. + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), field.Title); err != nil { + return nil, err + } + values[i] = "false" + } + return values, nil +} + +func TestInteractionConfirmationHidesExplicitPositionalSecrets(t *testing.T) { + for _, tc := range []struct { + name string + secretIndex int + alternative bool + }{ + {name: "first argument", secretIndex: 0}, + {name: "second argument", secretIndex: 1}, + {name: "alternative argument", secretIndex: 1, alternative: true}, + } { + t.Run(tc.name, func(t *testing.T) { + testInteractionPositionalSecret(t, tc.secretIndex, tc.alternative) + }) + } +} + +func testInteractionPositionalSecret(t *testing.T, secretIndex int, alternative bool) { + t.Helper() + f := newInteractionFixture(t) + const secret = "fixture-plaintext-positional-secret" + args := []string{"app-main", "app-main"} + args[secretIndex] = secret + secretInput := pluginsdk.InputSpec{Title: "Credential", Kind: "input", Argument: new(secretIndex), Required: true, Secret: true} + flags := []pluginsdk.FlagSpec{{Name: "confirm", Type: "bool", Default: "false"}} + if alternative { + secretInput.Argument = nil + secretInput.Flag, secretInput.AlternativeArgument = "credential", new(secretIndex) + flags = append(flags, pluginsdk.FlagSpec{Name: "credential", Type: "string"}) + } + f.manifest.Root.Subcommands = []pluginsdk.CommandSpec{{ + Use: "delete APP CREDENTIAL", Runnable: true, Confirm: true, Args: pluginsdk.ArgsSpec{Min: 2, Max: 2}, Flags: flags, + Inputs: []pluginsdk.InputSpec{ + {Title: "Application", Kind: "input", Argument: new(1 - secretIndex), Required: true}, + secretInput, + }, + }} + runner := &confirmationProbeRunner{t: t, hidden: []string{secret}} + err := f.execRoot(runner, append([]string{"ledger", "delete"}, args...)...) + assertCanceledConfirmation(f, runner, err, []string{"app-main"}) +} + +func TestInteractionVariableDeleteConfirmationNamesAppAndVariable(t *testing.T) { + for _, positional := range []bool{false, true} { + t.Run(fmt.Sprint(positional), func(t *testing.T) { + testInteractionVariableConfirmation(t, positional) + }) + } +} + +func testInteractionVariableConfirmation(t *testing.T, positional bool) { + t.Helper() + f := newInteractionFixture(t) + const body = `{"metadata":{"reason":"private-body-note"}}` + const secret = "fixture-private-flag-secret" + variable := pluginsdk.InputSpec{Title: "Variable", Kind: "input", Flag: "key", Required: true} + args := []string{"auth", "apps", "variables", "delete", "--app-id", "app-main", "--data", body, "--credential", secret} + if positional { + variable.AlternativeArgument = new(0) + args = append(args, "DATABASE_URL") + } else { + args = append(args, "--key", "DATABASE_URL") + } + deletion := pluginsdk.CommandSpec{ + Use: "delete [KEY]", Runnable: true, Confirm: true, Args: pluginsdk.ArgsSpec{Max: 1}, + Flags: []pluginsdk.FlagSpec{ + {Name: "confirm", Type: "bool", Default: "false"}, + {Name: "app-id", Type: "string", Required: true}, {Name: "key", Type: "string"}, + {Name: "data", Type: "string", Body: true}, {Name: "credential", Type: "string"}, + }, + Inputs: []pluginsdk.InputSpec{ + {Title: "Application", Kind: "input", Flag: "app-id", Required: true}, + variable, + {Title: "Organization", Kind: "input", Context: "organization"}, + {Title: "Stack", Kind: "input", Context: "stack"}, + {Title: "Request JSON", Kind: "text", Flag: "data", ValueType: "json"}, + {Title: "Reason", Kind: "input", BodyPointer: "/metadata/reason"}, + {Title: "Credential", Kind: "input", Flag: "credential", Secret: true}, + }, + } + f.manifest.Name, f.manifest.Service, f.manifest.Root.Use = "auth", "auth", "auth" + f.manifest.Root.Subcommands = []pluginsdk.CommandSpec{{Use: "apps", Subcommands: []pluginsdk.CommandSpec{ + {Use: "variables", Subcommands: []pluginsdk.CommandSpec{deletion}}, + }}} + f.metadata["organization"] = "app-main" // The same value must appear only once. + runner := &confirmationProbeRunner{t: t, hidden: []string{secret, body, "private-body-note"}} + err := f.execRoot(runner, args...) + assertCanceledConfirmation(f, runner, err, []string{"app-main", "DATABASE_URL", "stack-1"}) +} + +func assertCanceledConfirmation(f *interactionFixture, runner *confirmationProbeRunner, err error, resources []string) { + f.t.Helper() + if !errors.Is(err, interactive.ErrCanceled) { + f.t.Fatal("declining the confirmation did not cancel the command") + } + f.assertWrites(0) + if len(runner.titles) != 1 || len(f.httpCalls()) != 0 || len(f.requests) != 0 { + f.t.Fatal("confirmation prompted more than once or reached HTTP execution") + } + if f.out.Len() != 0 || !strings.Contains(f.stderr.String(), runner.titles[0]) { + f.t.Fatal("confirmation did not use stderr or printed a command result") + } + for _, resource := range resources { + if strings.Count(runner.titles[0], resource) != 1 { + f.t.Fatalf("confirmation must identify resource %q exactly once", resource) + } + } + assertConfirmationHidden(f.t, f.out.String()+f.stderr.String()+strings.Join(runner.titles, "\n")+err.Error(), runner.hidden) +} + +func assertConfirmationHidden(t *testing.T, text string, hidden []string) { + t.Helper() + for _, value := range hidden { + if strings.Contains(text, value) { + t.Fatal("confirmation exposed a secret or request body value") + } + } +} + +func TestInteractionChoiceExclusionsRejectOnlyBooleanTrue(t *testing.T) { + f := newInteractionFixture(t) + f.manifest.Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields = []string{"deprecated"} + f.list = func(*http.Request) string { + return `{"data":[{"id":"v4.0.0","deprecated":true},{"id":"v4.0.0-beta.1","deprecated":false},{"id":"v4.1.0"},{"id":"v4.string","deprecated":"true"},{"id":"v4.number","deprecated":1},{"id":"v4.null","deprecated":null},{"deprecated":true}]}` + } + runner := &interactionRunner{t: t, err: interactive.ErrCanceled, answers: []interactionAnswer{{ + title: "Bucket", kind: "select", value: "v4.0.0-beta.1", options: []interactive.Option{ + {Label: "v4.0.0-beta.1", Value: "v4.0.0-beta.1"}, + {Label: "v4.1.0", Value: "v4.1.0"}, + {Label: "v4.string", Value: "v4.string"}, + {Label: "v4.number", Value: "v4.number"}, + {Label: "v4.null", Value: "v4.null"}, + }, + }}} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("filtered choice form error = %v, want canceled", err) + } + f.assertWrites(0) + if len(f.httpCalls()) != 1 || runner.fields != 1 { + t.Fatal("filtered choices did not reach exactly one selector after discovery") + } +} + +func TestInteractionExcludedFullPageStillUsesRawAfterValue(t *testing.T) { + f := newInteractionFixture(t) + source := f.manifest.Root.Subcommands[0].Inputs[0].Source + source.ExcludeTrueFields, source.AfterField = []string{"deprecated"}, "id" + const lastID = "9007199254740993099" + firstPage := interactionExcludedKeysetPage(t, lastID) + f.list = func(r *http.Request) string { + if r.URL.Query().Get("after") == lastID { + return `{"data":[{"id":"v4.1.0"}]}` + } + return firstPage + } + runner := &interactionRunner{t: t, err: interactive.ErrCanceled, answers: []interactionAnswer{{ + title: "Bucket", kind: "select", value: "v4.1.0", options: []interactive.Option{{Label: "v4.1.0", Value: "v4.1.0"}}, + }}} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("choice form after excluded page error = %v, want canceled", err) + } + f.assertWrites(0) + calls := f.httpCalls() + if len(calls) != 2 || runner.fields != 1 || calls[0].query.Get("after") != "" || calls[1].query.Get("after") != lastID { + t.Fatal("filtering a full page stopped discovery or changed its raw after value") + } + for _, call := range calls { + if call.method != http.MethodGet || call.query.Get("page-size") != "100" || call.query.Has("cursor") { + t.Fatal("filtered keyset discovery lost its read-only pagination settings") + } + } +} + +func interactionExcludedKeysetPage(t *testing.T, lastID string) string { + t.Helper() + rows := make([]map[string]any, 100) + for i := range 100 { + rows[i] = map[string]any{"id": fmt.Sprintf("v4.0.%d", i), "deprecated": true} + } + rows[99]["id"] = json.Number(lastID) + page, err := json.Marshal(map[string]any{"data": rows}) + if err != nil { + t.Fatal(err) + } + return string(page) +} + +func TestInteractionRegistryClonesChoiceExclusionFields(t *testing.T) { + manifest := interactionFixtureManifest() + manifest.Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields = []string{"deprecated", "hidden"} + registry := new(plugin.Registry) + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory); err != nil { + t.Fatal(err) + } + manifest.Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields[0] = "caller-mutated" + first := registry.List() + want := []string{"deprecated", "hidden"} + if !slices.Equal(first[0].Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields, want) { + t.Fatal("registered choice exclusions alias the metadata provider") + } + first[0].Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields[1] = "reader-mutated" + registry.Freeze() + if !slices.Equal(registry.List()[0].Root.Subcommands[0].Inputs[0].Source.ExcludeTrueFields, want) { + t.Fatal("reading registry metadata exposed mutable choice exclusions") + } +} + +func TestInteractionMatchFieldsKeepsCursorAdvancingPastIneligiblePage(t *testing.T) { + for _, tc := range []struct { + name string + states []string + options []interactive.Option + }{ + {name: "active only", states: []string{"ACTIVE"}, options: []interactive.Option{{Label: "eligible", Value: "eligible"}}}, + {name: "active or disabled", states: []string{"ACTIVE", "DISABLED"}, options: []interactive.Option{{Label: "disabled", Value: "disabled"}, {Label: "eligible", Value: "eligible"}}}, + } { + t.Run(tc.name, func(t *testing.T) { testInteractionMatchFields(t, tc.states, tc.options) }) + } +} + +func testInteractionMatchFields(t *testing.T, states []string, options []interactive.Option) { + t.Helper() + f := newInteractionFixture(t) + f.manifest.Root.Subcommands[0].Inputs[0].Source.MatchFields = map[string][]string{"state": states, "status": {"READY"}} + f.list = func(r *http.Request) string { + if r.URL.Query().Get("cursor") == "eligible-page" { + return `{"data":[{"id":"eligible","state":"ACTIVE","status":"READY"},{"id":"unready","state":"ACTIVE","status":"DISABLED"}],"hasMore":false}` + } + return `{"data":[{"id":"deleted","state":"DELETED","status":"READY"},{"id":"disabled","state":"DISABLED","status":"READY"}],"hasMore":true,"next":"eligible-page"}` + } + runner := &interactionRunner{t: t, err: interactive.ErrCanceled, answers: []interactionAnswer{{ + title: "Bucket", kind: "select", value: "eligible", options: options, + }}} + err := f.execRoot(runner, "ledger", "create", "demo", "--data", interactionBody) + if !errors.Is(err, interactive.ErrCanceled) { + t.Fatalf("eligible choice form error = %v, want canceled", err) + } + f.assertWrites(0) + calls := f.httpCalls() + if len(calls) != 2 || runner.fields != 1 || calls[0].query.Get("cursor") != "" || calls[1].query.Get("cursor") != "eligible-page" { + t.Fatal("an ineligible page stopped discovery or lost its next cursor") + } + for _, call := range calls { + if call.method != http.MethodGet || call.query.Get("stack-id") != "stack-1" { + t.Fatal("eligibility filtering changed discovery authority or host context") + } + } +} + +func TestInteractionConfirmationExcludesRenamedPayloadBodyFlag(t *testing.T) { + f := newInteractionFixture(t) + const secret = "fixture-secret-in-payload-json" + const body = `{"token":"fixture-secret-in-payload-json"}` + f.manifest.Root.Subcommands = []pluginsdk.CommandSpec{{ + Use: "delete NAME", Runnable: true, Confirm: true, Args: pluginsdk.ArgsSpec{Min: 1, Max: 1}, + Flags: []pluginsdk.FlagSpec{ + {Name: "confirm", Type: "bool", Default: "false"}, + {Name: "payload", Type: "string", Body: true}, + }, + Inputs: []pluginsdk.InputSpec{ + {Title: "Ledger", Kind: "input", Argument: new(0), Required: true}, + {Title: "Request JSON", Kind: "text", Flag: "payload", ValueType: "json"}, + {Title: "Token", Kind: "input", BodyPointer: "/token"}, + }, + }} + runner := &confirmationProbeRunner{t: t, hidden: []string{secret, body}} + err := f.execRoot(runner, "ledger", "delete", "demo", "--payload", body) + assertCanceledConfirmation(f, runner, err, []string{"demo"}) +} diff --git a/internal/plugin/interaction_transport.go b/internal/plugin/interaction_transport.go new file mode 100644 index 00000000..825efb13 --- /dev/null +++ b/internal/plugin/interaction_transport.go @@ -0,0 +1,27 @@ +package plugin + +import ( + "fmt" + "net/http" +) + +// Choice discovery has no authority to mutate a service resource, even if a +// malformed manifest points a selector at the wrong command. +type readOnlyTransport struct{ base http.RoundTripper } + +func (r readOnlyTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodGet { + return nil, fmt.Errorf("choice discovery refused a mutating request") + } + base := r.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(request) +} + +func choiceClient(client *http.Client) *http.Client { + copy := *client + copy.Transport = readOnlyTransport{base: client.Transport} + return © +} diff --git a/internal/plugin/progress.go b/internal/plugin/progress.go new file mode 100644 index 00000000..3fc7fa14 --- /dev/null +++ b/internal/plugin/progress.go @@ -0,0 +1,155 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const pluginProgressInterval = 200 * time.Millisecond + +type pluginProgress struct { + ctx context.Context + writer io.Writer + enabled bool + failures *pluginStreamErrors + mu sync.Mutex + last time.Time + now func() time.Time + latest *pluginsdk.ProgressEvent + width int + columns int + frame int + finished bool +} + +func newPluginProgress(cmd *cobra.Command, failures *pluginStreamErrors) *pluginProgress { + columns := 80 + if file, ok := cmd.ErrOrStderr().(*os.File); ok { + if width, _, err := term.GetSize(file.Fd()); err == nil && width > 1 { + columns = width + } + } + return &pluginProgress{ctx: cmd.Context(), writer: cmd.ErrOrStderr(), enabled: pluginProgressEnabled(cmd), failures: failures, now: time.Now, columns: columns} +} + +func pluginProgressEnabled(cmd *cobra.Command) bool { + file, ok := cmd.ErrOrStderr().(*os.File) + return pluginProgressAllowed(cmd, ok && term.IsTerminal(file.Fd())) +} + +func pluginProgressAllowed(cmd *cobra.Command, terminal bool) bool { + if flag := lookupFlag(cmd, "no-input"); flag != nil && flag.Value.String() == "true" { + return false + } + if pluginProgressTruthy(os.Getenv("CI")) || pluginProgressTruthy(os.Getenv("FCTL_NO_INPUT")) { + return false + } + if flag := lookupFlag(cmd, "output"); flag != nil && flag.Changed && flag.Value.String() == "json" { + return false + } + return terminal +} + +func pluginProgressTruthy(value string) bool { + return value != "" && value != "0" && !strings.EqualFold(value, "false") +} + +func (p *pluginProgress) Report(event pluginsdk.ProgressEvent) error { + // Validation is unconditional, including suppressed and throttled events. + if err := event.Validate(); err != nil { + return p.failures.Record(err) + } + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ctx.Err(); err != nil { + return p.failures.Record(err) + } + if p.finished { + return p.failures.Record(fmt.Errorf("plugin progress already finished")) + } + if event.Total != nil { + event.Total = new(*event.Total) + } + p.latest = &event + if !p.enabled { + return nil + } + now := p.now() + if !p.last.IsZero() && now.Sub(p.last) < pluginProgressInterval { + return nil + } + p.last = now + line := pluginProgressLine(event, p.frame, false) + p.frame++ + return p.writeLine(line) +} + +// Finish bypasses throttling once, then terminates the stderr line before the +// command renders a result. Only a successful invocation can display 100%. +func (p *pluginProgress) Finish(execErr error) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.finished { + return nil + } + p.finished = true + if !p.enabled || p.latest == nil { + return p.ctx.Err() + } + failed := execErr != nil || p.ctx.Err() != nil || p.failures.Err() != nil + line := pluginProgressLine(*p.latest, max(0, p.frame-1), !failed) + if failed { + line += " (failed)" + } + lineErr := p.writeLine(line) + n, newlineErr := io.WriteString(p.writer, "\n") + if newlineErr == nil && n != 1 { + newlineErr = io.ErrShortWrite + } + return errors.Join(lineErr, p.failures.Record(newlineErr), p.ctx.Err()) +} + +func (p *pluginProgress) writeLine(line string) error { + if p.columns > 1 { + line = ansi.Truncate(line, p.columns-1, "...") + } + width := ansi.StringWidth(line) + output := "\r" + line + strings.Repeat(" ", max(0, p.width-width)) + n, err := io.WriteString(p.writer, output) + if err == nil && n != len(output) { + err = io.ErrShortWrite + } + p.width = width + return p.failures.Record(err) +} + +func pluginProgressLine(event pluginsdk.ProgressEvent, frame int, finished bool) string { + if event.Total == nil { + return fmt.Sprintf("%s %c %d %s", event.Phase, "|/-\\"[frame%4], event.Completed, event.Unit) + } + percent := 0 + if *event.Total > 0 { + percent = int(float64(event.Completed) / float64(*event.Total) * 100) + } else if finished { + percent = 100 + } + if !finished || event.Completed != *event.Total { + percent = min(percent, 99) + } + const barWidth = 20 + filled := percent * barWidth / 100 + bar := strings.Repeat("=", filled) + strings.Repeat("-", barWidth-filled) + return fmt.Sprintf("%s [%s] %3d%% %d/%d %s", event.Phase, bar, percent, event.Completed, *event.Total, event.Unit) +} diff --git a/internal/plugin/progress_test.go b/internal/plugin/progress_test.go new file mode 100644 index 00000000..3e5a2d3d --- /dev/null +++ b/internal/plugin/progress_test.go @@ -0,0 +1,228 @@ +package plugin + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestPluginProgressTerminalOptOut(t *testing.T) { + for _, tc := range []struct { + name, ci, noInput, output string + terminal, disabled, want bool + }{ + {name: "terminal", terminal: true, want: true}, + {name: "nonterminal"}, + {name: "CI", terminal: true, ci: "true"}, + {name: "numeric CI", terminal: true, ci: "1"}, + {name: "false CI", terminal: true, ci: "false", want: true}, + {name: "no-input", terminal: true, disabled: true}, + {name: "FCTL_NO_INPUT", terminal: true, noInput: "1"}, + {name: "explicit JSON", terminal: true, output: "json"}, + {name: "explicit table", terminal: true, output: "table", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CI", tc.ci) + t.Setenv("FCTL_NO_INPUT", tc.noInput) + root := &cobra.Command{} + root.PersistentFlags().StringP("output", "o", "json", "output") + root.PersistentFlags().Bool("no-input", tc.disabled, "no input") + if tc.output != "" { + if err := root.PersistentFlags().Set("output", tc.output); err != nil { + t.Fatal(err) + } + } + cmd := &cobra.Command{} + root.AddCommand(cmd) + if got := pluginProgressAllowed(cmd, tc.terminal); got != tc.want { + t.Fatalf("progress enabled=%t, want %t", got, tc.want) + } + }) + } +} + +func TestPluginProgressValidatesHiddenAndThrottledEvents(t *testing.T) { + t.Parallel() + for _, enabled := range []bool{false, true} { + var failures pluginStreamErrors + p := &pluginProgress{ctx: t.Context(), writer: io.Discard, enabled: enabled, failures: &failures, now: time.Now} + if err := p.Report(pluginsdk.ProgressEvent{Phase: "import", Unit: "records"}); err != nil { + t.Fatal(err) + } + for _, event := range []pluginsdk.ProgressEvent{ + {Phase: "bad\x1b[2J", Unit: "records"}, + {Phase: "import", Unit: "bad\n"}, + {Phase: "import", Unit: "records", Completed: -1}, + {Phase: "import", Unit: "records", Total: new(int64(-1))}, + {Phase: "import", Unit: "records", Completed: 2, Total: new(int64(1))}, + } { + if err := p.Report(event); err == nil || failures.Err() == nil { + t.Fatal("suppression bypassed progress validation") + } + } + } +} + +func TestPluginProgressThrottlesAndUsesOnlyStderr(t *testing.T) { + t.Parallel() + var stderr bytes.Buffer + var failures pluginStreamErrors + now := time.Unix(100, 0) + p := &pluginProgress{ctx: t.Context(), writer: &stderr, enabled: true, failures: &failures, now: func() time.Time { return now }} + for i := range 10000 { + // Phase changes cannot bypass the throttle. + event := pluginsdk.ProgressEvent{Phase: strings.Repeat("a", 1+i%3), Completed: int64(i), Total: new(int64(10000)), Unit: "records"} + if err := p.Report(event); err != nil { + t.Fatal(err) + } + } + if !strings.HasPrefix(stderr.String(), "\ra [--------------------]") || !strings.HasSuffix(stderr.String(), "0% 0/10000 records") || strings.Count(stderr.String(), "\r") != 1 || strings.ContainsAny(stderr.String(), "\n\x1b") { + t.Fatalf("progress was not bounded or contained terminal controls: %q", stderr.String()) + } + now = now.Add(pluginProgressInterval) + if err := p.Report(pluginsdk.ProgressEvent{Phase: "import", Completed: 10000, Unit: "records"}); err != nil { + t.Fatal(err) + } + if strings.Count(stderr.String(), "\r") != 2 || !strings.Contains(stderr.String(), "\rimport / 10000 records") || strings.ContainsAny(stderr.String(), "\n\x1b") { + t.Fatal("throttled progress did not resume") + } +} + +func TestPluginProgressFinishFlushesLatestAndNewline(t *testing.T) { + t.Parallel() + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "failure"}[failed], func(t *testing.T) { + t.Parallel() + testPluginProgressFinish(t, failed) + }) + } +} + +func testPluginProgressFinish(t *testing.T, failed bool) { + t.Helper() + var stderr bytes.Buffer + var failures pluginStreamErrors + p := &pluginProgress{ctx: t.Context(), writer: &stderr, enabled: true, failures: &failures, now: func() time.Time { return time.Unix(100, 0) }} + event := pluginsdk.ProgressEvent{Phase: "import", Completed: 1, Total: new(int64(100)), Unit: "records"} + if err := p.Report(event); err != nil { + t.Fatal(err) + } + event.Completed = 100 + if err := p.Report(event); err != nil { + t.Fatal(err) + } + if strings.Contains(stderr.String(), "100%") || strings.Count(stderr.String(), "\r") != 1 { + t.Fatal("throttled reports claimed completion before execution ended") + } + *event.Total = 1000 // Finishing must use the accepted snapshot, not a retained plugin pointer. + var execErr error + if failed { + execErr = errors.New("plugin failed") + } + if err := p.Finish(execErr); err != nil { + t.Fatal(err) + } + want := "100% 100/100 records\n" + if failed { + want = "99% 100/100 records (failed)\n" + } + if !strings.HasSuffix(stderr.String(), want) || strings.Count(stderr.String(), "\n") != 1 || strings.Count(stderr.String(), "\r") != 2 { + t.Fatalf("Finish lost pending progress or newline: %q", stderr.String()) + } + before := stderr.String() + if err := p.Finish(execErr); err != nil || stderr.String() != before { + t.Fatal("Finish was not idempotent") + } +} + +func TestPluginProgressPadsShorterLinesAndFlushesUnknownCounter(t *testing.T) { + t.Parallel() + var stderr bytes.Buffer + var failures pluginStreamErrors + now := time.Unix(100, 0) + p := &pluginProgress{ctx: t.Context(), writer: &stderr, enabled: true, failures: &failures, now: func() time.Time { return now }} + if err := p.Report(pluginsdk.ProgressEvent{Phase: "long import phase", Completed: 10, Unit: "records"}); err != nil { + t.Fatal(err) + } + firstWidth := p.width + now = now.Add(pluginProgressInterval) + if err := p.Report(pluginsdk.ProgressEvent{Phase: "apply", Completed: 3, Unit: "rows"}); err != nil { + t.Fatal(err) + } + want := "\rapply / 3 rows" + strings.Repeat(" ", firstWidth-len("apply / 3 rows")) + if !strings.HasSuffix(stderr.String(), want) { + t.Fatalf("short progress left old text: %q", stderr.String()) + } + if err := p.Report(pluginsdk.ProgressEvent{Phase: "apply", Completed: 4, Unit: "rows"}); err != nil { + t.Fatal(err) + } + if err := p.Finish(nil); err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(stderr.String(), "\rapply / 4 rows\n") || strings.Contains(stderr.String(), "%") { + t.Fatalf("indeterminate finish invented a total or lost the last counter: %q", stderr.String()) + } +} + +func TestPluginProgressFinishCanceledDoesNotClaimSuccess(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var stderr bytes.Buffer + var failures pluginStreamErrors + p := &pluginProgress{ctx: ctx, writer: &stderr, enabled: true, failures: &failures, now: time.Now} + if err := p.Report(pluginsdk.ProgressEvent{Phase: "apply", Completed: 1, Total: new(int64(1)), Unit: "records"}); err != nil { + t.Fatal(err) + } + cancel() + if err := p.Finish(nil); !errors.Is(err, context.Canceled) || strings.Contains(stderr.String(), "100%") || !strings.HasSuffix(stderr.String(), "(failed)\n") { + t.Fatalf("canceled finish claimed success or lost cancellation: %q (%v)", stderr.String(), err) + } +} + +func TestPluginProgressBoundsTerminalLineAndHugeTotals(t *testing.T) { + t.Parallel() + var stderr bytes.Buffer + var failures pluginStreamErrors + p := &pluginProgress{ctx: t.Context(), writer: &stderr, enabled: true, failures: &failures, now: time.Now, columns: 40} + const total int64 = 1<<63 - 1 + event := pluginsdk.ProgressEvent{Phase: strings.Repeat("x", 128), Completed: total - 1, Total: new(total), Unit: "bytes"} + if err := p.Report(event); err != nil { + t.Fatal(err) + } + if strings.Count(stderr.String(), "\r") != 1 || len(stderr.String()) > 40 { + t.Fatal("long label wrapped the terminal line") + } + if line := pluginProgressLine(event, 0, true); strings.Contains(line, "100%") { + t.Fatalf("rounded float ratio invented completion: %q", line) + } +} + +type progressFailingWriter struct{} + +func (progressFailingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestPluginProgressCancellationAndWriterErrors(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var failures pluginStreamErrors + p := &pluginProgress{ctx: ctx, writer: progressFailingWriter{}, enabled: true, failures: &failures, now: time.Now} + event := pluginsdk.ProgressEvent{Phase: "import", Unit: "records"} + if err := p.Report(event); !errors.Is(err, io.ErrClosedPipe) || !errors.Is(failures.Err(), io.ErrClosedPipe) { + t.Fatalf("progress write error lost: %v", err) + } + cancel() + p.enabled = false + if err := p.Report(event); !errors.Is(err, context.Canceled) { + t.Fatalf("hidden progress lost cancellation: %v", err) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 00000000..562754c4 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,152 @@ +// Package plugin adapts serializable plugin manifests to the host CLI. +package plugin + +import ( + "context" + "fmt" + "maps" + "net/http" + "slices" + "sync" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +// Factory injects the host's authenticated HTTP client into a plugin instance. +type Factory func(*http.Client) pluginsdk.Plugin + +type registration struct { + manifest pluginsdk.Manifest + factory Factory +} + +// Registry accepts embedded plugins during startup and freezes before attachment. +// Its zero value is ready for registration. It never executes service requests. +type Registry struct { + mu sync.Mutex + frozen bool + entries []registration +} + +// Register reads and validates metadata without resolving an authenticated client. +func (r *Registry) Register(ctx context.Context, metadata pluginsdk.Plugin, factory Factory) error { + if metadata == nil || factory == nil { + return fmt.Errorf("plugin metadata and factory are required") + } + manifest, err := metadata.GetManifest(ctx) + if err != nil { + return fmt.Errorf("get plugin manifest: %w", err) + } + if err := validateManifest(manifest); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + if r.frozen { + return fmt.Errorf("plugin registry is frozen") + } + for _, entry := range r.entries { + if err := validateRootNames(manifest.Root, entry.manifest.Root); err != nil { + return err + } + } + r.entries = append(r.entries, registration{cloneManifest(manifest), factory}) + return nil +} + +func validateRootNames(root, existing pluginsdk.CommandSpec) error { + known := commandNames(existing) + for _, name := range commandNames(root) { + if slices.Contains(known, name) { + return fmt.Errorf("duplicate plugin root or alias %q", name) + } + } + return nil +} + +// Freeze prevents further registrations. List returns independent metadata copies. +func (r *Registry) Freeze() { r.mu.Lock(); defer r.mu.Unlock(); r.frozen = true } + +func (r *Registry) List() []pluginsdk.Manifest { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]pluginsdk.Manifest, 0, len(r.entries)) + for _, entry := range r.entries { + result = append(result, cloneManifest(entry.manifest)) + } + return result +} + +func (r *Registry) snapshot() []registration { + r.mu.Lock() + defer r.mu.Unlock() + r.frozen = true + entries := slices.Clone(r.entries) + for i := range entries { + entries[i].manifest = cloneManifest(entries[i].manifest) + } + return entries +} + +func cloneManifest(manifest pluginsdk.Manifest) pluginsdk.Manifest { + manifest.Root = cloneCommand(manifest.Root) + return manifest +} +func cloneCommand(command pluginsdk.CommandSpec) pluginsdk.CommandSpec { + command.Aliases = slices.Clone(command.Aliases) + command.Flags = slices.Clone(command.Flags) + command.Inputs = cloneInputs(command.Inputs) + command.Files = cloneFiles(command.Files) + if command.Streams != nil { + command.Streams = new(*command.Streams) + } + command.Subcommands = slices.Clone(command.Subcommands) + for i := range command.Subcommands { + command.Subcommands[i] = cloneCommand(command.Subcommands[i]) + } + return command +} + +func cloneFiles(files *pluginsdk.FileSpec) *pluginsdk.FileSpec { + if files == nil { + return nil + } + result := *files + if result.ReadArgument != nil { + result.ReadArgument = new(*result.ReadArgument) + } + return &result +} + +func cloneInputs(inputs []pluginsdk.InputSpec) []pluginsdk.InputSpec { + result := slices.Clone(inputs) + for i := range result { + input := &result[i] + input.Options = slices.Clone(input.Options) + if input.Argument != nil { + value := *input.Argument + input.Argument = &value + } + if input.AlternativeArgument != nil { + value := *input.AlternativeArgument + input.AlternativeArgument = &value + } + if input.Source != nil { + input.Source = cloneChoiceSource(*input.Source) + } + } + return result +} + +func cloneChoiceSource(source pluginsdk.ChoiceSource) *pluginsdk.ChoiceSource { + source.CommandPath = slices.Clone(source.CommandPath) + source.Args = slices.Clone(source.Args) + source.LabelFields = slices.Clone(source.LabelFields) + source.ExcludeTrueFields = slices.Clone(source.ExcludeTrueFields) + source.Flags = maps.Clone(source.Flags) + source.MatchFields = maps.Clone(source.MatchFields) + for field, values := range source.MatchFields { + source.MatchFields[field] = slices.Clone(values) + } + return &source +} diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go new file mode 100644 index 00000000..430c707e --- /dev/null +++ b/internal/plugin/registry_test.go @@ -0,0 +1,186 @@ +package plugin_test + +import ( + "context" + "errors" + "net/http" + "reflect" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +func inertFactory(*http.Client) pluginsdk.Plugin { return &fakePlugin{} } + +func TestRegistryRejectsInvalidManifests(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + mutate func(*pluginsdk.Manifest) + }{ + {"protocol", func(m *pluginsdk.Manifest) { m.ProtocolVersion = 2 }}, + {"empty name", func(m *pluginsdk.Manifest) { m.Name = "" }}, + {"empty version", func(m *pluginsdk.Manifest) { m.Version = "" }}, + {"empty service", func(m *pluginsdk.Manifest) { m.Service = "" }}, + {"mismatched root", func(m *pluginsdk.Manifest) { m.Root.Use = "auth" }}, + {"reserved root", func(m *pluginsdk.Manifest) { m.Name = "login"; m.Root.Use = "login" }}, + {"profiles root", func(m *pluginsdk.Manifest) { m.Name = "profiles"; m.Root.Use = "profiles" }}, + {"leading command whitespace", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Use = " write" }}, + {"command tab", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Use = "write\tNAME" }}, + {"empty command", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Use = "" }}, + {"invalid command", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Use = "--bad" }}, + {"reserved help", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Use = "help" }}, + {"negative args", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Args.Min = -1 }}, + {"inverted args", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Args.Max = 0 }}, + {"group args", func(m *pluginsdk.Manifest) { m.Root.Args = pluginsdk.ArgsSpec{Min: 1, Max: 1} }}, + {"group confirm", func(m *pluginsdk.Manifest) { m.Root.Confirm = true }}, + {"duplicate command", func(m *pluginsdk.Manifest) { m.Root.Subcommands = append(m.Root.Subcommands, m.Root.Subcommands[0]) }}, + {"duplicate flag", func(m *pluginsdk.Manifest) { m.Root.Flags = append(m.Root.Flags, m.Root.Flags[0]) }}, + {"inherited flag collision", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Flags = append(m.Root.Subcommands[0].Flags, m.Root.Flags[0]) + }}, + {"reserved flag", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Name = "profile" }}, + {"unsupported type", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Type = "duration" }}, + {"bool default", func(m *pluginsdk.Manifest) { m.Root.Flags[1].Default = "1" }}, + {"uint32 default", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags[2].Default = "4294967296" }}, + {"negative uint32", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags[2].Default = "-1" }}, + {"reserved shorthand", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Shorthand = "o" }}, + {"profile shorthand", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Shorthand = "p" }}, + {"long shorthand", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Shorthand = "ab" }}, + {"invalid shorthand", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Shorthand = "-" }}, + {"duplicate shorthand", func(m *pluginsdk.Manifest) { m.Root.Flags[0].Shorthand = "x"; m.Root.Flags[1].Shorthand = "x" }}, + {"inherited shorthand collision", func(m *pluginsdk.Manifest) { + m.Root.Flags[0].Shorthand = "x" + m.Root.Subcommands[0].Flags[0].Shorthand = "x" + }}, + {"non-string body", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags[2].Body = true }}, + {"duplicate body", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Flags = append(m.Root.Subcommands[0].Flags, pluginsdk.FlagSpec{Name: "other", Type: "string", Body: true}) + }}, + {"missing confirm flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags = m.Root.Subcommands[0].Flags[1:] }}, + {"confirm true by default", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Flags[0].Default = "true" }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m := testManifest() + tc.mutate(&m) + var registry plugin.Registry + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, inertFactory); err == nil { + t.Fatal("invalid manifest accepted") + } + if len(registry.List()) != 0 { + t.Fatal("invalid manifest entered registry") + } + }) + } +} + +func TestRegistryFreezeAndIndependentMetadata(t *testing.T) { + t.Parallel() + var registry plugin.Registry + m := testManifest() + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, inertFactory); err != nil { + t.Fatal(err) + } + m.Root.Flags[0].Name = "mutated" + first := registry.List() + first[0].Root.Subcommands[0].Flags[0].Name = "mutated" + if got := registry.List(); !reflect.DeepEqual(got, []pluginsdk.Manifest{testManifest()}) { + t.Fatal("registry metadata aliases callers") + } + if err := registry.Register(t.Context(), &fakePlugin{manifest: testManifest()}, inertFactory); err == nil { + t.Fatal("duplicate root accepted") + } + registry.Freeze() + other := testManifest() + other.Name = "auth" + other.Root.Use = "auth" + if err := registry.Register(t.Context(), &fakePlugin{manifest: other}, inertFactory); err == nil { + t.Fatal("frozen registry accepted plugin") + } +} + +func TestRegistryMetadataFailures(t *testing.T) { + t.Parallel() + var registry plugin.Registry + sentinel := errors.New("manifest unavailable") + if err := registry.Register(t.Context(), &fakePlugin{manifestErr: sentinel}, inertFactory); !errors.Is(err, sentinel) { + t.Fatalf("manifest error lost: %v", err) + } + if err := registry.Register(t.Context(), nil, inertFactory); err == nil { + t.Fatal("nil metadata accepted") + } + if err := registry.Register(t.Context(), &fakePlugin{manifest: testManifest()}, nil); err == nil { + t.Fatal("nil factory accepted") + } +} + +func TestRegistryStreamMetadataIsIndependent(t *testing.T) { + t.Parallel() + manifest := streamManifest(true, true) + var registry plugin.Registry + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory); err != nil { + t.Fatal(err) + } + manifest.Root.Subcommands[0].Streams.Input = false + first := registry.List() + first[0].Root.Subcommands[0].Streams.Output = false + got := registry.List()[0].Root.Subcommands[0].Streams + if !got.Input || !got.Output { + t.Fatal("stream declarations alias caller-owned metadata") + } +} + +func TestAdapterHostCollisionsAndAtomicAttachment(t *testing.T) { + t.Parallel() + for _, kind := range []string{"command", "alias", "flag", "shorthand"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + root := &cobra.Command{Use: "fctl"} + m := testManifest() + configureHostCollision(root, &m, kind) + var registry plugin.Registry + first := testManifest() + first.Name = "auth" + first.Root.Use = "auth" + first.Root.Flags = nil + first.Root.Subcommands = nil + if err := registry.Register(t.Context(), &fakePlugin{manifest: first}, inertFactory); err != nil { + t.Fatal(err) + } + if err := registry.Register(t.Context(), &fakePlugin{manifest: m}, inertFactory); err != nil { + t.Fatal(err) + } + before := len(root.Commands()) + adapter := plugin.NewCommand(®istry, func(context.Context, string) (*api.Client, error) { + t.Error("attachment resolved HTTP") + return nil, nil + }) + if err := adapter.AddTo(root); err == nil { + t.Fatal("host collision accepted") + } + if len(root.Commands()) != before { + t.Fatal("failed attachment partially changed root") + } + }) + } +} + +func configureHostCollision(root *cobra.Command, m *pluginsdk.Manifest, kind string) { + switch kind { + case "command": + root.AddCommand(&cobra.Command{Use: "ledger"}) + case "alias": + root.AddCommand(&cobra.Command{Use: "existing", Aliases: []string{"ledger"}}) + case "flag": + root.PersistentFlags().String("tenant", "", "host") + case "shorthand": + root.PersistentFlags().StringP("host", "x", "", "host") + m.Root.Flags[0].Shorthand = "x" + } +} diff --git a/internal/plugin/service_loader_test.go b/internal/plugin/service_loader_test.go new file mode 100644 index 00000000..a31edda5 --- /dev/null +++ b/internal/plugin/service_loader_test.go @@ -0,0 +1,57 @@ +package plugin + +import ( + "context" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type loaderTransport func(*http.Request) (*http.Response, error) + +func (f loaderTransport) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +//nolint:gocognit // The same version-cache and exact-mismatch lifecycle applies to both services. +func TestExternalServiceVersionBeforeProcess(t *testing.T) { + for _, service := range []string{"auth", "ledger"} { + t.Run(service, func(t *testing.T) { + var requests atomic.Int32 + p := &external{manifest: pluginsdk.Manifest{Service: service, Version: "1.2.3"}, http: &http.Client{Transport: loaderTransport(func(request *http.Request) (*http.Response, error) { + requests.Add(1) + if request.Method != http.MethodGet || request.URL.Path != "/api/"+service+"/_info" { + t.Fatalf("version request: %s %s", request.Method, request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.2.3"}`))}, nil + })}} + endpoint := "https://stack.example/api/" + service + for range 2 { + if err := p.checkVersion(t.Context(), endpoint); err != nil { + t.Fatal(err) + } + } + if requests.Load() != 1 { + t.Fatalf("version checks: %d", requests.Load()) + } + p.manifest.Version = "1.2.4" + p.checked = "" + err := p.checkVersion(t.Context(), endpoint) + if err == nil || !strings.Contains(err.Error(), service+" 1.2.4") { + t.Fatalf("service mismatch: %v", err) + } + }) + } +} + +func TestExternalAuthVersionFailureDoesNotStartExecutable(t *testing.T) { + p := &external{verify: func(_ context.Context) (string, error) { return "/does/not/exist", nil }, manifest: pluginsdk.Manifest{Service: "auth", Version: "1.2.3"}, http: &http.Client{Transport: loaderTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"1.2.4"}`))}, nil + })}} + _, err := p.Execute(t.Context(), pluginsdk.ExecuteRequest{Endpoint: "https://auth.example"}) + if err == nil || !strings.Contains(err.Error(), "targets auth 1.2.3") { + t.Fatalf("version check did not precede startup: %v", err) + } +} diff --git a/internal/plugin/service_test.go b/internal/plugin/service_test.go new file mode 100644 index 00000000..1b25c8b3 --- /dev/null +++ b/internal/plugin/service_test.go @@ -0,0 +1,277 @@ +package plugin_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "maps" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +func multiServiceManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "multi", Version: "test", Service: "default", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{ + Use: "multi", Short: "Multi service fixture", Runnable: true, Service: "root", + Subcommands: []pluginsdk.CommandSpec{ + {Use: "inherited", Runnable: true}, + {Use: "branch", Service: "branch", Subcommands: []pluginsdk.CommandSpec{ + {Use: "inherited", Runnable: true}, {Use: "leaf", Service: "leaf", Runnable: true}, + }}, + }, + }} +} + +func attachRequest(t *testing.T, manifest pluginsdk.Manifest, resolver plugin.RequestResolver, factory plugin.Factory) *cobra.Command { + t.Helper() + registry := &plugin.Registry{} + if err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, factory); err != nil { + t.Fatal(err) + } + root := &cobra.Command{Use: "fctl", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().String("profile", "", "host setting") + if err := plugin.NewCommandWithRequest(registry, resolver).AddTo(root); err != nil { + t.Fatal(err) + } + root.SetOut(io.Discard) + root.SetErr(io.Discard) + return root +} + +func TestRequestResolverRejectsBeforeResolution(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + args []string + }{ + {"experimental-omitted", []string{"ledger", "write", "fixture", "--tenant=t", "--confirm"}}, + {"experimental-false", []string{"ledger", "write", "fixture", "--tenant=t", "--confirm", "--experimental=false"}}, + {"confirm-false", []string{"ledger", "write", "fixture", "--tenant=t", "--experimental", "--confirm=false"}}, + {"confirm-omitted", []string{"ledger", "write", "fixture", "--tenant=t", "--experimental"}}, + {"bad-body", []string{"ledger", "write", "fixture", "--tenant=t", "--experimental", "--confirm", "--data={"}}, + {"bad-type", []string{"ledger", "write", "fixture", "--tenant=t", "--experimental", "--confirm", "--limit=4294967296"}}, + {"missing-argument", []string{"ledger", "write", "--tenant=t", "--experimental", "--confirm"}}, + {"unknown-path", []string{"unknown"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := testManifest() + manifest.Root.Flags = append(manifest.Root.Flags, pluginsdk.FlagSpec{Name: "experimental", Type: "bool", Default: "false", RequireTrue: true, Persistent: true}) + calls, factories := 0, 0 + root := attachRequest(t, manifest, func(context.Context, string, pluginsdk.ExecuteRequest) (*api.Client, error) { calls++; return nil, nil }, func(*http.Client) pluginsdk.Plugin { factories++; return nil }) + root.SetArgs(tc.args) + if err := root.ExecuteContext(t.Context()); err == nil { + t.Fatal("invalid request accepted") + } + if calls != 0 || factories != 0 { + t.Fatalf("rejected request resolved %d times and constructed %d plugins", calls, factories) + } + }) + } +} + +func TestRegistryRejectsNonBooleanRequireTrue(t *testing.T) { + t.Parallel() + for _, kind := range []string{"string", "uint32"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + manifest := multiServiceManifest() + manifest.Root.Flags = []pluginsdk.FlagSpec{{Name: "experimental", Type: kind, Default: "0", RequireTrue: true}} + registry := &plugin.Registry{} + err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, func(*http.Client) pluginsdk.Plugin { t.Fatal("registration constructed a plugin"); return nil }) + if err == nil || !strings.Contains(err.Error(), "boolean") { + t.Fatalf("invalid gate declaration: %v", err) + } + }) + } +} + +func TestOnePluginRoutesMultipleServices(t *testing.T) { + t.Parallel() + manifest := multiServiceManifest() + clients := map[string]*api.Client{} + for _, service := range []string{"default", "root", "branch", "leaf"} { + clients[service] = serviceClient(t, service) + } + probe := &serviceProbe{} + root := attachServiceProbe(t, manifest, clients, probe) + for _, tc := range []struct{ path, service string }{{"multi", "root"}, {"multi inherited", "root"}, {"multi branch inherited", "branch"}, {"multi branch leaf", "leaf"}} { + probe.path, probe.service = strings.Fields(tc.path), tc.service + root.SetArgs(probe.path) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + } + if probe.resolutions != 4 || probe.factories != 4 || probe.executions != 4 { + t.Fatalf("resolution/factory/execution counts = %d/%d/%d", probe.resolutions, probe.factories, probe.executions) + } +} + +type serviceProbe struct { + path []string + service string + resolutions, factories, executions int +} + +func attachServiceProbe(t *testing.T, manifest pluginsdk.Manifest, clients map[string]*api.Client, probe *serviceProbe) *cobra.Command { + t.Helper() + root := attachRequest(t, manifest, func(_ context.Context, service string, req pluginsdk.ExecuteRequest) (*api.Client, error) { + probe.resolutions++ + if service != probe.service || !reflect.DeepEqual(req.CommandPath, probe.path) { + t.Errorf("resolution = %q, %v", service, req.CommandPath) + } + if req.Endpoint != "" || req.Context != nil { + t.Error("host metadata injected before resolution") + } + return clients[service], nil + }, func(client *http.Client) pluginsdk.Plugin { + probe.factories++ + if client != clients[probe.service].HTTPClient() { + t.Error("wrong service transport injected") + } + return &fakePlugin{execute: func(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + probe.executions++ + checkServiceRequest(t, req, probe.service, clients[probe.service]) + publicClient, err := httpclient.New(req.Endpoint, client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + data, err := publicClient.Do(ctx, http.MethodGet, "/probe", nil, nil, nil) + return pluginsdk.ExecuteResponse{Data: data}, err + }} + }) + return root +} + +func serviceClient(t *testing.T, service string) *api.Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/"+service+"/probe" || r.Method != http.MethodGet { + t.Error("incorrect host or service route") + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("service transport authentication missing") + } + if _, err := io.WriteString(w, `{"ok":true}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := api.New(server.URL+"/api/"+service, &http.Client{Transport: bearerTransport{base: server.Client().Transport}}) + if err != nil { + t.Fatal(err) + } + return client.WithContext(map[string]string{"service": service, "stack": "fixture"}) +} + +func checkServiceRequest(t *testing.T, req pluginsdk.ExecuteRequest, service string, client *api.Client) { + t.Helper() + if req.Endpoint != client.Endpoint() || !maps.Equal(req.Context, map[string]string{"service": service, "stack": "fixture"}) { + t.Error("resolved endpoint or metadata lost") + } + if strings.Contains(string(mustRequestJSON(t, req)), "test-token") { + t.Error("authentication material entered plugin request") + } + req.Context["stack"] = "handler-mutation" + if client.Context()["stack"] != "fixture" { + t.Error("handler changed host metadata") + } +} + +func mustRequestJSON(t *testing.T, req pluginsdk.ExecuteRequest) []byte { + t.Helper() + data, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestRequestResolverReceivesValidatedBodyAndFlags(t *testing.T) { + t.Parallel() + for _, kind := range []string{"inline", "file", "stdin"} { + t.Run(kind, func(t *testing.T) { t.Parallel(); checkResolvedBody(t, kind) }) + } +} + +func checkResolvedBody(t *testing.T, kind string) { + t.Helper() + const body = `{"amount":90071992547409930001}` + client := serviceClient(t, "ledger") + resolutions, executions := 0, 0 + root := attachRequest(t, testManifest(), func(_ context.Context, service string, req pluginsdk.ExecuteRequest) (*api.Client, error) { + resolutions++ + if service != "ledger" || req.Endpoint != "" || req.Context != nil { + t.Error("invalid resolution boundary") + } + checkResolvedFlags(t, req, body) + return client, nil + }, func(injected *http.Client) pluginsdk.Plugin { + if injected != client.HTTPClient() { + t.Error("injected transport changed") + } + return &fakePlugin{execute: func(_ context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + executions++ + checkResolvedFlags(t, req, body) + if req.Endpoint != client.Endpoint() || !maps.Equal(req.Context, client.Context()) { + t.Error("missing host endpoint/context") + } + req.Flags["tenant"] = "handler-mutation" + return pluginsdk.ExecuteResponse{Data: req.Body}, nil + }} + }) + var output bytes.Buffer + root.SetOut(&output) + root.SetIn(strings.NewReader(body)) + root.SetArgs([]string{"--profile=private-host", "ledger", "write", "fixture", "--tenant=tenant-1", "--confirm", "--data=" + bodyInput(t, kind, body)}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, output.Bytes()); err != nil { + t.Fatal(err) + } + if resolutions != 1 || executions != 1 || compact.String() != body { + t.Fatalf("calls/output = %d/%d/%s", resolutions, executions, output.String()) + } + checkCallerTenant(t, root) +} + +func checkCallerTenant(t *testing.T, root *cobra.Command) { + t.Helper() + cmd, _, err := root.Find([]string{"ledger", "write"}) + if err != nil { + t.Fatal(err) + } + tenant, err := cmd.InheritedFlags().GetString("tenant") + if err != nil || tenant != "tenant-1" { + t.Fatalf("handler changed caller flag: %q, %v", tenant, err) + } +} + +func checkResolvedFlags(t *testing.T, req pluginsdk.ExecuteRequest, body string) { + t.Helper() + if string(req.Body) != body || req.Flags["limit"] != "10" || req.Flags["enabled"] != "true" || req.Flags["tenant"] != "tenant-1" || req.Flags["confirm"] != "true" { + t.Error("body precision or validated defaults lost") + } + if !req.ChangedFlags["data"] || !req.ChangedFlags["confirm"] || req.ChangedFlags["limit"] { + t.Error("changed flag provenance lost") + } + if _, found := req.Flags["profile"]; found { + t.Error("host flags entered plugin request") + } + if !reflect.DeepEqual(req.Args, []string{"fixture"}) || !reflect.DeepEqual(req.CommandPath, []string{"ledger", "write"}) { + t.Error("validated route or args lost") + } +} diff --git a/internal/plugin/streams.go b/internal/plugin/streams.go new file mode 100644 index 00000000..1f475f99 --- /dev/null +++ b/internal/plugin/streams.go @@ -0,0 +1,156 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/command" +) + +// Both adapter paths use this boundary after validation and confirmation. +func executePluginCommand(cmd *cobra.Command, instance pluginsdk.Plugin, spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest) error { + if spec.Streams == nil { + response, err := instance.Execute(cmd.Context(), request) + return renderPluginOutput(cmd, spec.Files, request, response, err) + } + streaming, ok := instance.(pluginsdk.StreamingPlugin) + if !ok { + return fmt.Errorf("plugin does not support declared streams") + } + if err := checkPluginStreamFormat(spec, request); err != nil { + return err + } + return executePluginStream(cmd, streaming, spec, request) +} + +func executePluginStream(cmd *cobra.Command, instance pluginsdk.StreamingPlugin, spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest) (err error) { + ctx := cmd.Context() + if err := ctx.Err(); err != nil { + return err + } + var failures pluginStreamErrors + progress := newPluginProgress(cmd, &failures) + streams := pluginsdk.ExecutionStreams{Report: progress.Report} + input, size, err := openPluginStreamInput(cmd, spec, request) + if err != nil { + return err + } + if input != nil { + defer func() { err = errors.Join(err, input.Close()) }() + streams.Input = &pluginStreamReader{ctx: ctx, reader: input, failures: &failures} + streams.InputSize = size + } + output, err := openPluginStreamOutput(cmd, spec, request, &failures) + if err != nil { + return err + } + defer func() { err = errors.Join(err, output.Close()) }() + streams.Output = output.writer + response, execErr := instance.ExecuteStream(ctx, request, streams) + // Close the source before committing an export. Input errors and close + // errors must prevent publication even if a plugin ignores them. + if input != nil { + execErr = errors.Join(execErr, input.Close()) + } + execErr = errors.Join(execErr, ctx.Err(), failures.Err()) + execErr = completePluginStream(ctx, output, progress, execErr) + if spec.Streams.Output { + if output.destination == "" || output.destination == "-" { + return execErr // stdout belongs exclusively to the raw export, including on failure. + } + if len(response.Data) > 0 { + return errors.Join(execErr, command.WriteJSON(cmd.OutOrStdout(), response.Data)) + } + return execErr + } + return renderPluginOutput(cmd, spec.Files, request, response, execErr) +} + +func completePluginStream(ctx context.Context, output *pluginStreamOutput, progress *pluginProgress, execErr error) error { + // A failing final progress write must not publish an export. + execErr = errors.Join(execErr, progress.Finish(execErr)) + if ctx.Err() != nil { + // The interrupted pipe/RPC errors are consequences of cancellation. + return ctx.Err() + } + if execErr == nil { + execErr = output.Commit(ctx) + } + return execErr +} + +// Record the first IO/report failure even when a plugin does not propagate it. +type pluginStreamErrors struct { + mu sync.Mutex + err error +} + +func (e *pluginStreamErrors) Record(err error) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.err == nil { + e.err = err + } + return err +} + +func (e *pluginStreamErrors) Err() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.err +} + +type pluginStreamReader struct { + ctx context.Context + reader io.Reader + failures *pluginStreamErrors +} + +func (r *pluginStreamReader) Close() error { + if closer, ok := r.reader.(io.Closer); ok { + return r.failures.Record(closer.Close()) + } + return nil +} + +func (r *pluginStreamReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, r.failures.Record(err) + } + n, err := r.reader.Read(data) + if r.ctx.Err() != nil { + err = r.ctx.Err() + } + if err != nil && !errors.Is(err, io.EOF) { + err = r.failures.Record(err) + } + return n, err +} + +type pluginStreamWriter struct { + ctx context.Context + writer io.Writer + failures *pluginStreamErrors + mu sync.Mutex +} + +func (w *pluginStreamWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if err := w.ctx.Err(); err != nil { + return 0, w.failures.Record(err) + } + n, err := w.writer.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + err = errors.Join(err, w.ctx.Err()) + return n, w.failures.Record(err) +} diff --git a/internal/plugin/streams_host_test.go b/internal/plugin/streams_host_test.go new file mode 100644 index 00000000..602bc02d --- /dev/null +++ b/internal/plugin/streams_host_test.go @@ -0,0 +1,495 @@ +package plugin_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/command" + "github.com/formancehq/fctl/v4/internal/interactive" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +type fakeStreamingPlugin struct { + fakePlugin + stream func(context.Context, pluginsdk.ExecuteRequest, pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) +} + +func (p *fakeStreamingPlugin) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + return p.stream(ctx, request, streams) +} + +func streamManifest(input, output bool) pluginsdk.Manifest { + manifest := fileManifest() + spec := &manifest.Root.Subcommands[0] + spec.Streams = &pluginsdk.StreamSpec{Input: input, Output: output} + if output { + spec.Files.WriteFormat = "ndjson" + spec.Flags[3].Default = "ndjson" + } + return manifest +} + +func streamAdapter(t *testing.T, manifest pluginsdk.Manifest, execute func(context.Context, pluginsdk.ExecuteRequest, pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error)) *cobra.Command { + t.Helper() + return attach(t, manifest, func(context.Context, string) (*api.Client, error) { + return api.New("https://ledger.example", &http.Client{}) + }, func(*http.Client) pluginsdk.Plugin { + return &fakeStreamingPlugin{fakePlugin: fakePlugin{execute: func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + t.Fatal("declared streams used unary execution") + return pluginsdk.ExecuteResponse{}, nil + }}, stream: execute} + }) +} + +func largeStreamFile(t *testing.T) (string, int64) { + t.Helper() + path := filepath.Join(t.TempDir(), "source.ndjson") + file, err := os.Create(path) //nolint:gosec // Test-owned temporary source. + if err != nil { + t.Fatal(err) + } + piece := bytes.Repeat([]byte("{\"n\":1}\n"), 8192) + const size = 65 << 20 + for range size / len(piece) { + if _, err := file.Write(piece); err != nil { + t.Fatal(errors.Join(err, file.Close())) + } + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return path, size +} + +func streamFileHash(t *testing.T, path string) []byte { + t.Helper() + file, err := os.Open(path) //nolint:gosec // Test-owned temporary file. + if err != nil { + t.Fatal(err) + } + hash := sha256.New() + _, err = io.Copy(hash, file) + if err := errors.Join(err, file.Close()); err != nil { + t.Fatal(err) + } + return hash.Sum(nil) +} + +func assertNoStreamTemp(t *testing.T, path string) { + t.Helper() + files, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".fctl-export-*")) + if err != nil || len(files) != 0 { + t.Fatalf("temporary export remains: %v (%v)", files, err) + } +} + +func TestHostStreamsLargeFileInputAndAtomicOutput(t *testing.T) { + t.Parallel() + source, size := largeStreamFile(t) + destination := filepath.Join(t.TempDir(), "export.ndjson") + if err := os.WriteFile(destination, []byte("previous\n"), 0o644); err != nil { //nolint:gosec // Verify a replacement becomes private even when the original file was not. + t.Fatal(err) + } + manifest := streamManifest(true, true) + manifest.Root.Subcommands[0].Flags[0].Required = true + root := streamAdapter(t, manifest, func(_ context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + if request.Body != nil || streams.Input == nil || streams.Output == nil || streams.InputSize == nil || *streams.InputSize != size { + t.Fatal("large source was buffered or its size was lost") + } + n, err := io.CopyBuffer(streams.Output, streams.Input, make([]byte, 64<<10)) + if n != size { + t.Fatalf("stream copied %d, want %d", n, size) + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"exported":true}`)}, err + }) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"ledger", "invoke", source, "--file=" + destination}) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + info, err := os.Stat(destination) + if err != nil || info.Size() != size || info.Mode().Perm() != 0o600 { + t.Fatalf("atomic private export failed: %v (%v)", info, err) + } + if !bytes.Equal(streamFileHash(t, source), streamFileHash(t, destination)) || !strings.Contains(stdout.String(), `"exported": true`) { + t.Fatal("export bytes or final file summary changed") + } + assertNoStreamTemp(t, destination) +} + +func TestHostStreamFailurePreservesDestination(t *testing.T) { + t.Parallel() + for _, failure := range []string{"plugin", "write", "cancel", "invalid report"} { + t.Run(failure, func(t *testing.T) { + t.Parallel() + testHostStreamFailure(t, failure) + }) + } +} + +func testHostStreamFailure(t *testing.T, failure string) { + t.Helper() + path := filepath.Join(t.TempDir(), "export.ndjson") + if err := os.WriteFile(path, []byte("original\n"), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + root := streamAdapter(t, streamManifest(false, true), func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + if _, err := io.WriteString(streams.Output, "{\"partial\":true}\n"); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return pluginsdk.ExecuteResponse{}, streamFailure(t, failure, cancel, streams) + }) + root.SetOut(io.Discard) + root.SetArgs([]string{"ledger", "invoke", "--file=" + path}) + err := root.ExecuteContext(ctx) + if err == nil || (failure == "cancel" && !errors.Is(err, context.Canceled)) { + t.Fatalf("stream failure not returned: %v", err) + } + data, readErr := os.ReadFile(path) //nolint:gosec // Test-owned temporary destination. + if readErr != nil || string(data) != "original\n" { + t.Fatalf("failed export replaced destination: %q (%v)", data, readErr) + } + assertNoStreamTemp(t, path) +} + +func streamFailure(t *testing.T, failure string, cancel context.CancelFunc, streams pluginsdk.ExecutionStreams) error { + t.Helper() + switch failure { + case "cancel": + cancel() + return nil // Ignored cancellation must still prevent commit. + case "invalid report": + if err := streams.Report(pluginsdk.ProgressEvent{Phase: "bad\x1b", Unit: "records"}); err == nil { + t.Fatal("invalid hidden progress accepted") + } + return nil // Ignored Report errors must still prevent commit. + case "write": + return io.ErrShortWrite + default: + return errors.New("plugin failed") + } +} + +func TestHostStreamStdoutNeverAppendsSummary(t *testing.T) { + t.Parallel() + for _, destination := range []string{"", "-"} { + for _, failed := range []bool{false, true} { + root := streamAdapter(t, streamManifest(false, true), func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + _, err := io.WriteString(streams.Output, "{\"record\":1}\n") + if failed { + err = errors.Join(err, errors.New("partial export")) + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"summary":"private"}`)}, err + }) + var out bytes.Buffer + root.SetOut(&out) + args := []string{"ledger", "invoke"} + if destination != "" { + args = append(args, "--file="+destination) + } + root.SetArgs(args) + err := root.ExecuteContext(t.Context()) + if (err != nil) != failed || out.String() != "{\"record\":1}\n" { + t.Fatalf("stdout export corrupted: %q (%v)", out.String(), err) + } + } + } +} + +func TestHostStreamStdinAndLegacyBody(t *testing.T) { + t.Parallel() + for _, body := range []bool{false, true} { + called := false + root := streamAdapter(t, streamManifest(true, false), func(_ context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + called = true + assertStreamImportInput(t, body, request, streams) + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"imported":1}`)}, nil + }) + var stdout bytes.Buffer + root.SetOut(&stdout) + args := []string{"ledger", "invoke", "-"} + root.SetIn(strings.NewReader("{\"raw\":true}\n")) + if body { + root.SetIn(forbiddenReader{t}) + args = []string{"ledger", "invoke", `--data={"legacy":true}`} + } + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil || !called || !strings.Contains(stdout.String(), `"imported": 1`) { + t.Fatalf("import summary failed: %q (%v)", stdout.String(), err) + } + } +} + +func assertStreamImportInput(t *testing.T, body bool, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) { + t.Helper() + if streams.Output != nil || streams.InputSize != nil { + t.Fatal("import received output or invented stdin size") + } + if body { + if streams.Input != nil || string(request.Body) != `{"legacy":true}` { + t.Fatal("legacy body became a raw stream") + } + return + } + data, err := io.ReadAll(streams.Input) + if err != nil || string(data) != "{\"raw\":true}\n" || request.Body != nil { + t.Fatalf("stdin was changed or buffered: %q (%v)", data, err) + } +} + +func TestHostStreamFormatAndSourceConflictsBeforeIO(t *testing.T) { + t.Parallel() + for _, args := range [][]string{{"-", "--format=yaml"}, {"-", "--data={}"}, {"-", "--input=-"}} { + root := attach(t, streamManifest(true, true), func(context.Context, string) (*api.Client, error) { + t.Fatal("invalid stream request reached auth") + return nil, nil + }, inertFactory) + root.SetIn(forbiddenReader{t}) + root.SetOut(io.Discard) + root.SetArgs(append([]string{"ledger", "invoke"}, args...)) + if err := root.ExecuteContext(t.Context()); err == nil { + t.Fatal("invalid stream request accepted") + } + } +} + +type streamConfirmationRunner struct { + t *testing.T + accepted bool +} + +func (r streamConfirmationRunner) Run(_ context.Context, _ *cobra.Command, fields []interactive.Field) ([]string, error) { + if len(fields) != 1 || fields[0].Kind != "confirm" { + r.t.Fatal("stream input prompted for a body instead of confirmation") + } + if r.accepted { + return []string{"true"}, nil + } + return []string{"false"}, nil +} + +func TestHostStreamInteractiveConfirmationPrecedesFileOpen(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("FCTL_NO_INPUT", "") + for _, accepted := range []bool{false, true} { + manifest := streamManifest(true, true) + spec := &manifest.Root.Subcommands[0] + spec.Confirm = true + spec.Flags[0].Required = true + spec.Inputs = []pluginsdk.InputSpec{{Title: "Body", Kind: "text", Flag: "data", ValueType: "json", Required: true}} + called := false + root := streamAdapter(t, manifest, func(_ context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + called = true + if request.Flags["confirm"] != "true" || request.Body != nil { + t.Fatal("unconfirmed or buffered stream executed") + } + _, err := io.Copy(streams.Output, streams.Input) + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"summary":true}`)}, err + }) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"ledger", "invoke", "-"}) + root.SetIn(forbiddenReader{t}) + if accepted { + root.SetIn(strings.NewReader("{\"record\":1}\n")) + } + ctx := interactive.WithRunner(t.Context(), streamConfirmationRunner{t, accepted}) + err := root.ExecuteContext(ctx) + if accepted { + if err != nil || !called || out.String() != "{\"record\":1}\n" { + t.Fatalf("interactive stream failed: %q (%v)", out.String(), err) + } + } else if !errors.Is(err, interactive.ErrCanceled) || called || out.Len() != 0 { + t.Fatalf("declined stream executed: %v", err) + } + } +} + +func TestHostStreamStdinCancellation(t *testing.T) { + t.Parallel() + input, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := errors.Join(input.Close(), writer.Close()); err != nil { + t.Error(err) + } + }) + started := make(chan struct{}) + root := streamAdapter(t, streamManifest(true, false), func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + close(started) + _, err := io.Copy(io.Discard, streams.Input) + return pluginsdk.ExecuteResponse{}, err + }) + root.SetIn(input) + root.SetOut(io.Discard) + root.SetArgs([]string{"ledger", "invoke", "-"}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- root.ExecuteContext(ctx) }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("stdin was buffered before execution") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("stdin cancellation lost: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("stdin read did not unblock on cancellation") + } +} + +func TestHostStreamRegistryDeclarations(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + valid bool + edit func(*pluginsdk.CommandSpec) + }{ + {"raw string", true, func(*pluginsdk.CommandSpec) {}}, + {"raw ndjson", true, func(s *pluginsdk.CommandSpec) { s.Files.ReadFormat = "ndjson" }}, + {"empty stream declaration", true, func(s *pluginsdk.CommandSpec) { s.Streams = &pluginsdk.StreamSpec{}; s.Files = nil }}, + {"missing files", false, func(s *pluginsdk.CommandSpec) { s.Files = nil }}, + {"missing source", false, func(s *pluginsdk.CommandSpec) { s.Files.ReadArgument = nil; s.Files.ReadFlag = "" }}, + {"YAML conversion", false, func(s *pluginsdk.CommandSpec) { s.Files.ReadFormat = "yaml" }}, + {"JSON conversion", false, func(s *pluginsdk.CommandSpec) { s.Files.ReadFormat = "json" }}, + {"mismatched output default", false, func(s *pluginsdk.CommandSpec) { s.Flags[3].Default = "json" }}, + {"group", false, func(s *pluginsdk.CommandSpec) { s.Runnable = false }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := streamManifest(true, true) + tc.edit(&manifest.Root.Subcommands[0]) + registry := new(plugin.Registry) + err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, inertFactory) + if (err == nil) != tc.valid { + t.Fatalf("declaration valid=%t, error=%v", tc.valid, err) + } + }) + } +} + +func TestHostStreamProgressOnly(t *testing.T) { + t.Parallel() + manifest := streamManifest(false, false) + manifest.Root.Subcommands[0].Files = nil + root := streamAdapter(t, manifest, func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + if streams.Input != nil || streams.Output != nil || streams.InputSize != nil || streams.Report == nil { + t.Fatal("progress-only command acquired raw streams") + } + err := streams.Report(pluginsdk.ProgressEvent{Phase: "apply", Completed: 1, Total: new(int64(1)), Unit: "records"}) + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"ok":true}`)}, err + }) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"ledger", "invoke"}) + if err := root.ExecuteContext(t.Context()); err != nil || !strings.Contains(stdout.String(), `"ok": true`) || stderr.Len() != 0 { + t.Fatalf("progress-only execution failed: out=%q, err=%q (%v)", stdout.String(), stderr.String(), err) + } +} + +type streamBrokenWriter struct{ err error } + +func (w streamBrokenWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestHostStreamIgnoredStdoutWriteFailure(t *testing.T) { + t.Parallel() + failure := errors.New("broken stdout") + root := streamAdapter(t, streamManifest(false, true), func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + if _, err := streams.Output.Write([]byte("record\n")); !errors.Is(err, failure) { + t.Fatalf("writer lost failure: %v", err) + } + return pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"summary":true}`)}, nil + }) + root.SetOut(streamBrokenWriter{failure}) + root.SetArgs([]string{"ledger", "invoke"}) + if err := root.ExecuteContext(t.Context()); !errors.Is(err, failure) { + t.Fatalf("host ignored export write error: %v", err) + } +} + +func TestHostStreamUnsupportedPluginBeforeSourceRead(t *testing.T) { + t.Parallel() + root := fileAdapter(t, streamManifest(true, false), func(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + t.Fatal("unsupported stream fell back to unary") + return pluginsdk.ExecuteResponse{}, nil + }) + root.SetIn(forbiddenReader{t}) + root.SetArgs([]string{"ledger", "invoke", "-"}) + if err := root.ExecuteContext(t.Context()); err == nil || !strings.Contains(err.Error(), "does not support") { + t.Fatalf("unsupported streaming plugin accepted: %v", err) + } +} + +type streamBlockedOutput struct { + *io.PipeWriter + started chan struct{} +} + +func (w *streamBlockedOutput) Write(data []byte) (int, error) { + close(w.started) + return w.PipeWriter.Write(data) +} + +func TestHostStreamStdoutCancellation(t *testing.T) { + t.Parallel() + reader, writer := io.Pipe() + t.Cleanup(func() { + if err := errors.Join(reader.Close(), writer.Close()); err != nil { + t.Error(err) + } + }) + output := &streamBlockedOutput{PipeWriter: writer, started: make(chan struct{})} + root := streamAdapter(t, streamManifest(false, true), func(_ context.Context, _ pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + _, err := io.WriteString(streams.Output, "record\n") + return pluginsdk.ExecuteResponse{}, err + }) + root.SetOut(output) + root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + return command.ConfigureOutput(cmd, "json", "never") + } + root.SetArgs([]string{"ledger", "invoke"}) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- root.ExecuteContext(ctx) }() + select { + case <-output.started: + case <-time.After(5 * time.Second): + t.Fatal("export was not started") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) || err.Error() != context.Canceled.Error() { + t.Fatalf("stdout cancellation was lost or repeated: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("stdout Write did not unblock on cancellation") + } +} diff --git a/internal/plugin/validate.go b/internal/plugin/validate.go new file mode 100644 index 00000000..f21d2ed0 --- /dev/null +++ b/internal/plugin/validate.go @@ -0,0 +1,244 @@ +package plugin + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var identifier = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) +var aliasIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) + +var coreFlags = map[string]bool{ + "help": true, "profile": true, "config-dir": true, "timeout": true, "output": true, + "stack-url": true, "ledger-url": true, "auth-url": true, "connectivity-url": true, "auth-mode": true, "token-url": true, + "client-id": true, "scopes": true, "issuer": true, "organization": true, "stack": true, + "no-browser": true, "debug": true, "color": true, "no-input": true, +} +var coreRoots = map[string]bool{"help": true, "completion": true, "version": true, "profiles": true, "login": true, "logout": true} + +type validationScope struct { + names map[string]bool + shorts map[string]bool + body string + confirm bool +} + +func validateManifest(m pluginsdk.Manifest) error { + if m.ProtocolVersion != pluginsdk.ProtocolVersion { + return fmt.Errorf("unsupported plugin protocol version %d", m.ProtocolVersion) + } + if !identifier.MatchString(m.Name) || !identifier.MatchString(m.Service) || strings.TrimSpace(m.Version) == "" { + return fmt.Errorf("plugin name, service and version are required and must be valid") + } + fields := strings.Fields(m.Root.Use) + if len(fields) == 0 || fields[0] != m.Name || coreRoots[fields[0]] { + return fmt.Errorf("invalid or reserved plugin root %q", m.Root.Use) + } + for _, alias := range m.Root.Aliases { + if coreRoots[alias] { + return fmt.Errorf("reserved plugin root alias %q", alias) + } + } + if err := validateCommand(m.Root, validationScope{names: map[string]bool{}, shorts: map[string]bool{"h": true, "o": true, "p": true}}); err != nil { + return err + } + if err := validateFiles(m.Root, nil); err != nil { + return err + } + return validateInteraction(m) +} + +func validateFiles(spec pluginsdk.CommandSpec, inherited map[string]pluginsdk.FlagSpec) error { + flags := interactionFlags(spec, inherited, false) + if err := validateStreamSpec(spec, flags); err != nil { + return fmt.Errorf("command %q streams: %w", spec.Use, err) + } + if spec.Files != nil { + if err := validateFileSpec(spec, flags); err != nil { + return fmt.Errorf("command %q files: %w", spec.Use, err) + } + } + next := interactionFlags(spec, inherited, true) + for _, child := range spec.Subcommands { + if err := validateFiles(child, next); err != nil { + return err + } + } + return nil +} + +func validateFileSpec(spec pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec) error { + f := spec.Files + if !spec.Runnable || len(spec.Subcommands) != 0 { + return fmt.Errorf("files require a runnable leaf") + } + if f.ReadArgument != nil && (*f.ReadArgument < 0 || *f.ReadArgument >= spec.Args.Max) { + return fmt.Errorf("read argument is outside command bounds") + } + if !validPluginReadFormat(spec) || + !slices.Contains([]string{"", "json", "ndjson", "yaml"}, f.WriteFormat) { + return fmt.Errorf("unsupported file format") + } + if f.ReadArgument == nil && f.ReadFlag == "" && f.ReadFormat != "" { + return fmt.Errorf("read format requires an input source") + } + return validateFileFlags(f, flags) +} + +func validateFileFlags(f *pluginsdk.FileSpec, flags map[string]pluginsdk.FlagSpec) error { + for _, name := range []string{f.ReadFlag, f.WriteFlag, f.FormatFlag} { + if name == "" { + continue + } + flag, exists := flags[name] + if !exists || flag.Type != "string" || flag.Body { + return fmt.Errorf("file flag %q must bind a declared non-body string flag", name) + } + } + if (f.ReadFlag != "" && (f.ReadFlag == f.WriteFlag || f.ReadFlag == f.FormatFlag)) || + (f.WriteFlag != "" && f.WriteFlag == f.FormatFlag) { + return fmt.Errorf("file flag bindings must be distinct") + } + if f.FormatFlag != "" && !slices.Contains([]string{"", "json", "ndjson", "yaml", "yml"}, flags[f.FormatFlag].Default) { + return fmt.Errorf("format flag has an unsupported default") + } + return nil +} + +func validateCommand(spec pluginsdk.CommandSpec, inherited validationScope) error { + if err := validateCommandHeader(spec); err != nil { + return err + } + scope := validationScope{names: maps.Clone(inherited.names), shorts: maps.Clone(inherited.shorts), body: inherited.body, confirm: inherited.confirm} + next := validationScope{names: maps.Clone(inherited.names), shorts: maps.Clone(inherited.shorts), body: inherited.body, confirm: inherited.confirm} + for _, flag := range spec.Flags { + if err := validateFlag(flag, &scope); err != nil { + return fmt.Errorf("command %q: %w", spec.Use, err) + } + if flag.Persistent { + if err := validateFlag(flag, &next); err != nil { + return err + } + } + } + if spec.Confirm && !scope.confirm { + return fmt.Errorf("command %q requires a boolean confirm flag", spec.Use) + } + return validateChildren(spec, next) +} + +func validateCommandHeader(spec pluginsdk.CommandSpec) error { + if spec.Service != "" && !identifier.MatchString(spec.Service) { + return fmt.Errorf("invalid command service %q", spec.Service) + } + fields := strings.Fields(spec.Use) + if len(fields) == 0 || !identifier.MatchString(fields[0]) || fields[0] == "help" { + return fmt.Errorf("invalid plugin command use %q", spec.Use) + } + if strings.TrimSpace(spec.Use) != spec.Use || strings.ContainsAny(spec.Use, "\t\r\n") { + return fmt.Errorf("invalid plugin command whitespace %q", spec.Use) + } + if spec.Args.Min < 0 || spec.Args.Max < spec.Args.Min { + return fmt.Errorf("invalid argument bounds for %q", spec.Use) + } + if !spec.Runnable && (spec.Confirm || spec.Args.Min != 0 || spec.Args.Max != 0) { + return fmt.Errorf("non-runnable command %q has execution constraints", spec.Use) + } + return validateAliases(spec) +} + +func validateChildren(spec pluginsdk.CommandSpec, next validationScope) error { + names := map[string]bool{} + for _, child := range spec.Subcommands { + if err := validateCommand(child, next); err != nil { + return err + } + for _, name := range commandNames(child) { + if names[name] { + return fmt.Errorf("duplicate command or alias %q under %q", name, spec.Use) + } + names[name] = true + } + } + return nil +} + +func commandNames(spec pluginsdk.CommandSpec) []string { + return append([]string{pluginsdk.CommandName(spec)}, spec.Aliases...) +} + +func validateAliases(spec pluginsdk.CommandSpec) error { + names := map[string]bool{pluginsdk.CommandName(spec): true} + for _, alias := range spec.Aliases { + if !aliasIdentifier.MatchString(alias) || alias == "help" || names[alias] { + return fmt.Errorf("command %q has invalid, reserved or duplicate alias %q", spec.Use, alias) + } + names[alias] = true + } + return nil +} + +func validateFlag(flag pluginsdk.FlagSpec, scope *validationScope) error { + if !identifier.MatchString(flag.Name) || coreFlags[flag.Name] || scope.names[flag.Name] { + return fmt.Errorf("invalid, reserved or duplicate flag %q", flag.Name) + } + if flag.Shorthand != "" && (!shorthandName.MatchString(flag.Shorthand) || scope.shorts[flag.Shorthand]) { + return fmt.Errorf("invalid or duplicate shorthand %q", flag.Shorthand) + } + if err := validateDefault(flag); err != nil { + return err + } + if err := validateSpecialFlag(flag, scope); err != nil { + return err + } + scope.names[flag.Name] = true + if flag.Shorthand != "" { + scope.shorts[flag.Shorthand] = true + } + return nil +} + +var shorthandName = regexp.MustCompile(`^[a-zA-Z]$`) + +func validateSpecialFlag(flag pluginsdk.FlagSpec, scope *validationScope) error { + if flag.RequireTrue && flag.Type != "bool" { + return fmt.Errorf("a required enabled flag must be boolean") + } + if flag.Body { + if flag.Type != "string" || scope.body != "" { + return fmt.Errorf("body must be a single string flag") + } + scope.body = flag.Name + } + if flag.Name == "confirm" { + if flag.Type != "bool" || flag.Default != "false" { + return fmt.Errorf("confirm must be a boolean defaulting to false") + } + scope.confirm = true + } + return nil +} + +func validateDefault(flag pluginsdk.FlagSpec) error { + switch flag.Type { + case "string": + return nil + case "bool": + if flag.Default == "true" || flag.Default == "false" { + return nil + } + case "uint32": + if _, err := strconv.ParseUint(flag.Default, 10, 32); err == nil { + return nil + } + default: + return fmt.Errorf("unsupported flag type %q", flag.Type) + } + return fmt.Errorf("invalid default for flag %q", flag.Name) +} diff --git a/internal/plugin/validate_interaction.go b/internal/plugin/validate_interaction.go new file mode 100644 index 00000000..c0511d28 --- /dev/null +++ b/internal/plugin/validate_interaction.go @@ -0,0 +1,406 @@ +package plugin + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "maps" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func validateInteraction(manifest pluginsdk.Manifest) error { + return validateCommandInteraction(manifest, manifest.Root, nil) +} + +func validateCommandInteraction(manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, inherited map[string]pluginsdk.FlagSpec) error { + if !slices.Contains([]string{"", "identity", "organization", "stack"}, command.Target) { + return fmt.Errorf("command %q: invalid target %q", command.Use, command.Target) + } + if len(command.Inputs) > 0 && (!command.Runnable || len(command.Subcommands) > 0) { + return fmt.Errorf("command %q: inputs require a runnable leaf", command.Use) + } + flags := interactionFlags(command, inherited, false) + if err := validateInteractionInputs(manifest, command, flags); err != nil { + return err + } + next := interactionFlags(command, inherited, true) + for _, child := range command.Subcommands { + if err := validateCommandInteraction(manifest, child, next); err != nil { + return err + } + } + return nil +} + +func validateInteractionInputs(manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec) error { + bindings := make(map[string]bool) + for _, input := range command.Inputs { + if err := validateInteractionInput(manifest, command, flags, input); err != nil { + return fmt.Errorf("command %q input %q: %w", command.Use, input.Title, err) + } + for _, binding := range interactionBindings(input) { + if bindings[binding] { + return fmt.Errorf("command %q: duplicate input binding %q", command.Use, binding) + } + bindings[binding] = true + } + } + return nil +} + +func interactionFlags(command pluginsdk.CommandSpec, inherited map[string]pluginsdk.FlagSpec, persistentOnly bool) map[string]pluginsdk.FlagSpec { + flags := maps.Clone(inherited) + if flags == nil { + flags = make(map[string]pluginsdk.FlagSpec) + } + for _, flag := range command.Flags { + if !persistentOnly || flag.Persistent { + flags[flag.Name] = flag + } + } + return flags +} + +func validateInteractionInput(manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec, input pluginsdk.InputSpec) error { + checks := []func() error{ + func() error { return validateInputPresentation(input) }, + func() error { return validateInputBinding(command, flags, input) }, + func() error { return validateInputAlternatives(command, flags, input) }, + func() error { return validateInputChoices(input, flags) }, + } + for _, check := range checks { + if err := check(); err != nil { + return err + } + } + if input.Default != "" { + if err := validateInputValue(input, flags, input.Default); err != nil { + return fmt.Errorf("invalid default: %w", err) + } + } + if input.Source != nil { + return validateChoiceSource(manifest, command, flags, *input.Source) + } + return nil +} + +func validateInputPresentation(input pluginsdk.InputSpec) error { + if strings.TrimSpace(input.Title) == "" { + return fmt.Errorf("input title is required") + } + if !slices.Contains([]string{"input", "text", "select", "confirm"}, input.Kind) { + return fmt.Errorf("invalid input kind %q", input.Kind) + } + if !slices.Contains([]string{"", "string", "json", "bool", "number"}, input.ValueType) { + return fmt.Errorf("invalid input value type %q", input.ValueType) + } + if input.Kind == "confirm" && input.ValueType != "" && input.ValueType != "bool" { + return fmt.Errorf("confirm input requires a boolean value type") + } + if input.Secret && (input.Kind != "input" || (input.ValueType != "" && input.ValueType != "string") || input.Default != "") { + return fmt.Errorf("secret input requires a password field without a default") + } + return nil +} + +func validateInputBinding(command pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec, input pluginsdk.InputSpec) error { + bindings := interactionBindings(input) + if len(bindings) != 1 { + return fmt.Errorf("input requires exactly one binding") + } + if input.Flag != "" { + flag, exists := flags[input.Flag] + if !exists { + return fmt.Errorf("input refers to undeclared flag %q", input.Flag) + } + if input.Secret && flag.Type != "string" { + return fmt.Errorf("secret input requires a string flag") + } + } + if input.Argument != nil { + return validateInputArgument(command, *input.Argument) + } + if input.Context != "" && !slices.Contains([]string{"organization", "stack"}, input.Context) { + return fmt.Errorf("invalid input context %q", input.Context) + } + if input.Secret && input.Context != "" { + return fmt.Errorf("resource context cannot be a secret input") + } + if input.BodyPointer != "" { + if !slices.ContainsFunc(slices.Collect(maps.Values(flags)), func(flag pluginsdk.FlagSpec) bool { return flag.Body }) { + return fmt.Errorf("body input requires a declared body flag") + } + return validateInputPointer(input.BodyPointer) + } + return nil +} + +func validateInputArgument(command pluginsdk.CommandSpec, index int) error { + if index < 0 || index >= command.Args.Max { + return fmt.Errorf("input argument index %d is outside command bounds", index) + } + return nil +} + +func validateInputAlternatives(command pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec, input pluginsdk.InputSpec) error { + if input.AlternativeArgument != nil { + if err := validateInputArgument(command, *input.AlternativeArgument); err != nil { + return fmt.Errorf("alternative argument: %w", err) + } + } + if input.AlternativeFlag != "" { + if _, exists := flags[input.AlternativeFlag]; !exists { + return fmt.Errorf("alternative refers to undeclared flag %q", input.AlternativeFlag) + } + } + return nil +} + +func interactionBindings(input pluginsdk.InputSpec) []string { + var bindings []string + for name, value := range map[string]string{"flag": input.Flag, "body": input.BodyPointer, "context": input.Context} { + if value != "" { + bindings = append(bindings, name+":"+value) + } + } + if input.Argument != nil { + bindings = append(bindings, fmt.Sprintf("argument:%d", *input.Argument)) + } + return bindings +} + +func validateInputPointer(pointer string) error { + if !utf8.ValidString(pointer) || !strings.HasPrefix(pointer, "/") { + return fmt.Errorf("body input requires an RFC 6901 JSON pointer") + } + for index := 0; index < len(pointer); index++ { + if pointer[index] == '~' { + index++ + if index == len(pointer) || (pointer[index] != '0' && pointer[index] != '1') { + return fmt.Errorf("invalid JSON pointer escape") + } + } + } + return nil +} + +func validateInputChoices(input pluginsdk.InputSpec, flags map[string]pluginsdk.FlagSpec) error { + if len(input.Options) > 0 && input.Source != nil { + return fmt.Errorf("input options and source are mutually exclusive") + } + if input.Kind != "select" { + if len(input.Options) > 0 || input.Source != nil { + return fmt.Errorf("only select inputs can declare choices") + } + return nil + } + if len(input.Options) == 0 && input.Source == nil { + return fmt.Errorf("select input requires options or a source") + } + seen := make(map[string]bool) + for _, option := range input.Options { + if strings.TrimSpace(option.Label) == "" || (input.Required && option.Value == "") || seen[option.Value] { + return fmt.Errorf("input options require labels and distinct usable values") + } + seen[option.Value] = true + if err := validateInputValue(input, flags, option.Value); err != nil { + return fmt.Errorf("invalid option value: %w", err) + } + } + return nil +} + +func validateInputValue(input pluginsdk.InputSpec, flags map[string]pluginsdk.FlagSpec, value string) error { + kind := cmp.Or(input.ValueType, "string") + if input.Kind == "confirm" { + kind = "bool" + } + if err := validateInteractionValue(kind, value); err != nil { + return err + } + if flag, exists := flags[input.Flag]; exists { + flag.Default = value + return validateDefault(flag) + } + return nil +} + +func validateInteractionValue(kind, value string) error { + switch kind { + case "string": + return nil + case "bool": + if value == "true" || value == "false" { + return nil + } + case "json", "number": + if !json.Valid([]byte(value)) { + break + } + if kind == "json" { + return nil + } + decoder := json.NewDecoder(bytes.NewReader([]byte(value))) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err == nil { + if _, ok := decoded.(json.Number); ok { + return nil + } + } + } + return fmt.Errorf("value must be a valid %s", kind) +} + +func validateChoiceSource(manifest pluginsdk.Manifest, owner pluginsdk.CommandSpec, ownerFlags map[string]pluginsdk.FlagSpec, source pluginsdk.ChoiceSource) error { + command, flags, err := findChoiceSourceCommand(manifest, source.CommandPath) + if err != nil { + return err + } + if err := validateChoiceSourceHeader(command, flags, source); err != nil { + return err + } + for _, argument := range source.Args { + if err := validateChoiceReference(owner, ownerFlags, argument); err != nil { + return err + } + } + return validateChoiceSourceFlags(owner, ownerFlags, flags, source.Flags) +} + +func validateChoiceSourceHeader(command pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec, source pluginsdk.ChoiceSource) error { + if len(source.Args) < command.Args.Min || len(source.Args) > command.Args.Max { + return fmt.Errorf("choice source arguments are outside command bounds") + } + if strings.TrimSpace(source.ValueField) == "" { + return fmt.Errorf("choice source value field is required") + } + for _, label := range source.LabelFields { + if strings.TrimSpace(label) == "" { + return fmt.Errorf("choice source label fields must not be empty") + } + } + if err := validateChoiceExclusions(source.ExcludeTrueFields); err != nil { + return err + } + if err := validateChoiceMatches(source.MatchFields); err != nil { + return err + } + return validateChoicePagination(flags, source) +} + +var choiceFieldName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]*$`) + +func validateChoiceExclusions(fields []string) error { + seen := make(map[string]bool) + for _, field := range fields { + if !choiceFieldName.MatchString(field) { + return fmt.Errorf("choice source excluded field %q must be a valid field name", field) + } + if seen[field] { + return fmt.Errorf("choice source has duplicate excluded field %q", field) + } + seen[field] = true + } + return nil +} + +func validateChoiceMatches(fields map[string][]string) error { + for field, values := range fields { + if !choiceFieldName.MatchString(field) { + return fmt.Errorf("choice source match field %q must be a valid field name", field) + } + if len(values) == 0 { + return fmt.Errorf("choice source match field %q requires at least one value", field) + } + seen := make(map[string]bool) + for _, value := range values { + if value == "" { + return fmt.Errorf("choice source match field %q requires nonempty values", field) + } + if seen[value] { + return fmt.Errorf("choice source match field %q has duplicate value %q", field, value) + } + seen[value] = true + } + } + return nil +} + +func validateChoicePagination(flags map[string]pluginsdk.FlagSpec, source pluginsdk.ChoiceSource) error { + if source.AfterField == "" { + return nil + } + if !choiceFieldName.MatchString(source.AfterField) { + return fmt.Errorf("choice source after field must be a valid field name") + } + if flag, exists := flags["after"]; !exists || flag.Type != "string" { + return fmt.Errorf("choice source after field requires a declared string after flag") + } + if flag, exists := flags["page-size"]; !exists || flag.Type != "uint32" { + return fmt.Errorf("choice source after field requires a declared uint32 page-size flag") + } + return nil +} + +func findChoiceSourceCommand(manifest pluginsdk.Manifest, path []string) (pluginsdk.CommandSpec, map[string]pluginsdk.FlagSpec, error) { + command, err := pluginsdk.FindCommand(manifest, path) + if err != nil || !command.Runnable { + return pluginsdk.CommandSpec{}, nil, fmt.Errorf("choice source must name a runnable command in the same plugin") + } + var flags map[string]pluginsdk.FlagSpec + for index := range path { + current, err := pluginsdk.FindCommand(manifest, path[:index+1]) + if err != nil { + return pluginsdk.CommandSpec{}, nil, err + } + flags = interactionFlags(current, flags, index < len(path)-1) + } + return command, flags, nil +} + +func validateChoiceSourceFlags(owner pluginsdk.CommandSpec, ownerFlags, sourceFlags map[string]pluginsdk.FlagSpec, values map[string]string) error { + for name, value := range values { + flag, exists := sourceFlags[name] + if !exists { + return fmt.Errorf("choice source refers to undeclared flag %q", name) + } + if strings.HasPrefix(value, "$") { + if err := validateChoiceReference(owner, ownerFlags, value); err != nil { + return err + } + } else { + flag.Default = value + if err := validateDefault(flag); err != nil { + return fmt.Errorf("choice source flag: %w", err) + } + } + } + return nil +} + +func validateChoiceReference(owner pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec, value string) error { + name, reference := strings.CutPrefix(value, "$") + if !reference { + return nil + } + if raw, argument := strings.CutPrefix(name, "arg"); argument { + if index, err := strconv.Atoi(raw); err == nil { + return validateInputArgument(owner, index) + } + } + if _, exists := flags[name]; exists || name == "organization" || name == "stack" { + return nil + } + if slices.ContainsFunc(owner.Inputs, func(input pluginsdk.InputSpec) bool { return input.Context == name && name != "" }) { + return nil + } + return fmt.Errorf("choice source refers to undeclared input %q", name) +} diff --git a/internal/plugin/validate_interaction_test.go b/internal/plugin/validate_interaction_test.go new file mode 100644 index 00000000..c1dc05f1 --- /dev/null +++ b/internal/plugin/validate_interaction_test.go @@ -0,0 +1,359 @@ +package plugin_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/plugin" + cloudplugin "github.com/formancehq/fctl/v4/plugins/cloud" +) + +func validationInteractionManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "fixture", Service: "ledger", Version: "test", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "fixture", Target: "stack", Flags: []pluginsdk.FlagSpec{ + {Name: "tenant", Type: "string", Persistent: true}, {Name: "root-only", Type: "string"}, + }, Subcommands: []pluginsdk.CommandSpec{ + {Use: "write [NAME]", Runnable: true, Args: pluginsdk.ArgsSpec{Max: 2}, Flags: []pluginsdk.FlagSpec{ + {Name: "name", Type: "string"}, {Name: "data", Type: "string", Body: true}, + {Name: "enabled", Type: "bool", Default: "false"}, {Name: "limit", Type: "uint32", Default: "10"}, + }, Inputs: []pluginsdk.InputSpec{{Title: "Name", Kind: "input", Flag: "name"}}}, + {Use: "list [FILTER]", Runnable: true, Args: pluginsdk.ArgsSpec{Max: 1}, Flags: []pluginsdk.FlagSpec{ + {Name: "filter", Type: "string"}, {Name: "enabled", Type: "bool", Default: "false"}, + {Name: "after", Type: "string"}, {Name: "page-size", Type: "uint32", Default: "100"}, + }}, + {Use: "group"}, + }}, + } +} + +func interactionSource() *pluginsdk.ChoiceSource { + return &pluginsdk.ChoiceSource{CommandPath: []string{"fixture", "list"}, ValueField: "id", LabelFields: []string{"name", "id"}} +} + +func registerInteractionManifest(t *testing.T, manifest pluginsdk.Manifest) error { + t.Helper() + registry := &plugin.Registry{} + err := registry.Register(t.Context(), &fakePlugin{manifest: manifest}, func(*http.Client) pluginsdk.Plugin { + t.Fatal("declaration validation instantiated an executable plugin") + return nil + }) + if err != nil && len(registry.List()) != 0 { + t.Fatal("invalid metadata entered the registry") + } + return err +} + +func TestInteractionRejectsInvalidInputDeclarations(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, message string + mutate func(*pluginsdk.InputSpec) + }{ + {"missing title", "title", func(i *pluginsdk.InputSpec) { i.Title = " " }}, + {"no binding", "exactly one", func(i *pluginsdk.InputSpec) { i.Flag = "" }}, + {"multiple bindings", "exactly one", func(i *pluginsdk.InputSpec) { i.Argument = new(0) }}, + {"unknown flag", "undeclared flag", func(i *pluginsdk.InputSpec) { i.Flag = "missing" }}, + {"host flag", "undeclared flag", func(i *pluginsdk.InputSpec) { i.Flag = "organization" }}, + {"nonpersistent ancestor flag", "undeclared flag", func(i *pluginsdk.InputSpec) { i.Flag = "root-only" }}, + {"negative argument", "outside command bounds", func(i *pluginsdk.InputSpec) { i.Flag, i.Argument = "", new(-1) }}, + {"argument at max", "outside command bounds", func(i *pluginsdk.InputSpec) { i.Flag, i.Argument = "", new(2) }}, + {"unknown kind", "input kind", func(i *pluginsdk.InputSpec) { i.Kind = "password" }}, + {"unknown value type", "value type", func(i *pluginsdk.InputSpec) { i.ValueType = "float" }}, + {"secret textarea", "secret input", func(i *pluginsdk.InputSpec) { i.Kind, i.Secret = "text", true }}, + {"secret selection", "secret input", func(i *pluginsdk.InputSpec) { i.Kind, i.Secret = "select", true }}, + {"secret default", "secret input", func(i *pluginsdk.InputSpec) { i.Secret, i.Default = true, "synthetic-value" }}, + {"secret numeric value", "secret input", func(i *pluginsdk.InputSpec) { i.Secret, i.ValueType = true, "number" }}, + {"secret boolean flag", "string flag", func(i *pluginsdk.InputSpec) { i.Secret, i.Flag = true, "enabled" }}, + {"unknown context", "input context", func(i *pluginsdk.InputSpec) { i.Flag, i.Context = "", "token" }}, + {"secret host context", "resource context", func(i *pluginsdk.InputSpec) { i.Flag, i.Context, i.Secret = "", "stack", true }}, + {"confirm string", "boolean value type", func(i *pluginsdk.InputSpec) { i.Kind, i.ValueType = "confirm", "string" }}, + {"invalid boolean default", "invalid default", func(i *pluginsdk.InputSpec) { i.Flag, i.ValueType, i.Default = "enabled", "bool", "1" }}, + {"invalid uint32 default", "invalid default", func(i *pluginsdk.InputSpec) { i.Flag, i.Default = "limit", "4294967296" }}, + {"invalid JSON default", "invalid default", func(i *pluginsdk.InputSpec) { i.ValueType, i.Default = "json", "{" }}, + {"quoted numeric default", "invalid default", func(i *pluginsdk.InputSpec) { i.ValueType, i.Default = "number", `"42"` }}, + {"null numeric default", "invalid default", func(i *pluginsdk.InputSpec) { i.ValueType, i.Default = "number", "null" }}, + {"invalid confirm default", "invalid default", func(i *pluginsdk.InputSpec) { i.Kind, i.Default = "confirm", "yes" }}, + {"negative alternative argument", "alternative argument", func(i *pluginsdk.InputSpec) { i.AlternativeArgument = new(-1) }}, + {"alternative argument at max", "alternative argument", func(i *pluginsdk.InputSpec) { i.AlternativeArgument = new(2) }}, + {"missing alternative flag", "alternative", func(i *pluginsdk.InputSpec) { i.AlternativeFlag = "missing" }}, + {"host alternative flag", "alternative", func(i *pluginsdk.InputSpec) { i.AlternativeFlag = "timeout" }}, + {"select without choices", "requires options", func(i *pluginsdk.InputSpec) { i.Kind = "select" }}, + {"options on input", "only select", func(i *pluginsdk.InputSpec) { i.Options = []pluginsdk.InputOption{{Label: "A", Value: "a"}} }}, + {"source on input", "only select", func(i *pluginsdk.InputSpec) { i.Source = interactionSource() }}, + {"source and options", "mutually exclusive", func(i *pluginsdk.InputSpec) { + i.Kind, i.Source, i.Options = "select", interactionSource(), []pluginsdk.InputOption{{Label: "A", Value: "a"}} + }}, + {"duplicate option values", "distinct", func(i *pluginsdk.InputSpec) { + i.Kind, i.Options = "select", []pluginsdk.InputOption{{Label: "A", Value: "a"}, {Label: "Other A", Value: "a"}} + }}, + {"empty option label", "labels", func(i *pluginsdk.InputSpec) { i.Kind, i.Options = "select", []pluginsdk.InputOption{{Value: "a"}} }}, + {"empty required option", "usable", func(i *pluginsdk.InputSpec) { + i.Kind, i.Required, i.Options = "select", true, []pluginsdk.InputOption{{Label: "None"}} + }}, + {"wrong option type", "option value", func(i *pluginsdk.InputSpec) { + i.Kind, i.ValueType, i.Options = "select", "number", []pluginsdk.InputOption{{Label: "One", Value: "true"}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + tc.mutate(&manifest.Root.Subcommands[0].Inputs[0]) + if err := registerInteractionManifest(t, manifest); err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("rejection = %v, want %q", err, tc.message) + } + }) + } +} + +func TestInteractionRejectsInvalidCommandBindings(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, message string + mutate func(*pluginsdk.Manifest) + }{ + {"root target", "invalid target", func(m *pluginsdk.Manifest) { m.Root.Target = "tenant" }}, + {"leaf target", "invalid target", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Target = "organizations" }}, + {"group input", "runnable leaf", func(m *pluginsdk.Manifest) { m.Root.Inputs = m.Root.Subcommands[0].Inputs }}, + {"runnable branch input", "runnable leaf", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Subcommands = []pluginsdk.CommandSpec{{Use: "nested"}} + }}, + {"missing body flag", "body flag", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Flags[1].Body = false + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Name", Kind: "input", BodyPointer: "/name"}} + }}, + {"duplicate flag binding", "duplicate input binding", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = append(m.Root.Subcommands[0].Inputs, m.Root.Subcommands[0].Inputs[0]) + }}, + {"duplicate argument binding", "duplicate input binding", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "A", Kind: "input", Argument: new(0)}, {Title: "B", Kind: "input", Argument: new(0)}} + }}, + {"duplicate body binding", "duplicate input binding", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "A", Kind: "input", BodyPointer: "/name"}, {Title: "B", Kind: "input", BodyPointer: "/name"}} + }}, + {"duplicate context binding", "duplicate input binding", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "A", Kind: "input", Context: "stack"}, {Title: "B", Kind: "input", Context: "stack"}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + tc.mutate(&manifest) + if err := registerInteractionManifest(t, manifest); err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("rejection = %v, want %q", err, tc.message) + } + }) + } +} + +func TestInteractionValidatesRFC6901Pointers(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + pointer string + valid bool + }{ + {"/name", true}, {"/a~1b/~0", true}, {"/~01", true}, {"/", true}, {"/a//b", true}, {"/clé", true}, + {"name", false}, {"#/name", false}, {"/name~", false}, {"/~2", false}, {"/~~0", false}, {"/" + string([]byte{0xff}), false}, + } { + t.Run(tc.pointer, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + manifest.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Body", Kind: "input", BodyPointer: tc.pointer}} + if err := registerInteractionManifest(t, manifest); (err == nil) != tc.valid { + t.Fatalf("pointer %q accepted=%v, want %v: %v", tc.pointer, err == nil, tc.valid, err) + } + }) + } +} + +func TestInteractionRejectsInvalidChoiceSources(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, message string + mutate func(*pluginsdk.ChoiceSource) + }{ + {"other plugin", "same plugin", func(s *pluginsdk.ChoiceSource) { s.CommandPath = []string{"auth", "clients", "list"} }}, + {"missing path", "same plugin", func(s *pluginsdk.ChoiceSource) { s.CommandPath = nil }}, + {"unknown command", "same plugin", func(s *pluginsdk.ChoiceSource) { s.CommandPath = []string{"fixture", "missing"} }}, + {"group command", "runnable command", func(s *pluginsdk.ChoiceSource) { s.CommandPath = []string{"fixture", "group"} }}, + {"too many arguments", "arguments", func(s *pluginsdk.ChoiceSource) { s.Args = []string{"one", "two"} }}, + {"missing value field", "value field", func(s *pluginsdk.ChoiceSource) { s.ValueField = " " }}, + {"empty label field", "label fields", func(s *pluginsdk.ChoiceSource) { s.LabelFields = []string{"id", " "} }}, + {"undeclared source flag", "undeclared flag", func(s *pluginsdk.ChoiceSource) { s.Flags = map[string]string{"missing": "value"} }}, + {"nonpersistent source flag", "undeclared flag", func(s *pluginsdk.ChoiceSource) { s.Flags = map[string]string{"root-only": "value"} }}, + {"invalid source flag value", "choice source flag", func(s *pluginsdk.ChoiceSource) { s.Flags = map[string]string{"enabled": "yes"} }}, + {"unknown input reference", "undeclared input", func(s *pluginsdk.ChoiceSource) { s.Flags = map[string]string{"filter": "$missing"} }}, + {"empty input reference", "undeclared input", func(s *pluginsdk.ChoiceSource) { s.Args = []string{"$"} }}, + {"argument reference too large", "outside command bounds", func(s *pluginsdk.ChoiceSource) { s.Args = []string{"$arg2"} }}, + {"negative argument reference", "outside command bounds", func(s *pluginsdk.ChoiceSource) { s.Args = []string{"$arg-1"} }}, + {"invalid after field", "after field", func(s *pluginsdk.ChoiceSource) { s.AfterField = "id/name" }}, + {"blank after field", "after field", func(s *pluginsdk.ChoiceSource) { s.AfterField = " " }}, + {"empty exclusion field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.ExcludeTrueFields = []string{""} }}, + {"blank exclusion field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.ExcludeTrueFields = []string{" "} }}, + {"nested exclusion field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.ExcludeTrueFields = []string{"catalog.deprecated"} }}, + {"numeric exclusion field prefix", "valid field name", func(s *pluginsdk.ChoiceSource) { s.ExcludeTrueFields = []string{"1deprecated"} }}, + {"duplicate exclusion field", "duplicate excluded field", func(s *pluginsdk.ChoiceSource) { + s.ExcludeTrueFields = []string{"deprecated", "archived", "deprecated"} + }}, + {"empty match field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"": {"READY"}} }}, + {"blank match field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{" ": {"READY"}} }}, + {"nested match field", "valid field name", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"stack.status": {"READY"}} }}, + {"numeric match field prefix", "valid field name", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"1status": {"READY"}} }}, + {"empty match value", "nonempty values", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"status": {"READY", ""}} }}, + {"nil match values", "at least one value", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"status": nil} }}, + {"empty match values", "at least one value", func(s *pluginsdk.ChoiceSource) { s.MatchFields = map[string][]string{"status": {}} }}, + {"duplicate match value", "duplicate value", func(s *pluginsdk.ChoiceSource) { + s.MatchFields = map[string][]string{"state": {"ACTIVE", "DISABLED", "ACTIVE"}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + input := &manifest.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + tc.mutate(input.Source) + if err := registerInteractionManifest(t, manifest); err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("rejection = %v, want %q", err, tc.message) + } + }) + } +} + +func TestInteractionKeysetPaginationRequiresTypedFlags(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, kind string }{{"after", "bool"}, {"page-size", "string"}, {"after", ""}, {"page-size", ""}} { + t.Run(tc.name+tc.kind, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + input := &manifest.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + input.Source.AfterField = "id" + changePaginationFlag(&manifest.Root.Subcommands[1], tc.name, tc.kind) + if err := registerInteractionManifest(t, manifest); err == nil || !strings.Contains(err.Error(), "after field requires") { + t.Fatalf("invalid pagination accepted: %v", err) + } + }) + } +} + +func changePaginationFlag(command *pluginsdk.CommandSpec, name, kind string) { + for index := range command.Flags { + flag := &command.Flags[index] + if flag.Name != name { + continue + } + if kind == "" { + command.Flags = append(command.Flags[:index], command.Flags[index+1:]...) + return + } + flag.Type = kind + if kind == "bool" { + flag.Default = "false" + } + return + } +} + +func TestInteractionAllowsSupportedDeclarations(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + mutate func(*pluginsdk.Manifest) + }{ + {"inherited persistent flag", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Inputs[0].Flag = "tenant" }}, + {"password input", func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Inputs[0].Secret = true }}, + {"shared alternative flags", sharedAlternativeInputs}, + {"shared alternative arguments", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{ + {Title: "Name", Kind: "input", Flag: "name", AlternativeArgument: new(0)}, + {Title: "Stack", Kind: "input", Context: "stack", AlternativeArgument: new(0)}, + {Title: "Position", Kind: "input", Argument: new(0)}, + } + }}, + {"static default outside options", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Name", Kind: "select", Flag: "name", Default: "not-listed", Options: []pluginsdk.InputOption{{Label: "A", Value: "a"}}}} + }}, + {"optional empty option", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Name", Kind: "select", Flag: "name", Options: []pluginsdk.InputOption{{Label: "None"}}}} + }}, + {"JSON textarea default", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "JSON", Kind: "text", Flag: "data", ValueType: "json", Default: `{"amount":900719925474099312345}`}} + }}, + {"exact number default", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Amount", Kind: "input", BodyPointer: "/amount", ValueType: "number", Default: "1e10000"}} + }}, + {"boolean body default", func(m *pluginsdk.Manifest) { + m.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{Title: "Public", Kind: "confirm", BodyPointer: "/public", ValueType: "bool", Default: "false"}} + }}, + {"dynamic default and preferred prefix", func(m *pluginsdk.Manifest) { + input := &m.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source, input.Default = "select", interactionSource(), "not-yet-listed" + input.Source.PreferredPrefix, input.Source.AfterField = "v4.", "id" + input.Source.Args, input.Source.Flags = []string{"$arg0"}, map[string]string{"filter": "$tenant", "enabled": "true"} + }}, + {"exclusion field names", func(m *pluginsdk.Manifest) { + input := &m.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + input.Source.ExcludeTrueFields = []string{"deprecated", "Deprecated", "is_archived", "legacy-status", "_hidden2"} + }}, + {"matching scalar field values with exclusions", func(m *pluginsdk.Manifest) { + input := &m.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + input.Source.ExcludeTrueFields = []string{"deprecated"} + input.Source.MatchFields = map[string][]string{"state": {"ACTIVE", "DISABLED"}, "status": {"READY"}, "lastState": {"READY"}, "is_enabled": {"true"}, "legacy-status": {"Needs review"}, "_revision2": {"7", "8"}} + }}, + {"source mutation guarded at execution", func(m *pluginsdk.Manifest) { + input := &m.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + input.Source.CommandPath = []string{"fixture", "write"} + }}, + {"host resource reference", func(m *pluginsdk.Manifest) { + input := &m.Root.Subcommands[0].Inputs[0] + input.Kind, input.Source = "select", interactionSource() + input.Source.Args = []string{"$organization"} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := validationInteractionManifest() + tc.mutate(&manifest) + if err := registerInteractionManifest(t, manifest); err != nil { + t.Fatalf("valid metadata rejected: %v", err) + } + }) + } +} + +func sharedAlternativeInputs(manifest *pluginsdk.Manifest) { + manifest.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{ + {Title: "Name", Kind: "input", Flag: "name", AlternativeFlag: "data"}, + {Title: "Description", Kind: "input", BodyPointer: "/description", AlternativeFlag: "data"}, + {Title: "Position", Kind: "input", Argument: new(0), AlternativeFlag: "data"}, + } +} + +func TestInteractionAllEmbeddedManifestsRegisterAndRootStarts(t *testing.T) { + t.Parallel() + registry := &plugin.Registry{} + for _, factory := range []plugin.Factory{cloudplugin.New} { + if err := registry.Register(t.Context(), factory(nil), factory); err != nil { + t.Fatalf("embedded manifest registration failed: %v", err) + } + } + if len(registry.List()) != 1 { + t.Fatal("an embedded manifest was not registered") + } + root := cmd.NewRootCommand() + for _, name := range []string{"auth", "cloud", "ledger"} { + leaf, _, err := root.Find([]string{name}) + if err != nil || leaf == root { + t.Fatalf("root command did not attach %s: %v", name, err) + } + } + if leaf, _, err := root.Find([]string{"connectivity"}); err == nil && leaf != root { + t.Fatal("removed Connectivity root is still attached") + } +} diff --git a/internal/plugin/validate_stream.go b/internal/plugin/validate_stream.go new file mode 100644 index 00000000..06dd5d79 --- /dev/null +++ b/internal/plugin/validate_stream.go @@ -0,0 +1,62 @@ +package plugin + +import ( + "cmp" + "fmt" + "slices" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func streamInput(spec pluginsdk.CommandSpec) bool { + return spec.Streams != nil && spec.Streams.Input +} + +func validPluginReadFormat(spec pluginsdk.CommandSpec) bool { + return (streamInput(spec) && spec.Files.ReadFormat == "ndjson") || + slices.Contains([]string{"", "string", "json", "yaml"}, spec.Files.ReadFormat) +} + +func validateStreamSpec(spec pluginsdk.CommandSpec, flags map[string]pluginsdk.FlagSpec) error { + if spec.Streams == nil { + return nil + } + if !spec.Runnable || len(spec.Subcommands) != 0 { + return fmt.Errorf("streams require a runnable leaf") + } + if !spec.Streams.Input && !spec.Streams.Output { + return nil + } + if spec.Files == nil { + return fmt.Errorf("streams require a file specification") + } + if spec.Streams.Input { + if spec.Files.ReadArgument == nil && spec.Files.ReadFlag == "" { + return fmt.Errorf("input stream requires a declared source") + } + if !slices.Contains([]string{"string", "ndjson"}, spec.Files.ReadFormat) { + return fmt.Errorf("input stream requires a raw string or ndjson format") + } + } + if spec.Streams.Output && spec.Files.FormatFlag != "" { + return checkPluginStreamFormat(spec, pluginsdk.ExecuteRequest{Flags: map[string]string{ + spec.Files.FormatFlag: flags[spec.Files.FormatFlag].Default, + }}) + } + return nil +} + +func checkPluginStreamFormat(spec pluginsdk.CommandSpec, request pluginsdk.ExecuteRequest) error { + if spec.Streams == nil || !spec.Streams.Output { + return nil + } + format, err := pluginOutputFormat(spec.Files, request) + if err != nil { + return err + } + declared := cmp.Or(spec.Files.WriteFormat, "json") + if cmp.Or(format, "json") != declared { + return fmt.Errorf("output stream format must match declared format %q", declared) + } + return nil +} diff --git a/internal/pluginhost/bootstrap.go b/internal/pluginhost/bootstrap.go new file mode 100644 index 00000000..53b52a9a --- /dev/null +++ b/internal/pluginhost/bootstrap.go @@ -0,0 +1,136 @@ +package pluginhost + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// PrepareService selects an external provider without starting it for cached help. +func PrepareService(ctx context.Context, root *cobra.Command, settings *connection.Settings, args []string, service string) (plugin.Factory, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return nil, err + } + plan, err := parsePluginBootstrap(root, args) + if err != nil { + return nil, err + } + manager, err := pluginManager(settings, root) + if err != nil { + return nil, err + } + prep := servicePreparation{root: root, settings: settings, manager: manager, service: descriptor} + lock, err := prep.resolve(ctx, plan) + if errors.Is(err, pluginmanager.ErrNotInstalled) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("prepare %s plugin: %w", descriptor.title, err) + } + if err := validateServiceManifest(ctx, root, service, lock.Manifest); err != nil { + return nil, err + } + return plugin.ExternalFactoryWithVerifier(lock.Manifest, func(ctx context.Context) (string, error) { + return manager.BinaryContext(ctx, lock) + }), nil +} + +type pluginBootstrap struct { + commands []string + help bool +} + +func parsePluginBootstrap(root *cobra.Command, args []string) (pluginBootstrap, error) { + bootstrap := pflag.NewFlagSet("plugin selection", pflag.ContinueOnError) + bootstrap.SetOutput(io.Discard) + bootstrap.AddFlagSet(root.PersistentFlags()) + bootstrap.ParseErrorsWhitelist.UnknownFlags = true + var plan pluginBootstrap + bootstrap.BoolVarP(&plan.help, "help", "h", false, "") + err := bootstrap.Parse(args) + plan.commands = bootstrap.Args() + return plan, err +} + +type servicePreparation struct { + service serviceDescriptor + root *cobra.Command + settings *connection.Settings + manager *pluginmanager.Manager +} + +func (p servicePreparation) resolve(ctx context.Context, plan pluginBootstrap) (pluginmanager.Lock, error) { + lock, loadErr := p.cached() + catalogue := os.Getenv("FCTL_PLUGIN_CATALOGUE") + if catalogue == "" && loadErr == nil { + catalogue = lock.Catalogue + } + if len(plan.commands) == 1 && plan.commands[0] == p.service.name { + return lock, loadErr + } + if catalogue != "" && !plan.help && len(plan.commands) > 0 && plan.commands[0] == p.service.name { + return p.matchVersion(ctx, catalogue, nil) + } + if catalogue == "" && errors.Is(loadErr, pluginmanager.ErrNotInstalled) && !plan.help && + len(plan.commands) > 0 && plan.commands[0] == p.service.name { + return p.discover(ctx) + } + return lock, loadErr +} + +func (p servicePreparation) discover(ctx context.Context) (pluginmanager.Lock, error) { + catalogue, err := p.manager.Discover(ctx, pluginmanager.DefaultCatalogue) + if err != nil { + return pluginmanager.Lock{}, err + } + if catalogue.HasService(p.service.name, pluginmanager.CurrentPlatform()) { + return p.matchVersion(ctx, pluginmanager.DefaultCatalogue, &catalogue) + } + return pluginmanager.Lock{}, fmt.Errorf("%w: %s has no published plugin for %s; prepare it with fctl plugins sync --service %s or plugins install --service %s --binary PATH", pluginmanager.ErrNoRelease, p.service.title, pluginmanager.CurrentPlatform(), p.service.name, p.service.name) +} + +func (p servicePreparation) cached() (pluginmanager.Lock, error) { + return cachedServiceLock(p.settings, p.root, p.manager, p.service.name) +} + +func (p servicePreparation) matchVersion(ctx context.Context, catalogue string, discovered *pluginmanager.Catalogue) (pluginmanager.Lock, error) { + target, version, err := pluginServiceSyncTarget(ctx, p.settings, p.root, p.service.name, "") + if err != nil { + return pluginmanager.Lock{}, err + } + lock, err := p.manager.Load(target, p.service.name) + if err == nil && lock.ServiceVersion == version { + return lock, nil + } + if err != nil && !errors.Is(err, pluginmanager.ErrNotInstalled) { + return lock, err + } + var release pluginmanager.Release + if discovered != nil { + release, err = p.manager.ResolveCatalogue(ctx, *discovered, p.service.name, version, 0) + } else { + release, err = p.manager.Resolve(ctx, catalogue, p.service.name, version, 0) + } + if err != nil { + return pluginmanager.Lock{}, err + } + if err := validateServiceManifest(ctx, p.root, p.service.name, release.Manifest); err != nil { + return pluginmanager.Lock{}, err + } + lock, err = p.manager.InstallResolved(ctx, catalogue, target, release) + if err != nil { + return lock, err + } + _, err = fmt.Fprintf(p.root.ErrOrStderr(), "Prepared %s plugin %s (revision %d).\n", p.service.title, lock.ServiceVersion, lock.Revision) + return lock, err +} diff --git a/internal/pluginhost/catalogue.go b/internal/pluginhost/catalogue.go new file mode 100644 index 00000000..b3ac3c61 --- /dev/null +++ b/internal/pluginhost/catalogue.go @@ -0,0 +1,24 @@ +package pluginhost + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func servicePluginCatalogue(cmd *cobra.Command, settings *connection.Settings, manager *pluginmanager.Manager, service, catalogue string) (string, error) { + if catalogue != "" { + return catalogue, nil + } + if value := os.Getenv("FCTL_PLUGIN_CATALOGUE"); value != "" { + return value, nil + } + lock, err := cachedServiceLock(settings, cmd, manager, service) + if err == nil && lock.Catalogue != "" { + return lock.Catalogue, nil + } + return pluginmanager.DefaultCatalogue, nil +} diff --git a/internal/pluginhost/catalogue_references_test.go b/internal/pluginhost/catalogue_references_test.go new file mode 100644 index 00000000..d3fc7b2f --- /dev/null +++ b/internal/pluginhost/catalogue_references_test.go @@ -0,0 +1,271 @@ +package pluginhost + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// referencedRegistry serves real digest-addressed OCI objects published through +// the manager's public API. Its binary is deliberately not executable: bootstrap +// and cached metadata must never launch a downloaded plugin. +type referencedRegistry struct { + server *httptest.Server + mu sync.Mutex + objects map[string][]byte + reads map[string]int + version string +} + +func newReferencedRegistry(t *testing.T) *referencedRegistry { + t.Helper() + r := &referencedRegistry{objects: make(map[string][]byte), reads: make(map[string]int), version: "1.0.0"} + r.server = httptest.NewServer(http.HandlerFunc(r.serve)) + t.Cleanup(r.server.Close) + return r +} + +func (r *referencedRegistry) serve(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + path := req.URL.Path + switch req.Method { + case http.MethodPost: + w.Header().Set("Location", "/v2/formance/auth/blobs/uploads/fixture") + w.WriteHeader(http.StatusAccepted) + case http.MethodPut: + data, err := io.ReadAll(req.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if strings.Contains(path, "/uploads/") { + path = "/v2/formance/auth/blobs/" + req.URL.Query().Get("digest") + } + r.objects[path] = data + w.WriteHeader(http.StatusCreated) + case http.MethodGet: + r.reads[path]++ + data, exists := r.objects[path] + if path == "/_info" { + data, exists = []byte(`{"version":"v`+r.version+`"}`), true + } + if !exists { + w.WriteHeader(http.StatusNotFound) + return + } + if strings.Contains(path, "/manifests/") { + w.Header().Set("Content-Type", pluginmanager.ImageManifestMediaType) + } + if _, err := w.Write(data); err != nil { + return + } + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func referenceJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func referenceHash(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func (r *referencedRegistry) populate(t *testing.T, manager *pluginmanager.Manager) { + t.Helper() + binary := filepath.Join(t.TempDir(), "metadata-only") + if err := os.WriteFile(binary, []byte("metadata must not launch this binary"), 0o600); err != nil { + t.Fatal(err) + } + var refs []pluginmanager.CatalogueReference + for _, version := range []string{"1.0.0", "1.0.1"} { + manifest := loaderManifest("auth") + manifest.Version = version + release, err := manager.Publish(t.Context(), r.server.URL+"/formance/auth", pluginmanager.Release{ + Service: "auth", ServiceVersion: version, Revision: 1, Platform: pluginmanager.CurrentPlatform(), Manifest: manifest, + }, binary) + if err != nil { + t.Fatal(err) + } + data := referenceJSON(t, pluginmanager.Catalogue{SchemaVersion: 1, Releases: []pluginmanager.Release{release}}) + path := "/auth/" + version + "/catalogue.json" + r.mu.Lock() + r.objects[path] = data + r.mu.Unlock() + refs = append(refs, pluginmanager.CatalogueReference{ServiceVersion: version, Catalogue: r.server.URL + path, SHA256: referenceHash(data)}) + } + index := referenceJSON(t, pluginmanager.Catalogue{SchemaVersion: 2, Plugins: map[string]pluginmanager.ProductCatalogues{"auth": {Releases: refs}}}) + r.mu.Lock() + r.objects["/index.json"] = index + r.mu.Unlock() +} + +func referencedPreparation(t *testing.T) (servicePreparation, *referencedRegistry) { + t.Helper() + r := newReferencedRegistry(t) + root, settings := loaderSettings(t) + loaderFlag(t, root, "auth-url", r.server.URL) + root.SetContext(t.Context()) + root.SetErr(io.Discard) + manager, err := pluginManager(settings, root) + if err != nil { + t.Fatal(err) + } + r.populate(t, manager) + // Only the public root index is redirected; service and OCI traffic still + // passes through real loopback HTTP endpoints. + transport := http.DefaultTransport + manager, err = pluginmanager.New(filepath.Join(settings.Directory, "plugins"), &http.Client{Transport: discoveryTransport(func(req *http.Request) (*http.Response, error) { + if req.URL.String() == pluginmanager.DefaultCatalogue { + clone := req.Clone(req.Context()) + parsed, parseErr := url.Parse(r.server.URL + "/index.json") + if parseErr != nil { + return nil, parseErr + } + clone.URL = parsed + req = clone + } + return transport.RoundTrip(req) + })}) + if err != nil { + t.Fatal(err) + } + return servicePreparation{root: root, settings: settings, manager: manager, service: serviceDescriptor{name: "auth", title: "Auth"}}, r +} + +func TestReferencedCatalogueAutomaticDiscoveryAndUpgrade(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep, registry := referencedPreparation(t) + plan := pluginBootstrap{commands: []string{"auth", "probe"}} + first, err := prep.resolve(t.Context(), plan) + if err != nil { + t.Fatal(err) + } + if first.ServiceVersion != "1.0.0" || first.Catalogue != pluginmanager.DefaultCatalogue || first.Local { + t.Fatalf("unexpected discovered lock: %+v", first) + } + registry.mu.Lock() + initialReads, futureReads := registry.reads["/auth/1.0.0/catalogue.json"], registry.reads["/auth/1.0.1/catalogue.json"] + registry.version = "1.0.1" + registry.mu.Unlock() + if initialReads != 1 || futureReads != 0 { + t.Fatalf("initial/future catalogue reads: %d/%d", initialReads, futureReads) + } + upgraded, err := prep.resolve(t.Context(), plan) + if err != nil { + t.Fatal(err) + } + if upgraded.ServiceVersion != "1.0.1" || upgraded.Target != first.Target || upgraded.Catalogue != pluginmanager.DefaultCatalogue { + t.Fatalf("upgrade lost original index or target: %+v", upgraded) + } + locks, err := prep.manager.List() + if err != nil || len(locks) != 1 || locks[0].ServiceVersion != "1.0.1" { + t.Fatalf("target upgrade: %+v, %v", locks, err) + } +} + +func TestReferencedCatalogueExplicitSyncAndOfflineMetadata(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep, registry := referencedPreparation(t) + lock, err := Sync(prep.root, prep.settings, "auth", registry.server.URL+"/index.json", "1.0.0", 0) + if err != nil { + t.Fatal(err) + } + if lock.Catalogue != registry.server.URL+"/index.json" || lock.ServiceVersion != "1.0.0" { + t.Fatalf("sync lock: %+v", lock) + } + binary, err := prep.manager.Binary(lock) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } + registry.server.Close() + for _, args := range [][]string{{"auth", "--help"}, {"__complete", "auth", ""}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { testReferencedOfflineMetadata(t, prep, args) }) + } +} + +func testReferencedOfflineMetadata(t *testing.T, prep servicePreparation, args []string) { + t.Helper() + root := &cobra.Command{Use: "fctl", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().AddFlagSet(prep.root.PersistentFlags()) + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(io.Discard) + factory, err := PrepareService(t.Context(), root, prep.settings, args, "auth") + if err != nil || factory == nil { + t.Fatalf("offline factory: %v", err) + } + registered := &plugin.Registry{} + if err := registered.Register(t.Context(), factory(nil), factory); err != nil { + t.Fatal(err) + } + adapter := plugin.NewCommand(registered, func(context.Context, string) (*api.Client, error) { + t.Fatal("metadata attempted service execution") + return nil, context.Canceled + }) + if err := adapter.AddTo(root); err != nil { + t.Fatal(err) + } + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "probe") { + t.Fatalf("cached command missing: %s", output.String()) + } +} + +func TestReferencedCatalogueHashFailureDoesNotInstall(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep, registry := referencedPreparation(t) + registry.mu.Lock() + registry.objects["/auth/1.0.0/catalogue.json"] = append(registry.objects["/auth/1.0.0/catalogue.json"], '\n') + registry.mu.Unlock() + _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"auth", "probe"}}) + if err == nil || !strings.Contains(err.Error(), "SHA256 mismatch") { + t.Fatalf("tampered catalogue: %v", err) + } + target, err := pluginServiceTarget(prep.settings, prep.root, "auth") + if err != nil { + t.Fatal(err) + } + if _, err := prep.manager.Load(target, "auth"); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("tampered catalogue persisted a lock: %v", err) + } + registry.mu.Lock() + defer registry.mu.Unlock() + for path, count := range registry.reads { + if strings.HasPrefix(path, "/v2/") && count != 0 { + t.Fatalf("tampered catalogue downloaded artifact %s", path) + } + } +} diff --git a/internal/pluginhost/discovery_test.go b/internal/pluginhost/discovery_test.go new file mode 100644 index 00000000..d9affc9e --- /dev/null +++ b/internal/pluginhost/discovery_test.go @@ -0,0 +1,101 @@ +package pluginhost + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func discoveryPreparation(t *testing.T, transport http.RoundTripper) servicePreparation { + t.Helper() + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + settings.Options.AuthMode = "none" + settings.Options.LedgerURL = "http://127.0.0.1:1" + manager, err := pluginmanager.New(t.TempDir(), &http.Client{Transport: transport}) + if err != nil { + t.Fatal(err) + } + return servicePreparation{service: serviceDescriptor{name: "ledger", title: "Ledger"}, root: root, settings: settings, manager: manager} +} + +func TestOfficialDiscoveryRequiresPublishedLedgerPlugin(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + for _, test := range []struct { + name, data string + status int + want error + }{ + {"empty", "schemaVersion: 1\nreleases: []\n", http.StatusOK, pluginmanager.ErrNoRelease}, + {"unavailable", "", http.StatusServiceUnavailable, pluginmanager.ErrCatalogueUnavailable}, + {"invalid", "schemaVersion: 1\nreleases: invalid\n", http.StatusOK, nil}, + } { + t.Run(test.name, func(t *testing.T) { + requests := 0 + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + requests++ + if request.URL.String() != pluginmanager.DefaultCatalogue { + t.Fatalf("unexpected registry: %s", request.URL) + } + return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.data)), Header: make(http.Header)}, nil + })) + _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"ledger", "list"}}) + if err == nil || errors.Is(err, pluginmanager.ErrNotInstalled) || (test.want != nil && !errors.Is(err, test.want)) || requests != 1 { + t.Fatalf("want=%v, requests=%d, error=%v", test.want, requests, err) + } + }) + } +} + +func TestOfficialDiscoverySkipsOfflineMetadataAndOtherModules(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("metadata attempted discovery: %s", request.URL) + return nil, context.Canceled + })) + for _, plan := range []pluginBootstrap{ + {commands: []string{"ledger"}, help: true}, + {commands: []string{"__complete", "ledger"}}, + {commands: []string{"auth", "info"}}, + {}, + } { + if _, err := prep.resolve(t.Context(), plan); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("offline bootstrap: %v", err) + } + } +} + +func TestPluginCatalogueUsesOfficialDefaultAndExplicitOverrides(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + prep := discoveryPreparation(t, discoveryTransport(func(*http.Request) (*http.Response, error) { + t.Fatal("source selection attempted a download") + return nil, context.Canceled + })) + for _, test := range []struct{ explicit, environment, want string }{ + {"", "", pluginmanager.DefaultCatalogue}, + {"", "https://example.com/environment.yaml", "https://example.com/environment.yaml"}, + {"explicit.yaml", "https://example.com/environment.yaml", "explicit.yaml"}, + } { + t.Setenv("FCTL_PLUGIN_CATALOGUE", test.environment) + got, err := servicePluginCatalogue(prep.root, prep.settings, prep.manager, "ledger", test.explicit) + if err != nil || got != test.want { + t.Fatalf("catalogue: %q, %v", got, err) + } + } +} + +type discoveryTransport func(*http.Request) (*http.Response, error) + +func (f discoveryTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/internal/pluginhost/inspection.go b/internal/pluginhost/inspection.go new file mode 100644 index 00000000..85ad72af --- /dev/null +++ b/internal/pluginhost/inspection.go @@ -0,0 +1,83 @@ +package pluginhost + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +// SelectionTarget identifies a provider independently of its artifact cache. +func SelectionTarget(settings *connection.Settings, cmd *cobra.Command, service string) (pluginselection.Target, error) { + options, _, name, _, err := settings.Resolve(cmd) + if err != nil { + return pluginselection.Target{}, err + } + endpoint := "" + switch service { + case "ledger": + if options.LedgerURL != "" { + endpoint = options.LedgerURL + } + case "auth": + if options.AuthURL != "" { + endpoint = options.AuthURL + } + } + if endpoint == "" && options.StackURL != "" { + endpoint = strings.TrimRight(options.StackURL, "/") + "/api/" + service + } + if options.AuthMode == "cloud" { + endpoint = options.Issuer + if endpoint == "" { + endpoint = "https://app.formance.cloud/api" + } + } + return pluginselection.Target{Profile: name, Organization: options.Organization, Stack: options.Stack, Endpoint: endpoint}, nil +} + +func InspectService(ctx context.Context, root *cobra.Command, settings *connection.Settings, service string) (pluginselection.Selection, error) { + client, err := settings.Client(ctx, root, service) + if err != nil { + return pluginselection.Selection{}, err + } + data, err := client.Do(ctx, http.MethodGet, "/_info", nil, nil, nil) + if err != nil { + return pluginselection.Selection{}, fmt.Errorf("discover %s version: %w", service, err) + } + version, err := pluginsdk.ServiceVersion(data) + if err != nil { + return pluginselection.Selection{}, err + } + line, err := settings.StackVersion(ctx, root) + if err != nil { + return pluginselection.Selection{}, err + } + target, err := SelectionTarget(settings, root, service) + if err != nil { + return pluginselection.Selection{}, err + } + return pluginselection.Selection{Target: target, Service: service, ServiceVersion: version, StackVersion: line}, nil +} + +// SelectedCommand inspects core flags before a provider supplies its command tree. +func SelectedCommand(root *cobra.Command, args []string) (string, bool, error) { + plan, err := parsePluginBootstrap(root, args) + if err != nil { + return "", false, err + } + if len(plan.commands) == 0 { + return "", true, nil + } + if plan.commands[0] == "__complete" || plan.commands[0] == "completion" { + return "", true, nil + } + return plan.commands[0], plan.help || len(plan.commands) == 1, nil +} diff --git a/internal/pluginhost/inspection_test.go b/internal/pluginhost/inspection_test.go new file mode 100644 index 00000000..7c49e98e --- /dev/null +++ b/internal/pluginhost/inspection_test.go @@ -0,0 +1,92 @@ +package pluginhost + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/formancehq/fctl/v4/internal/pluginselection" +) + +func TestInspectionSelectionSeparatesGatewayAndStandaloneEndpoints(t *testing.T) { + t.Setenv("FCTL_PROFILE", "") + for _, service := range []string{"ledger", "auth"} { + t.Run(service, func(t *testing.T) { + testInspectionSelectionEndpoints(t, service) + }) + } +} + +func testInspectionSelectionEndpoints(t *testing.T, service string) { + t.Helper() + root, settings := loaderSettings(t) + const endpoint = "https://stack.example/gateway" + loaderFlag(t, root, "stack-url", endpoint+"///") + gateway, err := SelectionTarget(settings, root, service) + if err != nil { + t.Fatal(err) + } + client, err := settings.Client(t.Context(), root, service) + if err != nil || gateway.Endpoint != endpoint+"/api/"+service || gateway.Endpoint != client.Endpoint() { + t.Fatalf("selection does not identify the actual gateway service: target=%+v err=%v", gateway, err) + } + directory := filepath.Join(settings.Directory, "plugins") + selected := pluginselection.Selection{Target: gateway, Service: service, ServiceVersion: "2.4.15", Provider: "legacy", PluginVersion: "1.0.0"} + if err := pluginselection.Save(directory, selected); err != nil { + t.Fatal(err) + } + // The same literal URL has a different meaning as a service override. + loaderFlag(t, root, service+"-url", endpoint) + standalone, err := SelectionTarget(settings, root, service) + if err != nil || standalone.Endpoint != endpoint || standalone == gateway { + t.Fatalf("standalone target collided with gateway: target=%+v err=%v", standalone, err) + } + client, err = settings.Client(t.Context(), root, service) + if err != nil || standalone.Endpoint != client.Endpoint() { + t.Fatalf("direct selection differs from request endpoint: target=%+v err=%v", standalone, err) + } + if _, err := pluginselection.Load(directory, standalone, service); !errors.Is(err, pluginselection.ErrNotSelected) { + t.Fatalf("standalone connection reused gateway selection: %v", err) + } + if cached, err := pluginselection.Load(directory, gateway, service); err != nil || cached != selected { + t.Fatalf("gateway selection changed: %+v err=%v", cached, err) + } +} + +func TestInspectionSnapshotUsesExactRequestEndpoint(t *testing.T) { + t.Setenv("FCTL_PROFILE", "") + for _, service := range []string{"ledger", "auth"} { + t.Run(service, func(t *testing.T) { testInspectionSnapshotEndpoint(t, service) }) + } +} + +func testInspectionSnapshotEndpoint(t *testing.T, service string) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("inspection made a mutation: %s %s", r.Method, r.URL.Path) + } + version := "2.4.15" + if r.URL.Path != "/gateway/api/"+service+"/_info" { + t.Errorf("inspection queried the wrong endpoint: %s", r.URL.Path) + version = "3.0.0" + } + if err := json.NewEncoder(w).Encode(map[string]any{"data": map[string]string{"version": version}}); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + root, settings := loaderSettings(t) + loaderFlag(t, root, "stack-url", server.URL+"/gateway/") + selection, err := InspectService(t.Context(), root, settings, service) + if err != nil { + t.Fatal(err) + } + want := pluginselection.Selection{Target: pluginselection.Target{Endpoint: server.URL + "/gateway/api/" + service}, Service: service, ServiceVersion: "2.4.15"} + if selection != want { + t.Fatalf("inspected snapshot = %+v, want %+v", selection, want) + } +} diff --git a/internal/pluginhost/management.go b/internal/pluginhost/management.go new file mode 100644 index 00000000..3dc5e717 --- /dev/null +++ b/internal/pluginhost/management.go @@ -0,0 +1,99 @@ +package pluginhost + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// Install validates and caches a trusted local service executable. +func Install(cmd *cobra.Command, settings *connection.Settings, service, binary, localVersion string) (pluginmanager.Lock, error) { + if err := validateService(service); err != nil { + return pluginmanager.Lock{}, err + } + if binary == "" { + return pluginmanager.Lock{}, fmt.Errorf("provide --binary PATH to the %s plugin executable", service) + } + path, err := filepath.Abs(binary) + if err != nil { + return pluginmanager.Lock{}, err + } + manifest, err := readPluginManifest(cmd.Context(), path) + if err != nil { + return pluginmanager.Lock{}, err + } + + if err := validateServiceManifest(cmd.Context(), cmd.Root(), service, manifest); err != nil { + return pluginmanager.Lock{}, err + } + target, version, err := pluginServiceSyncTarget(cmd.Context(), settings, cmd, service, localVersion) + if err != nil { + return pluginmanager.Lock{}, err + } + if manifest.Version != version { + return pluginmanager.Lock{}, fmt.Errorf("plugin targets %s %s, selected service is %s; build the matching plugin", service, manifest.Version, version) + } + manager, err := pluginManager(settings, cmd) + if err != nil { + return pluginmanager.Lock{}, err + } + return manager.InstallLocal(cmd.Context(), path, target, manifest) +} + +// Sync prepares the plugin for the exact selected service version. +func Sync(cmd *cobra.Command, settings *connection.Settings, service, catalogue, serviceVersion string, revision int) (pluginmanager.Lock, error) { + if err := validateService(service); err != nil { + return pluginmanager.Lock{}, err + } + manager, err := pluginManager(settings, cmd) + if err != nil { + return pluginmanager.Lock{}, err + } + catalogue, err = servicePluginCatalogue(cmd, settings, manager, service, catalogue) + if err != nil { + return pluginmanager.Lock{}, err + } + + target, version, err := pluginServiceSyncTarget(cmd.Context(), settings, cmd, service, serviceVersion) + if err != nil { + return pluginmanager.Lock{}, err + } + release, err := manager.Resolve(cmd.Context(), catalogue, service, version, revision) + if err != nil { + return pluginmanager.Lock{}, err + } + if err := validateServiceManifest(cmd.Context(), cmd.Root(), service, release.Manifest); err != nil { + return pluginmanager.Lock{}, err + } + return manager.InstallResolved(cmd.Context(), catalogue, target, release) +} + +// List returns all prepared service locks. +func List(settings *connection.Settings, cmd *cobra.Command) ([]pluginmanager.Lock, error) { + manager, err := pluginManager(settings, cmd) + if err != nil { + return nil, err + } + return manager.List() +} + +// Show returns cached metadata for the selected service target. +func Show(settings *connection.Settings, cmd *cobra.Command, service string) (pluginmanager.Lock, error) { + if err := validateService(service); err != nil { + return pluginmanager.Lock{}, err + } + manager, err := pluginManager(settings, cmd) + if err != nil { + return pluginmanager.Lock{}, err + } + return cachedServiceLock(settings, cmd, manager, service) +} + +func validateService(service string) error { + _, err := supportedPluginService(service) + return err +} diff --git a/internal/pluginhost/manager.go b/internal/pluginhost/manager.go new file mode 100644 index 00000000..47b9abc6 --- /dev/null +++ b/internal/pluginhost/manager.go @@ -0,0 +1,55 @@ +// Package pluginhost prepares external service providers and manages their target caches. +package pluginhost + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func pluginManager(settings *connection.Settings, cmd *cobra.Command) (*pluginmanager.Manager, error) { + dir, err := settings.DirectoryPath() + if err != nil { + return nil, err + } + client := settings.HTTPClient(cmd.ErrOrStderr()) + client.CheckRedirect = nil // The manager validates public download redirects. + return pluginmanager.New(filepath.Join(dir, "plugins"), client) +} + +func pluginServiceSyncTarget(ctx context.Context, settings *connection.Settings, cmd *cobra.Command, service, version string) (pluginmanager.Target, string, error) { + if _, err := supportedPluginService(service); err != nil { + return pluginmanager.Target{}, "", err + } + if version == "" { + client, err := settings.Client(ctx, cmd, service) + if err != nil { + return pluginmanager.Target{}, "", err + } + version, err = pluginServiceVersion(ctx, client, service) + if err != nil { + return pluginmanager.Target{}, "", err + } + } + target, err := pluginServiceTarget(settings, cmd, service) + if err != nil { + return target, "", err + } + options, _, _, _, err := settings.Resolve(cmd) + if err != nil { + return target, "", err + } + if err := connection.Validate(options); err != nil { + return target, "", err + } + if options.AuthMode == "cloud" && (target.Organization == "" || target.Stack == "") { + err = fmt.Errorf("choose --organization and --stack, or omit --service-version to select the Cloud target") + } + return target, strings.TrimPrefix(version, "v"), err +} diff --git a/internal/pluginhost/manifest.go b/internal/pluginhost/manifest.go new file mode 100644 index 00000000..d11c72d4 --- /dev/null +++ b/internal/pluginhost/manifest.go @@ -0,0 +1,65 @@ +package pluginhost + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +// External providers may replace only the root and connection boundary of their service. +func serviceManifestIdentity(service string, manifest pluginsdk.Manifest) error { + descriptor, err := supportedPluginService(service) + if err != nil { + return err + } + if manifest.Name != descriptor.name || manifest.Service != descriptor.name || pluginsdk.CommandName(manifest.Root) != descriptor.name { + return fmt.Errorf("%s plugins must use the %s service and command root", descriptor.title, descriptor.name) + } + return validateServiceOverrides(service, manifest.Root) +} + +func validateServiceOverrides(service string, command pluginsdk.CommandSpec) error { + if command.Service != "" && command.Service != service { + return fmt.Errorf("%s plugin command %q cannot override service with %q", service, command.Use, command.Service) + } + for _, child := range command.Subcommands { + if err := validateServiceOverrides(service, child); err != nil { + return err + } + } + return nil +} + +//nolint:contextcheck // Validation builds callbacks without executing them. +func validateServiceManifest(ctx context.Context, root *cobra.Command, service string, manifest pluginsdk.Manifest) error { + if err := serviceManifestIdentity(service, manifest); err != nil { + return err + } + registry := &plugin.Registry{} + factory := plugin.ExternalFactory("", manifest) + if err := registry.Register(ctx, factory(nil), factory); err != nil { + return fmt.Errorf("invalid %s plugin manifest: %w", service, err) + } + check := &cobra.Command{Use: "fctl"} + check.PersistentFlags().AddFlagSet(root.PersistentFlags()) + return plugin.NewCommand(registry, func(context.Context, string) (*api.Client, error) { + return nil, fmt.Errorf("manifest validation does not execute commands") + }).AddTo(check) +} + +func readPluginManifest(ctx context.Context, path string) (pluginsdk.Manifest, error) { + instance, err := transport.Open(ctx, path, nil, "") + if err != nil { + return pluginsdk.Manifest{}, err + } + manifest, err := instance.GetManifest(ctx) + return manifest, errors.Join(err, instance.Close()) +} diff --git a/internal/pluginhost/resolver.go b/internal/pluginhost/resolver.go new file mode 100644 index 00000000..b8ebfb86 --- /dev/null +++ b/internal/pluginhost/resolver.go @@ -0,0 +1,34 @@ +package pluginhost + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/plugin" +) + +// RequestResolver resolves authenticated clients for plugin requests. +func RequestResolver(root *cobra.Command, settings *connection.Settings) plugin.RequestResolver { + return func(ctx context.Context, service string, request pluginsdk.ExecuteRequest) (*api.Client, error) { + cmd, _, err := root.Find(request.CommandPath) + if err != nil { + return nil, err + } + for _, name := range []string{"organization", "stack"} { + if value := request.Context[name]; value != "" { + if err := root.PersistentFlags().Set(name, value); err != nil { + return nil, err + } + } + } + if service == "cloud-apps" { + return settings.ApplicationClient(ctx, cmd, request.Flags["deploy-app-alias"]) + } + return settings.Client(ctx, cmd, service) + } +} diff --git a/internal/pluginhost/service_loader_test.go b/internal/pluginhost/service_loader_test.go new file mode 100644 index 00000000..76819831 --- /dev/null +++ b/internal/pluginhost/service_loader_test.go @@ -0,0 +1,269 @@ +package pluginhost + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func loaderManifest(service string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: service, Service: service, Version: "1.0.0", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: service, Short: "Cached external " + service, Subcommands: []pluginsdk.CommandSpec{{Use: "probe", Short: "External probe", Runnable: true}}}} +} + +func loaderSettings(t *testing.T) (*cobra.Command, *connection.Settings) { + t.Helper() + root := &cobra.Command{Use: "fctl"} + settings := &connection.Settings{} + settings.Bind(root) + settings.Directory = t.TempDir() + loaderFlag(t, root, "auth-mode", "none") + return root, settings +} + +func loaderFlag(t *testing.T, root *cobra.Command, name, value string) { + t.Helper() + if err := root.PersistentFlags().Set(name, value); err != nil { + t.Fatal(err) + } +} + +func TestServiceLoaderTargets(t *testing.T) { + for _, service := range []string{"auth", "ledger"} { + t.Run(service, func(t *testing.T) { testServiceLoaderTarget(t, service) }) + } + root, settings := loaderSettings(t) + if _, err := pluginServiceTarget(settings, root, "unknown"); err == nil { + t.Fatal("unknown service accepted") + } + if _, err := cachedServiceLock(settings, root, nil, "unknown"); err == nil { + t.Fatal("unknown cached service accepted") + } + if _, _, err := pluginServiceSyncTarget(t.Context(), settings, root, "unknown", "1.0.0"); err == nil { + t.Fatal("unknown sync service accepted") + } + if _, err := PrepareService(t.Context(), root, settings, nil, "unknown"); err == nil { + t.Fatal("unknown provider accepted") + } +} + +func testServiceLoaderTarget(t *testing.T, service string) { + t.Helper() + root, settings := loaderSettings(t) + loaderFlag(t, root, "stack-url", "https://stack.example/") + target, err := pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != "https://stack.example/api/"+service { + t.Fatalf("stack target: %+v %v", target, err) + } + loaderFlag(t, root, "auth-url", "https://auth.example") + loaderFlag(t, root, "ledger-url", "https://ledger.example") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != "https://"+service+".example" { + t.Fatalf("direct target: %+v %v", target, err) + } + loaderFlag(t, root, "auth-mode", "cloud") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != cloud.DefaultIssuer { + t.Fatalf("cloud default: %+v %v", target, err) + } + loaderFlag(t, root, "issuer", "https://issuer.example") + target, err = pluginServiceTarget(settings, root, service) + if err != nil || target.Endpoint != settings.Options.Issuer { + t.Fatalf("cloud identity: %+v %v", target, err) + } +} + +func TestServiceLoaderManifestBoundary(t *testing.T) { + root, _ := loaderSettings(t) + for _, service := range []string{"auth", "ledger"} { + manifest := loaderManifest(service) + if err := validateServiceManifest(t.Context(), root, service, manifest); err != nil { + t.Fatal(err) + } + for _, change := range []func(*pluginsdk.Manifest){ + func(m *pluginsdk.Manifest) { m.Name = "cloud" }, + func(m *pluginsdk.Manifest) { m.Service = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Use = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Service = "cloud" }, + func(m *pluginsdk.Manifest) { m.Root.Subcommands[0].Service = "cloud-apps" }, + } { + invalid := loaderManifest(service) + change(&invalid) + if err := validateServiceManifest(t.Context(), root, service, invalid); err == nil { + t.Fatalf("accepted foreign identity/boundary: %+v", invalid) + } + } + } + if err := validateServiceManifest(t.Context(), root, "cloud", loaderManifest("cloud")); err == nil { + t.Fatal("unsupported manifest accepted") + } +} + +func installLoaderFixture(t *testing.T, manager *pluginmanager.Manager, target pluginmanager.Target, service string) pluginmanager.Lock { + t.Helper() + path := filepath.Join(t.TempDir(), "not-an-executable") + if err := os.WriteFile(path, []byte("cached help must never start this file"), 0o600); err != nil { + t.Fatal(err) + } + lock, err := manager.InstallLocal(t.Context(), path, target, loaderManifest(service)) + if err != nil { + t.Fatal(err) + } + return lock +} + +func TestAuthLoaderOfficialErrorsAndOffline(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + for _, test := range []struct { + name, data string + status int + want error + }{ + {"empty", "schemaVersion: 1\nreleases: []\n", http.StatusOK, pluginmanager.ErrNoRelease}, + {"unavailable", "", http.StatusServiceUnavailable, pluginmanager.ErrCatalogueUnavailable}, + {"invalid", "schemaVersion: 1\nreleases: invalid\n", http.StatusOK, nil}, + } { + t.Run(test.name, func(t *testing.T) { + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != pluginmanager.DefaultCatalogue { + t.Fatalf("unexpected service request: %s", request.URL) + } + return &http.Response{StatusCode: test.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(test.data))}, nil + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"auth", "probe"}}) + if err == nil || errors.Is(err, pluginmanager.ErrNotInstalled) || (test.want != nil && !errors.Is(err, test.want)) { + t.Fatalf("Auth discovery: %v, want %v", err, test.want) + } + }) + } + prep := discoveryPreparation(t, discoveryTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("offline Auth accessed network: %s", request.URL) + return nil, context.Canceled + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + for _, plan := range []pluginBootstrap{{commands: []string{"auth"}}, {commands: []string{"auth"}, help: true}, {commands: []string{"__complete", "auth"}}, {commands: []string{"ledger", "list"}}, {}} { + if _, err := prep.resolve(t.Context(), plan); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("offline Auth: %v", err) + } + } +} + +func TestAuthLoaderExactVersionAndStrictTargets(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + base := http.DefaultTransport + http.DefaultTransport = discoveryTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != "http://127.0.0.1:1/_info" { + t.Fatalf("wrong Auth version endpoint: %s", request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.0.1"}`))}, nil + }) + t.Cleanup(func() { http.DefaultTransport = base }) + prep := discoveryPreparation(t, discoveryTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + })) + prep.service = serviceDescriptor{name: "auth", title: "Auth"} + loaderFlag(t, prep.root, "auth-mode", "none") + loaderFlag(t, prep.root, "auth-url", "http://127.0.0.1:1") + target, version, err := pluginServiceSyncTarget(t.Context(), prep.settings, prep.root, "auth", "") + if err != nil || version != "1.0.1" { + t.Fatalf("exact Auth discovery: %+v %s %v", target, version, err) + } + empty := &pluginmanager.Catalogue{SchemaVersion: pluginmanager.SchemaVersion} + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, empty); !errors.Is(err, pluginmanager.ErrNoRelease) { + t.Fatalf("official unprepared missing release: %v", err) + } + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://custom.example/catalogue") + if _, err := prep.resolve(t.Context(), pluginBootstrap{commands: []string{"auth", "probe"}}); !errors.Is(err, pluginmanager.ErrCatalogueUnavailable) { + t.Fatalf("custom unavailable target fell back: %v", err) + } + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + installLoaderFixture(t, prep.manager, target, "auth") + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, empty); !errors.Is(err, pluginmanager.ErrNoRelease) { + t.Fatalf("prepared target accepted missing exact release: %v", err) + } + if _, err := prep.matchVersion(t.Context(), pluginmanager.DefaultCatalogue, nil); !errors.Is(err, pluginmanager.ErrCatalogueUnavailable) { + t.Fatalf("prepared unavailable target fell back: %v", err) + } +} + +func TestLedgerLoaderCompatibility(t *testing.T) { + root, settings := loaderSettings(t) + loaderFlag(t, root, "ledger-url", "https://ledger.example") + target, err := pluginServiceTarget(settings, root, "ledger") + if err != nil { + t.Fatal(err) + } + manager, err := pluginManager(settings, root) + if err != nil { + t.Fatal(err) + } + installLoaderFixture(t, manager, target, "ledger") + if _, err := cachedServiceLock(settings, root, manager, "ledger"); err != nil { + t.Fatal(err) + } + if _, err := soleServiceLock(manager, target, "ledger"); err != nil { + t.Fatal(err) + } + if _, version, err := pluginServiceSyncTarget(t.Context(), settings, root, "ledger", "v1.0.0"); err != nil || version != "1.0.0" { + t.Fatalf("Ledger sync: %s %v", version, err) + } + if err := serviceManifestIdentity("ledger", loaderManifest("ledger")); err != nil { + t.Fatal(err) + } + if err := validateServiceManifest(t.Context(), root, "ledger", loaderManifest("ledger")); err != nil { + t.Fatal(err) + } + if factory, err := PrepareService(t.Context(), root, settings, nil, "ledger"); err != nil || factory == nil { + t.Fatalf("cached external Ledger root: %v", err) + } + client, err := api.New("https://ledger.example", &http.Client{Transport: discoveryTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"v1.0.0"}`))}, nil + })}) + if err != nil { + t.Fatal(err) + } + if version, err := pluginServiceVersion(t.Context(), client, "ledger"); err != nil || version != "1.0.0" { + t.Fatalf("Ledger version: %s %v", version, err) + } +} + +func TestServiceLoaderCacheIsolation(t *testing.T) { + root, settings := loaderSettings(t) + loaderFlag(t, root, "auth-mode", "cloud") + loaderFlag(t, root, "issuer", "https://issuer.example") + manager, err := pluginManager(settings, root) + if err != nil { + t.Fatal(err) + } + target := pluginmanager.Target{Organization: "org", Stack: "stack", Endpoint: settings.Options.Issuer} + for _, service := range []string{"auth", "ledger"} { + installLoaderFixture(t, manager, target, service) + if lock, err := cachedServiceLock(settings, root, manager, service); err != nil || lock.Service != service { + t.Fatalf("cached %s: %+v %v", service, lock, err) + } + } + installLoaderFixture(t, manager, pluginmanager.Target{Organization: "org", Stack: "other", Endpoint: settings.Options.Issuer}, "auth") + if _, err := cachedServiceLock(settings, root, manager, "auth"); !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatalf("ambiguous Auth metadata: %v", err) + } + if lock, err := cachedServiceLock(settings, root, manager, "ledger"); err != nil || lock.Service != "ledger" { + t.Fatalf("Auth ambiguity affected Ledger: %+v %v", lock, err) + } +} diff --git a/internal/pluginhost/target.go b/internal/pluginhost/target.go new file mode 100644 index 00000000..56271960 --- /dev/null +++ b/internal/pluginhost/target.go @@ -0,0 +1,128 @@ +package pluginhost + +import ( + "cmp" + "context" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/v4/internal/api" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +type serviceDescriptor struct { + name string + title string +} + +func supportedPluginService(service string) (serviceDescriptor, error) { + switch service { + case "auth": + return serviceDescriptor{name: "auth", title: "Auth"}, nil + case "ledger": + return serviceDescriptor{name: "ledger", title: "Ledger"}, nil + default: + return serviceDescriptor{}, fmt.Errorf("unsupported external plugin service %q", service) + } +} + +func pluginServiceTarget(settings *connection.Settings, cmd *cobra.Command, service string) (pluginmanager.Target, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return pluginmanager.Target{}, err + } + options, _, name, _, err := settings.Resolve(cmd) + if err != nil { + return pluginmanager.Target{}, err + } + endpoint := options.LedgerURL + if descriptor.name == "auth" { + endpoint = options.AuthURL + } + if options.AuthMode == "cloud" { + endpoint = cmp.Or(options.Issuer, cloud.DefaultIssuer) + } else if endpoint == "" && options.StackURL != "" { + endpoint = strings.TrimRight(options.StackURL, "/") + "/api/" + descriptor.name + } + if endpoint == "" { + return pluginmanager.Target{}, fmt.Errorf("select a profile or configure --%s-url or --stack-url", descriptor.name) + } + return pluginmanager.Target{Profile: name, Organization: options.Organization, Stack: options.Stack, Endpoint: endpoint}, nil +} + +// A sole prepared Cloud target supplies offline metadata when profile options +// omit organization or stack. It does not select the execution target: the +// authenticated connection still resolves that from verified Cloud claims. +func cachedServiceLock(settings *connection.Settings, cmd *cobra.Command, manager *pluginmanager.Manager, service string) (pluginmanager.Lock, error) { + if _, err := supportedPluginService(service); err != nil { + return pluginmanager.Lock{}, err + } + target, err := pluginServiceTarget(settings, cmd, service) + if err != nil { + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled + } + lock, err := manager.Load(target, service) + if !errors.Is(err, pluginmanager.ErrNotInstalled) || (target.Organization != "" && target.Stack != "") { + return lock, err + } + options, _, _, _, err := settings.Resolve(cmd) + if err != nil || options.AuthMode != "cloud" { + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled + } + return soleServiceLock(manager, target, service) +} + +func soleServiceLock(manager *pluginmanager.Manager, target pluginmanager.Target, service string) (pluginmanager.Lock, error) { + locks, err := manager.List() + if err != nil { + return pluginmanager.Lock{}, err + } + var selected pluginmanager.Lock + for _, lock := range locks { + if lock.Service != service || lock.Target.Profile != target.Profile || lock.Target.Endpoint != target.Endpoint { + continue + } + if (target.Organization != "" && lock.Target.Organization != target.Organization) || (target.Stack != "" && lock.Target.Stack != target.Stack) { + continue + } + if selected.Service != "" { + return pluginmanager.Lock{}, pluginmanager.ErrNotInstalled + } + selected = lock + } + if selected.Service == "" { + return selected, pluginmanager.ErrNotInstalled + } + return selected, nil +} + +func pluginServiceVersion(ctx context.Context, client *api.Client, service string) (string, error) { + descriptor, err := supportedPluginService(service) + if err != nil { + return "", err + } + data, err := client.Do(ctx, "GET", "/_info", nil, nil, nil) + if err != nil { + return "", fmt.Errorf("discover %s version: %w", descriptor.title, err) + } + version, err := pluginsdk.ServiceVersion(data) + if err != nil { + return "", fmt.Errorf("decode %s version: %w", descriptor.title, err) + } + return version, nil +} + +// ServiceCommand identifies the top-level service of a Cobra command. +func ServiceCommand(cmd *cobra.Command) string { + for cmd.Parent() != nil && cmd.Parent().Parent() != nil { + cmd = cmd.Parent() + } + return cmd.Name() +} diff --git a/internal/pluginhost/unprepared.go b/internal/pluginhost/unprepared.go new file mode 100644 index 00000000..f41ff592 --- /dev/null +++ b/internal/pluginhost/unprepared.go @@ -0,0 +1,28 @@ +package pluginhost + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// UnpreparedServiceCommand explains how to prepare a missing service provider. +func UnpreparedServiceCommand(service, title string, preparationErr error) *cobra.Command { + command := &cobra.Command{ + Use: service, Short: "Manage " + title + " through an installed plugin", + Long: fmt.Sprintf("%s commands are discovered from the plugin catalogue and cached locally.\nPrepare your target with fctl plugins sync --service %s, or install a local\nexecutable with fctl plugins install --service %s --binary PATH.", title, service, service), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + return fmt.Errorf("%s plugin is not installed for this target; run fctl plugins sync --service %s or fctl plugins install --service %s --binary PATH", service, service, service) + }, + } + command.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + if preparationErr != nil { + return preparationErr + } + return err + }) + return command +} diff --git a/internal/pluginmanager/auth_catalogue_test.go b/internal/pluginmanager/auth_catalogue_test.go new file mode 100644 index 00000000..8b4fd79e --- /dev/null +++ b/internal/pluginmanager/auth_catalogue_test.go @@ -0,0 +1,84 @@ +package pluginmanager + +import ( + "errors" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func authManifest(version string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "auth", Service: "auth", Version: version, ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "auth", Short: "Auth", Subcommands: []pluginsdk.CommandSpec{{Use: "clients", Runnable: true}}}} +} + +func TestAuthCatalogueExactVersionAndIndependentLocks(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + ledger := publish(t, m, registry, "3.0.0", 1) + auth, err := m.Publish(t.Context(), registry.server.URL+"/formance/ledger", Release{Service: "auth", ServiceVersion: "1.0.0", Revision: 1, Platform: CurrentPlatform(), Manifest: authManifest("1.0.0")}, localBinary(t, []byte("auth fixture"))) + if err != nil { + t.Fatal(err) + } + catalogue := catalogueFile(t, ledger, auth) + for service, version := range map[string]string{"ledger": "3.0.0", "auth": "1.0.0"} { + release, err := m.Resolve(t.Context(), catalogue, service, version, 0) + if err != nil { + t.Fatal(err) + } + lock, err := m.InstallResolved(t.Context(), catalogue, target("shared"), release) + if err != nil || lock.Service != service { + t.Fatalf("install %s: %+v %v", service, lock, err) + } + } + if _, err := m.Resolve(t.Context(), catalogue, "auth", "1.0.1", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("Auth version fallback: %v", err) + } + registry.server.Close() + for _, service := range []string{"ledger", "auth"} { + lock, err := m.Load(target("shared"), service) + if err != nil || lock.Service != service || lock.Manifest.Service != service { + t.Fatalf("independent offline lock %s: %+v %v", service, lock, err) + } + } + locks, err := m.List() + if err != nil || len(locks) != 2 { + t.Fatalf("multi-service locks: %+v %v", locks, err) + } +} + +func TestAuthCatalogueRejectsVersionAndIdentityMismatchBeforeDownload(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release, err := m.Publish(t.Context(), registry.server.URL+"/formance/ledger", Release{Service: "auth", ServiceVersion: "1.0.0", Revision: 1, Platform: CurrentPlatform(), Manifest: authManifest("1.0.0")}, localBinary(t, []byte("auth fixture"))) + if err != nil { + t.Fatal(err) + } + before := registry.requests.Load() + for _, test := range []struct { + name string + mutate func(*Release) + }{ + {"wrong manifest service", func(r *Release) { r.Manifest.Service = "ledger" }}, + {"wrong exact version", func(r *Release) { r.Manifest.Version = "1.0.1" }}, + {"wrong command root", func(r *Release) { r.Manifest.Root.Use = "ledger" }}, + } { + t.Run(test.name, func(t *testing.T) { + invalid := release + test.mutate(&invalid) + catalogue := catalogueFile(t, invalid) + if _, err := m.Resolve(t.Context(), catalogue, "auth", "1.0.0", 0); err == nil { + t.Fatal("invalid Auth catalogue accepted") + } + if _, err := m.InstallResolved(t.Context(), catalogue, target("reject"), invalid); err == nil { + t.Fatal("invalid resolved Auth release installed") + } + if registry.requests.Load() != before { + t.Fatal("invalid metadata reached registry") + } + if _, err := m.Load(target("reject"), "auth"); !errors.Is(err, ErrNotInstalled) { + t.Fatalf("invalid metadata wrote lock: %v", err) + } + }) + } +} diff --git a/internal/pluginmanager/cache.go b/internal/pluginmanager/cache.go new file mode 100644 index 00000000..404126db --- /dev/null +++ b/internal/pluginmanager/cache.go @@ -0,0 +1,463 @@ +package pluginmanager + +import ( + "cmp" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/gofrs/flock" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +// New opens an explicitly selected cache directory. The HTTP client is copied; +// its transport may customize TLS/proxies, but must not inject service tokens. +func New(directory string, client *http.Client) (*Manager, error) { + if directory == "" { + return nil, fmt.Errorf("plugin cache directory is required") + } + directory, err := filepath.Abs(directory) + if err != nil { + return nil, err + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, err + } + root, err := os.OpenRoot(directory) + if err != nil { + return nil, err + } + if err := root.Close(); err != nil { + return nil, err + } + return &Manager{directory: directory, client: publicClient(client), platform: CurrentPlatform()}, nil +} + +func publicClient(original *http.Client) *http.Client { + client := http.Client{Timeout: 2 * time.Minute} + if original != nil { + client = *original + client.Timeout = cmp.Or(client.Timeout, 2*time.Minute) + } + previous := client.CheckRedirect + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if _, err := downloadURL(request.URL.String()); err != nil { + return err + } + if len(via) >= 10 { + return fmt.Errorf("too many plugin download redirects") + } + if len(via) > 0 && request.URL.Host != via[0].URL.Host { + request.Header.Del("Authorization") + } + if previous != nil { + return previous(request, via) + } + return nil + } + return &client +} + +func (m *Manager) Sync(ctx context.Context, catalogue string, target Target, service, version string) (Lock, error) { + return m.SyncRevision(ctx, catalogue, target, service, version, 0) +} + +func (m *Manager) SyncRevision(ctx context.Context, catalogue string, target Target, service, version string, revision int) (Lock, error) { + if err := validateTarget(target); err != nil { + return Lock{}, err + } + release, err := m.Resolve(ctx, catalogue, service, version, revision) + if err != nil { + return Lock{}, err + } + return m.install(ctx, catalogue, target, release) +} + +func (m *Manager) install(ctx context.Context, catalogue string, target Target, release Release) (Lock, error) { + if err := validateTarget(target); err != nil { + return Lock{}, err + } + if err := validateRelease(release, false); err != nil { + return Lock{}, err + } + if release.Platform != m.platform || release.Manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return Lock{}, fmt.Errorf("plugin platform or protocol is incompatible with this host") + } + catalogue, err := catalogueSource(catalogue) + if err != nil { + return Lock{}, err + } + lock := newLock(target, release) + lock.Catalogue = catalogue + if err := m.withCacheLock(ctx, lock, func(root *os.Root) error { + if err := verifyBinary(ctx, root, lock); err == nil { + return saveLock(root, lock) + } + if err := m.pull(ctx, root, lock); err != nil { + return err + } + return saveLock(root, lock) + }); err != nil { + return Lock{}, err + } + return lock, nil +} + +func newLock(target Target, release Release) Lock { + return Lock{ + SchemaVersion: SchemaVersion, Target: target, Service: release.Service, + ServiceVersion: release.ServiceVersion, Revision: release.Revision, + ArtifactDigest: release.Artifact.Digest, Platform: release.Platform, + SHA256: release.SHA256, Manifest: release.Manifest, Artifact: release.Artifact, + InstalledAt: time.Now().UTC(), + } +} + +// InstallLocal accepts a manifest inspected by the caller. Its version is used +// as the local service version; no remote catalogue is consulted or retained. +func (m *Manager) InstallLocal(ctx context.Context, binary string, target Target, manifest pluginsdk.Manifest) (_ Lock, err error) { + if err := validateTarget(target); err != nil { + return Lock{}, err + } + if err := validateManifest(manifest, manifest.Service); err != nil { + return Lock{}, err + } + if manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return Lock{}, fmt.Errorf("unsupported plugin protocol %d", manifest.ProtocolVersion) + } + file, err := os.Open(binary) //nolint:gosec // Local install explicitly selects a source executable; remote entries cannot supply local paths. + if err != nil { + return Lock{}, err + } + defer func() { err = joinClose(err, file) }() + if err := regularFile(file); err != nil { + return Lock{}, err + } + checksum, _, err := hashReader(ctx, file, maxBinaryBytes) + if err != nil { + return Lock{}, err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return Lock{}, err + } + release := Release{Service: manifest.Service, ServiceVersion: manifest.Version, Revision: 1, + Platform: m.platform, Artifact: Artifact{Digest: "sha256:" + checksum}, SHA256: checksum, Manifest: manifest} + lock := newLock(target, release) + lock.Local = true + if err := m.withCacheLock(ctx, lock, func(root *os.Root) error { + if err := verifyBinary(ctx, root, lock); err != nil { + if err := writeBinary(ctx, root, lock, file, -1); err != nil { + return err + } + } + return saveLock(root, lock) + }); err != nil { + return Lock{}, err + } + return lock, nil +} + +// Load reads only a target's cached manifest and lock, without network access. +func (m *Manager) Load(target Target, service string) (_ Lock, err error) { + if err := validateTarget(target); err != nil { + return Lock{}, err + } + if !servicePattern.MatchString(service) { + return Lock{}, fmt.Errorf("invalid plugin service") + } + root, err := os.OpenRoot(m.directory) + if err != nil { + return Lock{}, err + } + defer func() { err = joinClose(err, root) }() + lock, err := readLock(root, lockPath(target, service)) + if errors.Is(err, os.ErrNotExist) { + return Lock{}, fmt.Errorf("%w: %s", ErrNotInstalled, service) + } + if err != nil { + return Lock{}, err + } + if lock.Target != target || lock.Service != service { + return Lock{}, fmt.Errorf("cached plugin lock does not match the target") + } + return lock, nil +} + +// Binary verifies checksum, size, platform and protocol before returning the +// cached executable path. It never downloads or updates a plugin. +func (m *Manager) Binary(lock Lock) (_ string, err error) { + return m.BinaryContext(context.Background(), lock) +} + +// BinaryContext is Binary with cancellation during checksum verification. +func (m *Manager) BinaryContext(ctx context.Context, lock Lock) (_ string, err error) { + if err := validateLock(lock); err != nil { + return "", err + } + if lock.Platform != m.platform || lock.Manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return "", fmt.Errorf("cached plugin platform or protocol is incompatible with this host") + } + root, err := os.OpenRoot(m.directory) + if err != nil { + return "", err + } + defer func() { err = joinClose(err, root) }() + if err := verifyBinary(ctx, root, lock); err != nil { + return "", fmt.Errorf("cached plugin is unavailable or corrupt; run plugins sync: %w", err) + } + return filepath.Join(m.directory, binaryPath(lock)), nil +} + +func (m *Manager) List() (_ []Lock, err error) { + root, err := os.OpenRoot(m.directory) + if err != nil { + return nil, err + } + defer func() { err = joinClose(err, root) }() + directory, err := root.Open("targets") + if errors.Is(err, os.ErrNotExist) { + return []Lock{}, nil + } + if err != nil { + return nil, err + } + entries, readErr := directory.ReadDir(-1) + if err := errors.Join(readErr, directory.Close()); err != nil { + return nil, err + } + locks := make([]Lock, 0, len(entries)) + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + lock, err := readLock(root, filepath.Join("targets", entry.Name())) + if err != nil { + return nil, err + } + if entry.Name() != filepath.Base(lockPath(lock.Target, lock.Service)) { + return nil, fmt.Errorf("cached plugin lock filename does not match its target") + } + locks = append(locks, lock) + } + slices.SortFunc(locks, func(a, b Lock) int { + return strings.Compare(lockPath(a.Target, a.Service), lockPath(b.Target, b.Service)) + }) + return locks, nil +} + +func validateLock(lock Lock) error { + if lock.SchemaVersion != SchemaVersion || !validDigest(lock.ArtifactDigest) { + return fmt.Errorf("invalid cached plugin lock schema or artifact digest") + } + if err := validateTarget(lock.Target); err != nil { + return err + } + if lock.Artifact.Digest != lock.ArtifactDigest { + return fmt.Errorf("cached plugin artifact digest does not match its lock") + } + return validateRelease(Release{Service: lock.Service, ServiceVersion: lock.ServiceVersion, + Revision: lock.Revision, Platform: lock.Platform, Artifact: lock.Artifact, + SHA256: lock.SHA256, Manifest: lock.Manifest}, lock.Local) +} + +func readLock(root *os.Root, name string) (_ Lock, err error) { + file, err := root.Open(name) + if err != nil { + return Lock{}, err + } + defer func() { err = joinClose(err, file) }() + if err := regularFile(file); err != nil { + return Lock{}, err + } + data, err := readBounded(file, maxCatalogueBytes) + if err != nil { + return Lock{}, err + } + var lock Lock + if err := json.Unmarshal(data, &lock); err != nil { + return Lock{}, fmt.Errorf("decode cached plugin lock: %w", err) + } + return lock, validateLock(lock) +} + +func lockPath(target Target, service string) string { + // Validated identities cannot contain NUL; separators retain empty fields. + data := strings.Join([]string{target.Profile, target.Organization, target.Stack, target.Endpoint, service}, "\x00") + return filepath.Join("targets", checksum([]byte(data))+".json") +} + +func binaryPath(lock Lock) string { + name := "plugin" + if lock.Platform.OS == "windows" { + name += ".exe" + } + return filepath.Join("artifacts", strings.TrimPrefix(lock.ArtifactDigest, "sha256:"), lock.Platform.OS+"_"+lock.Platform.Arch, name) +} + +func (m *Manager) withCacheLock(ctx context.Context, lock Lock, work func(*os.Root) error) (err error) { + root, err := os.OpenRoot(m.directory) + if err != nil { + return err + } + defer func() { err = joinClose(err, root) }() + if err := root.MkdirAll("guards", 0o700); err != nil { + return err + } + name := filepath.Join("guards", strings.TrimPrefix(lock.ArtifactDigest, "sha256:")+"-"+lock.Platform.OS+"_"+lock.Platform.Arch+".lock") + file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) + if errors.Is(err, os.ErrExist) || errors.Is(err, os.ErrNotExist) { + // Root's containment check and another creator may race. Once a creator + // wins, open the existing file without the create flag. + file, err = root.OpenFile(name, os.O_RDWR, 0o600) + } + if err != nil { + return err + } + if err := regularFile(file); err != nil { + return errors.Join(err, file.Close()) + } + if err := file.Close(); err != nil { + return err + } + guard := flock.New(filepath.Join(m.directory, name)) + defer func() { err = errors.Join(err, guard.Close()) }() + locked, err := guard.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return err + } + if !locked { + return fmt.Errorf("plugin installation lock unavailable") + } + if err := ctx.Err(); err != nil { + return err + } + return work(root) +} + +func verifyBinary(ctx context.Context, root *os.Root, lock Lock) (err error) { + file, err := root.Open(binaryPath(lock)) + if err != nil { + return err + } + defer func() { err = joinClose(err, file) }() + if err := regularFile(file); err != nil { + return err + } + info, err := file.Stat() + if err != nil { + return err + } + if lock.Platform.OS != "windows" && info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("cached plugin is not executable") + } + hash, size, err := hashReader(ctx, file, maxBinaryBytes) + if err != nil { + return err + } + if size == 0 || hash != lock.SHA256 { + return fmt.Errorf("cached executable SHA256 mismatch") + } + return nil +} + +func regularFile(file *os.File) error { + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("plugin data must be a regular file") + } + return nil +} + +func saveLock(root *os.Root, lock Lock) error { + data, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return err + } + return atomicFile(root, lockPath(lock.Target, lock.Service), 0o600, func(file *os.File) error { + _, err := file.Write(append(data, '\n')) + return err + }) +} + +func writeBinary(ctx context.Context, root *os.Root, lock Lock, reader io.Reader, expectedSize int64) error { + return atomicFile(root, binaryPath(lock), 0o700, func(file *os.File) error { + hash := sha256.New() + size, err := io.Copy(io.MultiWriter(file, hash), &contextReader{ctx: ctx, reader: io.LimitReader(reader, maxBinaryBytes+1)}) + if err != nil { + return err + } + if size == 0 || size > maxBinaryBytes || expectedSize >= 0 && size != expectedSize { + return fmt.Errorf("invalid executable size: received %d, expected %d", size, expectedSize) + } + if hex.EncodeToString(hash.Sum(nil)) != lock.SHA256 { + return fmt.Errorf("downloaded executable SHA256 mismatch") + } + return nil + }) +} + +func atomicFile(root *os.Root, name string, mode os.FileMode, write func(*os.File) error) (err error) { + if err := root.MkdirAll(filepath.Dir(name), 0o700); err != nil { + return err + } + temporary := filepath.Join(filepath.Dir(name), ".tmp-"+rand.Text()) + file, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + defer func() { + if cleanup := root.Remove(temporary); cleanup != nil && !errors.Is(cleanup, os.ErrNotExist) { + err = errors.Join(err, cleanup) + } + }() + writeErr := write(file) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return root.Rename(temporary, name) +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *contextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(data) +} + +func hashReader(ctx context.Context, reader io.Reader, limit int64) (string, int64, error) { + hash := sha256.New() + size, err := io.Copy(hash, &contextReader{ctx: ctx, reader: io.LimitReader(reader, limit+1)}) + if err != nil { + return "", 0, err + } + if size > limit { + return "", 0, fmt.Errorf("plugin executable exceeds %d bytes", limit) + } + return hex.EncodeToString(hash.Sum(nil)), size, nil +} + +func checksum(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } + +func joinClose(err error, closer io.Closer) error { return errors.Join(err, closer.Close()) } diff --git a/internal/pluginmanager/catalogue.go b/internal/pluginmanager/catalogue.go new file mode 100644 index 00000000..0f3bf06d --- /dev/null +++ b/internal/pluginmanager/catalogue.go @@ -0,0 +1,263 @@ +package pluginmanager + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var ( + servicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) + platformPattern = regexp.MustCompile(`^[a-z0-9]{1,32}$`) + shaPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + repositoryPattern = regexp.MustCompile(`^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*$`) +) + +// DefaultCatalogue is the official public plugin catalogue. +const DefaultCatalogue = "https://raw.githubusercontent.com/formancehq/fctl-plugin-registry/main/registry.yaml" + +func (m *Manager) resolve(ctx context.Context, source, service, version string, revision int) (Release, error) { + if !servicePattern.MatchString(service) || !validVersion(version) || revision < 0 { + return Release{}, fmt.Errorf("invalid plugin service, service version or revision") + } + catalogue, err := m.Discover(ctx, source) + if err != nil { + return Release{}, err + } + return m.ResolveCatalogue(ctx, catalogue, service, version, revision) +} + +// Discover reads a YAML or JSON catalogue without downloading or starting plugins. +func (m *Manager) Discover(ctx context.Context, source string) (Catalogue, error) { + data, err := m.catalogueBytes(ctx, source) + if err != nil { + return Catalogue{}, err + } + var catalogue Catalogue + if err := yaml.UnmarshalStrict(data, &catalogue); err != nil { + return Catalogue{}, fmt.Errorf("decode plugin catalogue: %w", err) + } + if err := validateCatalogue(catalogue); err != nil { + return Catalogue{}, err + } + return catalogue, nil +} + +// Resolve selects an exact service version and platform from an inspected catalogue. +func (c Catalogue) Resolve(platform Platform, service, version string, revision int) (Release, error) { + if !servicePattern.MatchString(service) || !validVersion(version) || revision < 0 { + return Release{}, fmt.Errorf("invalid plugin service, service version or revision") + } + return selectRelease(c, platform, service, version, revision) +} + +func validateCatalogue(catalogue Catalogue) error { + if catalogue.SchemaVersion == RegistrySchemaVersion { + return validateReferences(catalogue) + } + if catalogue.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported catalogue schema %d", catalogue.SchemaVersion) + } + if catalogue.Plugins != nil { + return fmt.Errorf("release catalogue must not contain plugin references") + } + seen := make(map[string]bool) + for _, release := range catalogue.Releases { + if err := validateRelease(release, false); err != nil { + return fmt.Errorf("invalid catalogue release: %w", err) + } + key := fmt.Sprintf("%s/%s/%d/%s", release.Service, release.ServiceVersion, release.Revision, release.Platform) + if seen[key] { + return fmt.Errorf("duplicate catalogue release %s", key) + } + seen[key] = true + } + return nil +} + +func selectRelease(catalogue Catalogue, platform Platform, service, version string, revision int) (Release, error) { + if err := validateCatalogue(catalogue); err != nil { + return Release{}, err + } + var selected Release + for _, release := range catalogue.Releases { + if release.Service == service && release.ServiceVersion == version && release.Platform == platform && + (revision == 0 || release.Revision == revision) && release.Revision > selected.Revision { + selected = release + } + } + if selected.Revision == 0 { + return Release{}, fmt.Errorf("%w: %s %s (%s), revision %d", ErrNoRelease, service, version, platform, revision) + } + if selected.Manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return Release{}, fmt.Errorf("unsupported plugin protocol %d (host supports %d)", selected.Manifest.ProtocolVersion, pluginsdk.ProtocolVersion) + } + return selected, nil +} + +func (m *Manager) catalogueBytes(ctx context.Context, source string) (_ []byte, err error) { + if source == "" { + return nil, fmt.Errorf("configure a plugin catalogue URL or file") + } + source, err = catalogueSource(source) + if err != nil { + return nil, err + } + if strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "http://") { + u, err := downloadURL(source) + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + response, err := m.client.Do(request) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCatalogueUnavailable, err) + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: HTTP %d", ErrCatalogueUnavailable, response.StatusCode) + } + return readBounded(response.Body, maxCatalogueBytes) + } + if err := ctx.Err(); err != nil { + return nil, err + } + file, err := os.Open(source) //nolint:gosec // An explicit local catalogue path is selected by the caller, not by a remote manifest. + if err != nil { + return nil, fmt.Errorf("open plugin catalogue: %w", err) + } + defer func() { err = joinClose(err, file) }() + if err := regularFile(file); err != nil { + return nil, err + } + return readBounded(file, maxCatalogueBytes) +} + +func catalogueSource(source string) (string, error) { + if source == "" { + return "", nil + } + if strings.HasPrefix(source, "https://") || strings.HasPrefix(source, "http://") { + if _, err := downloadURL(source); err != nil { + return "", err + } + return source, nil + } + if strings.Contains(source, "://") { + return "", fmt.Errorf("catalogue must be an HTTPS URL or an explicit file path") + } + return filepath.Abs(source) +} + +func readBounded(reader io.Reader, limit int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("plugin data exceeds %d bytes", limit) + } + return data, nil +} + +func validVersion(version string) bool { + return version != "" && len(version) <= 128 && strings.TrimSpace(version) == version && !strings.ContainsAny(version, "/\\\x00\r\n\t") +} + +func validateRelease(release Release, local bool) error { + if !servicePattern.MatchString(release.Service) || !validVersion(release.ServiceVersion) || release.Revision < 1 { + return fmt.Errorf("invalid service, exact version or revision") + } + if !platformPattern.MatchString(release.Platform.OS) || !platformPattern.MatchString(release.Platform.Arch) { + return fmt.Errorf("invalid plugin platform") + } + if !shaPattern.MatchString(release.SHA256) { + return fmt.Errorf("invalid executable SHA256") + } + if err := validateManifest(release.Manifest, release.Service); err != nil { + return err + } + if release.Manifest.Version != release.ServiceVersion { + return fmt.Errorf("manifest version %q does not match exact service version %q", release.Manifest.Version, release.ServiceVersion) + } + if local { + return nil + } + if err := validateArtifact(release.Artifact); err != nil { + return err + } + return nil +} + +func validateManifest(manifest pluginsdk.Manifest, service string) error { + if manifest.Service != service || !servicePattern.MatchString(manifest.Name) || !validVersion(manifest.Version) || + pluginsdk.CommandName(manifest.Root) != manifest.Name || manifest.ProtocolVersion < 1 { + return fmt.Errorf("manifest must name the release service, plugin version, root command and protocol") + } + return nil +} + +func validateArtifact(artifact Artifact) error { + u, err := downloadURL(artifact.Registry) + if err != nil { + return err + } + if u.Path != "" && u.Path != "/" || u.RawQuery != "" { + return fmt.Errorf("registry must be an HTTPS origin") + } + if len(artifact.Repository) > 255 || !repositoryPattern.MatchString(artifact.Repository) { + return fmt.Errorf("invalid OCI repository") + } + if !validDigest(artifact.Digest) { + return fmt.Errorf("OCI artifact must use an immutable sha256 digest") + } + return nil +} + +func validDigest(digest string) bool { + checksum, ok := strings.CutPrefix(digest, "sha256:") + return ok && shaPattern.MatchString(checksum) +} + +func downloadURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { + return nil, fmt.Errorf("invalid public plugin URL") + } + if u.Scheme == "https" { + return u, nil + } + ip := net.ParseIP(u.Hostname()) + if u.Scheme == "http" && (u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { + return u, nil + } + return nil, fmt.Errorf("public plugin downloads require HTTPS (HTTP is allowed on loopback only)") +} + +func validateTarget(target Target) error { + for _, field := range []string{target.Profile, target.Organization, target.Stack} { + if len(field) > 256 || strings.ContainsAny(field, "\x00\r\n") { + return fmt.Errorf("invalid plugin target identity") + } + } + u, err := url.Parse(target.Endpoint) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" || + (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("plugin target endpoint must be a stable HTTP(S) URL without credentials, query or fragment") + } + return nil +} diff --git a/internal/pluginmanager/catalogue_references.go b/internal/pluginmanager/catalogue_references.go new file mode 100644 index 00000000..d2bc1a1b --- /dev/null +++ b/internal/pluginmanager/catalogue_references.go @@ -0,0 +1,114 @@ +package pluginmanager + +import ( + "context" + "fmt" + "strings" + + "sigs.k8s.io/yaml" +) + +func validateReferences(catalogue Catalogue) error { + if catalogue.Releases != nil || catalogue.Plugins == nil { + return fmt.Errorf("registry schema 2 requires plugins and must not embed releases") + } + for service, product := range catalogue.Plugins { + if !servicePattern.MatchString(service) || len(product.Releases) == 0 { + return fmt.Errorf("registry requires a valid service and at least one catalogue reference") + } + seen := make(map[string]bool) + for _, reference := range product.Releases { + if err := validateReference(reference); err != nil { + return fmt.Errorf("invalid %s catalogue reference: %w", service, err) + } + if seen[reference.ServiceVersion] { + return fmt.Errorf("duplicate catalogue reference for %s %s", service, reference.ServiceVersion) + } + seen[reference.ServiceVersion] = true + } + } + return nil +} + +func validateReference(reference CatalogueReference) error { + if !validVersion(reference.ServiceVersion) || strings.HasPrefix(reference.ServiceVersion, "v") { + return fmt.Errorf("exact service version without v prefix is required") + } + if !shaPattern.MatchString(reference.SHA256) { + return fmt.Errorf("invalid catalogue SHA256") + } + _, err := downloadURL(reference.Catalogue) + return err +} + +// HasService checks advertised services without downloading product catalogues. +// A schema-2 index defers platform selection until its selected catalogue is read. +func (c Catalogue) HasService(service string, platform Platform) bool { + if c.SchemaVersion == RegistrySchemaVersion { + return len(c.Plugins[service].Releases) > 0 + } + for _, release := range c.Releases { + if release.Service == service && release.Platform == platform { + return true + } + } + return false +} + +// ResolveCatalogue resolves one exact service version from an inspected index +// or a schema-1 catalogue. Only the selected reference is downloaded. +func (m *Manager) ResolveCatalogue(ctx context.Context, catalogue Catalogue, service, version string, revision int) (Release, error) { + if !servicePattern.MatchString(service) || !validVersion(version) || revision < 0 { + return Release{}, fmt.Errorf("invalid plugin service, service version or revision") + } + if err := validateCatalogue(catalogue); err != nil { + return Release{}, err + } + if catalogue.SchemaVersion == SchemaVersion { + return catalogue.Resolve(m.platform, service, version, revision) + } + reference, err := findReference(catalogue, service, version) + if err != nil { + return Release{}, err + } + product, err := m.readReferencedCatalogue(ctx, reference, service) + if err != nil { + return Release{}, err + } + return product.Resolve(m.platform, service, version, revision) +} + +func findReference(catalogue Catalogue, service, version string) (CatalogueReference, error) { + for _, reference := range catalogue.Plugins[service].Releases { + if reference.ServiceVersion == version { + return reference, nil + } + } + return CatalogueReference{}, fmt.Errorf("%w: no catalogue for %s %s", ErrNoRelease, service, version) +} + +func (m *Manager) readReferencedCatalogue(ctx context.Context, reference CatalogueReference, service string) (Catalogue, error) { + data, err := m.catalogueBytes(ctx, reference.Catalogue) + if err != nil { + return Catalogue{}, fmt.Errorf("read %s %s catalogue: %w", service, reference.ServiceVersion, err) + } + if checksum(data) != reference.SHA256 { + return Catalogue{}, fmt.Errorf("referenced catalogue SHA256 mismatch for %s %s", service, reference.ServiceVersion) + } + var product Catalogue + if err := yaml.UnmarshalStrict(data, &product); err != nil { + return Catalogue{}, fmt.Errorf("decode referenced catalogue: %w", err) + } + if product.SchemaVersion != SchemaVersion { + return Catalogue{}, fmt.Errorf("referenced catalogue must use schema 1; nested references are unsupported") + } + if err := validateCatalogue(product); err != nil { + return Catalogue{}, err + } + for _, release := range product.Releases { + if release.Service != service || release.ServiceVersion != reference.ServiceVersion { + return Catalogue{}, fmt.Errorf("referenced catalogue contains a different service or version") + } + } + return product, nil +} diff --git a/internal/pluginmanager/catalogue_references_test.go b/internal/pluginmanager/catalogue_references_test.go new file mode 100644 index 00000000..4d6cfa33 --- /dev/null +++ b/internal/pluginmanager/catalogue_references_test.go @@ -0,0 +1,189 @@ +package pluginmanager + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func referenceIndex(version, address string, data []byte) Catalogue { + return Catalogue{SchemaVersion: RegistrySchemaVersion, Plugins: map[string]ProductCatalogues{ + "ledger": {Releases: []CatalogueReference{{ServiceVersion: version, Catalogue: address, SHA256: checksum(data)}}}, + }} +} + +func jsonFixture(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestReferencedCatalogueSyncSelectsVersionAndRetainsOfflineLock(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + first := publish(t, m, registry, "3.0.0", 1) + latest := publish(t, m, registry, "3.0.0", 2) + const source = "https://catalogue.example/registry.yaml" + const productURL = "https://catalogue.example/ledger-3.0.0.json" + product := jsonFixture(t, Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{first, latest}}) + index := referenceIndex("3.0.0", productURL, product) + index.Plugins["auth"] = ProductCatalogues{Releases: []CatalogueReference{{ + ServiceVersion: "2.5.2", Catalogue: "https://catalogue.example/never-auth.json", SHA256: strings.Repeat("a", 64), + }}} + index.Plugins["ledger"] = ProductCatalogues{Releases: append(index.Plugins["ledger"].Releases, CatalogueReference{ + ServiceVersion: "3.0.1", Catalogue: "https://catalogue.example/never-ledger-3.0.1.json", SHA256: strings.Repeat("a", 64), + })} + indexData := jsonFixture(t, index) + var indexRequests, productRequests int + m.client.Transport = catalogueTransport(func(request *http.Request) (*http.Response, error) { + var data []byte + switch request.URL.String() { + case source: + indexRequests++ + data = indexData + case productURL: + productRequests++ + data = product + default: + if strings.HasPrefix(request.URL.String(), registry.server.URL) { + return http.DefaultTransport.RoundTrip(request) + } + t.Fatalf("downloaded unrelated catalogue: %s", request.URL) + } + if request.Header.Get("Authorization") != "" { + t.Fatal("service credentials leaked to a catalogue") + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(data)))}, nil + }) + lock, err := m.Sync(t.Context(), source, target("references"), "ledger", "3.0.0") + if err != nil { + t.Fatal(err) + } + if lock.Revision != 2 || lock.Catalogue != source || !reflect.DeepEqual(lock.Manifest, latest.Manifest) { + t.Fatalf("wrong selected release or source: %+v", lock) + } + if indexRequests != 1 || productRequests != 1 { + t.Fatalf("repeated downloads: index=%d product=%d", indexRequests, productRequests) + } + m.client.Transport = catalogueTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("offline cache tried a download: %s", request.URL) + return nil, errors.New("offline") + }) + cached, err := m.Load(target("references"), "ledger") + if err != nil || !reflect.DeepEqual(cached, lock) { + t.Fatalf("cached metadata lost: %+v %v", cached, err) + } + if _, err := m.Binary(cached); err != nil { + t.Fatal(err) + } +} + +func TestReferencedCatalogueRejectsTamperingAndWrongIdentity(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + valid := jsonFixture(t, Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{release}}) + wrongService := release + wrongService.Service = "auth" + wrongService.Manifest.Name, wrongService.Manifest.Service, wrongService.Manifest.Root.Use = "auth", "auth", "auth" + wrongVersion := release + wrongVersion.ServiceVersion, wrongVersion.Manifest.Version = "3.0.1", "3.0.1" + for _, test := range []struct { + name, want string + data []byte + badHash bool + }{ + {"tampered", "SHA256 mismatch", []byte("{not even json"), true}, + {"wrong-service", "different service or version", jsonFixture(t, Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{wrongService}}), false}, + {"wrong-version", "different service or version", jsonFixture(t, Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{wrongVersion}}), false}, + {"nested", "nested references", []byte(`{"schemaVersion":2,"plugins":{}}`), false}, + {"unknown-field", "decode referenced catalogue", []byte(`{"schemaVersion":1,"releases":[],"typo":true}`), false}, + } { + t.Run(test.name, func(t *testing.T) { + index := referenceIndex("3.0.0", "https://catalogue.example/product.json", test.data) + if test.badHash { + index = referenceIndex("3.0.0", "https://catalogue.example/product.json", valid) + } + m.client.Transport = catalogueTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(test.data)))}, nil + }) + _, err := m.ResolveCatalogue(t.Context(), index, "ledger", "3.0.0", 0) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("invalid catalogue accepted or wrong error: %v", err) + } + if _, err := m.Load(target(test.name), "ledger"); !errors.Is(err, ErrNotInstalled) { + t.Fatalf("failed resolution installed a lock: %v", err) + } + }) + } +} + +func TestRegistryIndexStrictValidationAndMissingVersion(t *testing.T) { + sha := strings.Repeat("a", 64) + ref := `{"serviceVersion":"3.0.0","catalogue":"https://example.com/catalogue.json","sha256":"` + sha + `"}` + for _, test := range []struct{ name, data string }{ + {"missing-plugins", `{"schemaVersion":2}`}, + {"mixed-formats", `{"schemaVersion":2,"plugins":{},"releases":[]}`}, + {"legacy-with-references", `{"schemaVersion":1,"releases":[],"plugins":{}}`}, + {"unknown-field", `{"schemaVersion":2,"plugins":{},"typo":1}`}, + {"duplicate-service", `{"schemaVersion":2,"plugins":{"ledger":{"releases":[` + ref + `]},"ledger":{"releases":[` + ref + `]}}}`}, + {"duplicate-version", `{"schemaVersion":2,"plugins":{"ledger":{"releases":[` + ref + `,` + ref + `]}}}`}, + {"unknown-reference-field", `{"schemaVersion":2,"plugins":{"ledger":{"releases":[` + strings.TrimSuffix(ref, "}") + `,"manifest":{}}]}}}`}, + {"local-path", `{"schemaVersion":2,"plugins":{"ledger":{"releases":[` + strings.Replace(ref, "https://example.com/catalogue.json", "/tmp/catalogue.json", 1) + `]}}}`}, + {"bad-hash", `{"schemaVersion":2,"plugins":{"ledger":{"releases":[` + strings.Replace(ref, sha, "invalid", 1) + `]}}}`}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "registry.json") + if err := os.WriteFile(path, []byte(test.data), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager(t).Discover(t.Context(), path); err == nil { + t.Fatal("invalid registry accepted") + } + }) + } + m := manager(t) + m.client.Transport = catalogueTransport(func(request *http.Request) (*http.Response, error) { + t.Fatalf("missing version attempted download: %s", request.URL) + return nil, errors.New("unexpected download") + }) + index := referenceIndex("3.0.0", "https://example.com/catalogue.json", []byte("unused")) + if _, err := m.ResolveCatalogue(t.Context(), index, "ledger", "3.0.1", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("wrong missing version error: %v", err) + } + if !index.HasService("ledger", CurrentPlatform()) || index.HasService("auth", CurrentPlatform()) { + t.Fatal("index advertised the wrong services") + } +} + +func TestReferencedCatalogueExplicitRevisionAndMissingPlatform(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + first := publish(t, m, registry, "3.0.0", 1) + latest := publish(t, m, registry, "3.0.0", 2) + data := jsonFixture(t, Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{first, latest}}) + index := referenceIndex("3.0.0", "https://example.com/product.json", data) + m.client.Transport = catalogueTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(data)))}, nil + }) + release, err := m.ResolveCatalogue(t.Context(), index, "ledger", "3.0.0", 1) + if err != nil || release.Revision != 1 { + t.Fatalf("explicit revision ignored: %+v %v", release, err) + } + if _, err := m.ResolveCatalogue(t.Context(), index, "ledger", "3.0.0", 3); !errors.Is(err, ErrNoRelease) { + t.Fatalf("missing revision resolved: %v", err) + } + m.platform = Platform{OS: "linux", Arch: "s390x"} + if _, err := m.ResolveCatalogue(t.Context(), index, "ledger", "3.0.0", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("missing platform resolved: %v", err) + } +} diff --git a/internal/pluginmanager/catalogue_yaml_test.go b/internal/pluginmanager/catalogue_yaml_test.go new file mode 100644 index 00000000..9970b97b --- /dev/null +++ b/internal/pluginmanager/catalogue_yaml_test.go @@ -0,0 +1,82 @@ +package pluginmanager + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestCatalogueYAMLAndJSONPreserveManifest(t *testing.T) { + m := manager(t) + release := Release{ + Service: "ledger", ServiceVersion: "3.0.0-beta.10", Revision: 2, + Platform: CurrentPlatform(), SHA256: strings.Repeat("a", 64), + Artifact: Artifact{Registry: "https://ghcr.io", Repository: "formancehq/ledger-fctl-plugin", Digest: "sha256:" + strings.Repeat("b", 64)}, + Manifest: manifest("3.0.0-beta.10"), + } + release.Manifest.Root.Flags = []pluginsdk.FlagSpec{{Name: "ledger", Type: "string", Default: "90071992547409930001", Persistent: true}} + release.Manifest.Root.Subcommands[0].Inputs = []pluginsdk.InputSpec{{ + Title: "Ledger", Kind: "select", Flag: "ledger", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "list"}, ValueField: "name", MatchFields: map[string][]string{"status": {"yes", "no"}}}, + }} + catalogue := Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{release}} + data, err := yaml.Marshal(catalogue) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "registry.yaml") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + for _, source := range []string{path, catalogueFile(t, release)} { + got, err := m.Resolve(t.Context(), source, "ledger", release.ServiceVersion, 0) + if err != nil || !reflect.DeepEqual(got, release) { + t.Fatalf("manifest changed after catalogue decoding: %+v, %v", got, err) + } + if _, err := m.Resolve(t.Context(), source, "ledger", "3.0.0-beta.11", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("selected a different service version: %v", err) + } + } +} + +func TestCatalogueYAMLDiscoveryValidation(t *testing.T) { + for _, test := range []struct { + name, data string + valid bool + }{ + {"empty", "# Official registry\nschemaVersion: 1\nreleases: []\n", true}, + {"unknown-field", "schemaVersion: 1\nreleases: []\nrelases: []\n", false}, + {"duplicate-field", "schemaVersion: 1\nschemaVersion: 2\nreleases: []\n", false}, + {"schema", "schemaVersion: 2\nreleases: []\n", false}, + {"invalid-release", "schemaVersion: 1\nreleases:\n - service: ledger\n revision: 1\n", false}, + } { + t.Run(test.name, func(t *testing.T) { + m := manager(t) + m.client.Transport = catalogueTransport(func(request *http.Request) (*http.Response, error) { + if request.URL.String() != DefaultCatalogue || request.Header.Get("Authorization") != "" { + t.Fatalf("unexpected discovery request: %s", request.URL) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.data)), Header: make(http.Header)}, nil + }) + catalogue, err := m.Discover(t.Context(), DefaultCatalogue) + if (err == nil) != test.valid { + t.Fatalf("discovery: %+v %v", catalogue, err) + } + }) + } +} + +type catalogueTransport func(*http.Request) (*http.Response, error) + +func (f catalogueTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/internal/pluginmanager/manager_test.go b/internal/pluginmanager/manager_test.go new file mode 100644 index 00000000..ceed2c2f --- /dev/null +++ b/internal/pluginmanager/manager_test.go @@ -0,0 +1,436 @@ +package pluginmanager + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type registryFixture struct { + server *httptest.Server + mu sync.Mutex + objects map[string][]byte + override map[string][]byte + length map[string]int + requests atomic.Int32 + manifestReads atomic.Int32 + tokenRequests atomic.Int32 + denyToken atomic.Bool +} + +func newRegistry(t *testing.T) *registryFixture { + t.Helper() + registry := ®istryFixture{objects: make(map[string][]byte), override: make(map[string][]byte), length: make(map[string]int)} + registry.server = httptest.NewServer(http.HandlerFunc(registry.serve)) + t.Cleanup(registry.server.Close) + return registry +} + +func (r *registryFixture) serve(writer http.ResponseWriter, request *http.Request) { + r.requests.Add(1) + if request.URL.Path == "/token" { + r.serveToken(writer, request) + return + } + if request.Header.Get("Authorization") != "Bearer public-token" { + writer.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm=%q,service="test-registry",scope="repository:formance/ledger:pull,push"`, r.server.URL+"/token")) + writer.WriteHeader(http.StatusUnauthorized) + return + } + r.mu.Lock() + defer r.mu.Unlock() + switch request.Method { + case http.MethodPost: + writer.Header().Set("Location", "/v2/formance/ledger/blobs/uploads/fixture?upload-state=keep") + writer.WriteHeader(http.StatusAccepted) + case http.MethodPut: + r.servePut(writer, request) + case http.MethodGet: + r.serveGet(writer, request) + default: + writer.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (r *registryFixture) serveToken(writer http.ResponseWriter, request *http.Request) { + r.tokenRequests.Add(1) + if r.denyToken.Load() { + writer.WriteHeader(http.StatusForbidden) + return + } + if request.URL.Query().Get("service") != "test-registry" || + !strings.HasPrefix(request.URL.Query().Get("scope"), "repository:formance/ledger:") { + writer.WriteHeader(http.StatusBadRequest) + return + } + writer.Header().Set("Content-Type", "application/json") + writeFixture(writer, []byte(`{"access_token":"public-token"}`)) +} + +func (r *registryFixture) servePut(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + path := request.URL.Path + digest := filepath.Base(path) + if strings.Contains(path, "/blobs/uploads/") { + digest = request.URL.Query().Get("digest") + if request.URL.Query().Get("upload-state") != "keep" { + writer.WriteHeader(http.StatusBadRequest) + return + } + path = "/v2/formance/ledger/blobs/" + digest + } + if digest != "sha256:"+checksum(data) { + writer.WriteHeader(http.StatusBadRequest) + return + } + r.objects[path] = data + writer.Header().Set("Docker-Content-Digest", digest) + writer.WriteHeader(http.StatusCreated) +} + +func (r *registryFixture) serveGet(writer http.ResponseWriter, request *http.Request) { + data, ok := r.objects[request.URL.Path] + if replacement, exists := r.override[request.URL.Path]; exists { + data, ok = replacement, true + } + if !ok { + writer.WriteHeader(http.StatusNotFound) + return + } + if strings.Contains(request.URL.Path, "/manifests/") { + r.manifestReads.Add(1) + writer.Header().Set("Content-Type", ImageManifestMediaType) + } + if length, exists := r.length[request.URL.Path]; exists { + writer.Header().Set("Content-Length", strconv.Itoa(length)) + } + writeFixture(writer, data) +} + +func writeFixture(writer http.ResponseWriter, data []byte) { + // Deliberate client cancellation can close the test server's connection. + if _, err := writer.Write(data); err != nil { + return + } +} + +func manifest(version string) pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "ledger", Service: "ledger", Version: version, ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "ledger", Short: "Ledger", Subcommands: []pluginsdk.CommandSpec{{Use: "list", Runnable: true}}}} +} + +func manager(t *testing.T) *Manager { + t.Helper() + m, err := New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + return m +} + +func localBinary(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fctl-plugin-ledger") + if err := os.WriteFile(path, data, 0o700); err != nil { //nolint:gosec // Fixture deliberately represents a native executable. + t.Fatal(err) + } + return path +} + +func publish(t *testing.T, m *Manager, registry *registryFixture, version string, revision int) Release { + t.Helper() + data := fmt.Appendf(nil, "native-plugin-%s-%d\n", version, revision) + release, err := m.Publish(t.Context(), registry.server.URL+"/formance/ledger", Release{ + Service: "ledger", ServiceVersion: version, Revision: revision, Platform: CurrentPlatform(), Manifest: manifest(version), + }, localBinary(t, data)) + if err != nil { + t.Fatal(err) + } + return release +} + +func catalogueFile(t *testing.T, releases ...Release) string { + t.Helper() + data, err := json.Marshal(Catalogue{SchemaVersion: SchemaVersion, Releases: releases}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "catalogue.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func target(stack string) Target { + return Target{Profile: "sandbox", Organization: "example", Stack: stack, Endpoint: "https://app.formance.cloud"} +} + +func TestPublishSyncExactVersionsRevisionsAndOfflineTargets(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + v1 := publish(t, m, registry, "3.0.0", 1) + v2 := publish(t, m, registry, "3.0.0", 2) + vNext := publish(t, m, registry, "3.0.1", 1) + catalogue := catalogueFile(t, v1, v2, vNext) + first, err := m.SyncRevision(t.Context(), catalogue, target("one"), "ledger", "3.0.0", 1) + if err != nil { + t.Fatal(err) + } + second, err := m.Sync(t.Context(), catalogue, target("two"), "ledger", "3.0.0") + if err != nil { + t.Fatal(err) + } + if first.Revision != 1 || second.Revision != 2 || second.ServiceVersion != "3.0.0" { + t.Fatalf("incorrect exact selection: first=%+v, second=%+v", first, second) + } + if first.ArtifactDigest == second.ArtifactDigest { + t.Fatal("different revisions unexpectedly share an artifact") + } + if first.Catalogue != catalogue { + t.Fatalf("catalogue was not retained: %q", first.Catalogue) + } + if _, err := m.Resolve(t.Context(), catalogue, "ledger", "3.0.2", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("got %v", err) + } + if _, err := m.Resolve(t.Context(), catalogue, "ledger", "3.0.0", 3); !errors.Is(err, ErrNoRelease) { + t.Fatalf("got %v", err) + } + assertOfflineTargets(t, m, registry, catalogue, second) +} + +func assertOfflineTargets(t *testing.T, m *Manager, registry *registryFixture, catalogue string, second Lock) { + t.Helper() + registry.server.Close() + before := registry.requests.Load() + loaded, err := m.Load(target("one"), "ledger") + if err != nil { + t.Fatal(err) + } + if loaded.Revision != 1 || len(loaded.Manifest.Root.Subcommands) != 1 { + t.Fatal("cached help manifest changed") + } + if _, err := m.Binary(loaded); err != nil { + t.Fatal(err) + } + if _, err := m.Binary(second); err != nil { + t.Fatal(err) + } + // A new target reuses the already verified digest without a registry call. + oneOff := Target{Endpoint: "http://localhost:3068"} + if _, err := m.Sync(t.Context(), catalogue, oneOff, "ledger", "3.0.0"); err != nil { + t.Fatal(err) + } + locks, err := m.List() + if err != nil || len(locks) != 3 { + t.Fatalf("list: %v, %d locks", err, len(locks)) + } + if registry.requests.Load() != before { + t.Fatal("offline cache attempted network access") + } + if registry.tokenRequests.Load() == 0 { + t.Fatal("bearer token flow was not exercised") + } +} + +func TestInstallFailurePreservesPreviousLock(t *testing.T) { + for _, failure := range []string{"manifest", "config", "binary", "truncated"} { + t.Run(failure, func(t *testing.T) { assertInstallFailure(t, failure) }) + } +} + +func assertInstallFailure(t *testing.T, failure string) { + t.Helper() + m := manager(t) + registry := newRegistry(t) + first := publish(t, m, registry, "3.0.0", 1) + second := publish(t, m, registry, "3.0.1", 1) + if _, err := m.Install(t.Context(), target("one"), first); err != nil { + t.Fatal(err) + } + registry.mu.Lock() + path := corruptDownload(registry, second, failure) + registry.mu.Unlock() + if _, err := m.Install(t.Context(), target("one"), second); err == nil { + t.Fatal("corrupt download accepted") + } + loaded, err := m.Load(target("one"), "ledger") + if err != nil || loaded.ArtifactDigest != first.Artifact.Digest { + t.Fatalf("previous lock changed: %v, %+v", err, loaded) + } + if _, err := m.Binary(loaded); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(m.directory, binaryPath(newLock(target("one"), second)))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("failed executable published: %v", err) + } + registry.mu.Lock() + delete(registry.override, path) + delete(registry.length, path) + registry.mu.Unlock() + if _, err := m.Install(t.Context(), target("one"), second); err != nil { + t.Fatalf("retry did not recover: %v", err) + } +} + +func corruptDownload(registry *registryFixture, release Release, failure string) string { + path := "/v2/formance/ledger/blobs/sha256:" + release.SHA256 + switch failure { + case "manifest": + path = "/v2/formance/ledger/manifests/" + release.Artifact.Digest + case "config": + path = "/v2/formance/ledger/blobs/sha256:" + checksum([]byte("{}")) + } + original := registry.objects[path] + if failure == "truncated" { + registry.override[path] = original[:2] + registry.length[path] = len(original) + } else { + corrupt := append([]byte(nil), original...) + corrupt[0] ^= 1 + registry.override[path] = corrupt + } + return path +} + +func TestConcurrentInstallSharesDownloadAndCancelsWait(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + var wait sync.WaitGroup + failures := make(chan error, 12) + for i := range 12 { + wait.Go(func() { + _, err := m.Install(t.Context(), target(strconv.Itoa(i)), release) + failures <- err + }) + } + wait.Wait() + close(failures) + for err := range failures { + if err != nil { + t.Fatal(err) + } + } + if registry.manifestReads.Load() != 1 { + t.Fatalf("downloaded manifest %d times", registry.manifestReads.Load()) + } + locks, err := m.List() + if err != nil || len(locks) != 12 { + t.Fatalf("list: %v, %d locks", err, len(locks)) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := m.Install(ctx, target("cancelled"), release); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v", err) + } +} + +func TestCancellationWhileWaitingForInstallationLock(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + held := make(chan struct{}) + unlock := make(chan struct{}) + finished := make(chan error, 1) + go func() { + finished <- m.withCacheLock(t.Context(), newLock(target("holder"), release), func(*os.Root) error { + close(held) + <-unlock + return nil + }) + }() + <-held + before := registry.requests.Load() + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + _, err := m.Install(ctx, target("waiting"), release) + close(unlock) + if cleanupErr := <-finished; cleanupErr != nil { + t.Fatal(cleanupErr) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v", err) + } + if registry.requests.Load() != before { + t.Fatal("a cancelled lock waiter downloaded an executable") + } + if _, err := m.Load(target("waiting"), "ledger"); !errors.Is(err, ErrNotInstalled) { + t.Fatalf("cancelled waiter published a lock: %v", err) + } +} + +func TestInstallLocalCopiesAndVerifiesWithEndpointIdentity(t *testing.T) { + m := manager(t) + original := localBinary(t, []byte("original-executable")) + one := target("one") + two := one + two.Endpoint = "http://localhost:3068/api/ledger" + first, err := m.InstallLocal(t.Context(), original, one, manifest("3.0.0")) + if err != nil { + t.Fatal(err) + } + if _, err := m.InstallLocal(t.Context(), original, two, manifest("3.0.0")); err != nil { + t.Fatal(err) + } + if err := os.Remove(original); err != nil { + t.Fatal(err) + } + path, err := m.Binary(first) + if err != nil { + t.Fatal(err) + } + if !first.Local || first.Catalogue != "" { + t.Fatalf("incorrect local lock %+v", first) + } + locks, err := m.List() + if err != nil || len(locks) != 2 { + t.Fatalf("endpoint identity collided: %v, %d", err, len(locks)) + } + if err := os.WriteFile(path, []byte("corrupt"), 0o700); err != nil { //nolint:gosec // Keep the fixture executable to specifically test checksum validation. + t.Fatal(err) + } + if _, err := m.Binary(first); err == nil { + t.Fatal("corrupted cache executable accepted") + } + if _, err := m.Load(one, "ledger"); err != nil { + t.Fatalf("metadata-only help should remain available: %v", err) + } +} + +func TestHTTPDownloadDeadlineAndAnonymousAuthDenied(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + registry.denyToken.Store(true) + if _, err := m.Install(t.Context(), target("one"), release); err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("got %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + <-request.Context().Done() + })) + defer server.Close() + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + if _, err := m.Resolve(ctx, server.URL, "ledger", "3.0.0", 0); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v", err) + } +} diff --git a/internal/pluginmanager/publish.go b/internal/pluginmanager/publish.go new file mode 100644 index 00000000..54eae7f3 --- /dev/null +++ b/internal/pluginmanager/publish.go @@ -0,0 +1,122 @@ +package pluginmanager + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +// Publish uploads a raw executable, empty config and OCI manifest, in that +// order, and returns the complete catalogue release with an immutable digest. +// reference is an HTTPS registry/repository URL without a tag or digest, for +// example https://ghcr.io/formancehq/fctl-plugin-ledger. Platform and manifest +// come from release; SHA256 and Artifact are filled from the uploaded bytes. +// Anonymous push works against a local registry. For authenticated publishing, +// use the release job's OCI tool or an explicitly authenticated HTTP transport. +// Publish never rewrites an existing target lock or updates a catalogue file. +func (m *Manager) Publish(ctx context.Context, reference string, release Release, binary string) (_ Release, err error) { + u, err := downloadURL(reference) + if err != nil { + return Release{}, err + } + if u.RawQuery != "" { + return Release{}, fmt.Errorf("OCI publish reference must not have a query") + } + file, err := os.Open(binary) //nolint:gosec // The release publisher explicitly chooses a local build artifact. + if err != nil { + return Release{}, err + } + defer func() { err = joinClose(err, file) }() + if err := regularFile(file); err != nil { + return Release{}, err + } + data, err := readBounded(&contextReader{ctx: ctx, reader: file}, maxBinaryBytes) + if err != nil { + return Release{}, err + } + if len(data) == 0 { + return Release{}, fmt.Errorf("cannot publish an empty executable") + } + release.SHA256 = checksum(data) + release.Artifact = Artifact{Registry: u.Scheme + "://" + u.Host, Repository: strings.TrimPrefix(u.Path, "/"), Digest: "sha256:" + checksum(nil)} + if err := validateRelease(release, false); err != nil { + return Release{}, err + } + config := []byte("{}") + manifest := imageManifest{ + SchemaVersion: 2, MediaType: ImageManifestMediaType, ArtifactType: ArtifactMediaType, + Config: descriptor{MediaType: EmptyConfigMediaType, Digest: "sha256:" + checksum(config), Size: int64(len(config))}, + Layers: []descriptor{{MediaType: ExecutableMediaType, Digest: "sha256:" + release.SHA256, Size: int64(len(data)), + Annotations: map[string]string{"org.opencontainers.image.title": "fctl-plugin-" + release.Manifest.Name}}}, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return Release{}, err + } + release.Artifact.Digest = "sha256:" + checksum(manifestBytes) + session := registrySession{client: m.client, registry: release.Artifact.Registry, repository: release.Artifact.Repository, actions: "pull,push"} + if err := session.pushBlob(ctx, manifest.Config.Digest, config); err != nil { + return Release{}, err + } + if err := session.pushBlob(ctx, manifest.Layers[0].Digest, data); err != nil { + return Release{}, err + } + if err := session.pushManifest(ctx, release.Artifact.Digest, manifestBytes); err != nil { + return Release{}, err + } + return release, nil +} + +func (s *registrySession) pushBlob(ctx context.Context, digest string, data []byte) (err error) { + response, err := s.do(ctx, http.MethodPost, s.path("blobs/uploads", ""), nil, "") + if err != nil { + return err + } + location, locationErr := uploadLocation(response) + closeErr := response.Body.Close() + if response.StatusCode != http.StatusAccepted { + return fmt.Errorf("OCI upload initialization returned HTTP %d", response.StatusCode) + } + if locationErr != nil || closeErr != nil { + return errors.Join(locationErr, closeErr) + } + u, err := url.Parse(location) + if err != nil { + return err + } + query := u.Query() + query.Set("digest", digest) + u.RawQuery = query.Encode() + response, err = s.do(ctx, http.MethodPut, u.String(), data, "application/octet-stream") + if err != nil { + return err + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusCreated { + return fmt.Errorf("OCI blob upload returned HTTP %d", response.StatusCode) + } + if actual := response.Header.Get("Docker-Content-Digest"); actual != "" && actual != digest { + return fmt.Errorf("published OCI blob digest mismatch") + } + return nil +} + +func (s *registrySession) pushManifest(ctx context.Context, digest string, data []byte) (err error) { + response, err := s.do(ctx, http.MethodPut, s.path("manifests", digest), data, ImageManifestMediaType) + if err != nil { + return err + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusCreated { + return fmt.Errorf("OCI manifest publication returned HTTP %d", response.StatusCode) + } + if actual := response.Header.Get("Docker-Content-Digest"); actual != "" && actual != digest { + return fmt.Errorf("published OCI manifest digest mismatch") + } + return nil +} diff --git a/internal/pluginmanager/registry.go b/internal/pluginmanager/registry.go new file mode 100644 index 00000000..ae831ee7 --- /dev/null +++ b/internal/pluginmanager/registry.go @@ -0,0 +1,241 @@ +package pluginmanager + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime" + "net/http" + "net/url" + "os" + "strings" +) + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` + Annotations map[string]string `json:"annotations,omitzero"` +} + +type imageManifest struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + ArtifactType string `json:"artifactType"` + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` +} + +type registrySession struct { + client *http.Client + registry string + repository string + actions string + token string +} + +func (m *Manager) pull(ctx context.Context, root *os.Root, lock Lock) error { + session := registrySession{client: m.client, registry: strings.TrimRight(lock.Artifact.Registry, "/"), repository: lock.Artifact.Repository, actions: "pull"} + data, err := session.bytes(ctx, session.path("manifests", lock.ArtifactDigest), maxManifestBytes) + if err != nil { + return fmt.Errorf("download OCI manifest: %w", err) + } + if "sha256:"+checksum(data) != lock.ArtifactDigest { + return fmt.Errorf("OCI manifest digest mismatch") + } + var manifest imageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return fmt.Errorf("decode OCI manifest: %w", err) + } + if err := validateImage(manifest, lock.SHA256); err != nil { + return err + } + config, err := session.bytes(ctx, session.path("blobs", manifest.Config.Digest), maxManifestBytes) + if err != nil { + return fmt.Errorf("download OCI config: %w", err) + } + if int64(len(config)) != manifest.Config.Size || "sha256:"+checksum(config) != manifest.Config.Digest { + return fmt.Errorf("OCI config digest or size mismatch") + } + return session.binary(ctx, root, lock, manifest.Layers[0]) +} + +func validateImage(manifest imageManifest, binarySHA string) error { + if manifest.SchemaVersion != 2 || manifest.MediaType != ImageManifestMediaType || manifest.ArtifactType != ArtifactMediaType { + return fmt.Errorf("unsupported OCI plugin artifact format") + } + if len(manifest.Layers) != 1 || manifest.Layers[0].MediaType != ExecutableMediaType { + return fmt.Errorf("OCI plugin requires exactly one raw executable layer") + } + if !validDigest(manifest.Config.Digest) || manifest.Config.Size < 0 || manifest.Config.Size > maxManifestBytes { + return fmt.Errorf("invalid OCI config descriptor") + } + layer := manifest.Layers[0] + if !validDigest(layer.Digest) || layer.Size <= 0 || layer.Size > maxBinaryBytes { + return fmt.Errorf("invalid OCI executable descriptor") + } + if layer.Digest != "sha256:"+binarySHA { + return fmt.Errorf("OCI executable digest does not match catalogue SHA256") + } + return nil +} + +func (s *registrySession) path(kind, digest string) string { + return s.registry + "/v2/" + s.repository + "/" + kind + "/" + digest +} + +func (s *registrySession) bytes(ctx context.Context, address string, limit int64) (_ []byte, err error) { + response, err := s.do(ctx, http.MethodGet, address, nil, "") + if err != nil { + return nil, err + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OCI registry returned HTTP %d", response.StatusCode) + } + return readBounded(response.Body, limit) +} + +func (s *registrySession) binary(ctx context.Context, root *os.Root, lock Lock, layer descriptor) (err error) { + response, err := s.do(ctx, http.MethodGet, s.path("blobs", layer.Digest), nil, "") + if err != nil { + return err + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("OCI executable download returned HTTP %d", response.StatusCode) + } + if response.ContentLength >= 0 && response.ContentLength != layer.Size { + return fmt.Errorf("OCI executable content length does not match descriptor") + } + return writeBinary(ctx, root, lock, response.Body, layer.Size) +} + +func (s *registrySession) do(ctx context.Context, method, address string, data []byte, contentType string) (*http.Response, error) { + response, err := s.request(ctx, method, address, data, contentType) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusUnauthorized { + return response, nil + } + challenge := response.Header.Get("WWW-Authenticate") + if err := response.Body.Close(); err != nil { + return nil, err + } + if err := s.authenticate(ctx, challenge); err != nil { + return nil, err + } + return s.request(ctx, method, address, data, contentType) +} + +func (s *registrySession) request(ctx context.Context, method, address string, data []byte, contentType string) (*http.Response, error) { + u, err := downloadURL(address) + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(data)) + if err != nil { + return nil, err + } + if s.token != "" && u.Scheme+"://"+u.Host == s.registry { + request.Header.Set("Authorization", "Bearer "+s.token) + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + request.Header.Set("Accept", ImageManifestMediaType+", application/octet-stream") + return s.client.Do(request) +} + +func (s *registrySession) authenticate(ctx context.Context, challenge string) (err error) { + realm, service, err := bearerChallenge(challenge) + if err != nil { + return err + } + u, err := downloadURL(realm) + if err != nil { + return fmt.Errorf("invalid OCI token realm: %w", err) + } + query := u.Query() + query.Set("service", service) + // Never use a registry-provided scope to request write privileges on pulls. + query.Set("scope", "repository:"+s.repository+":"+s.actions) + u.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return err + } + response, err := s.client.Do(request) + if err != nil { + return err + } + defer func() { err = joinClose(err, response.Body) }() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("OCI anonymous token request returned HTTP %d", response.StatusCode) + } + data, err := readBounded(response.Body, 64<<10) + if err != nil { + return err + } + var result struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("invalid OCI token response") + } + s.token = result.Token + if s.token == "" { + s.token = result.AccessToken + } + if s.token == "" || strings.ContainsAny(s.token, "\r\n") { + return fmt.Errorf("OCI token response has no valid token") + } + return nil +} + +func bearerChallenge(challenge string) (string, string, error) { + scheme, parameters, ok := strings.Cut(challenge, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + return "", "", fmt.Errorf("OCI registry requires unsupported authentication") + } + // MIME parameters have the same quoted-string escaping as this challenge. + // Translate only commas outside quoted strings; scope itself contains commas. + translated := make([]rune, 0, len(parameters)) + quoted, escaped := false, false + for _, char := range parameters { + if char == '"' && !escaped { + quoted = !quoted + } + if char == ',' && !quoted { + char = ';' + } + translated = append(translated, char) + escaped = char == '\\' && !escaped + } + _, values, err := mime.ParseMediaType("bearer;" + string(translated)) + if err != nil || values["realm"] == "" || values["service"] == "" { + return "", "", fmt.Errorf("invalid OCI bearer challenge") + } + return values["realm"], values["service"], nil +} + +// OCI registries may return relative upload locations or signed HTTPS blob +// locations. Resolve them against the responding request, preserving its query. +func uploadLocation(response *http.Response) (string, error) { + location := response.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("OCI upload response has no location") + } + u, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("invalid OCI upload location") + } + u = response.Request.URL.ResolveReference(u) + if _, err := downloadURL(u.String()); err != nil { + return "", err + } + return u.String(), nil +} diff --git a/internal/pluginmanager/types.go b/internal/pluginmanager/types.go new file mode 100644 index 00000000..474d9f44 --- /dev/null +++ b/internal/pluginmanager/types.go @@ -0,0 +1,143 @@ +// Package pluginmanager resolves exact service versions from a configured public +// catalogue and installs native executables from digest-addressed OCI artifacts. +// It does not start plugins or access the host's service authentication. +// +// A product catalogue has schemaVersion 1 and a releases array. A schema-2 +// registry maps services and exact versions to checksum-pinned product catalogues. +// Only the selected product catalogue is fetched; references cannot be nested. +// Each product release identifies +// service, serviceVersion, revision, platform {os, arch}, artifact {registry, +// repository, digest}, sha256, and the complete pluginsdk manifest. The registry +// is an HTTPS origin (HTTP is allowed for loopback development registries). +// Artifacts use an OCI image manifest, an empty JSON config and exactly one raw +// executable layer; archives and image indexes are deliberately unsupported. +// +// New(cacheDirectory, client) creates the manager. Sync(ctx, catalogue, target, +// service, exactVersion) selects the highest revision for that exact version and +// the current platform, installs it, and atomically writes the target's lock. +// Load and List read only local metadata; Binary verifies the cached executable. +// InstallLocal copies an explicitly selected binary with a manifest obtained by +// the caller. The caller must verify the running binary's manifest against the +// locked manifest before executing commands, for both local and OCI installs. +package pluginmanager + +import ( + "context" + "errors" + "net/http" + "runtime" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const ( + SchemaVersion = 1 + RegistrySchemaVersion = 2 + ArtifactMediaType = "application/vnd.formance.fctl.plugin.v1" + ExecutableMediaType = "application/vnd.formance.fctl.plugin.executable.v1" + ImageManifestMediaType = "application/vnd.oci.image.manifest.v1+json" + EmptyConfigMediaType = "application/vnd.oci.empty.v1+json" + maxCatalogueBytes int64 = 8 << 20 + maxManifestBytes int64 = 4 << 20 + maxBinaryBytes int64 = 128 << 20 +) + +var ( + ErrNotInstalled = errors.New("plugin is not installed for this target") + ErrNoRelease = errors.New("no plugin release for the exact service version and platform") + ErrCatalogueUnavailable = errors.New("plugin catalogue is unavailable") +) + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +func CurrentPlatform() Platform { return Platform{OS: runtime.GOOS, Arch: runtime.GOARCH} } + +func (p Platform) String() string { return p.OS + "/" + p.Arch } + +type Artifact struct { + Registry string `json:"registry"` + Repository string `json:"repository"` + Digest string `json:"digest"` +} + +type Release struct { + Service string `json:"service"` + ServiceVersion string `json:"serviceVersion"` + Revision int `json:"revision"` + Platform Platform `json:"platform"` + Artifact Artifact `json:"artifact"` + SHA256 string `json:"sha256"` + Manifest pluginsdk.Manifest `json:"manifest"` +} + +type Catalogue struct { + SchemaVersion int `json:"schemaVersion"` + Releases []Release `json:"releases,omitzero"` + Plugins map[string]ProductCatalogues `json:"plugins,omitzero"` +} + +// ProductCatalogues references product-owned release catalogues, not executables. +type ProductCatalogues struct { + Releases []CatalogueReference `json:"releases"` +} + +// CatalogueReference pins the raw bytes of one exact product version's catalogue. +type CatalogueReference struct { + ServiceVersion string `json:"serviceVersion"` + Catalogue string `json:"catalogue"` + SHA256 string `json:"sha256"` +} + +// Target is a stable connection identity, not a transient API gateway URL. +// Profile may be empty for one-off connections. Endpoint is a local service URL +// or a Cloud issuer; it never contains credentials, query values or a fragment. +type Target struct { + Profile string `json:"profile"` + Organization string `json:"organization,omitzero"` + Stack string `json:"stack,omitzero"` + Endpoint string `json:"endpoint"` +} + +type Lock struct { + SchemaVersion int `json:"schemaVersion"` + Target Target `json:"target"` + Service string `json:"service"` + ServiceVersion string `json:"serviceVersion"` + Revision int `json:"revision"` + ArtifactDigest string `json:"artifactDigest"` + Platform Platform `json:"platform"` + SHA256 string `json:"sha256"` + Manifest pluginsdk.Manifest `json:"manifest"` + Catalogue string `json:"catalogue,omitzero"` + Artifact Artifact `json:"artifact,omitzero"` + Local bool `json:"local,omitzero"` + InstalledAt time.Time `json:"installedAt"` +} + +type Manager struct { + directory string + client *http.Client + platform Platform +} + +// Resolve reads a catalogue file or URL, without executing a plugin. A zero +// revision selects the highest revision for the exact serviceVersion only. +func (m *Manager) Resolve(ctx context.Context, catalogue, service, serviceVersion string, revision int) (Release, error) { + return m.resolve(ctx, catalogue, service, serviceVersion, revision) +} + +// Install installs a previously resolved release. Sync should usually be used +// instead so that the source catalogue is persisted for later automatic sync. +func (m *Manager) Install(ctx context.Context, target Target, release Release) (Lock, error) { + return m.install(ctx, "", target, release) +} + +// InstallResolved persists the already inspected release without re-fetching +// its catalogue. This lets the host validate manifest collisions first. +func (m *Manager) InstallResolved(ctx context.Context, catalogue string, target Target, release Release) (Lock, error) { + return m.install(ctx, catalogue, target, release) +} diff --git a/internal/pluginmanager/validation_test.go b/internal/pluginmanager/validation_test.go new file mode 100644 index 00000000..fcd23b60 --- /dev/null +++ b/internal/pluginmanager/validation_test.go @@ -0,0 +1,273 @@ +package pluginmanager + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMalformedCatalogueAndPlatformAreRejected(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + valid := publish(t, m, registry, "3.0.0", 1) + for _, test := range []struct { + name string + mutate func(*Release) + }{ + {"repository traversal", func(r *Release) { r.Artifact.Repository = "../ledger" }}, + {"digest traversal", func(r *Release) { r.Artifact.Digest = "sha256:../../outside" }}, + {"registry credentials", func(r *Release) { r.Artifact.Registry = "https://user:secret@registry.example" }}, + {"registry insecure", func(r *Release) { r.Artifact.Registry = "http://registry.example" }}, + {"registry query", func(r *Release) { r.Artifact.Registry += "?token=secret" }}, + {"registry path", func(r *Release) { r.Artifact.Registry += "/repo" }}, + {"invalid hash", func(r *Release) { r.SHA256 = "bad" }}, + {"service traversal", func(r *Release) { r.Service = "../ledger" }}, + {"platform traversal", func(r *Release) { r.Platform.OS = "../outside" }}, + {"service mismatch", func(r *Release) { r.Manifest.Service = "auth" }}, + {"empty root", func(r *Release) { r.Manifest.Root.Use = "" }}, + {"unsupported protocol", func(r *Release) { r.Manifest.ProtocolVersion++ }}, + } { + t.Run(test.name, func(t *testing.T) { + release := valid + test.mutate(&release) + if _, err := m.Resolve(t.Context(), catalogueFile(t, release), "ledger", "3.0.0", 0); err == nil { + t.Fatal("unsafe catalogue entry accepted") + } + }) + } + other := valid + other.Platform = Platform{OS: "unsupported", Arch: "unsupported"} + if _, err := m.Resolve(t.Context(), catalogueFile(t, other), "ledger", "3.0.0", 0); !errors.Is(err, ErrNoRelease) { + t.Fatalf("got %v", err) + } + if _, err := m.Install(t.Context(), target("one"), other); err == nil { + t.Fatal("wrong-platform install accepted") + } + if _, err := m.Resolve(t.Context(), catalogueFile(t, valid, valid), "ledger", "3.0.0", 0); err == nil { + t.Fatal("ambiguous duplicate release accepted") + } +} + +func TestUnsafeTargetsAndForgedLocksAreRejected(t *testing.T) { + m := manager(t) + for _, endpoint := range []string{"", "https://user:secret@example.org", "https://example.org?token=secret", "https://example.org#secret", "file:///tmp/ledger"} { + if _, err := m.Load(Target{Endpoint: endpoint}, "ledger"); err == nil || errors.Is(err, ErrNotInstalled) { + t.Fatalf("invalid endpoint accepted: %q, %v", endpoint, err) + } + } + if _, err := m.Load(Target{Profile: "evil\x00profile", Endpoint: "http://localhost:3068"}, "ledger"); err == nil { + t.Fatal("NUL identity accepted") + } + if _, err := m.Load(target("one"), "../ledger"); err == nil { + t.Fatal("unsafe service accepted") + } + if _, err := m.Load(target("missing"), "ledger"); !errors.Is(err, ErrNotInstalled) { + t.Fatalf("got %v", err) + } + lock, err := m.InstallLocal(t.Context(), localBinary(t, []byte("executable")), target("one"), manifest("3.0.0")) + if err != nil { + t.Fatal(err) + } + lock.Platform.OS = "../../outside" + if _, err := m.Binary(lock); err == nil { + t.Fatal("forged cache path accepted") + } +} + +func TestRootContainsCachePathsAndLeavesNoTemporaryFiles(t *testing.T) { + for _, directory := range []string{"targets", "artifacts", "guards"} { + t.Run(directory, func(t *testing.T) { + m := manager(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(m.directory, directory)); err != nil { + t.Fatal(err) + } + if _, err := m.InstallLocal(t.Context(), localBinary(t, []byte("executable")), target("one"), manifest("3.0.0")); err == nil { + t.Fatal("cache symlink escaped root") + } + entries, err := os.ReadDir(outside) + if err != nil || len(entries) != 0 { + t.Fatalf("outside cache was written: %v, %v", entries, err) + } + }) + } +} + +func TestCatalogueHTTPAndBounds(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + data, err := json.Marshal(Catalogue{SchemaVersion: SchemaVersion, Releases: []Release{release}}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/catalogue": + writeFixture(writer, data) + case "/invalid": + writeFixture(writer, []byte(`{"schemaVersion":999}`)) + case "/malformed": + writeFixture(writer, []byte("{")) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + if _, err := m.Sync(t.Context(), server.URL+"/catalogue", target("one"), "ledger", "3.0.0"); err != nil { + t.Fatal(err) + } + for _, source := range []string{server.URL + "/invalid", server.URL + "/malformed", server.URL + "/missing", "file:///tmp/catalogue", "http://example.org/catalogue"} { + if _, err := m.Resolve(t.Context(), source, "ledger", "3.0.0", 0); err == nil { + t.Fatalf("invalid catalogue accepted: %s", source) + } + } + if _, err := readBounded(strings.NewReader("12345"), 4); err == nil { + t.Fatal("oversized response accepted") + } +} + +func TestRegistryRedirectDropsBearerToken(t *testing.T) { + var gotAuthorization string + blob := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + gotAuthorization = request.Header.Get("Authorization") + writeFixture(writer, []byte("binary")) + })) + defer blob.Close() + registry := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, blob.URL, http.StatusTemporaryRedirect) + })) + defer registry.Close() + m := manager(t) + session := registrySession{client: m.client, registry: registry.URL, repository: "formance/ledger", actions: "pull", token: "private-registry-token"} + if _, err := session.bytes(t.Context(), registry.URL, 32); err != nil { + t.Fatal(err) + } + if gotAuthorization != "" { + t.Fatal("registry bearer token leaked to redirected host") + } +} + +func TestOCIFormatBoundsAndChecksumAgreement(t *testing.T) { + base := imageManifest{SchemaVersion: 2, MediaType: ImageManifestMediaType, ArtifactType: ArtifactMediaType, + Config: descriptor{Digest: "sha256:" + checksum([]byte("{}")), Size: 2}, + Layers: []descriptor{{MediaType: ExecutableMediaType, Digest: "sha256:" + checksum([]byte("binary")), Size: 6}}} + for _, test := range []struct { + name string + change func(*imageManifest) + }{ + {"archive", func(m *imageManifest) { m.Layers[0].MediaType = "application/gzip" }}, + {"too large", func(m *imageManifest) { m.Layers[0].Size = maxBinaryBytes + 1 }}, + {"empty binary", func(m *imageManifest) { m.Layers[0].Size = 0 }}, + {"wrong checksum", func(m *imageManifest) { m.Layers[0].Digest = "sha256:" + checksum(nil) }}, + {"config digest", func(m *imageManifest) { m.Config.Digest = "not-a-digest" }}, + {"config size", func(m *imageManifest) { m.Config.Size = maxManifestBytes + 1 }}, + {"image index", func(m *imageManifest) { m.MediaType = "application/vnd.oci.image.index.v1+json" }}, + } { + t.Run(test.name, func(t *testing.T) { + copy := base + copy.Layers = append([]descriptor(nil), base.Layers...) + test.change(©) + if err := validateImage(copy, checksum([]byte("binary"))); err == nil { + t.Fatal("invalid OCI format accepted") + } + }) + } +} + +func TestLocalSourcesMustBeRegularAndNonEmpty(t *testing.T) { + m := manager(t) + if _, err := m.InstallLocal(t.Context(), t.TempDir(), target("one"), manifest("3.0.0")); err == nil { + t.Fatal("directory accepted as executable") + } + if _, err := m.InstallLocal(t.Context(), localBinary(t, nil), target("one"), manifest("3.0.0")); err == nil { + t.Fatal("empty executable accepted") + } + if _, err := m.Resolve(t.Context(), t.TempDir(), "ledger", "3.0.0", 0); err == nil { + t.Fatal("directory accepted as catalogue") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, _, err := hashReader(ctx, bytes.NewReader([]byte("binary")), 16); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v", err) + } + if _, _, err := hashReader(t.Context(), bytes.NewReader([]byte("binary")), 2); err == nil { + t.Fatal("unbounded local executable read") + } +} + +func TestBearerChallengeRejectsUnsupportedOrMalformedAuthentication(t *testing.T) { + for _, challenge := range []string{"", `Basic realm="registry"`, `Bearer realm="https://example.org/token"`, `Bearer realm="unterminated`} { + if _, _, err := bearerChallenge(challenge); err == nil { + t.Fatalf("invalid challenge accepted: %s", challenge) + } + } + realm, service, err := bearerChallenge(`Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:formance/ledger:pull,push"`) + if err != nil || realm != "https://ghcr.io/token" || service != "ghcr.io" { + t.Fatalf("GHCR challenge rejected: %q, %q, %v", realm, service, err) + } +} + +func TestInstallResolvedDoesNotRefetchAndOfflineHelpDoesNotNeedBinary(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + catalogue := catalogueFile(t, release) + resolved, err := m.Resolve(t.Context(), catalogue, "ledger", "3.0.0", 0) + if err != nil { + t.Fatal(err) + } + // Simulate a changed catalogue after the host validates the manifest. + if err := os.WriteFile(catalogue, []byte("invalid replacement"), 0o600); err != nil { + t.Fatal(err) + } + lock, err := m.InstallResolved(t.Context(), catalogue, target("one"), resolved) + if err != nil { + t.Fatal(err) + } + if lock.Catalogue != catalogue || lock.ArtifactDigest != release.Artifact.Digest { + t.Fatalf("installed a different resolution: %+v", lock) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := m.BinaryContext(ctx, lock); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled checksum verification returned %v", err) + } + binary, err := m.Binary(lock) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } + registry.server.Close() + loaded, err := m.Load(target("one"), "ledger") + if err != nil || loaded.Manifest.Name != "ledger" { + t.Fatalf("offline manifest unavailable: %v", err) + } + if _, err := m.Binary(loaded); err == nil { + t.Fatal("execution allowed with a missing binary") + } +} + +func TestInstallResolvedRejectsUntrustedCatalogueSource(t *testing.T) { + m := manager(t) + registry := newRegistry(t) + release := publish(t, m, registry, "3.0.0", 1) + before := registry.requests.Load() + for _, source := range []string{"file:///tmp/catalogue.json", "http://insecure.example/catalogue", "https://user:secret@example.org/catalogue", "https://example.org/catalogue#fragment"} { + if _, err := m.InstallResolved(t.Context(), source, target("one"), release); err == nil { + t.Fatalf("unsafe resolved catalogue source accepted: %q", source) + } + } + if registry.requests.Load() != before { + t.Fatal("unsafe source caused an artifact download") + } +} diff --git a/internal/pluginselection/selection.go b/internal/pluginselection/selection.go new file mode 100644 index 00000000..1b65aa4e --- /dev/null +++ b/internal/pluginselection/selection.go @@ -0,0 +1,367 @@ +// Package pluginselection records the prepared provider for an exact service +// target. It is independent of provider binaries and the modern OCI cache. +package pluginselection + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "io/fs" + "net/url" + "os" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +const maxSelectionBytes = 16 << 10 + +var ( + // ErrNotSelected means no provider was prepared for this exact target/service. + ErrNotSelected = errors.New("plugin provider is not selected for this target") + identityPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$`) + servicePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) + versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,255}$`) + filenamePattern = regexp.MustCompile(`^[a-f0-9]{64}\.json$`) +) + +// Target contains stable, secret-free identity. Profile, Organization and Stack +// may be empty for direct connections; no field is treated as a wildcard. +type Target struct { + Profile string `json:"profile"` + Organization string `json:"organization,omitzero"` + Stack string `json:"stack,omitzero"` + Endpoint string `json:"endpoint"` +} + +// Selection records exact versions, without credentials or executable paths. +type Selection struct { + Target Target `json:"target"` + Service string `json:"service"` + ServiceVersion string `json:"serviceVersion"` + StackVersion string `json:"stackVersion"` + Provider string `json:"provider"` + PluginVersion string `json:"pluginVersion"` +} + +// Load reads only the selection for target and service. An empty endpoint before +// login, or missing directories/files, returns ErrNotSelected. Corrupt or unsafe +// records return another error. +func Load(directory string, target Target, service string) (selection Selection, err error) { + if target.Endpoint == "" { + return Selection{}, ErrNotSelected + } + if err := validateKey(target, service); err != nil { + return Selection{}, err + } + root, err := openSelections(directory, false) + if errors.Is(err, fs.ErrNotExist) { + return Selection{}, ErrNotSelected + } + if err != nil { + return Selection{}, err + } + defer func() { err = errors.Join(err, root.Close()) }() + selection, err = readSelection(root, filename(target, service)) + if errors.Is(err, fs.ErrNotExist) { + return Selection{}, ErrNotSelected + } + if err != nil { + return Selection{}, err + } + if selection.Target != target || selection.Service != service { + return Selection{}, errors.New("plugin selection identity does not match requested target and service") + } + return selection, nil +} + +// Save atomically replaces one target/service selection. Versions and provider +// are values, not keys, so switching a provider cannot retain a stale selection. +// ServiceVersion is required. StackVersion is optional for standalone targets; +// PluginVersion is optional for modern providers, whose revision is owned by +// the OCI cache. +func Save(directory string, selection Selection) (err error) { + if err := validateSelection(selection); err != nil { + return err + } + data, err := json.Marshal(selection) + if err != nil { + return err + } + data = append(data, '\n') + if len(data) > maxSelectionBytes { + return errors.New("plugin selection exceeds size limit") + } + root, err := openSelections(directory, true) + if err != nil { + return err + } + defer func() { err = errors.Join(err, root.Close()) }() + name := filename(selection.Target, selection.Service) + if err := regularFile(root, name); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return atomicWrite(root, name, data) +} + +// List returns validated selections in filename order. Missing caches are empty; +// any invalid record fails the list rather than exposing partial routing data. +func List(directory string) (selections []Selection, err error) { + root, err := openSelections(directory, false) + if errors.Is(err, fs.ErrNotExist) { + return []Selection{}, nil + } + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, root.Close()) }() + entries, err := fs.ReadDir(root.FS(), ".") + if err != nil { + return nil, err + } + selections = make([]Selection, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".json") { + continue // Includes temporary files from interrupted writers. + } + if !filenamePattern.MatchString(name) { + return nil, errors.New("invalid plugin selection filename") + } + selection, err := readSelection(root, name) + if err != nil { + return nil, err + } + if name != filename(selection.Target, selection.Service) { + return nil, errors.New("plugin selection identity does not match filename") + } + selections = append(selections, selection) + } + return selections, nil +} + +func validateKey(target Target, service string) error { + for _, identity := range []string{target.Profile, target.Organization, target.Stack} { + if identity != "" && !identityPattern.MatchString(identity) { + return errors.New("invalid plugin selection target identity") + } + } + if !servicePattern.MatchString(service) { + return errors.New("invalid plugin selection service") + } + endpoint := target.Endpoint + if len(endpoint) > 4096 || !utf8.ValidString(endpoint) || strings.ContainsAny(endpoint, "?#") || + strings.ContainsFunc(endpoint, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) { + return errors.New("plugin selection endpoint must be a secret-free absolute HTTP(S) URL") + } + u, err := url.Parse(endpoint) + if err != nil || !u.IsAbs() || u.Hostname() == "" || u.User != nil || u.Opaque != "" || + (u.Scheme != "http" && u.Scheme != "https") { + return errors.New("plugin selection endpoint must be a secret-free absolute HTTP(S) URL") + } + return nil +} + +func validateSelection(selection Selection) error { + if err := validateKey(selection.Target, selection.Service); err != nil { + return err + } + if selection.Provider != "legacy" && selection.Provider != "modern" { + return errors.New("plugin selection provider must be legacy or modern") + } + if !versionPattern.MatchString(selection.ServiceVersion) { + return errors.New("plugin selection service version must be a nonempty version identifier") + } + if selection.StackVersion != "" && !versionPattern.MatchString(selection.StackVersion) { + return errors.New("invalid plugin selection stack version") + } + if (selection.Provider == "legacy" || selection.PluginVersion != "") && !versionPattern.MatchString(selection.PluginVersion) { + return errors.New("invalid plugin selection plugin version") + } + return nil +} + +func filename(target Target, service string) string { + // A fixed JSON tuple prevents separator collisions and includes every field. + data, _ := json.Marshal([5]string{target.Profile, target.Organization, target.Stack, target.Endpoint, service}) //nolint:errcheck // A string array cannot fail JSON marshaling. + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) + ".json" +} + +func openSelections(directory string, create bool) (*os.Root, error) { + if strings.TrimSpace(directory) == "" { + return nil, errors.New("plugin selection directory is required") + } + if create { + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, err + } + } + base, err := os.OpenRoot(directory) + if err != nil { + return nil, err + } + root, err := openSelectionDirectory(base, create) + if closeErr := base.Close(); closeErr != nil { + if root != nil { + closeErr = errors.Join(closeErr, root.Close()) + } + return nil, errors.Join(err, closeErr) + } + return root, err +} + +func openSelectionDirectory(base *os.Root, create bool) (*os.Root, error) { + if create { + if err := privateDirectory(base); err != nil { + return nil, err + } + if err := base.Mkdir("selections", 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return nil, err + } + } + info, err := base.Lstat("selections") + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, errors.New("plugin selections must be a directory, not a symlink") + } + root, err := base.OpenRoot("selections") + if err != nil { + return nil, err + } + if create { + if err := privateDirectory(root); err != nil { + return nil, errors.Join(err, root.Close()) + } + } + return root, nil +} + +func privateDirectory(root *os.Root) error { + file, err := root.Open(".") + if err != nil { + return err + } + // Chmod the open descriptor, avoiding Root.Chmod's Unix symlink race. + return errors.Join(file.Chmod(0o700), file.Close()) +} + +func regularFile(root *os.Root, name string) error { + info, err := root.Lstat(name) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("plugin selection must be a regular file, not a symlink") + } + return nil +} + +func readSelection(root *os.Root, name string) (_ Selection, err error) { + if err := regularFile(root, name); err != nil { + return Selection{}, err + } + file, err := root.Open(name) + if err != nil { + return Selection{}, err + } + defer func() { err = errors.Join(err, file.Close()) }() + info, err := file.Stat() + if err != nil { + return Selection{}, err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || info.Size() > maxSelectionBytes { + return Selection{}, errors.New("plugin selection must be a private, bounded regular file") + } + data, err := io.ReadAll(io.LimitReader(file, maxSelectionBytes+1)) + if err != nil { + return Selection{}, err + } + if len(data) > maxSelectionBytes || !utf8.Valid(data) { + return Selection{}, errors.New("plugin selection exceeds size limit or contains invalid UTF-8") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var selection Selection + if err := decoder.Decode(&selection); err != nil { + return Selection{}, errors.New("invalid plugin selection JSON") + } + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return Selection{}, errors.New("invalid trailing plugin selection JSON") + } + // encoding/json accepts duplicate keys and null string fields by default. + // Neither is an unambiguous provider selection. + if err := strictJSONValue(json.NewDecoder(bytes.NewReader(data))); err != nil { + return Selection{}, err + } + if err := validateSelection(selection); err != nil { + return Selection{}, err + } + return selection, nil +} + +func strictJSONValue(decoder *json.Decoder) error { + value, err := decoder.Token() + if err != nil { + return errors.New("invalid plugin selection JSON") + } + if _, ok := value.(string); ok { + return nil + } + if value != json.Delim('{') { + return errors.New("plugin selection JSON must contain only objects and strings") + } + return strictJSONObject(decoder) +} + +func strictJSONObject(decoder *json.Decoder) error { + seen := make(map[string]bool) + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return errors.New("invalid plugin selection JSON") + } + name, ok := key.(string) + // Match encoding/json's case-insensitive handling of struct field names. + name = strings.ToLower(name) + if !ok || seen[name] { + return errors.New("ambiguous plugin selection JSON field") + } + seen[name] = true + if err := strictJSONValue(decoder); err != nil { + return err + } + } + if end, err := decoder.Token(); err != nil || end != json.Delim('}') { + return errors.New("invalid plugin selection JSON") + } + return nil +} + +func atomicWrite(root *os.Root, name string, data []byte) (err error) { + temporary := ".tmp-" + rand.Text() + file, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { + if cleanup := root.Remove(temporary); cleanup != nil && !errors.Is(cleanup, fs.ErrNotExist) { + err = errors.Join(err, cleanup) + } + }() + if err := file.Chmod(0o600); err != nil { + return errors.Join(err, file.Close()) + } + _, writeErr := file.Write(data) + if err := errors.Join(writeErr, file.Sync(), file.Close()); err != nil { + return err + } + return root.Rename(temporary, name) +} diff --git a/internal/pluginselection/selection_test.go b/internal/pluginselection/selection_test.go new file mode 100644 index 00000000..64e6441a --- /dev/null +++ b/internal/pluginselection/selection_test.go @@ -0,0 +1,614 @@ +package pluginselection + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func fixture() Selection { + return Selection{ + Target: Target{Profile: "sandbox", Organization: "org-1", Stack: "stack-1", Endpoint: "https://stack.example.invalid/api"}, + Service: "ledger", ServiceVersion: "2.2.0", StackVersion: "3.2.0", + Provider: "legacy", PluginVersion: "1.0.0", + } +} + +func mustSave(t *testing.T, directory string, selection Selection) { + t.Helper() + if err := Save(directory, selection); err != nil { + t.Fatal(err) + } +} + +func assertLoad(t *testing.T, directory string, want Selection) { + t.Helper() + got, err := Load(directory, want.Target, want.Service) + if err != nil || got != want { + t.Fatalf("Load = %+v, %v; want %+v", got, err, want) + } +} + +func writeRecord(t *testing.T, directory string, selection Selection, data []byte) string { + t.Helper() + path := filepath.Join(directory, "selections", filename(selection.Target, selection.Service)) + writeFile(t, path, data) + return path +} + +func writeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) //nolint:gosec // Paths belong to test-owned temporary directories. + if err != nil { + t.Fatal(err) + } + return data +} + +func makeDirectory(t *testing.T, path string) { + t.Helper() + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } +} + +func removePath(t *testing.T, path string) { + t.Helper() + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } +} + +func assertAbsent(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected absent path %s: %v", path, err) + } +} + +func prepareEmptyCache(t *testing.T, directory, state string) { + t.Helper() + if state != "missing base" { + makeDirectory(t, directory) + } + if state == "empty selections" { + makeDirectory(t, filepath.Join(directory, "selections")) + } +} + +func TestMissingSelectionReadsDoNotCreateCache(t *testing.T) { + for _, state := range []string{"missing base", "missing selections", "empty selections"} { + t.Run(state, func(t *testing.T) { + directory := filepath.Join(t.TempDir(), "plugins") + prepareEmptyCache(t, directory, state) + selection := fixture() + got, err := Load(directory, selection.Target, selection.Service) + if got != (Selection{}) || !errors.Is(err, ErrNotSelected) { + t.Fatalf("Load = %+v, %v; want ErrNotSelected", got, err) + } + listed, err := List(directory) + if err != nil || listed == nil || len(listed) != 0 { + t.Fatalf("List = %+v, %v; want empty list", listed, err) + } + if state == "missing base" { + assertAbsent(t, directory) + } + if state == "missing selections" { + assertAbsent(t, filepath.Join(directory, "selections")) + } + }) + } +} + +func TestLoadBeforeLoginIsNotSelected(t *testing.T) { + for _, directory := range []string{"", filepath.Join(t.TempDir(), "missing")} { + got, err := Load(directory, Target{}, "ledger") + if got != (Selection{}) || !errors.Is(err, ErrNotSelected) { + t.Fatalf("Load without target = %+v, %v; want ErrNotSelected", got, err) + } + } + selection := fixture() + selection.Target.Endpoint = "" + if err := Save(t.TempDir(), selection); err == nil { + t.Fatal("Save accepted an empty endpoint") + } +} + +func TestRoundTripAndVersionSwitches(t *testing.T) { + directory := t.TempDir() + initial := fixture() + mustSave(t, directory, initial) + assertLoad(t, directory, initial) + modern := initial + modern.Provider = "modern" + modern.ServiceVersion = "3.0.0-beta.10" + modern.StackVersion = "4.0.0" + modern.PluginVersion = "" // The OCI cache owns the modern plugin revision. + mustSave(t, directory, modern) + assertLoad(t, directory, modern) + legacy := initial + legacy.ServiceVersion = "2.3.0+build.1" + legacy.StackVersion = "3.3.0" + legacy.PluginVersion = "1.1.0" + mustSave(t, directory, legacy) + mustSave(t, directory, legacy) + assertLoad(t, directory, legacy) + listed, err := List(directory) + if err != nil || len(listed) != 1 || listed[0] != legacy { + t.Fatalf("switch retained stale records: %+v, %v", listed, err) + } + entries, err := os.ReadDir(filepath.Join(directory, "selections")) + if err != nil || len(entries) != 1 || !filenamePattern.MatchString(entries[0].Name()) { + t.Fatalf("expected one deterministic hashed file: %+v, %v", entries, err) + } +} + +func TestStandaloneTargetsHaveNoStackVersion(t *testing.T) { + for _, provider := range []string{"legacy", "modern"} { + t.Run(provider, func(t *testing.T) { + directory := t.TempDir() + selection := fixture() + selection.Target = Target{Endpoint: "http://localhost:8080/api/ledger"} + selection.StackVersion = "" + selection.Provider = provider + if provider == "modern" { + selection.PluginVersion = "" + } + mustSave(t, directory, selection) + assertLoad(t, directory, selection) + listed, err := List(directory) + if err != nil || len(listed) != 1 || listed[0] != selection { + t.Fatalf("standalone selection = %+v, %v", listed, err) + } + }) + } +} + +func TestOptionalStringFieldsCannotBeNull(t *testing.T) { + selection := fixture() + selection.Target.Profile = "" + selection.StackVersion = "" + selection.Provider, selection.PluginVersion = "modern", "" + valid, err := json.Marshal(selection) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"profile", "stackVersion", "pluginVersion"} { + t.Run(field, func(t *testing.T) { + directory := t.TempDir() + mustSave(t, directory, selection) + data := bytes.Replace(valid, []byte(`"`+field+`":""`), []byte(`"`+field+`":null`), 1) + writeRecord(t, directory, selection, data) + if _, err := Load(directory, selection.Target, selection.Service); err == nil { + t.Fatal("Load accepted a null string field") + } + if _, err := List(directory); err == nil { + t.Fatal("List accepted a null string field") + } + }) + } +} + +func TestExactTargetAndServiceIsolation(t *testing.T) { + directory := t.TempDir() + initial := fixture() + mustSave(t, directory, initial) + changes := map[string]func(*Selection){ + "profile": func(s *Selection) { s.Target.Profile = "other" }, + "organization": func(s *Selection) { s.Target.Organization = "other" }, + "stack": func(s *Selection) { s.Target.Stack = "other" }, + "endpoint": func(s *Selection) { s.Target.Endpoint = "https://other.example.invalid/api" }, + "URL spelling": func(s *Selection) { s.Target.Endpoint += "/" }, + "service": func(s *Selection) { s.Service = "auth" }, + "direct": func(s *Selection) { s.Target.Profile, s.Target.Organization, s.Target.Stack = "", "", "" }, + "incomplete": func(s *Selection) { s.Target.Organization, s.Target.Stack = "", "" }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + other := initial + change(&other) + if _, err := Load(directory, other.Target, other.Service); !errors.Is(err, ErrNotSelected) { + t.Fatalf("unprepared target found another selection: %v", err) + } + mustSave(t, directory, other) + assertLoad(t, directory, other) + assertLoad(t, directory, initial) + }) + } + listed, err := List(directory) + if err != nil || len(listed) != len(changes)+1 { + t.Fatalf("List = %d records, %v", len(listed), err) + } + again, err := List(directory) + if err != nil { + t.Fatal(err) + } + for i := range listed { + if listed[i] != again[i] { + t.Fatal("list order is nondeterministic") + } + } +} + +func TestPrivateFilesAndIndependentCache(t *testing.T) { + directory := t.TempDir() + selections := filepath.Join(directory, "selections") + if err := os.Mkdir(selections, 0o755); err != nil { //nolint:gosec // Verify Save repairs pre-existing public directory permissions. + t.Fatal(err) + } + if err := os.Chmod(directory, 0o755); err != nil { //nolint:gosec // Verify Save repairs pre-existing public directory permissions. + t.Fatal(err) + } + otherCache := filepath.Join(directory, "locks") + makeDirectory(t, otherCache) + modernLock := filepath.Join(otherCache, "modern.json") + lockData := []byte(`{"access_token":"synthetic-secret","provider":"modern"}`) + writeFile(t, modernLock, lockData) + selection := fixture() + for range 2 { + mustSave(t, directory, selection) + } + for path, want := range map[string]os.FileMode{ + directory: 0o700, selections: 0o700, + filepath.Join(selections, filename(selection.Target, selection.Service)): 0o600, + } { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != want { + t.Fatalf("unexpected private permissions for %s: %v, %v", path, info, err) + } + } + gotLock := readFile(t, modernLock) + if !bytes.Equal(gotLock, lockData) { + t.Fatalf("selection changed OCI cache: %s", gotLock) + } + listed, err := List(directory) + if err != nil || len(listed) != 1 || listed[0] != selection { + t.Fatalf("selection list used OCI cache: %+v, %v", listed, err) + } + data := readFile(t, filepath.Join(selections, filename(selection.Target, selection.Service))) + for _, forbidden := range []string{"synthetic-secret", "access_token", "Authorization", "executable", "catalogue"} { + if bytes.Contains(data, []byte(forbidden)) { + t.Fatalf("selection contains unrelated/private material %q", forbidden) + } + } +} + +func TestValidationRejectsUnsafeIdentitiesAndVersions(t *testing.T) { + changes := map[string]func(*Selection){ + "profile traversal": func(s *Selection) { s.Target.Profile = "../outside" }, + "absolute org": func(s *Selection) { s.Target.Organization = "/outside" }, + "backslash stack": func(s *Selection) { s.Target.Stack = `..\outside` }, + "dot identity": func(s *Selection) { s.Target.Stack = ".." }, + "identity control": func(s *Selection) { s.Target.Profile = "sandbox\x00" }, + "identity newline": func(s *Selection) { s.Target.Organization = "org\n" }, + "identity space": func(s *Selection) { s.Target.Stack = "stack other" }, + "identity length": func(s *Selection) { s.Target.Profile = strings.Repeat("x", 257) }, + "empty service": func(s *Selection) { s.Service = "" }, + "service traversal": func(s *Selection) { s.Service = "../../outside" }, + "invalid provider": func(s *Selection) { s.Provider = "automatic" }, + "empty version": func(s *Selection) { s.ServiceVersion = "" }, + "legacy revision": func(s *Selection) { s.PluginVersion = "" }, + "version traversal": func(s *Selection) { s.PluginVersion = "../outside" }, + "version newline": func(s *Selection) { s.StackVersion = "3.2.0\n" }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + directory := filepath.Join(t.TempDir(), "plugins") + selection := fixture() + change(&selection) + if err := Save(directory, selection); err == nil { + t.Fatal("invalid selection was saved") + } + if _, err := os.Stat(directory); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("validation failure created cache: %v", err) + } + }) + } +} + +func TestEndpointValidationAndSecretFreeErrors(t *testing.T) { + for _, endpoint := range []string{ + "", "/api", "//stack.example.invalid", "https:stack.example.invalid", "file:///tmp/cache", + "https://", "https://:443/api", "https://user:synthetic-secret@stack.example.invalid", + "https://synthetic-secret@stack.example.invalid", "https://stack.example.invalid?token=synthetic-secret", + "https://stack.example.invalid?", "https://stack.example.invalid#synthetic-secret", + "https://stack.example.invalid#", "https://stack.example.invalid/%synthetic-secret", + "https://stack.example.invalid/ synthetic-secret", "https://stack.example.invalid/\nsynthetic-secret", + "https://stack.example.invalid/\xffsynthetic-secret", + } { + t.Run(fmt.Sprintf("URL %x", []byte(endpoint)), func(t *testing.T) { + selection := fixture() + selection.Target.Endpoint = endpoint + err := Save(t.TempDir(), selection) + if err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatalf("unsafe endpoint was saved or exposed: %v", err) + } + if endpoint != "" { + if _, err := Load(t.TempDir(), selection.Target, selection.Service); err == nil || errors.Is(err, ErrNotSelected) { + t.Fatalf("Load skipped endpoint validation: %v", err) + } + } + }) + } + for _, endpoint := range []string{"http://localhost:8080/api", "http://stack.example.invalid", "https://[::1]:443/api", "https://stack.example.invalid/path%20name"} { + selection := fixture() + selection.Target.Endpoint = endpoint + directory := t.TempDir() + mustSave(t, directory, selection) + assertLoad(t, directory, selection) + } +} + +func TestInvalidJSONFailsClosed(t *testing.T) { + selection := fixture() + valid, err := json.Marshal(selection) + if err != nil { + t.Fatal(err) + } + invalid := map[string][]byte{ + "empty": nil, + "null": []byte("null"), + "array": []byte("[]"), + "truncated": valid[:len(valid)-1], + "unknown field": bytes.Replace(valid, []byte(`"provider":`), []byte(`"synthetic-secret":"value","provider":`), 1), + "unknown target field": bytes.Replace(valid, []byte(`"profile":`), []byte(`"password":"synthetic-secret","profile":`), 1), + "extra document": append(bytes.Clone(valid), valid...), + "trailing malformed": append(bytes.Clone(valid), []byte(" {broken")...), + "trailing scalar": append(bytes.Clone(valid), []byte(" true")...), + "invalid provider": bytes.Replace(valid, []byte(`"legacy"`), []byte(`"wrong"`), 1), + "missing version": bytes.Replace(valid, []byte(`"2.2.0"`), []byte(`""`), 1), + "oversized": append(bytes.Clone(valid), bytes.Repeat([]byte(" "), maxSelectionBytes)...), + "invalid UTF-8": bytes.Replace(valid, []byte("sandbox"), []byte{'s', 0xff}, 1), + "duplicate provider": bytes.Replace(valid, []byte(`"provider":`), []byte(`"provider":"modern","provider":`), 1), + "case duplicate": bytes.Replace(valid, []byte(`"provider":`), []byte(`"Provider":"modern","provider":`), 1), + "duplicate target": bytes.Replace(valid, []byte(`"profile":`), []byte(`"profile":"other","profile":`), 1), + "null identity": bytes.Replace(valid, []byte(`"sandbox"`), []byte(`null`), 1), + } + for name, data := range invalid { + t.Run(name, func(t *testing.T) { + directory := t.TempDir() + mustSave(t, directory, selection) + writeRecord(t, directory, selection, data) + got, err := Load(directory, selection.Target, selection.Service) + if err == nil || errors.Is(err, ErrNotSelected) || got != (Selection{}) { + t.Fatalf("invalid selection was usable: %+v, %v", got, err) + } + if strings.Contains(err.Error(), "synthetic-secret") { + t.Fatalf("JSON error revealed untrusted input: %v", err) + } + listed, err := List(directory) + if err == nil || listed != nil { + t.Fatalf("invalid selection was listed: %+v, %v", listed, err) + } + }) + } +} + +func TestIdentityMismatchIsNotASelection(t *testing.T) { + directory := t.TempDir() + selection := fixture() + mustSave(t, directory, selection) + other := selection + other.Target.Stack = "other" + data, err := json.Marshal(other) + if err != nil { + t.Fatal(err) + } + writeRecord(t, directory, selection, data) + if _, err := Load(directory, selection.Target, selection.Service); err == nil || errors.Is(err, ErrNotSelected) { + t.Fatalf("Load accepted wrong identity: %v", err) + } + if _, err := List(directory); err == nil { + t.Fatal("List accepted content with a mismatched filename") + } +} + +func TestSymlinksCannotEscapeSelections(t *testing.T) { + for _, attack := range []string{"absolute directory", "relative directory", "absolute file", "relative file", "inside directory", "inside file"} { + t.Run(attack, func(t *testing.T) { + directory, protected, secret := installSymlinkAttack(t, attack) + selection := fixture() + if _, err := Load(directory, selection.Target, selection.Service); err == nil || errors.Is(err, ErrNotSelected) { + t.Fatalf("Load followed symlink: %v", err) + } + if _, err := List(directory); err == nil { + t.Fatal("List followed symlink") + } + if err := Save(directory, selection); err == nil { + t.Fatal("Save followed symlink") + } + assertFilesUnchanged(t, protected, secret) + }) + } +} + +func assertFilesUnchanged(t *testing.T, paths []string, want []byte) { + t.Helper() + for _, path := range paths { + if got := readFile(t, path); !bytes.Equal(got, want) { + t.Fatalf("cache write changed symlink destination: %q", got) + } + } +} + +func installSymlinkAttack(t *testing.T, attack string) (directory string, protected []string, secret []byte) { + t.Helper() + parent := t.TempDir() + directory = filepath.Join(parent, "plugins") + outside := filepath.Join(parent, "outside") + makeDirectory(t, outside) + selection := fixture() + name := filename(selection.Target, selection.Service) + secretPath := filepath.Join(outside, name) + secret = []byte("synthetic-secret must not be read or overwritten") + writeFile(t, secretPath, secret) + protected = append(protected, secretPath) + mustSave(t, directory, selection) + link := filepath.Join(directory, "selections") + destination := outside + if strings.Contains(attack, "file") { + link = filepath.Join(link, name) + destination = secretPath + } + removePath(t, link) + if strings.HasPrefix(attack, "inside") { + destination = filepath.Join(directory, "locks") + makeDirectory(t, destination) + insideFile := filepath.Join(destination, name) + writeFile(t, insideFile, secret) + protected = append(protected, insideFile) + if strings.Contains(attack, "file") { + destination = insideFile + } + } + if !strings.HasPrefix(attack, "absolute") { + relative, err := filepath.Rel(filepath.Dir(link), destination) + if err != nil { + t.Fatal(err) + } + destination = relative + } + if err := os.Symlink(destination, link); err != nil { + t.Fatal(err) + } + return directory, protected, secret +} + +func TestInvalidFilesAndTemporaryFiles(t *testing.T) { + for _, invalid := range []string{"directory record", "public record", "invalid filename"} { + t.Run(invalid, func(t *testing.T) { + directory := t.TempDir() + selection := fixture() + mustSave(t, directory, selection) + makeInvalidFile(t, directory, selection, invalid) + if _, err := List(directory); err == nil { + t.Fatal("invalid file was listed") + } + }) + } + directory := t.TempDir() + selection := fixture() + mustSave(t, directory, selection) + writeFile(t, filepath.Join(directory, "selections", ".tmp-interrupted"), []byte("{partial")) + listed, err := List(directory) + if err != nil || len(listed) != 1 || listed[0] != selection { + t.Fatalf("temporary file broke listing: %+v, %v", listed, err) + } +} + +func makeInvalidFile(t *testing.T, directory string, selection Selection, invalid string) { + t.Helper() + path := filepath.Join(directory, "selections", filename(selection.Target, selection.Service)) + switch invalid { + case "directory record": + removePath(t, path) + makeDirectory(t, path) + if err := Save(directory, selection); err == nil { + t.Fatal("Save replaced a directory") + } + case "public record": + if err := os.Chmod(path, 0o644); err != nil { //nolint:gosec // Fixture deliberately violates selection privacy to verify rejection. + t.Fatal(err) + } + case "invalid filename": + if err := os.Rename(path, filepath.Join(directory, "selections", "unhashed.json")); err != nil { + t.Fatal(err) + } + } +} + +func TestConcurrentDistinctTargets(t *testing.T) { + directory := t.TempDir() + const count = 48 + selections := make([]Selection, count) + failures := make(chan error, count) + var writers sync.WaitGroup + for i := range count { + selection := fixture() + selection.Target.Stack = fmt.Sprintf("stack-%d", i) + selection.ServiceVersion = fmt.Sprintf("2.2.%d", i) + selections[i] = selection + writers.Go(func() { + for range 3 { + if err := Save(directory, selection); err != nil { + failures <- err + return + } + } + }) + } + writers.Wait() + close(failures) + for err := range failures { + t.Error(err) + } + for _, selection := range selections { + assertLoad(t, directory, selection) + } + listed, err := List(directory) + if err != nil || len(listed) != count { + t.Fatalf("concurrent writes lost distinct targets: %d, %v", len(listed), err) + } + entries, err := os.ReadDir(filepath.Join(directory, "selections")) + if err != nil || len(entries) != count { + t.Fatalf("temporary writes were not cleaned up: %d, %v", len(entries), err) + } +} + +func TestConcurrentVersionSwitchesAreAtomic(t *testing.T) { + directory := t.TempDir() + legacy := fixture() + modern := legacy + modern.Provider, modern.ServiceVersion, modern.StackVersion, modern.PluginVersion = "modern", "3.0.0", "4.0.0", "" + mustSave(t, directory, legacy) + failures := make(chan error, 5) + var workers sync.WaitGroup + for _, selection := range []Selection{legacy, modern} { + workers.Go(func() { + for range 30 { + if err := Save(directory, selection); err != nil { + failures <- err + return + } + } + }) + } + for range 3 { + workers.Go(func() { + readConcurrentVersions(directory, legacy, modern, failures) + }) + } + workers.Wait() + close(failures) + for err := range failures { + t.Error(err) + } +} + +func readConcurrentVersions(directory string, legacy, modern Selection, failures chan<- error) { + for range 100 { + got, err := Load(directory, legacy.Target, legacy.Service) + if err != nil || got != legacy && got != modern { + failures <- errors.Join(fmt.Errorf("partial selection observed: %+v", got), err) + return + } + listed, err := List(directory) + if err != nil || len(listed) != 1 || listed[0] != legacy && listed[0] != modern { + failures <- errors.Join(fmt.Errorf("partial selection listed: %+v", listed), err) + return + } + } +} diff --git a/internal/presentation/columns.go b/internal/presentation/columns.go new file mode 100644 index 00000000..5d3f15ba --- /dev/null +++ b/internal/presentation/columns.go @@ -0,0 +1,49 @@ +package presentation + +import "strings" + +func listColumns(items []any, width int) ([]string, bool) { + fields, objects := listFields(items) + if !objects { + return nil, false + } + var selected []string + used := 1 + for _, key := range orderedKeys(fields) { + if nested(fields[key]) || len(selected) == 6 { + continue + } + cost := listColumnWidth(items, key) + 3 + if len(selected) != 0 && used+cost > width { + continue + } + selected = append(selected, key) + used += cost + } + return selected, true +} + +func listColumnWidth(items []any, key string) int { + width := max(2, textWidth(fieldLabel(key))) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + continue + } + // Width estimates only decide which columns fit. Render still displays + // the complete value, wrapping longer cells without ellipses. + if value, exists := object[key]; exists { + width = max(width, min(16, scalarWidth(value))) + } + } + return width +} + +func scalarWidth(value any) int { + if text, ok := value.(string); ok { + return textWidth(escape(text)) + } + // The regular field renderer preserves json.Number's original token. + r := renderer{} + return textWidth(strings.TrimSpace(r.flat(value))) +} diff --git a/internal/presentation/labels.go b/internal/presentation/labels.go new file mode 100644 index 00000000..70bf10c9 --- /dev/null +++ b/internal/presentation/labels.go @@ -0,0 +1,61 @@ +package presentation + +import ( + "slices" + "strings" + "unicode" +) + +func fieldLabel(key string) string { + if strings.ContainsFunc(key, func(char rune) bool { + return unicode.IsControl(char) || unicode.Is(unicode.Cf, char) + }) { + return escape(key) + } + chars := []rune(key) + var words strings.Builder + for i, char := range chars { + if char == '_' || char == '-' { + char = ' ' + } else if wordBoundary(chars, i) { + if _, err := words.WriteRune(' '); err != nil { + return escape(key) + } + } + if _, err := words.WriteRune(char); err != nil { + return escape(key) + } + } + labels := strings.Fields(words.String()) + for i, word := range labels { + labels[i] = labelWord(word) + } + return strings.Join(labels, " ") +} + +func labelWord(word string) string { + upper := strings.ToUpper(word) + if upper == "IDS" { + return "IDs" + } + if slices.Contains([]string{"ID", "URL", "URI", "API", "HTTP", "JSON", "JWT", "OIDC", "UUID"}, upper) { + return upper + } + letters := []rune(word) + letters[0] = unicode.ToUpper(letters[0]) + return string(letters) +} + +func wordBoundary(chars []rune, i int) bool { + if i == 0 || !unicode.IsUpper(chars[i]) { + return false + } + if unicode.IsLower(chars[i-1]) || unicode.IsDigit(chars[i-1]) { + return true + } + // Preserve plural acronyms such as IDs instead of splitting them as I Ds. + if i+1 < len(chars) && chars[i+1] == 's' && (i+2 == len(chars) || !unicode.IsLower(chars[i+2])) { + return false + } + return unicode.IsUpper(chars[i-1]) && i+1 < len(chars) && unicode.IsLower(chars[i+1]) +} diff --git a/internal/presentation/layout.go b/internal/presentation/layout.go new file mode 100644 index 00000000..0e11082e --- /dev/null +++ b/internal/presentation/layout.go @@ -0,0 +1,185 @@ +package presentation + +import ( + "fmt" + "strings" + "unicode" +) + +func escape(text string) string { + var output strings.Builder + for _, char := range text { + var piece string + switch char { + case '\n': + piece = `\n` + case '\r': + piece = `\r` + case '\t': + piece = `\t` + default: + piece = string(char) + if unicode.IsControl(char) || unicode.Is(unicode.Cf, char) { + piece = fmt.Sprintf("\\u%04x", char) + } + } + if _, err := output.WriteString(piece); err != nil { + return "" + } + } + return output.String() +} + +// Width calculations operate before adding ANSI styles. Control characters in +// user data have already been escaped, so they cannot introduce terminal codes. +func runeWidth(char rune) int { + if unicode.Is(unicode.Mn, char) || unicode.Is(unicode.Me, char) { + return 0 + } + if char == 0x303f { + return 1 + } + for _, interval := range [][2]rune{ + {0x1100, 0x115f}, {0x2329, 0x232a}, {0x2e80, 0xa4cf}, + {0xac00, 0xd7a3}, {0xf900, 0xfaff}, {0xfe10, 0xfe19}, + {0xfe30, 0xfe6f}, {0xff00, 0xff60}, {0xffe0, 0xffe6}, + {0x1f300, 0x1faff}, {0x20000, 0x3fffd}, + } { + if char >= interval[0] && char <= interval[1] { + return 2 + } + } + return 1 +} + +func textWidth(text string) int { + width := 0 + for _, char := range text { + width += runeWidth(char) + } + return width +} + +func wrap(text string, width int) []string { + width = max(width, 1) + if width == 1 { + text = narrowText(text) + } + var lines []string + var line strings.Builder + used := 0 + for _, char := range text { + size := runeWidth(char) + if used > 0 && used+size > width { + lines = append(lines, line.String()) + line.Reset() + used = 0 + } + if _, err := line.WriteRune(char); err != nil { + return nil + } + used += size + } + return append(lines, line.String()) +} + +func narrowText(text string) string { + var output strings.Builder + for _, char := range text { + piece := string(char) + if runeWidth(char) > 1 { + piece = fmt.Sprintf("\\u%04x", char) + } + if _, err := output.WriteString(piece); err != nil { + return "" + } + } + return output.String() +} + +func (r *renderer) table(headers []string, rows [][]string) { + if r.options.Width < 5*len(headers)+1 { + r.inlineRows(rows) + return + } + widths := columnWidths(headers, rows, r.options.Width) + border := tableBorder(widths) + r.line(border) + r.tableRow(headers, widths, true) + r.line(border) + for _, row := range rows { + r.tableRow(row, widths, false) + } + r.line(border) +} + +func (r *renderer) inlineRows(rows [][]string) { + for _, row := range rows { + for _, line := range wrap(strings.Join(row, ": "), r.options.Width) { + r.line(line) + } + } +} + +func columnWidths(headers []string, rows [][]string, limit int) []int { + widths := make([]int, len(headers)) + for i, header := range headers { + widths[i] = min(limit, max(2, textWidth(header))) + for _, row := range rows { + widths[i] = min(limit, max(widths[i], textWidth(row[i]))) + } + } + available := limit - (3*len(headers) + 1) + for totalWidth(widths) > available { + widest := 0 + for i, width := range widths { + if width > widths[widest] { + widest = i + } + } + widths[widest]-- + } + return widths +} + +func totalWidth(widths []int) int { + total := 0 + for _, width := range widths { + total += width + } + return total +} + +func tableBorder(widths []int) string { + parts := make([]string, len(widths)) + for i, width := range widths { + parts[i] = strings.Repeat("-", width+2) + } + return "+" + strings.Join(parts, "+") + "+" +} + +func (r *renderer) tableRow(row []string, widths []int, header bool) { + columns := make([][]string, len(widths)) + height := 1 + for i, cell := range row { + columns[i] = wrap(cell, widths[i]) + height = max(height, len(columns[i])) + } + for line := range height { + cells := make([]string, len(widths)) + for i, width := range widths { + text := "" + if line < len(columns[i]) { + text = columns[i][line] + } + padding := strings.Repeat(" ", max(0, width-textWidth(text))) + if header { + text = r.style(text, "1;36") + } else { + text = r.status(text) + } + cells[i] = " " + text + padding + " " + } + r.line("|" + strings.Join(cells, "|") + "|") + } +} diff --git a/internal/presentation/render.go b/internal/presentation/render.go new file mode 100644 index 00000000..4313e206 --- /dev/null +++ b/internal/presentation/render.go @@ -0,0 +1,336 @@ +// Package presentation renders exact JSON values as JSON or readable tables. +package presentation + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "maps" + "slices" + "strconv" + "strings" +) + +// Options controls formatting. The host resolves auto based on its terminal; +// an unresolved auto uses JSON. Width is the table line width, defaulting to 100. +type Options struct { + Format string + Color bool + Width int +} + +// ValidateFormat accepts the host's automatic mode and explicit output modes. +func ValidateFormat(format string) error { + if format == "auto" || format == "json" || format == "table" { + return nil + } + return fmt.Errorf("unsupported output format %q: use auto, json or table", format) +} + +// Render validates before writing. JSON indentation preserves numeric tokens; +// details summarize nested fields. Lists select scalar columns that fit the +// available width. Scalar values wrap rather than truncate. +func Render(out io.Writer, data json.RawMessage, options Options) error { + if err := ValidateFormat(options.Format); err != nil { + return err + } + if out == nil { + return fmt.Errorf("output writer is required") + } + if !json.Valid(data) { + return fmt.Errorf("response is not valid JSON") + } + if options.Format != "table" { + var formatted bytes.Buffer + if err := json.Indent(&formatted, data, "", " "); err != nil { + return fmt.Errorf("indent response: %w", err) + } + return writeOutput(out, strings.TrimRight(formatted.String(), "\r\n")+"\n") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return fmt.Errorf("decode response: %w", err) + } + if options.Width <= 0 { + options.Width = 100 + } + r := renderer{options: options} + r.value(value) + if r.err != nil { + return r.err + } + return writeOutput(out, r.output.String()) +} + +func writeOutput(out io.Writer, text string) error { + n, err := io.WriteString(out, text) + if err == nil && n != len(text) { + return io.ErrShortWrite + } + return err +} + +type renderer struct { + options Options + output strings.Builder + err error +} + +func (r *renderer) line(text string) { + if r.err == nil { + _, r.err = r.output.WriteString(text + "\n") + } +} + +func (r *renderer) title(text string) { + for _, line := range wrap(text, r.options.Width) { + r.line(r.style(line, "1;36")) + } +} + +func (r *renderer) value(value any) { + switch typed := value.(type) { + case map[string]any: + r.object(typed) + case []any: + r.list(typed) + default: + r.title("Result") + r.scalar(typed) + } +} + +func (r *renderer) scalar(value any) { + text := r.flat(value) + if flag, ok := value.(bool); ok { + if flag { + text = "Success (true)." + } else { + text = "Not successful (false)." + } + } + for _, line := range wrap(text, r.options.Width) { + r.line(r.status(line)) + } +} + +func (r *renderer) object(object map[string]any) { + remaining := maps.Clone(object) + if data, exists := remaining["data"]; exists { + delete(remaining, "data") + r.value(data) + } else if cursor, ok := remaining["cursor"].(map[string]any); ok { + delete(remaining, "cursor") + r.value(cursor) + } + if len(remaining) != 0 { + r.title("Details") + r.details(remaining) + } else if len(object) == 0 { + r.title("Details") + r.wrapped("No fields.") + } +} + +func (r *renderer) details(object map[string]any) { + rows := make([][]string, 0, len(object)) + for _, key := range orderedKeys(object) { + value := object[key] + text := r.flatScalarOrSummary(key, value) + rows = append(rows, []string{fieldLabel(key), text}) + } + r.table([]string{"Field", "Value"}, rows) +} + +func (r *renderer) list(items []any) { + items = resourceRows(items) + r.title("Results") + if len(items) == 0 { + r.wrapped("No results.") + return + } + keys, objects := listColumns(items, r.options.Width) + if !objects { + r.records(items) + return + } + if len(keys) == 0 { + r.recordNumbers(len(items)) + return + } + rows := make([][]string, 0, len(items)) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + r.err = fmt.Errorf("list item is not an object") + return + } + row := make([]string, len(keys)) + for i, key := range keys { + if value, exists := object[key]; exists { + row[i] = r.flat(value) + } + } + rows = append(rows, row) + } + headers := make([]string, len(keys)) + for i, key := range keys { + headers[i] = fieldLabel(key) + } + r.table(headers, rows) +} + +// Project plugin and Kubernetes resource identities for table output only; +// JSON retains the raw envelope. +func resourceRows(items []any) []any { + rows := make([]any, len(items)) + for i, item := range items { + object, ok := item.(map[string]any) + if !ok { + return items + } + if service, ok := object["service"].(string); ok && object["platform"] != nil && object["revision"] != nil && object["sha256"] != nil { + row := maps.Clone(object) + row["module"] = service + delete(row, "service") + rows[i] = row + continue + } + if object["name"] != nil || object["id"] != nil { + return items + } + metadata, ok := object["metadata"].(map[string]any) + if !ok { + return items + } + if name, ok := metadata["name"].(string); !ok || name == "" { + return items + } + rows[i] = resourceRow(object, metadata) + } + return rows +} + +func resourceRow(object, metadata map[string]any) map[string]any { + row := maps.Clone(object) + for _, key := range []string{"name", "namespace"} { + defaultColumn(row, key, metadata[key]) + } + defaultColumn(row, "createdAt", metadata["creationTimestamp"]) + if status, ok := object["status"].(map[string]any); ok { + defaultColumn(row, "phase", status["phase"]) + } + return row +} + +func defaultColumn(row map[string]any, key string, value any) { + if _, exists := row[key]; !exists && value != nil { + row[key] = value + } +} + +func (r *renderer) recordNumbers(count int) { + rows := make([][]string, count) + for i := range count { + rows[i] = []string{strconv.Itoa(i + 1)} + } + r.table([]string{"Record"}, rows) +} + +func (r *renderer) records(items []any) { + for i, item := range items { + r.title("Record " + strconv.Itoa(i+1)) + if object, ok := item.(map[string]any); ok && len(object) != 0 { + r.details(object) + } else { + r.scalar(item) + } + } +} + +func listFields(items []any) (map[string]any, bool) { + fields := make(map[string]any) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + return nil, false + } + for key, value := range object { + if !nested(fields[key]) { + fields[key] = value + } + } + } + return fields, true +} + +func nested(value any) bool { + switch value.(type) { + case map[string]any, []any: + return true + default: + return false + } +} + +func orderedKeys(object map[string]any) []string { + keys := slices.Sorted(maps.Keys(object)) + preferred := []string{"module", "id", "name", "state", "status", "region", "regionid", "version", "createdat", "asset", "account", "amount", "balance"} + slices.SortStableFunc(keys, func(a, b string) int { + return fieldRank(a, object[a], preferred) - fieldRank(b, object[b], preferred) + }) + return keys +} + +func fieldRank(key string, value any, preferred []string) int { + switch value.(type) { + case map[string]any, []any: + return len(preferred) + 1 + default: + if i := slices.Index(preferred, strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(key))); i >= 0 { + return i + } + return len(preferred) + } +} + +func (r *renderer) wrapped(text string) { + for _, line := range wrap(text, r.options.Width) { + r.line(line) + } +} + +func (r *renderer) flat(value any) string { + if text, ok := value.(string); ok { + return escape(text) + } + data, err := json.Marshal(value) + if err != nil { + r.err = fmt.Errorf("render field: %w", err) + return "" + } + return escape(string(data)) +} + +func (r *renderer) style(text, code string) string { + if !r.options.Color || text == "" { + return text + } + return "\x1b[" + code + "m" + text + "\x1b[0m" +} + +func (r *renderer) status(text string) string { + switch strings.ToLower(text) { + case "true", "success", "active", "ready", "running", "approved", "enabled", "succeeded", "success (true).": + return r.style(text, "32") + case "false", "error", "failed", "denied", "degraded", "unreachable", "not successful (false).": + return r.style(text, "31") + case "pending", "starting", "stopped", "progressing", "unknown", "disabled", "deleted", "deleting", "restoring", "upgrading": + return r.style(text, "33") + default: + return text + } +} diff --git a/internal/presentation/render_test.go b/internal/presentation/render_test.go new file mode 100644 index 00000000..6fb53bc1 --- /dev/null +++ b/internal/presentation/render_test.go @@ -0,0 +1,435 @@ +package presentation_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + "sync" + "testing" + "unicode" + + "github.com/formancehq/fctl/v4/internal/presentation" +) + +func render(t *testing.T, data string, options presentation.Options) string { + t.Helper() + var out bytes.Buffer + if err := presentation.Render(&out, json.RawMessage(data), options); err != nil { + t.Fatal(err) + } + if options.Format == "table" { + assertNoNotices(t, out.String()) + } + return out.String() +} + +func assertNoNotices(t *testing.T, out string) { + t.Helper() + if notice := omissionNoticePattern.FindString(out); notice != "" { + t.Errorf("table contains an automatic omission notice %q:\n%s", notice, out) + } +} + +var omissionNoticePattern = regexp.MustCompile(`Use -o json|\d+ additional fields? omitted|\+\d+ (?:fields?|more)`) + +func assertContains(t *testing.T, text string, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(text, value) { + t.Errorf("output missing %q:\n%s", value, text) + } + } +} + +func TestJSONPreservesNumericTokensAndIndentation(t *testing.T) { + data := `{"amount":9007199254740993123456789,"negative":-9007199254740993,"decimal":1.234567890123456789,"exponent":1e+100,"data":[true,null]}` + for _, format := range []string{"json", "auto"} { + t.Run(format, func(t *testing.T) { + out := render(t, data, presentation.Options{Format: format, Color: true}) + assertContains(t, out, "9007199254740993123456789", "-9007199254740993", "1.234567890123456789", "1e+100", "\n ", "\n") + if !json.Valid([]byte(out)) || strings.Contains(out, "\x1b") { + t.Fatal("JSON output is invalid or styled") + } + }) + } +} + +func TestListSelectsScalarFieldsAndPreservesPagination(t *testing.T) { + data := `{"cursor":{"data":[{"name":"alpha","id":"a","state":"ready","amount":9007199254740993,"payload":{"postings":[{"amount":1234567890123456789}]}},{"id":"b","region":"eu","extra":"only-second-row"}],"hasMore":true,"next":"cursor-next","previous":null,"pageSize":2},"requestId":"request-id"}` + out := render(t, data, presentation.Options{Format: "table", Width: 250}) + assertContains(t, out, "Results", "ID", "Name", "State", "Region", "alpha", "only-second-row", "9007199254740993", "Has More", "true", "cursor-next", "Previous", "null", "Page Size", "request-id") + if strings.Contains(out, "\x1b") || strings.Contains(out, "…") { + t.Fatal("plain output contains styling or truncated fields") + } + header := strings.Split(out, "\n")[2] + if strings.Index(header, "ID") > strings.Index(header, "Name") || strings.Index(header, "Name") > strings.Index(header, "State") { + t.Fatalf("preferred fields are not ordered before nested values: %s", header) + } +} + +func TestDetailsSummarizeNestedPayloadAndMetadata(t *testing.T) { + data := `{"data":{"id":"transaction","payload":{"script":"send [USD/2 9007199254740993]"},"metadata":{"nested":[false,null,{"amount":9007199254740995}]}},"status":"ok","custom":{"request":"trace-id"}}` + out := render(t, data, presentation.Options{Format: "table", Width: 180}) + assertContains(t, out, "Details", "transaction", "Payload", "9007199254740993", "nested: 3 items", "Status", "ok", "request: trace-id") +} + +func TestHumanReadableFieldLabels(t *testing.T) { + data := `{"client_id":"client","createdAt":"today","HTTPStatus":200,"hasMore":true,"stackURL":"https://example.test","organizationIDs":["org"]}` + out := render(t, data, presentation.Options{Format: "table", Width: 120}) + assertContains(t, out, "Client ID", "Created At", "HTTP Status", "Has More", "Stack URL", "Organization IDs") +} + +func TestLargeClaimsSummarizedOnlyInTables(t *testing.T) { + organizations := make([]map[string]any, 40) + for i := range organizations { + organizations[i] = map[string]any{"id": fmt.Sprintf("org-%02d", i), "stack": map[string]any{"amount": json.Number("9007199254740993"), "permission": "stack:Read"}} + } + data, err := json.Marshal(map[string]any{"claims": map[string]any{"organizations": organizations}}) + if err != nil { + t.Fatal(err) + } + out := render(t, string(data), presentation.Options{Format: "table", Width: 80}) + assertContains(t, out, "Claims", "40 items") + assertLineWidths(t, out, 80) + if strings.Count(out, "\n") > 12 { + t.Fatalf("claims expanded: %s", out) + } + jsonOutput := render(t, string(data), presentation.Options{Format: "json", Color: true}) + for i := range organizations { + assertContains(t, jsonOutput, fmt.Sprintf("org-%02d", i)) + } + if strings.Count(jsonOutput, "9007199254740993") != 40 || strings.Contains(jsonOutput, "\x1b") { + t.Fatal("JSON changed nested numbers or added styling") + } +} + +func TestEmptyListsSuccessAndMixedResults(t *testing.T) { + for _, tc := range []struct{ data, want string }{ + {`[]`, "No results."}, + {`{"data":[],"hasMore":false,"next":"last-cursor"}`, "last-cursor"}, + {`true`, "Success (true)."}, + {`false`, "Not successful (false)."}, + {`{"success":true,"message":"Client deleted"}`, "Client deleted"}, + {`[{"id":"one"},9007199254740993,"literal",null,[]]`, "9007199254740993"}, + {`{}`, "No fields."}, + } { + t.Run(tc.data, func(t *testing.T) { + out := render(t, tc.data, presentation.Options{Format: "table"}) + assertContains(t, out, tc.want) + }) + } +} + +func TestControlCharactersCannotInjectTerminalOutput(t *testing.T) { + data := `{"name":"a\nb\rc\td\u001b[31mINJECT\u0000","\u001btitle":"\u202eevil","nested":{"raw":"\u001b[2J"}}` + out := render(t, data, presentation.Options{Format: "table", Width: 200}) + assertContains(t, out, `a\nb\rc\td\u001b[31mINJECT\u0000`, `\u001btitle`, `\u202eevil`, `\u001b[2J`) + for _, char := range out { + if char != '\n' && (unicode.IsControl(char) || unicode.Is(unicode.Cf, char)) { + t.Fatalf("terminal control escaped sanitization: %U", char) + } + } +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func TestWrappingAccountsForWideCharactersAndANSIStyles(t *testing.T) { + data := `[{"id":"item","name":"界界界界界界","status":"ready","payload":{"amount":9007199254740993}}]` + for _, width := range []int{1, 8, 20, 32, 60} { + for _, color := range []bool{false, true} { + t.Run(fmt.Sprintf("width=%d/color=%t", width, color), func(t *testing.T) { + out := render(t, data, presentation.Options{Format: "table", Color: color, Width: width}) + plain := ansiPattern.ReplaceAllString(out, "") + assertLineWidths(t, plain, width) + if strings.Contains(out, "\x1b") != color { + t.Fatal("color option not respected") + } + }) + } + } +} + +func assertLineWidths(t *testing.T, text string, width int) { + t.Helper() + for line := range strings.SplitSeq(text, "\n") { + if got := visibleWidth(line); got > width { + t.Fatalf("line width=%d exceeds %d: %q", got, width, line) + } + } +} + +func visibleWidth(text string) int { + width := 0 + for _, char := range text { + if char == '界' { + width += 2 + } else { + width++ + } + } + return width +} + +func TestNarrowListsWrapSelectedFieldsWithoutEllipsis(t *testing.T) { + data := `[{"id":"one","name":"long-name","region":"eu","extra":"payload-value"}]` + out := render(t, data, presentation.Options{Format: "table", Width: 19}) + assertContains(t, out, "ID", "Name", "one", "long-n", "ame") + if strings.Contains(out, "...") || strings.Contains(out, "…") { + t.Fatal("narrow rendering truncated values") + } +} + +func TestCloudStackListAtEightyColumnsStaysCompact(t *testing.T) { + data := `{"data":[{"id":"stack-a","name":"alpha","status":"ready","regionID":"eu","version":"v3","amount":9007199254740993,"configuration":{"services":{"ledger":{"storage":"nested-only"}}},"metadata":{"team":"ops"},"replicas":3},{"id":"stack-b","name":"beta","status":"pending","regionID":"us","version":"v4","amount":9007199254740995,"configuration":{"services":{}},"metadata":{},"replicas":2}],"hasMore":true,"next":"next-page"}` + out := render(t, data, presentation.Options{Format: "table", Width: 80}) + assertContains(t, out, "stack-a", "stack-b", "alpha", "beta", "ready", "pending", "Region ID", "9007199254740993", "9007199254740995", "Has More", "next-page") + assertLineWidths(t, out, 80) + if strings.Contains(out, "Record 1") || strings.Contains(out, "nested-only") || strings.Count(out, "\n") > 22 { + t.Fatalf("list expanded into full records or nested configuration:\n%s", out) + } + header := strings.Split(out, "\n")[2] + if columns := strings.Count(header, "|") - 1; columns > 6 { + t.Fatalf("list has %d columns", columns) + } + jsonOutput := render(t, data, presentation.Options{Format: "json"}) + assertContains(t, jsonOutput, "nested-only", "replicas", "9007199254740993") +} + +func TestCloudStackDetailsSummarizeComplexFieldsAtEightyColumns(t *testing.T) { + conditions := make([]map[string]any, 20) + for i := range conditions { + conditions[i] = map[string]any{"type": "Ready", "status": "True", "message": strings.Repeat("condition-blob-", 40), "observedGeneration": json.Number("900719925474099312345")} + } + stack := map[string]any{ + "id": "stack-a", "name": "production", "status": "ready", "version": "v4.0", + "amount": json.Number("900719925474099312345"), + "modules": []any{map[string]any{"name": "ledger", "status": "ready"}, map[string]any{"name": "auth", "status": "ready"}, map[string]any{"name": "payments", "status": "pending"}}, + "cluster": map[string]any{"name": "eu-primary", "status": "ready", "conditions": conditions}, + "conditions": conditions, + "region": map[string]any{"id": "eu-west", "name": "Europe", "info": map[string]any{"provider": "aws", "location": "Paris"}, "capabilities": []any{"ledger", "auth", "payments", "search"}}, + "capabilities": []any{"ledger", "auth", "payments", "search", "reconciliation", "wallets"}, + } + data, err := json.Marshal(map[string]any{"data": stack}) + if err != nil { + t.Fatal(err) + } + for _, color := range []bool{false, true} { + out := render(t, string(data), presentation.Options{Format: "table", Width: 80, Color: color}) + plain := ansiPattern.ReplaceAllString(out, "") + assertContains(t, plain, "production", "900719925474099312345", "ledger", "auth", "payments", "pending", "eu-primary", "Europe", "20 items", "6 items") + assertLineWidths(t, plain, 80) + if lines := strings.Count(plain, "\n"); lines >= 50 || strings.Contains(plain, "condition-blob") || strings.Contains(plain, `{"`) { + t.Fatalf("details expanded raw nested data (%d lines):\n%s", lines, plain) + } + } + jsonOutput := render(t, string(data), presentation.Options{Format: "json", Color: true}) + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(jsonOutput)); err != nil { + t.Fatal(err) + } + if !bytes.Equal(compact.Bytes(), data) { + t.Fatal("machine output changed nested fields or numeric tokens") + } +} + +func TestDetailScalarsWrapCompletelyWhileNestedStringsAreBounded(t *testing.T) { + text := strings.Repeat("complete-scalar", 30) + data, err := json.Marshal(map[string]any{"description": text, "nested": map[string]any{"name": strings.Repeat("long-nested-name", 50)}}) + if err != nil { + t.Fatal(err) + } + out := render(t, string(data), presentation.Options{Format: "table", Width: 80}) + assertContains(t, detailValues(t, out), text) + assertContains(t, out, "...") + assertLineWidths(t, out, 80) +} + +// Reassemble the Value column across wrapped lines without table padding. +func detailValues(t *testing.T, out string) string { + t.Helper() + var reconstructed strings.Builder + for line := range strings.SplitSeq(out, "\n") { + cells := strings.Split(line, "|") + if len(cells) == 4 { + if _, err := reconstructed.WriteString(strings.TrimSpace(cells[2])); err != nil { + t.Fatal(err) + } + } + } + return reconstructed.String() +} + +func TestNestedSummaryHandlesEmptyDeepAndNumericValues(t *testing.T) { + data := `{"emptyObject":{},"emptyArray":[],"numeric":{"amount":900719925474099312345,"negative":-9007199254740993,"enabled":false},"deep":{"info":{"info":{"raw":"not-displayed"}}}}` + out := render(t, data, presentation.Options{Format: "table", Width: 80}) + assertContains(t, out, "Empty Object", "Empty Array", "—", "900719925474099312345", "false", "1 field") + assertLineWidths(t, out, 80) + assertContains(t, detailValues(t, out), "amount: 900719925474099312345", "negative: -9007199254740993") + if strings.Contains(out, "not-displayed") { + t.Fatal("deep field expanded") + } +} + +func TestNestedOnlyAndHeterogeneousListsStayCompact(t *testing.T) { + for _, data := range []string{ + `[{"payload":{"secret":"nested-content"}},{"payload":[]}]`, + `[{"id":"one","payload":7},{"id":"two","payload":{"secret":"nested-content"}}]`, + } { + out := render(t, data, presentation.Options{Format: "table", Width: 80}) + assertContains(t, out, "Results") + if strings.Contains(out, "nested-content") { + t.Fatal("nested payload expanded in list summary") + } + } +} + +func TestEmptyLedgerConfigurationSnapshot(t *testing.T) { + data := `{"data":{"name":"books","status":"ready","amount":0,"enabled":false,"schema":{"transactions":{},"accounts":[],"version":""},"mirrorSource":null,"mirrorSyncProgress":{"error":null,"state":""}}}` + want := "Details\n" + + "+----------------------+-------+\n" + + "| Field | Value |\n" + + "+----------------------+-------+\n" + + "| Name | books |\n" + + "| Status | ready |\n" + + "| Amount | 0 |\n" + + "| Enabled | false |\n" + + "| Mirror Source | — |\n" + + "| Mirror Sync Progress | — |\n" + + "| Schema | — |\n" + + "+----------------------+-------+\n" + for _, color := range []bool{false, true} { + out := render(t, data, presentation.Options{Format: "table", Width: 80, Color: color}) + if plain := ansiPattern.ReplaceAllString(out, ""); plain != want { + t.Fatalf("empty ledger details differ from snapshot:\n%s", plain) + } + } + assertExactJSON(t, data) +} + +func TestLedgerConfigurationPreservesMeaningfulDetails(t *testing.T) { + data := `{"data":{"name":"mirror","status":"syncing","amount":900719925474099312345,"schema":{"enabled":false,"version":0},"mirrorSource":{"name":"source","disabled":false},"mirrorSyncProgress":{"state":"SYNCING","cursor":0,"sourceLogCount":900719925474099312345}}}` + out := render(t, data, presentation.Options{Format: "table", Width: 100}) + assertContains(t, detailValues(t, out), "900719925474099312345", "version: 0; enabled: false", "name: source; disabled: false", "state: SYNCING; cursor: 0; sourceLogCount: 900719925474099312345") + assertLineWidths(t, out, 100) + assertExactJSON(t, data) +} + +func TestEmptyNestedFieldsDoNotHideUsefulSummary(t *testing.T) { + data := `{"configuration":{"a":{},"b":null,"c":[],"d":"","enabled":false,"remaining":0},"schema":{"enum":[null]},"diagnostic":{"name":"","message":"source unavailable"},"previous":null,"state":"failed"}` + out := render(t, data, presentation.Options{Format: "table", Width: 100}) + assertContains(t, detailValues(t, out), "enabled: false; remaining: 0", "enum: 1 item", "message: source unavailable", "failed", "null") + assertExactJSON(t, data) +} + +func TestCompactListSnapshot(t *testing.T) { + data := `[{"id":"a","name":"books","schema":{"version":1},"metadata":{"team":"ops"}}]` + want := "Results\n" + + "+----+-------+\n" + + "| ID | Name |\n" + + "+----+-------+\n" + + "| a | books |\n" + + "+----+-------+\n" + if out := render(t, data, presentation.Options{Format: "table", Width: 80}); out != want { + t.Fatalf("compact list differs from snapshot:\n%s", out) + } + assertExactJSON(t, data) +} + +func assertExactJSON(t *testing.T, data string) { + t.Helper() + out := render(t, data, presentation.Options{Format: "json", Color: true}) + var got, want bytes.Buffer + if err := json.Compact(&got, []byte(out)); err != nil { + t.Fatal(err) + } + if err := json.Compact(&want, []byte(data)); err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Bytes(), want.Bytes()) { + t.Fatalf("JSON fields or numeric tokens changed:\n%s", out) + } +} + +func TestStatusColorsAndPlainASCIIBorders(t *testing.T) { + data := `[{"id":"a","status":"ready"},{"id":"b","status":"failed"},{"id":"c","status":"pending"}]` + styled := render(t, data, presentation.Options{Format: "table", Color: true}) + assertContains(t, styled, "\x1b[1;36m", "\x1b[32mready\x1b[0m", "\x1b[31mfailed\x1b[0m", "\x1b[33mpending\x1b[0m") + plain := render(t, data, presentation.Options{Format: "table"}) + if styledPlain := ansiPattern.ReplaceAllString(styled, ""); styledPlain != plain { + t.Fatal("styles changed table alignment or values") + } + for _, char := range plain { + if char > 127 { + t.Fatal("ASCII input produced non-ASCII table decorations") + } + } +} + +func TestCloudStateColorsPreservePlainOutputAtEightyColumns(t *testing.T) { + data := `[{"name":"ledger","state":"enabled"},{"name":"auth","state":"progressing"},{"name":"payments","state":"degraded"}]` + styled := render(t, data, presentation.Options{Format: "table", Color: true, Width: 80}) + assertContains(t, styled, "\x1b[32menabled\x1b[0m", "\x1b[33mprogressing\x1b[0m", "\x1b[31mdegraded\x1b[0m") + plain := render(t, data, presentation.Options{Format: "table", Color: false, Width: 80}) + assertContains(t, plain, "ledger", "auth", "payments", "enabled", "progressing", "degraded") + if strings.Contains(plain, "\x1b") || ansiPattern.ReplaceAllString(styled, "") != plain { + t.Fatal("Cloud state colors changed plain text or table alignment") + } + assertLineWidths(t, plain, 80) +} + +func TestInvalidInputDoesNotWritePartialOutput(t *testing.T) { + for _, tc := range []struct{ data, format string }{{`{"broken":`, "table"}, {`true false`, "json"}, {`{}`, "yaml"}} { + var out bytes.Buffer + if err := presentation.Render(&out, json.RawMessage(tc.data), presentation.Options{Format: tc.format}); err == nil { + t.Error("accepted invalid JSON or format") + } + if out.Len() != 0 { + t.Fatal("invalid input wrote partial output") + } + } + if err := presentation.Render(nil, json.RawMessage(`{}`), presentation.Options{Format: "json"}); err == nil { + t.Fatal("accepted nil writer") + } +} + +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestWriterErrorsAreReturned(t *testing.T) { + want := errors.New("output unavailable") + for _, format := range []string{"json", "table"} { + if err := presentation.Render(failingWriter{want}, json.RawMessage(`{"ok":true}`), presentation.Options{Format: format}); !errors.Is(err, want) { + t.Errorf("writer error = %v", err) + } + } +} + +func TestValidateFormat(t *testing.T) { + for _, format := range []string{"auto", "json", "table"} { + if err := presentation.ValidateFormat(format); err != nil { + t.Fatal(err) + } + } + for _, format := range []string{"", "yaml", "JSON"} { + if err := presentation.ValidateFormat(format); err == nil { + t.Errorf("accepted unsupported format %q", format) + } + } +} + +func TestIndependentRendersCanRunConcurrently(t *testing.T) { + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + if err := presentation.Render(io.Discard, json.RawMessage(`{"data":[{"id":"a"}],"hasMore":true}`), presentation.Options{Format: "table", Color: true}); err != nil { + t.Error(err) + } + }) + } + wg.Wait() +} diff --git a/internal/presentation/resources_test.go b/internal/presentation/resources_test.go new file mode 100644 index 00000000..d5a8f123 --- /dev/null +++ b/internal/presentation/resources_test.go @@ -0,0 +1,56 @@ +package presentation_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/formancehq/fctl/v4/internal/presentation" +) + +func TestResourceIdentityTablesPreserveJSON(t *testing.T) { + t.Parallel() + body := json.RawMessage(`{"cursor":{"hasMore":false,"data":[{"metadata":{"name":"ingestion","namespace":"sandbox"},"spec":{"startSequence":9007199254740993},"status":{"phase":"Running"}}]}}`) + var table, raw bytes.Buffer + if err := presentation.Render(&table, body, presentation.Options{Format: "table"}); err != nil { + t.Fatal(err) + } + for _, want := range []string{"ingestion", "sandbox", "Running", "Name", "Phase"} { + if !strings.Contains(table.String(), want) { + t.Errorf("missing %s: %s", want, table.String()) + } + } + if err := presentation.Render(&raw, body, presentation.Options{Format: "json"}); err != nil { + t.Fatal(err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw.Bytes()); err != nil { + t.Fatal(err) + } + if compact.String() != string(body) { + t.Fatal("table projection changed raw JSON") + } +} + +func TestPluginListShowsModuleBeforeOtherColumns(t *testing.T) { + t.Parallel() + body := `[{"profile":"cloud","organization":"jdxmvkvwlyiy","stack":"bwxm","endpoint":"https://app.formance.cloud/api","service":"ledger","version":"3.0.0-beta.10","revision":1,"platform":"darwin/arm64","source":"local","sha256":"abc123"}]` + for _, width := range []int{40, 80, 160, 320} { + out := render(t, body, presentation.Options{Format: "table", Width: width}) + assertContains(t, out, "Module", "ledger") + header := strings.Split(out, "\n")[2] + if !strings.HasPrefix(header, "| Module") { + t.Fatalf("module is not the first column at width %d: %s", width, header) + } + assertLineWidths(t, out, width) + } + out := render(t, body, presentation.Options{Format: "json"}) + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(out)); err != nil { + t.Fatal(err) + } + if compact.String() != body { + t.Fatal("plugin table projection changed JSON output") + } +} diff --git a/internal/presentation/summary.go b/internal/presentation/summary.go new file mode 100644 index 00000000..aa5cfab0 --- /dev/null +++ b/internal/presentation/summary.go @@ -0,0 +1,146 @@ +package presentation + +import ( + "fmt" + "slices" + "strings" +) + +const summaryWidth = 120 + +// Only nested values have a display budget. The original JSON and top-level +// scalars never pass through this summarizer, including exact numeric tokens. +func (r *renderer) flatScalarOrSummary(key string, value any) string { + if slices.Contains([]string{"schema", "mirrorSource", "mirrorSyncProgress"}, key) && !hasDetails(value) { + return "—" + } + if !nested(value) { + return r.flat(value) + } + return r.summary(value, 0) +} + +// Empty containers, nulls and empty strings carry no configuration details. +// Numeric values and booleans remain meaningful, including zero and false. +func hasDetails(value any) bool { + switch typed := value.(type) { + case nil: + return false + case string: + return typed != "" + case map[string]any: + for _, child := range typed { + if hasDetails(child) { + return true + } + } + return false + case []any: + // A nonempty array retains its size and positions, even for null items. + return len(typed) != 0 + default: + return true + } +} + +func (r *renderer) summary(value any, depth int) string { + switch typed := value.(type) { + case map[string]any: + return r.objectSummary(typed, depth) + case []any: + return r.arraySummary(typed, depth) + case string: + text := escape(typed) + if chars := []rune(text); len(chars) > 40 { + return string(chars[:40]) + "..." + } + return text + default: + return r.flat(value) + } +} + +func (r *renderer) objectSummary(object map[string]any, depth int) string { + keys := summaryKeys(object) + keys = slices.DeleteFunc(keys, func(key string) bool { return !hasDetails(object[key]) }) + if len(keys) == 0 { + return "—" + } + if depth >= 2 { + return summaryCount(len(keys), "field") + } + parts := make([]string, 0, 3) + for _, key := range keys { + if diagnosticText(key, object) { + continue + } + part := escape(key) + ": " + r.summary(object[key], depth+1) + if !summaryFits(parts, part) { + continue + } + parts = append(parts, part) + if len(parts) == 3 { + break + } + } + if len(parts) == 0 { + return summaryCount(len(keys), "field") + } + return strings.Join(parts, "; ") +} + +func (r *renderer) arraySummary(items []any, depth int) string { + if len(items) == 0 { + return "—" + } + count := summaryCount(len(items), "item") + if depth >= 1 { + return count + } + parts := []string{count} + for _, item := range items[:min(3, len(items))] { + part := r.summary(item, depth+1) + if !summaryFits(parts, part) { + break + } + parts = append(parts, part) + } + return strings.Join(parts, "; ") +} + +func diagnosticText(key string, object map[string]any) bool { + if !slices.Contains([]string{"message", "description"}, strings.ToLower(key)) { + return false + } + // Named resources and typed conditions already have useful identifiers. + // Their verbose diagnostic text is available in the full JSON output. + return hasDetails(object["name"]) || hasDetails(object["type"]) +} + +func summaryFits(parts []string, part string) bool { + // Keep numeric tokens intact when selecting parts for the summary. + return textWidth(strings.Join(append(slices.Clone(parts), part), "; ")) <= summaryWidth +} + +func summaryKeys(object map[string]any) []string { + keys := orderedKeys(object) + preferred := []string{"name", "type", "status", "state", "version", "id", "info"} + slices.SortStableFunc(keys, func(a, b string) int { + return summaryRank(a, preferred) - summaryRank(b, preferred) + }) + return keys +} + +func summaryRank(key string, preferred []string) int { + if index := slices.Index(preferred, strings.ToLower(key)); index >= 0 { + return index + } + return len(preferred) +} + +func summaryCount(count int, noun string) string { + if count != 1 { + noun += "s" + } + return fmt.Sprintf("%d %s", count, noun) +} diff --git a/internal/stacktools/framing.go b/internal/stacktools/framing.go new file mode 100644 index 00000000..f1583e11 --- /dev/null +++ b/internal/stacktools/framing.go @@ -0,0 +1,77 @@ +package stacktools + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +func readMessage(reader *bufio.Reader) ([]byte, error) { + line, err := readLine(reader) + if err != nil { + return nil, err + } + for len(bytes.TrimSpace(line)) == 0 { + line, err = readLine(reader) + if err != nil { + return nil, err + } + } + if !strings.HasPrefix(strings.ToLower(string(line)), "content-length:") { + return bytes.TrimSpace(line), nil + } + _, value, _ := strings.Cut(string(line), ":") + size, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || size < 0 || size > MaxMessageSize { + return nil, fmt.Errorf("invalid MCP Content-Length (maximum %d bytes)", MaxMessageSize) + } + headers := len(line) + for { + line, err = readLine(reader) + if err != nil { + return nil, frameError(err) + } + headers += len(line) + if headers > MaxMessageSize { + return nil, fmt.Errorf("MCP headers exceed size limit") + } + if len(bytes.TrimSpace(line)) == 0 { + break + } + } + data := make([]byte, size) + _, err = io.ReadFull(reader, data) + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return data, err +} + +func readLine(reader *bufio.Reader) ([]byte, error) { + var line []byte + for { + part, err := reader.ReadSlice('\n') + if len(line)+len(part) > MaxMessageSize { + return nil, fmt.Errorf("MCP message exceeds size limit") + } + line = append(line, part...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) && len(line) > 0 { + return line, nil + } + return line, err + } +} + +func frameError(err error) error { + if errors.Is(err, io.EOF) { + return io.ErrUnexpectedEOF + } + return err +} diff --git a/internal/stacktools/http.go b/internal/stacktools/http.go new file mode 100644 index 00000000..c585b45a --- /dev/null +++ b/internal/stacktools/http.go @@ -0,0 +1,37 @@ +// Package stacktools provides authenticated stack utilities without CLI dependencies. +package stacktools + +import ( + "fmt" + "net" + "net/http" + "net/url" + "time" +) + +const MaxMessageSize = 10 * 1024 * 1024 + +func endpoint(value string) (*url.URL, error) { + u, err := url.Parse(value) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("stack endpoint must be an absolute URL without credentials, query or fragment") + } + if u.Scheme == "https" { + return u, nil + } + ip := net.ParseIP(u.Hostname()) + if u.Scheme == "http" && (u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback())) { + return u, nil + } + return nil, fmt.Errorf("stack endpoint requires HTTPS except on loopback") +} + +func requestClient(client *http.Client, timeout time.Duration) (*http.Client, error) { + if client == nil || timeout <= 0 { + return nil, fmt.Errorf("HTTP client and positive timeout are required") + } + copyClient := *client + copyClient.Timeout = timeout + copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + return ©Client, nil +} diff --git a/internal/stacktools/mcp.go b/internal/stacktools/mcp.go new file mode 100644 index 00000000..11ade3c2 --- /dev/null +++ b/internal/stacktools/mcp.go @@ -0,0 +1,278 @@ +package stacktools + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" + "time" +) + +type MCPOptions struct { + Endpoint string + Client *http.Client + Timeout time.Duration + Input io.Reader + Output io.Writer +} + +type rpcMessage struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type mcpRemote struct { + endpoint string + client *http.Client + sessionID string + version string +} + +type messageRead struct { + data []byte + err error +} + +// ServeMCP bridges sequential stdio requests to the stack's HTTP MCP endpoint. +func ServeMCP(ctx context.Context, options MCPOptions) error { + target, err := endpoint(options.Endpoint) + if err != nil { + return err + } + client, err := requestClient(options.Client, options.Timeout) + if err != nil { + return err + } + if options.Input == nil || options.Output == nil { + return fmt.Errorf("MCP input and output are required") + } + target.Path = strings.TrimRight(target.Path, "/") + "/api/mcp" + remote := &mcpRemote{endpoint: target.String(), client: client, version: "2024-11-05"} + err = remote.serve(ctx, options.Input, options.Output) + if ctx.Err() != nil { + if closer, ok := options.Input.(io.Closer); ok { + return errors.Join(err, closer.Close()) + } + } + return err +} + +func (remote *mcpRemote) serve(ctx context.Context, input io.Reader, output io.Writer) error { + reads := make(chan messageRead) + done := make(chan struct{}) + defer close(done) + go readMessages(input, reads, done) + for { + data, err := nextMessage(ctx, reads) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + if err := remote.handle(ctx, data, output); err != nil { + return err + } + } +} + +func nextMessage(ctx context.Context, reads <-chan messageRead) ([]byte, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case read := <-reads: + return read.data, read.err + } +} + +func readMessages(input io.Reader, reads chan<- messageRead, done <-chan struct{}) { + reader := bufio.NewReader(input) + for { + data, err := readMessage(reader) + select { + case reads <- messageRead{data: data, err: err}: + case <-done: + return + } + if err != nil { + return + } + } +} + +func (remote *mcpRemote) handle(ctx context.Context, data []byte, output io.Writer) error { + var message rpcMessage + if err := json.Unmarshal(data, &message); err != nil { + return writeRPCError(output, json.RawMessage("null"), -32700, "parse error") + } + if message.JSONRPC != "2.0" || message.Method == "" || !validID(message.ID) { + return writeRPCError(output, json.RawMessage("null"), -32600, "invalid request") + } + if message.Method == "ping" && len(message.ID) > 0 { + response := struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result struct{} `json:"result"` + }{JSONRPC: "2.0", ID: message.ID} + return json.NewEncoder(output).Encode(response) + } + response, err := remote.send(ctx, message, data) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if len(message.ID) == 0 { + return err + } + return writeRPCError(output, message.ID, -32000, err.Error()) + } + if len(message.ID) == 0 { + return nil + } + return json.NewEncoder(output).Encode(response) +} + +func validID(id json.RawMessage) bool { + if len(id) == 0 { + return true + } + decoder := json.NewDecoder(bytes.NewReader(id)) + decoder.UseNumber() + var value any + if decoder.Decode(&value) != nil { + return false + } + switch value.(type) { + case nil, string, json.Number: + return true + default: + return false + } +} + +func writeRPCError(out io.Writer, id json.RawMessage, code int, message string) error { + response := struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + }{JSONRPC: "2.0", ID: id} + response.Error.Code, response.Error.Message = code, message + return json.NewEncoder(out).Encode(response) +} + +func (remote *mcpRemote) send(ctx context.Context, message rpcMessage, data []byte) (result json.RawMessage, sendErr error) { + request, err := remote.request(ctx, message, data) + if err != nil { + return nil, err + } + response, err := remote.client.Do(request) + if err != nil { + return nil, fmt.Errorf("MCP request failed") + } + defer func() { sendErr = errors.Join(sendErr, response.Body.Close()) }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("MCP HTTP %d", response.StatusCode) + } + if id := response.Header.Get("Mcp-Session-Id"); id != "" { + remote.sessionID = id + } + if len(message.ID) == 0 { + return nil, nil + } + result, err = decodeResponse(response, message.ID) + if err == nil && message.Method == "initialize" { + remote.negotiatedVersion(result) + } + return result, err +} + +func (remote *mcpRemote) request(ctx context.Context, message rpcMessage, data []byte) (*http.Request, error) { + if message.Method == "initialize" { + var params struct { + ProtocolVersion string `json:"protocolVersion"` + } + if err := json.Unmarshal(message.Params, ¶ms); err != nil { + return nil, fmt.Errorf("invalid initialize parameters") + } + if params.ProtocolVersion != "" { + remote.version = params.ProtocolVersion + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, remote.endpoint, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("cannot build MCP request") + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + request.Header.Set("MCP-Protocol-Version", remote.version) + if remote.sessionID != "" { + request.Header.Set("Mcp-Session-Id", remote.sessionID) + } + return request, nil +} + +func (remote *mcpRemote) negotiatedVersion(payload json.RawMessage) { + var response struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result"` + } + if json.Unmarshal(payload, &response) == nil && response.Result.ProtocolVersion != "" { + remote.version = response.Result.ProtocolVersion + } +} + +func decodeResponse(response *http.Response, id json.RawMessage) (json.RawMessage, error) { + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil { + return nil, fmt.Errorf("invalid MCP response content type") + } + if mediaType == "text/event-stream" { + return readSSE(response.Body, id) + } + if mediaType != "application/json" { + return nil, fmt.Errorf("unsupported MCP response content type") + } + payload, err := io.ReadAll(io.LimitReader(response.Body, MaxMessageSize+1)) + if err != nil { + return nil, fmt.Errorf("MCP response read failed") + } + if len(payload) > MaxMessageSize { + return nil, fmt.Errorf("MCP response exceeds size limit") + } + if !matchesResponse(payload, id) { + return nil, fmt.Errorf("MCP response ID does not match request") + } + return payload, nil +} + +func matchesResponse(payload, id json.RawMessage) bool { + var response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(payload, &response) != nil || response.JSONRPC != "2.0" { + return false + } + if (len(response.Result) == 0) == (len(response.Error) == 0) { + return false + } + var a, b bytes.Buffer + if json.Compact(&a, response.ID) != nil || json.Compact(&b, id) != nil { + return false + } + return bytes.Equal(a.Bytes(), b.Bytes()) +} diff --git a/internal/stacktools/mcp_test.go b/internal/stacktools/mcp_test.go new file mode 100644 index 00000000..d048add4 --- /dev/null +++ b/internal/stacktools/mcp_test.go @@ -0,0 +1,231 @@ +package stacktools_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/v4/internal/stacktools" +) + +func TestMCPHTTPAndSSESession(t *testing.T) { + t.Parallel() + var count atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + handleMCPFixture(t, w, r) + })) + t.Cleanup(server.Close) + input := `{"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":"2025-03-26"}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":9007199254740993,"method":"tools/call","params":{"name":"list_ledgers"}} +{"jsonrpc":"2.0","id":"fail","method":"tools/call"} +{"jsonrpc":"2.0","id":4,"method":"ping"} +` + var out bytes.Buffer + err := stacktools.ServeMCP(t.Context(), stacktools.MCPOptions{Endpoint: server.URL + "/gateway", Client: authenticatedClient(server), Timeout: time.Second, Input: strings.NewReader(input), Output: &out}) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + want := []string{`{"jsonrpc":"2.0","id":"init","result":{"protocolVersion":"2025-03-26","capabilities":{}}}`, `{"jsonrpc":"2.0","id":9007199254740993,"result":{"content":[]}}`, `{"jsonrpc":"2.0","id":"fail","error":{"code":-32602,"message":"invalid params","data":{"reason":"fixture"}}}`, `{"jsonrpc":"2.0","id":4,"result":{}}`} + if len(lines) != len(want) { + t.Fatalf("protocol output %s", out.String()) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("response %d = %s, want %s", i, lines[i], want[i]) + } + } + if count.Load() != 4 { + t.Fatalf("remote requests %d", count.Load()) + } +} + +func handleMCPFixture(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + if r.URL.Path != "/gateway/api/mcp" || r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer synthetic-stack-token" { + t.Error("incorrect MCP route/auth") + } + if r.Header.Get("MCP-Protocol-Version") != "2025-03-26" || r.Header.Get("Accept") != "application/json, text/event-stream" { + t.Error("protocol negotiation missing") + } + var message struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + if message.Method == "initialize" { + w.Header().Set("Mcp-Session-Id", "fixture-session") + w.Header().Set("Content-Type", "application/json") + writeFixture(t, w, `{"jsonrpc":"2.0","id":"init","result":{"protocolVersion":"2025-03-26","capabilities":{}}}`) + return + } + if r.Header.Get("Mcp-Session-Id") != "fixture-session" { + t.Error("MCP session missing") + } + if len(message.ID) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + w.Header().Set("Content-Type", "text/event-stream") + if string(message.ID) == `"fail"` { + writeFixture(t, w, "data: {\"jsonrpc\":\"2.0\",\"id\":\"fail\",\"error\":{\"code\":-32602,\"message\":\"invalid params\",\"data\":{\"reason\":\"fixture\"}}}\n\n") + return + } + writeFixture(t, w, ": keepalive\n\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\ndata: {\"jsonrpc\":\"2.0\",\"id\":9007199254740993,\"result\":{\"content\":[]}}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-r.Context().Done() +} + +func writeFixture(t *testing.T, w io.Writer, data string) { + t.Helper() + if _, err := io.WriteString(w, data); err != nil { + t.Error(err) + } +} + +func TestMCPFramingAndValidation(t *testing.T) { + t.Parallel() + tests := []struct { + name, input string + want string + failure bool + }{ + {name: "content length", input: fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(`{"jsonrpc":"2.0","id":1,"method":"ping"}`), `{"jsonrpc":"2.0","id":1,"method":"ping"}`), want: `"id":1`}, + {name: "parse error", input: "not json\n", want: `"code":-32700`}, + {name: "invalid id", input: `{"jsonrpc":"2.0","id":{},"method":"ping"}`, want: `"code":-32600`}, + {name: "truncated frame", input: "Content-Length: 10\r\n\r\n", failure: true}, + {name: "truncated headers", input: "Content-Length: 10\r\n", failure: true}, + {name: "oversized frame", input: fmt.Sprintf("Content-Length: %d\r\n\r\n", stacktools.MaxMessageSize+1), failure: true}, + {name: "oversized line", input: strings.Repeat("x", stacktools.MaxMessageSize+1), failure: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + err := stacktools.ServeMCP(t.Context(), stacktools.MCPOptions{Endpoint: "http://127.0.0.1", Client: &http.Client{}, Timeout: time.Second, Input: strings.NewReader(test.input), Output: &out}) + if test.failure { + if err == nil { + t.Fatal("invalid framing accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), test.want) { + t.Fatalf("output %s", out.String()) + } + }) + } +} + +func TestMCPRejectsMismatchedIDAndRedirect(t *testing.T) { + t.Parallel() + var external atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { external.Add(1) })) + t.Cleanup(other.Close) + tests := []struct { + name, body, contentType string + status int + }{ + {name: "wrong id", body: `{"jsonrpc":"2.0","id":2,"result":{}}`, contentType: "application/json", status: http.StatusOK}, + {name: "wrong SSE id", body: "data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}\n\n", contentType: "text/event-stream", status: http.StatusOK}, + {name: "redirect", status: http.StatusTemporaryRedirect}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", other.URL) + w.Header().Set("Content-Type", test.contentType) + w.WriteHeader(test.status) + writeFixture(t, w, test.body) + })) + t.Cleanup(server.Close) + var out bytes.Buffer + err := stacktools.ServeMCP(t.Context(), stacktools.MCPOptions{Endpoint: server.URL, Client: authenticatedClient(server), Timeout: time.Second, Input: strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`), Output: &out}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"id":1,"error":{"code":-32000`) || strings.Contains(out.String(), "synthetic-stack-token") { + t.Fatalf("unsafe response %s", out.String()) + } + }) + } + if external.Load() != 0 { + t.Fatal("MCP bearer followed redirect") + } +} + +func TestMCPCancellation(t *testing.T) { + t.Parallel() + input, writer := io.Pipe() + t.Cleanup(func() { + if err := writer.Close(); err != nil { + t.Error(err) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- stacktools.ServeMCP(ctx, stacktools.MCPOptions{Endpoint: "http://127.0.0.1", Client: &http.Client{}, Timeout: time.Second, Input: input, Output: io.Discard}) + }() + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancel: %v", err) + } + case <-time.After(time.Second): + t.Fatal("MCP did not cancel blocked input") + } +} + +func TestMCPNegotiatedVersionAndMultilineSSE(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var message struct { + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + t.Error(err) + return + } + if message.Method == "initialize" { + w.Header().Set("Content-Type", "application/json") + writeFixture(t, w, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}`) + return + } + if r.Header.Get("MCP-Protocol-Version") != "2024-11-05" { + t.Error("negotiated protocol ignored") + } + w.Header().Set("Content-Type", "text/event-stream") + writeFixture(t, w, "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\r\ndata: \"id\":2,\"result\":{\"tools\":[]}}\r\n\r\n") + })) + t.Cleanup(server.Close) + var out bytes.Buffer + input := "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\"}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n" + err := stacktools.ServeMCP(t.Context(), stacktools.MCPOptions{Endpoint: server.URL, Client: server.Client(), Timeout: time.Second, Input: strings.NewReader(input), Output: &out}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), `"id":2,"result":{"tools":[]}`) { + t.Fatalf("multiline SSE %s", out.String()) + } +} diff --git a/internal/stacktools/proxy.go b/internal/stacktools/proxy.go new file mode 100644 index 00000000..678fdc14 --- /dev/null +++ b/internal/stacktools/proxy.go @@ -0,0 +1,168 @@ +package stacktools + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "slices" + "strconv" + "strings" + "time" +) + +type ProxyOptions struct { + Endpoint string + Client *http.Client + Port uint32 + AllowedOrigins []string + Timeout time.Duration +} + +// ProxyHandler strips browser credentials before the authenticated transport runs. +func ProxyHandler(options ProxyOptions) (http.Handler, error) { + target, err := endpoint(options.Endpoint) + if err != nil { + return nil, err + } + client, err := requestClient(options.Client, options.Timeout) + if err != nil { + return nil, err + } + origins := slices.Clone(options.AllowedOrigins) + proxy := &httputil.ReverseProxy{ + Transport: client.Transport, + ErrorLog: log.New(io.Discard, "", 0), + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.Host = target.Host + for _, name := range []string{"Authorization", "Proxy-Authorization", "Cookie", "Origin", "Referer", "Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { + r.Out.Header.Del(name) + } + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + http.Error(w, "stack request failed", http.StatusBadGateway) + }, + ModifyResponse: func(response *http.Response) error { + for name := range response.Header { + if name == "Set-Cookie" || name == "Www-Authenticate" || name == "Authorization" || len(name) >= 15 && name[:15] == "Access-Control-" { + response.Header.Del(name) + } + } + return nil + }, + } + return proxyRequests(proxy, origins, options.Timeout), nil +} + +func proxyRequests(proxy *httputil.ReverseProxy, origins []string, timeout time.Duration) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopbackAuthority(r.Host) { + http.Error(w, "host is not allowed", http.StatusForbidden) + return + } + if !allowOrigin(w, r, origins) { + return + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + r = r.WithContext(ctx) + r.Body = http.MaxBytesReader(w, r.Body, MaxMessageSize) + proxy.ServeHTTP(w, r) + }) +} + +func loopbackAuthority(authority string) bool { + host, port, err := net.SplitHostPort(authority) + if err == nil { + if !validProxyPort(port) || (strings.HasPrefix(authority, "[") && !strings.Contains(host, ":")) { + return false + } + } else { + host = authority + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + if !strings.Contains(host, ":") { + return false + } + } else if strings.Contains(host, ":") { + return false + } + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func validProxyPort(port string) bool { + if port == "" || strings.IndexFunc(port, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return false + } + _, err := strconv.ParseUint(port, 10, 16) + return err == nil +} + +func allowOrigin(w http.ResponseWriter, r *http.Request, origins []string) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if !slices.Contains(origins, origin) && !slices.Contains(origins, "*") { + http.Error(w, "origin is not allowed", http.StatusForbidden) + return false + } + w.Header().Add("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type") + return true +} + +// ServeProxy binds only loopback and drains the server on command cancellation. +func ServeProxy(ctx context.Context, options ProxyOptions, ready func(string) error) error { + if options.Port > 65535 { + return fmt.Errorf("port must be between 0 and 65535") + } + handler, err := ProxyHandler(options) + if err != nil { + return err + } + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.FormatUint(uint64(options.Port), 10))) + if err != nil { + return err + } + server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: options.Timeout, IdleTimeout: time.Minute, BaseContext: func(_ net.Listener) context.Context { return ctx }} + if ready != nil { + if err := ready(listener.Addr().String()); err != nil { + return errors.Join(err, listener.Close()) + } + } + result := make(chan error, 1) + go func() { result <- server.Serve(listener) }() + select { + case err := <-result: + return err + case <-ctx.Done(): + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + err := server.Shutdown(cleanup) + if err != nil { + err = errors.Join(err, server.Close()) + } + serveErr := <-result + if errors.Is(serveErr, http.ErrServerClosed) { + serveErr = nil + } + return errors.Join(ctx.Err(), err, serveErr) + } +} diff --git a/internal/stacktools/proxy_test.go b/internal/stacktools/proxy_test.go new file mode 100644 index 00000000..217b4cb7 --- /dev/null +++ b/internal/stacktools/proxy_test.go @@ -0,0 +1,263 @@ +package stacktools_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/v4/internal/stacktools" +) + +type authenticatedTransport struct{ base http.RoundTripper } + +func (transport authenticatedTransport) RoundTrip(request *http.Request) (*http.Response, error) { + copyRequest := request.Clone(request.Context()) + copyRequest.Header.Set("Authorization", "Bearer synthetic-stack-token") + return transport.base.RoundTrip(copyRequest) +} + +func authenticatedClient(server *httptest.Server) *http.Client { + client := *server.Client() + client.Transport = authenticatedTransport{base: client.Transport} + return &client +} + +func TestProxyCredentialsOriginsAndPrefix(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + assertProxyUpstream(t, r) + w.Header().Set("Set-Cookie", "private=session") + w.Header().Set("Access-Control-Allow-Origin", "https://wrong.example") + if _, err := io.WriteString(w, "service response"); err != nil { + t.Error(err) + } + })) + t.Cleanup(upstream.Close) + options := stacktools.ProxyOptions{Endpoint: upstream.URL + "/gateway", Client: authenticatedClient(upstream), Timeout: time.Second, AllowedOrigins: []string{"https://console.example"}} + handler, err := stacktools.ProxyHandler(options) + if err != nil { + t.Fatal(err) + } + t.Run("allowed", func(t *testing.T) { assertProxyAllowed(t, handler) }) + t.Run("denied", func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1/api/ledger/v3/ledgers", nil) + request.Header.Set("Origin", "https://attacker.example") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("status %d", response.Code) + } + }) + t.Run("preflight", func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodOptions, "http://127.0.0.1/api/ledger/v3/ledgers", nil) + request.Header.Set("Origin", "https://console.example") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNoContent || strings.Contains(response.Header().Get("Access-Control-Allow-Headers"), "Authorization") { + t.Fatal("invalid preflight response") + } + }) + if requests.Load() != 1 { + t.Fatalf("unexpected upstream calls: %d", requests.Load()) + } +} + +func assertProxyAllowed(t *testing.T, handler http.Handler) { + t.Helper() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1/api/ledger/v3/ledgers?cursor=a%2Fb", nil) + request.Header.Set("Origin", "https://console.example") + request.Header.Set("Authorization", "Bearer browser-secret") + request.Header.Set("Cookie", "browser=secret") + request.Header.Set("Referer", "https://console.example/?secret=value") + request.Header.Set("Proxy-Authorization", "Basic browser-secret") + request.Header.Set("X-Forwarded-For", "attacker") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != "service response" { + t.Fatalf("proxy response: %d %s", response.Code, response.Body) + } + if response.Header().Get("Access-Control-Allow-Origin") != "https://console.example" || response.Header().Get("Set-Cookie") != "" { + t.Fatal("upstream CORS or cookie escaped") + } +} + +func TestProxyDoesNotFollowRedirect(t *testing.T) { + t.Parallel() + var external atomic.Int32 + other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { external.Add(1) })) + t.Cleanup(other.Close) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(upstream.Close) + handler, err := stacktools.ProxyHandler(stacktools.ProxyOptions{Endpoint: upstream.URL, Client: authenticatedClient(upstream), Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1/redirect", nil)) + if response.Code != http.StatusTemporaryRedirect || external.Load() != 0 { + t.Fatal("proxy followed an upstream redirect") + } +} + +func TestProxyCancellationAndLoopback(t *testing.T) { + t.Parallel() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) + t.Cleanup(upstream.Close) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ready := make(chan string, 1) + done := make(chan error, 1) + go func() { + done <- stacktools.ServeProxy(ctx, stacktools.ProxyOptions{Endpoint: upstream.URL, Client: upstream.Client(), Port: 0, Timeout: time.Second}, func(address string) error { ready <- address; return nil }) + }() + var address string + select { + case address = <-ready: + case <-time.After(3 * time.Second): + t.Fatal("proxy did not start") + } + if !strings.HasPrefix(address, "127.0.0.1:") { + t.Fatal("proxy is not loopback") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("shutdown: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("proxy did not stop") + } + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+address, nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Timeout: time.Second} + response, err := client.Do(request) + if err == nil { + if closeErr := response.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + t.Fatal("listener remained open") + } +} + +func TestProxyTimeout(t *testing.T) { + t.Parallel() + upstream := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + t.Cleanup(upstream.Close) + handler, err := stacktools.ProxyHandler(stacktools.ProxyOptions{Endpoint: upstream.URL, Client: upstream.Client(), Timeout: 20 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1/slow", nil)) + if response.Code != http.StatusBadGateway { + t.Fatalf("timeout status %d", response.Code) + } +} + +func assertProxyUpstream(t *testing.T, r *http.Request) { + t.Helper() + if r.URL.Path != "/gateway/api/ledger/v3/ledgers" || r.URL.RawQuery != "cursor=a%2Fb" { + t.Errorf("unexpected upstream route: %s", r.URL) + } + if r.Header.Get("Authorization") != "Bearer synthetic-stack-token" { + t.Error("stack bearer missing") + } + for _, name := range []string{"Cookie", "Origin", "Proxy-Authorization", "Referer", "X-Forwarded-For"} { + if r.Header.Get(name) != "" { + t.Errorf("upstream received %s", name) + } + } +} + +type acquiringTransport struct { + acquisitions *atomic.Int32 + base http.RoundTripper +} + +func (transport acquiringTransport) RoundTrip(request *http.Request) (*http.Response, error) { + transport.acquisitions.Add(1) + return authenticatedTransport{base: transport.base}.RoundTrip(request) +} + +func TestProxyHostValidationBeforeAuthentication(t *testing.T) { + t.Parallel() + var acquisitions, requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Authorization") != "Bearer synthetic-stack-token" { + t.Error("stack token missing") + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(upstream.Close) + client := *upstream.Client() + client.Transport = acquiringTransport{acquisitions: &acquisitions, base: client.Transport} + handler, err := stacktools.ProxyHandler(stacktools.ProxyOptions{Endpoint: upstream.URL, Client: &client, AllowedOrigins: []string{"*"}, Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + allowed := []string{"localhost", "LOCALHOST", "localhost:55001", "127.0.0.1", "127.1.2.3:55001", "[::1]", "[::1]:55001", "[::ffff:127.0.0.1]:55001"} + proxy := httptest.NewServer(handler) + t.Cleanup(proxy.Close) + for _, host := range allowed { + t.Run(host, func(t *testing.T) { assertProxyNetworkHost(t, proxy, host, http.StatusNoContent) }) + } + before := acquisitions.Load() + t.Run("DNS rebinding without Origin", func(t *testing.T) { assertProxyNetworkHost(t, proxy, "attacker.example:55001", http.StatusForbidden) }) + rejected := []string{"", "attacker.example", "localhost.attacker.example", "localhost.", "192.0.2.1:55001", "[2001:db8::1]:55001", "::1", "localhost:", "localhost:abc", "localhost:+80", "localhost:65536", "localhost:80:90", "[localhost]:80", "[127.0.0.1]", "[::1", "::1]", "[::1]:", "user@localhost:80", "localhost@attacker.example", "localhost/path", "localhost?query", " localhost", "localhost ", "[::1%lo0]:80"} + for _, host := range rejected { + t.Run("reject "+host, func(t *testing.T) { assertProxyRejectedHost(t, handler, host) }) + } + if acquisitions.Load() != before || int(requests.Load()) != len(allowed) { + t.Fatal("rejected Host reached token acquisition or upstream") + } +} + +func assertProxyNetworkHost(t *testing.T, proxy *httptest.Server, host string, status int) { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, proxy.URL+"/api/ledger/v3/ledgers", nil) + if err != nil { + t.Fatal(err) + } + request.Host = host + response, err := proxy.Client().Do(request) + if err != nil { + t.Fatal(err) + } + if err := response.Body.Close(); err != nil { + t.Error(err) + } + if response.StatusCode != status { + t.Fatalf("Host %q: status %d, want %d", host, response.StatusCode, status) + } +} + +func assertProxyRejectedHost(t *testing.T, handler http.Handler, host string) { + t.Helper() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1/api/ledger/v3/ledgers", nil) + request.Host = host + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("Host %q accepted with status %d", host, response.Code) + } + request.Header.Set("Origin", "https://attacker.example") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden || response.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("Host %q bypassed validation through wildcard CORS", host) + } +} diff --git a/internal/stacktools/sse.go b/internal/stacktools/sse.go new file mode 100644 index 00000000..0cce8956 --- /dev/null +++ b/internal/stacktools/sse.go @@ -0,0 +1,59 @@ +package stacktools + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +func readSSE(body io.Reader, id json.RawMessage) (json.RawMessage, error) { + reader := bufio.NewReader(io.LimitReader(body, MaxMessageSize+1)) + total := 0 + for { + data, err := nextEvent(reader, &total) + if matchesResponse(data, id) { + return json.RawMessage(data), nil + } + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("MCP event stream has no matching response") + } + if err != nil { + return nil, err + } + } +} + +func nextEvent(reader *bufio.Reader, total *int) ([]byte, error) { + var data []byte + for { + line, err := readLine(reader) + *total += len(line) + if *total > MaxMessageSize { + return nil, fmt.Errorf("MCP event stream exceeds size limit") + } + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("MCP event stream read failed") + } + line = bytes.TrimRight(line, "\r\n") + if len(line) == 0 { + return data, err + } + data = appendEventData(data, line) + if err != nil { + return data, err + } + } +} + +func appendEventData(data, line []byte) []byte { + if !bytes.HasPrefix(line, []byte("data:")) { + return data + } + if len(data) > 0 { + data = append(data, '\n') + } + return append(data, bytes.TrimPrefix(bytes.TrimPrefix(line, []byte("data:")), []byte(" "))...) +} diff --git a/main.go b/main.go index 0cc92d6b..5f1766c5 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,17 @@ package main import ( - "github.com/formancehq/fctl/v3/cmd" + "fmt" + "os" + + "github.com/formancehq/fctl/v4/cmd" ) func main() { - cmd.Execute() + if err := cmd.Execute(); err != nil { + if _, writeErr := fmt.Fprintln(os.Stderr, err); writeErr != nil { + os.Exit(1) + } + os.Exit(1) + } } diff --git a/misc/fctl-plugin/README.md b/misc/fctl-plugin/README.md new file mode 100644 index 00000000..4671b087 --- /dev/null +++ b/misc/fctl-plugin/README.md @@ -0,0 +1,75 @@ +# fctl legacy service plugin + +This library and plugin executable adapt the historical fctl service commands +from main commit `e00243b3e2e56aae6a09d7010b0c17890134388c` to the public fctl SDK. +The repository profile is library/CLI plugin: the fctl host owns authentication, +configuration, terminal interaction and presentation. The plugin has no service +lifecycle, Fx container or independent authentication layer. + +`New(*http.Client)` returns the complete `legacy` bundle. `NewService(service, +*http.Client)` returns a service facade using the same SDK contract. Metadata +works with a nil client and no network. `Compatibilities` declares the supported +API families; execution rejects unknown or modern target versions. + +Both public constructors also implement the optional `pluginsdk.StreamingPlugin`. +Ledger `import` declares `Streams.Input`; `export` declares `Streams.Output`. +The host should use `ExecuteStream` for these commands. The service and bundle +wrappers verify the same compatibility guards before reading input, writing +output or sending a mutation. Other commands keep their unary behavior. + +Streaming import uses NDJSON batches capped at 100 logs and 4 MiB including +newlines. Streaming export writes the backend's exact NDJSON bytes and returns +only a separate summary. Neither operation buffers the complete stream. A +record is limited to 4 MiB. The host owns file access and commits an export +destination only when execution succeeds. See [streaming logs](docs/streaming-logs.md) +for progress, resume, failure and direct SDK compatibility details. + +## Source layout + +```text +misc/fctl-plugin/ + cmd/fctl-plugin-legacy/ standalone transport entry point + legacy.go public bundle and service factory assembly + service.go service facade and execution version guard + compatibility.go supported API families + internal/ + auth/ clients, secrets and users + ledger/ ledgers, accounts, transactions, schemas and logs + payments/ connectors, accounts, pools and payment operations + orchestration/ workflows, instances and triggers + reconciliation/ policies, rules, evaluations and alerts + wallets/ wallets, balances, holds and movements + webhooks/ configs and deliveries + command/ declarative command and HTTP helpers + metadata/ bundle revision and historical source commit + testutil/ shared HTTP contract fixtures + docs/ command inventory and manual sandbox recipes +``` + +Each service package owns its manifest, routes, payload validation, aliases and +contract tests. Add an operation to its domain file and register it in that +service's `plugin.go`. `internal/command` supplies common SDK command builders, +route binding and JSON helpers; it does not choose services or contain service +rules. For example, Wallet name resolution belongs to `internal/wallets`, and +workflow polling belongs to `internal/orchestration`. + +The module root is the public Go API. Callers use `New`, `NewService` or +`Factories`; implementation packages are internal to this module. Services +import the public SDK and common internal helpers. They do not import one +another, the module root or the fctl core. + +## Validation and distribution + +```sh +go test -race ./... +go build ./cmd/fctl-plugin-legacy +``` + +The root fctl Justfile and GoReleaser include this module. The executable calls +`transport.Serve(New)` and obtains HTTP through the host callback; it receives +no tokens in arguments or environment. Its own version is independent of any +historical service release. See the repository's `docs/legacy-plugin.md` for +selection, cache, supported API families and validation. + +See [the historical command inventory](docs/port-inventory.md) and +[synthetic sandbox recipes](docs/live-fixtures.md) for the adapted service contracts. diff --git a/misc/fctl-plugin/cmd/fctl-plugin-legacy/main.go b/misc/fctl-plugin/cmd/fctl-plugin-legacy/main.go new file mode 100644 index 00000000..a2563e4f --- /dev/null +++ b/misc/fctl-plugin/cmd/fctl-plugin-legacy/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + legacy "github.com/formancehq/fctl/misc/fctl-plugin" +) + +func main() { transport.Serve(legacy.New) } diff --git a/misc/fctl-plugin/compatibility.go b/misc/fctl-plugin/compatibility.go new file mode 100644 index 00000000..65f1f2a6 --- /dev/null +++ b/misc/fctl-plugin/compatibility.go @@ -0,0 +1,70 @@ +// Package legacy supplies the historical fctl service commands through the public SDK. +package legacy + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +const Version = metadata.Version +const SourceCommit = metadata.SourceCommit + +// Compatibility is an explicit supported API family, independent of the plugin revision. +// A prerelease of the next major belongs to that next family. +type Compatibility struct { + Service string `json:"service"` + MinMajor int `json:"minMajor"` + MaxMajor int `json:"maxMajor"` +} + +func Compatibilities() []Compatibility { + return []Compatibility{{"ledger", 1, 2}, {"auth", 1, 2}, {"payments", 1, 3}, {"orchestration", 1, 2}, {"reconciliation", 1, 2}, {"wallets", 1, 2}, {"webhooks", 1, 2}} +} + +func Supported(service, version, stackVersion string) bool { + if stackVersion != "" && !LegacyStack(stackVersion) { + return false + } + major, ok := versionMajor(version) + if !ok { + return false + } + for _, c := range Compatibilities() { + if c.Service == service { + return major >= c.MinMajor && major <= c.MaxMajor + } + } + return false +} + +func LegacyStack(version string) bool { + major, ok := versionMajor(version) + return ok && major >= 1 && major <= 3 +} + +var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\.(0|[1-9][0-9]*))?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$`) + +func versionMajor(version string) (int, bool) { + version = strings.TrimPrefix(version, "v") + match := versionPattern.FindStringSubmatch(version) + if match == nil { + return 0, false + } + major, err := strconv.Atoi(match[1]) + return major, err == nil +} + +func Unsupported(service, version, stackVersion string) error { + target := service + if version != "" { + target += " " + version + } + if stackVersion != "" { + target += " on stack " + stackVersion + } + return fmt.Errorf("legacy plugin %s does not support %s; use the matching service plugin", Version, target) +} diff --git a/misc/fctl-plugin/compatibility_test.go b/misc/fctl-plugin/compatibility_test.go new file mode 100644 index 00000000..fa64cfbe --- /dev/null +++ b/misc/fctl-plugin/compatibility_test.go @@ -0,0 +1,29 @@ +package legacy + +import "testing" + +func TestCompatibility(t *testing.T) { + for _, test := range []struct { + service, version, line string + want bool + }{ + {"ledger", "v2.4.15", "v3.2", true}, + {"ledger", "v2.4.0-beta.1", "v3.2-rc", true}, + {"ledger", "3.0.0-alpha.1", "", false}, + {"ledger", "2.4.15", "v4.0-beta", false}, + {"auth", "2.5.1", "v3.2", true}, + {"auth", "2.5.1", "v4.0-beta", false}, + {"payments", "3.4.8", "v3.2", true}, + {"wallets", "2.2.1", "", true}, + {"unknown", "2.0.0", "v3.2", false}, + {"ledger", "commit-sha", "v3.2", false}, + {"ledger", "02.4.15", "v3.2", false}, + {"ledger", "2.bad", "v3.2", false}, + } { + t.Run(test.service+"/"+test.version+"/"+test.line, func(t *testing.T) { + if got := Supported(test.service, test.version, test.line); got != test.want { + t.Fatalf("got %v want %v", got, test.want) + } + }) + } +} diff --git a/misc/fctl-plugin/docs/live-fixtures.md b/misc/fctl-plugin/docs/live-fixtures.md new file mode 100644 index 00000000..3e4ba7d0 --- /dev/null +++ b/misc/fctl-plugin/docs/live-fixtures.md @@ -0,0 +1,179 @@ +# Synthetic sandbox fixture recipes + +These recipes document historical service operations using synthetic data. +They are examples, not an automated test suite. The verified results and limits +are recorded in the repository's `docs/legacy-validation.md`. + +Use a built fctl binary and an authenticated sandbox profile. The host owns +authentication, file reads and YAML decoding. DUMMYPAY requires backend support; +inspect the target's connector providers and stop the Payments-dependent +sequence if installation fails. + +## Common setup + +Set `QA_PROFILE` to an existing sandbox profile and `FCTL_BIN` to the built binary. `QA_LEDGER` must identify a dedicated existing Ledger fixture. No credentials are placed in these commands. Do not enable debug tracing or shell tracing. + +```sh +: "${FCTL_BIN:?Set the path to the built fctl binary}" +: "${QA_PROFILE:?Set the authenticated sandbox profile name}" +: "${QA_ORGANIZATION:?Set the organization ID}" +: "${QA_STACK:?Set the disposable sandbox Stack ID}" +: "${QA_LEDGER:?Set the existing dedicated QA ledger name}" +QA_TAG="legacy-qa-$(date -u +%Y%m%dT%H%M%SZ)" +QA_DIR="$(mktemp -d)" +QA_ACCOUNT="fctl:legacy:qa:${QA_TAG}" +fctlqa() { + "$FCTL_BIN" --profile "$QA_PROFILE" --organization "$QA_ORGANIZATION" --stack "$QA_STACK" --no-input --output json "$@" +} +``` + +Responses remain their API envelopes; successful object IDs are usually `.data.id`. File sources are optional alternatives to `--data`; do not combine them. All examples below use synthetic names and amounts. + +## Payments: conditional DUMMYPAY install, account, pool + +`directory` is a path **inside the Payments backend**, not the test machine. `/tmp` is a synthetic candidate; the tester must select an allowed existing server directory if it differs. DUMMYPAY requires backend development support, which the providers snapshot does not establish. The config has no PSP credentials. + +```sh +jq -n --arg name "$QA_TAG" \ + '{name:$name,directory:"/tmp",pollingPeriod:"30m"}' > "$QA_DIR/dummypay.json" +connector_json="$(fctlqa legacy payments connectors install dummypay "$QA_DIR/dummypay.json" --confirm)" +connector_id="$(printf '%s' "$connector_json" | jq -er '.data | select(type == "string" and length > 0)')" + +created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +jq -n --arg connector "$connector_id" --arg reference "$QA_TAG" --arg at "$created_at" \ + '{connectorID:$connector,createdAt:$at,reference:$reference,type:"INTERNAL",accountName:$reference,defaultAsset:"USD/2",metadata:{fixture:$reference}}' \ + > "$QA_DIR/payment-account.json" +account_json="$(fctlqa legacy payments accounts create "$QA_DIR/payment-account.json" --confirm)" +account_id="$(printf '%s' "$account_json" | jq -er '.data.id')" +fctlqa legacy payments accounts get "$account_id" +fctlqa legacy payments accounts balances "$account_id" + +jq -n --arg name "$QA_TAG" --arg account "$account_id" \ + '{name:$name,accountIDs:[$account]}' > "$QA_DIR/pool.json" +pool_json="$(fctlqa legacy payments pools create "$QA_DIR/pool.json" --confirm)" +pool_id="$(printf '%s' "$pool_json" | jq -er '.data.id')" +fctlqa legacy payments pools get "$pool_id" +fctlqa legacy payments pools latest-balances "$pool_id" +``` + +On Payments 3, the module uses V3 for install and pool create/latest-balances, and the historical unversioned V1 endpoints for account create/get/balances and pool get/delete/member operations. Every Payments execution first reads `/_info`. Accounts have no historical delete command; clean up the synthetic connector after removing its pool references. Keep the pool until the Reconciliation fixture finishes. + +## Wallet: create, credit, pending debit/void, drain + +```sh +wallet_json="$(fctlqa legacy wallets create "$QA_TAG" --metadata "fixture=$QA_TAG" --ik "$QA_TAG-create" --confirm)" +wallet_id="$(printf '%s' "$wallet_json" | jq -er '.data.id')" +fctlqa legacy wallets show --id "$wallet_id" +fctlqa legacy wallets credit 100 USD/2 --id "$wallet_id" --source account=world \ + --metadata "fixture=$QA_TAG" --ik "$QA_TAG-credit" --confirm +fctlqa legacy wallets balances show main --id "$wallet_id" + +hold_json="$(fctlqa legacy wallets debit 20 USD/2 --id "$wallet_id" --balance main --pending \ + --destination "account=$QA_ACCOUNT:sink" --description "$QA_TAG-hold" --ik "$QA_TAG-hold" --confirm)" +hold_id="$(printf '%s' "$hold_json" | jq -er '.data.id')" +fctlqa legacy wallets holds show "$hold_id" +fctlqa legacy wallets holds void "$hold_id" --ik "$QA_TAG-void" --confirm + +fctlqa legacy wallets debit 100 USD/2 --id "$wallet_id" --balance main \ + --destination "account=$QA_ACCOUNT:sink" --description "$QA_TAG-drain" --ik "$QA_TAG-drain" --confirm +fctlqa legacy wallets balances show main --id "$wallet_id" +fctlqa legacy wallets transactions list --id "$wallet_id" +``` + +`world` is the synthetic credit source. The fixture debits the same 100 units after voiding its pending hold. **Wallet delete does not exist in the historical command tree or SDK/API.** The drained wallet and its transaction history remain; no delete command is fabricated. A repeated run should use a fresh tag, rather than repeat amounts against the same wallet. + +## Workflow: historical YAML file, run, trigger, cleanup + +```sh +cat > "$QA_DIR/workflow.yaml" < "$QA_DIR/policy.json" +policy_json="$(fctlqa legacy reconciliation policies create "$QA_DIR/policy.json" --confirm)" +policy_id="$(printf '%s' "$policy_json" | jq -er '.data.id')" +fctlqa legacy reconciliation policies get "$policy_id" +at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +reconciliation_json="$(fctlqa legacy reconciliation policies reconcile "$policy_id" "$at" "$at")" +reconciliation_id="$(printf '%s' "$reconciliation_json" | jq -er '.data.id')" +fctlqa legacy reconciliation get "$reconciliation_id" +``` + +The rule fixture can run independently of DUMMYPAY. It uses two equal queries with opposite signs and an explicit USD tolerance. `ledger_invariant` is a backend template; its spec is forwarded intact. The synthetic account filter confines the rule to the test QA ledger. + +```sh +jq -n --arg name "$QA_TAG" --arg ledger "$QA_LEDGER" --arg address "$QA_ACCOUNT" \ + '{name:$name,templateKind:"ledger_invariant",enabled:true,severity:"info",cadence:"continuous",periodType:"continuous",schedule:{kind:"on_demand"},labels:{fixture:$name},templateSpec:{terms:[{ledger:$ledger,query:{"$match":{address:$address}},sign:1},{ledger:$ledger,query:{"$match":{address:$address}},sign:-1}],tolerance:{"USD/2":0}}}' \ + > "$QA_DIR/rule.json" +rule_json="$(fctlqa legacy reconciliation rules create "$QA_DIR/rule.json" --confirm)" +rule_id="$(printf '%s' "$rule_json" | jq -er '.data.id')" +fctlqa legacy reconciliation rules get "$rule_id" +evaluation_json="$(fctlqa legacy reconciliation rules evaluate "$rule_id" --safety-margin 1s --confirm)" +evaluation_id="$(printf '%s' "$evaluation_json" | jq -er '.data.id')" +fctlqa legacy reconciliation evaluations get "$evaluation_id" +printf '%s\n' '{"enabled":false}' > "$QA_DIR/rule-patch.json" +fctlqa legacy reconciliation rules update "$rule_id" "$QA_DIR/rule-patch.json" --confirm +fctlqa legacy reconciliation rules delete "$rule_id" --confirm +``` + +Rule deletion also deletes its evaluations and alerts. If the policy fixture was created, delete it before the pool: + +```sh +fctlqa legacy reconciliation policies delete "$policy_id" --confirm +fctlqa legacy payments pools remove-account "$pool_id" "$account_id" --confirm +fctlqa legacy payments pools delete "$pool_id" --confirm +fctlqa legacy payments connectors uninstall --connector-id "$connector_id" --confirm +``` + +Connector uninstall may complete asynchronously. If its response carries a task ID, check it with `fctlqa legacy payments tasks get TASK_ID` before declaring cleanup complete. There is no independent account delete in the historical CLI. + +## Webhook: non-routable URL, create/delete + +Capture the create response without printing it because it can contain a generated signing secret. The reserved `.invalid` URL has no real recipient; do not run `webhooks test` or replay commands for this fixture. + +```sh +webhook_json="$(fctlqa legacy webhooks create https://fctl-legacy-qa.invalid/webhook fctl.legacy.synthetic --confirm)" +webhook_id="$(printf '%s' "$webhook_json" | jq -er '.data.id')" +fctlqa legacy webhooks delete "$webhook_id" --confirm +``` + +## Historical underscore compatibility + +| Historical caller path | Canonical plugin path | +| --- | --- | +| `payments bank_accounts ...` | `payments bank-accounts ...` | +| `payments transfer_initiation ...` | `payments transfer-initiation ...` | +| `payments transfer_initiation update_status ...` | `payments transfer-initiation update-status ...` | +| `payments payment_initiations ...` | `payments transfer-initiation ...` | + +The first three are present in the pinned main command tree. `payment_initiations` is additional compatibility explicitly supported by the compatibility layer. No other canonical underscore names were found in the five historical module trees. Aliases are declared in the module manifests. The ambiguous historical `p` remains Payments' payments-group alias, `cr` remains wallet-create's alias, `r` remains transfer-retry's alias; `holds h` is unavailable because the host reserves `h` for help. diff --git a/misc/fctl-plugin/docs/port-inventory.md b/misc/fctl-plugin/docs/port-inventory.md new file mode 100644 index 00000000..91ef549f --- /dev/null +++ b/misc/fctl-plugin/docs/port-inventory.md @@ -0,0 +1,127 @@ +# Historical service command inventory + +Source baseline: `origin/main` pinned at `e00243b3e2e56aae6a09d7010b0c17890134388c`. All five historical `cmd/` trees were inspected, including helper/controllers and aliases. Generated routes, request JSON names, enums, pagination, and responses were checked against cached `formance-sdk-go/v4@v4.0.0`, the version pinned by that source. The shipped code imports only the Go standard library and public `pluginsdk` / `pluginsdk/httpclient`. + +The public module root assembles the five services documented here, plus Auth and Ledger. Their implementations live in separate `internal/` packages. Each manifest uses bundle version `1.0.0` and a stack target. The host supplies HTTP clients, endpoints, authentication and YAML/file/stdin/HTTPS reads. The public legacy service facade applies compatibility guards before execution. These modules perform no file IO and depend on no Cobra, core, UI or auth packages. + +## Command surface + +There are **111 runnable canonical commands**: Payments 45, Orchestration 16, Reconciliation 23, Wallets 14, Webhooks 13. Every historical API command in these trees is represented; no unsupported command is stubbed. + +Paths below are relative to the host-provided service endpoint. Resource IDs are escaped with `httpclient.Path`. Braces indicate a resource argument or wallet target. `list` flags use `cursor` and `page-size` wherever historically supported. Cursor continuation omits first-page query/body parameters for Payments, Wallets and Reconciliation. + +### Orchestration — 16 + +| Command | Method and relative route | Input details | +| --- | --- | --- | +| `workflows list` | GET `/workflows` | No args | +| `workflows show` | GET `/workflows/{id}` | ID | +| `workflows create` | POST `/workflows` | YAML/JSON source or Body; nonempty object stages | +| `workflows run` | POST `/workflows/{id}/instances` | `variable` JSON/CSV pairs or Body; `wait` query | +| `workflows delete` | DELETE `/workflows/{id}` | Confirmation | +| `instances list` | GET `/instances` | `workflow` -> `workflowID`; `running` | +| `instances show` | GET `/instances/{id}`, GET `/workflows/{workflowID}` | Raw instance + associated workflow | +| `instances describe` | GET `/instances/{id}/history`, GET `/instances/{id}/stages/{index}/history` | Additional histories for send stages | +| `instances send-event` | POST `/instances/{id}/events` | Event argument -> `name` | +| `instances stop` | PUT `/instances/{id}/abort` | Confirmation | +| `triggers list` | GET `/triggers` | `name` filter | +| `triggers show` | GET `/triggers/{id}` | ID | +| `triggers create` | POST `/triggers` | Event/workflow arguments; `name`, `filter`, `vars` | +| `triggers delete` | DELETE `/triggers/{id}` | Confirmation | +| `triggers test` | POST `/v2/triggers/{id}/test` | Event JSON argument or Body | +| `triggers occurrences list` | GET `/triggers/{id}/occurrences` | Trigger ID | + +### Payments — 45 + +Every execution reads `/_info` using public `ServiceVersion`, accepting historical top-level/enveloped info and commit versions. V1 SDK operations retain their unversioned routes on Payments 3; the port does not blindly prefix every operation with `/v3`. + +| Group | Commands and routes | +| --- | --- | +| `accounts` (4) | `create` POST `/accounts`; `get` GET `/accounts/{id}`; `list` GET `/accounts`; `balances` GET `/accounts/{id}/balances` | +| `payments` (4) | `create` POST `/payments`; `get` GET `/payments/{id}`; `list` GET `/payments`; `set-metadata` PATCH `/payments/{id}/metadata` with flat string metadata object | +| `bank-accounts` (5) | `create` POST, `list` GET `/bank-accounts`; `get` GET `/bank-accounts/{id}`; `forward` POST `/bank-accounts/{id}/forward` with `connectorID`; `update-metadata` PATCH `/bank-accounts/{id}/metadata` with metadata envelope; all select `/v3` on Payments 3 | +| `pools` (9) | `create` POST `/pools` (V3 on 3); `get` GET `/pools/{id}`; `list` GET `/pools`; `delete` DELETE `/pools/{id}`; `latest-balances` GET `/pools/{id}/balances/latest` (V3 on 3); `balances` GET `/pools/{id}/balances?at=...`; `add-account` POST `/pools/{id}/accounts` with `accountID`; `remove-account` DELETE `/pools/{id}/accounts/{accountID}`; `update-query` PATCH `/v3/pools/{id}/query` (>=3.1) | +| `transfer-initiation` (9) | `create` POST `/transfer-initiations`; `get` GET `/transfer-initiations/{id}`; `list` GET `/transfer-initiations`; `delete` DELETE `/transfer-initiations/{id}`; `retry` POST `/transfer-initiations/{id}/retry`; `reverse` POST `/transfer-initiations/{id}/reverse`; `update-status` POST `/transfer-initiations/{id}/status` (unavailable on 3); `approve` / `reject` POST `/v3/payment-initiations/{id}/approve` or `/reject` (3 required) | +| `connectors` (9) | `list`, `list-available`, `install`, `get-config`, `update-config`, `uninstall`; `schedules list`, `schedules get`, `schedules instances list` | +| `orders` (2) | `list` GET `/v3/orders`; `get` GET `/v3/orders/{id}` (>=3.3) | +| `conversions` (2) | `list` GET `/v3/conversions`; `get` GET `/v3/conversions/{id}` (>=3.3) | +| `tasks` (1) | `get` GET `/v3/tasks/{id}` (3 required) | + +Connector V3 routes: GET `/v3/connectors`, GET `/v3/connectors/configs`, POST `/v3/connectors/install/{provider}`, GET/PATCH `/v3/connectors/{id}/config`, DELETE `/v3/connectors/{id}`. Install/update resolve provider casing via configs, override its payload field without losing numeric precision, and retain the historical requested-name fallback for unlisted development providers. Backend errors propagate. Schedules use `/v3/connectors/{id}/schedules`, `/{scheduleID}`, `/{scheduleID}/instances`. + +Connector legacy routes: POST `/connectors/{PROVIDER}`; POST `/connectors/{PROVIDER}/{id}/config`; GET/DELETE provider/ID routes, with the provider-only v0 config/uninstall variant; GET `/connectors/configs` and GET `/connectors`. `provider` and `connector-id` select targets; get-config discovers a unique connector when legacy inputs are incomplete. Legacy list exposes `id` alongside original `connectorID` for declarative choices. Typed legacy provider names are mapped explicitly; V3 remains dynamic. + +Orders/conversions filters use a GET JSON `$and` of `$match` clauses with snake_case field names: `connector-id`, `reference`, `status`, `source-asset`, `destination-asset`; orders also expose `direction` and `type`. Account/payment amounts retain arbitrary precision; required request fields, timestamps, enum values, metadata values and pool version requirements are validated explicitly. All historical confirmed operations retain confirmations; all deletes require them. + +### Reconciliation — 23 + +| Commands | Method and relative route | Input details | +| --- | --- | --- | +| `list`, `get` | GET `/reconciliations`, `/reconciliations/{id}` | Pagination / ID | +| `policies list`, `policies get` | GET `/policies`, `/policies/{id}` | Pagination / ID | +| `policies create`, `policies delete` | POST `/policies`, DELETE `/policies/{id}` | Required name, ledgerName, paymentsPoolID, ledgerQuery; confirmations | +| `policies reconcile` | POST `/policies/{id}/reconciliation` | RFC3339 ledger/payments timestamps | +| `rules list`, `rules get` | GET `/rules`, `/rules/{id}` | Query-builder JSON GET body on first page / ID | +| `rules create`, `rules update`, `rules delete` | POST `/rules`, PATCH/DELETE `/rules/{id}` | Object JSON create/patch preserves unknown template fields and explicit false; confirmations | +| `rules evaluate` | POST `/rules/{id}/evaluate` | `at`, nonnegative `safety-margin`, per-source `source-pit`; confirmation | +| `evaluations list`, `evaluations get` | GET `/evaluations`, `/evaluations/{id}` | Query-builder JSON / ID | +| `alerts list`, `alerts get`, `alerts events` | GET `/alerts`, `/alerts/{id}`, `/alerts/{id}/events` | Query-builder JSON / ID / pagination | +| `alerts ack`, `resolve`, `accept`, `snooze`, `unsnooze` | POST `/alerts/{id}/{transition}` | Required `by`; `note` required for accept; `transaction-ref` for resolve; future `until` for snooze; confirmations | + +Clarity template payloads stay open object schemas, matching the historical raw API client. Backend validation determines supported template kinds/specs. There is no fabricated delete for an evaluation or alert. + +### Wallets — 14 + +| Commands | Method and relative route | Input details | +| --- | --- | --- | +| `create`, `update`, `list`, `show` | POST `/wallets`, PATCH `/wallets/{id}`, GET `/wallets`, GET `/wallets/{id}` | Name, metadata, `ik`; list pagination/name/metadata; show `id` or `name` | +| `credit`, `debit` | POST `/wallets/{id}/credit` or `/debit` | Arbitrary precision amount/asset; metadata, balance(s), `ik`; credit sources; debit destination/pending/description | +| `balances create`, `list`, `show` | POST/GET `/wallets/{id}/balances`, GET `/wallets/{id}/balances/{name}` | Expiry, integer priority; pagination; balance name | +| `holds list`, `show`, `confirm`, `void` | GET `/holds`, `/holds/{id}`; POST `/holds/{id}/confirm` or `/void` | Wallet/metadata filters; confirm amount/final/ik; void ik | +| `transactions list` | GET `/transactions` | Wallet filter, pagination | + +Wallet selection accepts `id` or exact `name`, resolves names through a bounded read and rejects zero/multiple matches. Subject forms are `account=ADDRESS`, `wallet=id:ID[/BALANCE]`, `wallet=name:NAME[/BALANCE]`. Metadata filters use deep-object keys. Writes retain their confirmation gates. **Wallet deletion is absent from the historical CLI and API and remains absent.** + +### Webhooks — 13 + +| Commands | Method and relative route | Input details | +| --- | --- | --- | +| `list`, `create`, `update`, `delete` | GET/POST `/configs`, PUT/DELETE `/configs/{id}` | List ID/endpoint; config endpoint/events/optional 24-byte base64 signing secret; confirmations for writes | +| `change-secret`, `activate`, `deactivate`, `test` | PUT `/configs/{id}/secret/change`, `/activate`, `/deactivate`; GET `/configs/{id}/test` | Secret optional; all confirm because test sends an event | +| `deliveries list`, `show`, `attempts` | GET `/deliveries`, `/deliveries/{id}`, `/deliveries/{id}/attempts` | Pagination, config/status/date filters | +| `deliveries replay`, `replay-bulk` | POST `/deliveries/{id}/replay`, `/deliveries/replay` | Required idempotency key and confirmation; bulk start/end, failed/pending status list, config IDs, cursor/page size | + +Update distinguishes omitted signing secret from explicitly empty secret. Replay validates its status/time bounds and preserves request idempotency headers. + +## File argument compatibility — 13 commands + +The generic payload helper declares `Files.ReadArgument` at the preexisting maximum positional index, sets YAML decoding (also accepts JSON), increments Args.Max and appends `[|-]` to Use. Host-provided Body/form/`--data` alternatives remain available. The module callback consumes Body and performs no file access. + +- Orchestration: `workflows create`. +- Payments: `accounts create`, `bank-accounts create`, `payments create`, `pools create`, `pools update-query`, `transfer-initiation create`, `transfer-initiation reverse`, `connectors install`, `connectors update-config`. +- Reconciliation: `policies create`, `rules create`, `rules update`. + +These are exactly the `|-` signatures found in the five pinned trees. Query flags, workflow variable bodies, and trigger event JSON arguments are not annotated as historical files. + +## Alias compatibility and limitations + +Canonical underscore names become `bank-accounts`, `transfer-initiation`, `update-status`. Their historical `bank_accounts`, `transfer_initiation`, `update_status` names remain declared aliases. The additional `payment_initiations` compatibility alias also resolves to `transfer-initiation`. Other unambiguous historical aliases are retained. Conflicting `p` belongs to payments, `cr` to wallet create, `r` to transfer retry. The historical `holds h` command alias is retained; the distinct `-h` flag continues to show help. + +No historical API commands are outstanding. Unsupported backend operations return API errors; guards explain version-specific commands. File IO and CLI rendering moved to the host. Old pterm views are replaced by raw JSON responses. The accidentally accepted, unused third wallet-debit argument is rejected rather than silently ignored. + +## Verification + +From the repository root, run `nix develop --impure --command just pre-commit` +and `nix develop --impure --command just tests`. These include the core, public +SDK and independent legacy module. From `misc/fctl-plugin`, run +`go test -race ./internal/...` to check service implementations and shared tools. + +HTTP contract tests cover reads/writes/deletes, path escaping, exact +query/body/header names, version/provider routing, precision above 2^53, API +errors/partial responses, dependent reads before writes, invalid input without +HTTP mutation, cancellation, manifest independence, underscore aliases and +every historical file-source signature. + +See [synthetic sandbox recipes](live-fixtures.md) for manual examples. The +repository's `docs/legacy-validation.md` records verified Cloud results and +remaining runtime limits. diff --git a/misc/fctl-plugin/docs/streaming-logs.md b/misc/fctl-plugin/docs/streaming-logs.md new file mode 100644 index 00000000..44af1cd2 --- /dev/null +++ b/misc/fctl-plugin/docs/streaming-logs.md @@ -0,0 +1,91 @@ +# Streaming legacy Ledger logs + +`legacy.New` and `legacy.NewService` return plugins that implement +`pluginsdk.StreamingPlugin` as well as the original unary `Plugin`. Protocol +version remains 1. Only Ledger import/export declare streams. Their existing +`FileSpec` remains unchanged: import reads a raw string source from argument 1 +or `--file`; export writes NDJSON through `--file` or stdout. Historical aliases +and other command manifests remain unchanged. + +The host uses `ExecuteStream(ctx, request, streams)` for those commands and +provides an `io.Reader` for import or an `io.Writer` for export. Do not supply +`request.Body` with a stream. The host owns and closes these resources. The +plugin never opens paths from arguments or flags. The same service/Stack version +guards run before stream IO and before mutation. Input is read on demand. + +## Import + +The source contains one JSON log object per line, with a nonnegative integer +`id`. The final line may lack a newline. Blank or malformed records fail. Raw +JSON preserves arbitrary integer precision and unknown fields. Import compacts +each object and appends a newline for the historical octet-stream endpoint. +Each batch contains at most 100 logs and at most 4 MiB, including newlines. A +single source record is limited to 4 MiB including its existing delimiter; its +compacted object plus the added delimiter must fit the batch limit. Oversized +records fail explicitly. Memory use is bounded by a batch, a record and their +encoding/validation buffers, independently of total source size. + +Progress events are absolute counters: + +| Phase | Unit | Completed | Total | +| --- | --- | --- | --- | +| `read` | `bytes` | Source bytes read, including whitespace/newlines and bounded read-ahead | Optional `InputSize`, including an explicit zero for an empty input | +| `import` | `logs` | Logs in successful, acknowledged batches | Absent | +| `export` | `logs` | Complete validated records written to the host writer | Absent | + +Bytes read do not imply successful import. A final import response contains +`{"imported":N,"skipped":N,"batches":N}`. A response also accompanies a failure; +imported/batch counts include only acknowledged successful batches. A structured +service failure adds its raw `error` object. Read, cancellation and reporter +errors stop execution. A pending batch is discarded when input validation fails. + +`--resume-from-last-log` fetches the latest backend log via +`GET /v2/NAME/logs?pageSize=1`. The plugin scans until it finds that exact integer +ID, skips through it, then begins writing. It does not compare IDs through +floating point or infer a checkpoint from ordering. An empty backend imports +from the beginning. A missing/invalid checkpoint makes no import writes; +`skipped` remains zero until the checkpoint has been found. + +No batch is automatically retried. A lost acknowledgment can mean the backend +already accepted it. Stop, inspect backend state, and start a new invocation +with resume when appropriate. Concurrent import invocations are not coordinated +by this plugin; backend log/hash validation remains authoritative. + +## Export + +The plugin consumes the HTTP response incrementally. It validates each JSON log +record and its integer ID before writing that record. Records are limited to +4 MiB including an existing newline. It preserves backend bytes exactly, +including spacing, unknown fields, huge numbers, CRLF and a final missing +newline. It never creates a complete JSON array or buffers the full response. +The final response is only `{"exported":N,"bytes":N}` and is separate from the +NDJSON output. The host must bypass table/JSON rendering for raw output. + +A malformed late record, backend read/close failure, output error, cancellation +or reporter error fails execution even after a valid prefix has been written. +The host must stage a file export and replace the destination only after full +success, so a failed export preserves an existing destination. A stdout caller +may already have received a valid prefix when a later record fails. Direct SDK +consumers must provide their own staging if they need atomic file replacement. +Arbitrary readers/writers must cooperate with cancellation to interrupt blocked +IO; the plugin checks context between reads, writes, reports and requests. + +## Unary compatibility and verification + +Existing direct SDK callers may continue using `Execute`. Import accepts a +JSON array or a JSON string of NDJSON within the 4 MiB Body limit and validates +all logs before writing. Unary export remains bounded at 32 MiB and returns a +JSON array. New hosts should prefer `ExecuteStream` for both commands. + +Run from this module: + +```sh +go test ./internal/ledger . -run '^TestStream' +go test -race ./... +``` + +Tests generate an import exceeding 64 MiB from one reusable record and stream +an export exceeding 64 MiB into a temporary file. They cover early output before +EOF, both batch bounds, exact integers, resume without checkpoint writes, late +malformed records, failed-batch non-replay, read/write/report/cancellation errors, +zero totals, unchanged file metadata and compatibility checks before stream IO. diff --git a/misc/fctl-plugin/go.mod b/misc/fctl-plugin/go.mod new file mode 100644 index 00000000..31be006b --- /dev/null +++ b/misc/fctl-plugin/go.mod @@ -0,0 +1,26 @@ +module github.com/formancehq/fctl/misc/fctl-plugin + +go 1.26.0 + +toolchain go1.26.1 + +require github.com/formancehq/fctl/pkg/pluginsdk v0.0.0-20261012095244-0f003a93e374 + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.8.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.57.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-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) + +replace github.com/formancehq/fctl/pkg/pluginsdk => ../../pkg/pluginsdk diff --git a/misc/fctl-plugin/go.sum b/misc/fctl-plugin/go.sum new file mode 100644 index 00000000..e4c8e0e9 --- /dev/null +++ b/misc/fctl-plugin/go.sum @@ -0,0 +1,56 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +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/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/misc/fctl-plugin/internal/auth/aliases.go b/misc/fctl-plugin/internal/auth/aliases.go new file mode 100644 index 00000000..fb0c8e51 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/aliases.go @@ -0,0 +1,24 @@ +package auth + +import ( + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var historicalAliases = map[string][]string{ + "auth clients": {"client", "c"}, "auth clients create": {"c"}, "auth clients delete": {"d", "del"}, "auth clients list": {"ls", "l"}, "auth clients show": {"s"}, "auth clients update": {"u", "upd"}, + "auth clients secrets": {"sec"}, "auth clients secrets create": {"c"}, "auth clients secrets delete": {"d"}, "auth users": {"u", "user"}, "auth users list": {"ls", "l"}, "auth users show": {"s"}, "auth clients users list": {"ls", "l"}, +} + +func annotateAliases(manifest pluginsdk.Manifest) pluginsdk.Manifest { + attachAliases(&manifest.Root, nil) + return manifest +} +func attachAliases(command *pluginsdk.CommandSpec, path []string) { + path = append(append([]string{}, path...), pluginsdk.CommandName(*command)) + command.Aliases = append([]string{}, historicalAliases[strings.Join(path, " ")]...) + for i := range command.Subcommands { + attachAliases(&command.Subcommands[i], path) + } +} diff --git a/misc/fctl-plugin/internal/auth/clientpayload.go b/misc/fctl-plugin/internal/auth/clientpayload.go new file mode 100644 index 00000000..2932a18d --- /dev/null +++ b/misc/fctl-plugin/internal/auth/clientpayload.go @@ -0,0 +1,203 @@ +package auth + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var clientFieldFlags = map[string]string{ + "name": "name", "description": "description", "public": "public", "trusted": "trusted", + "redirect-uri": "redirectUris", "post-logout-redirect-uri": "postLogoutRedirectUris", "client-scopes": "scopes", +} + +func bodyObject(req pluginsdk.ExecuteRequest) (map[string]json.RawMessage, error) { + if req.Flags["data"] != "" && req.Body == nil { + return nil, fmt.Errorf("--data must be decoded into Body by the host") + } + fields := make(map[string]json.RawMessage) + if req.Body != nil { + if err := json.Unmarshal(req.Body, &fields); err != nil || fields == nil { + return nil, fmt.Errorf("--data must be a JSON object") + } + } + return fields, nil +} + +func clientFields(req pluginsdk.ExecuteRequest, create bool) (json.RawMessage, error) { + fields, err := bodyObject(req) + if err != nil { + return nil, err + } + if err := applyClientFlags(fields, req); err != nil { + return nil, err + } + if create { + if err := initializeClientFields(fields, req.Args); err != nil { + return nil, err + } + } + if err := validateFields(fields, create, false); err != nil { + return nil, err + } + if !create && len(fields) == 0 { + return nil, fmt.Errorf("provide at least one client option using --data or an explicit update flag") + } + return json.Marshal(fields) +} + +func applyClientFlags(fields map[string]json.RawMessage, req pluginsdk.ExecuteRequest) error { + for flag, field := range clientFieldFlags { + if !req.ChangedFlags[flag] { + continue + } + if _, exists := fields[field]; exists { + return fmt.Errorf("provide %s using either --data or --%s", field, flag) + } + value, err := encodeFlag(flag, req.Flags[flag]) + if err != nil { + return err + } + fields[field] = value + } + return nil +} + +func initializeClientFields(fields map[string]json.RawMessage, args []string) error { + if len(args) > 0 { + if err := setName(fields, args[0]); err != nil { + return err + } + } + for _, field := range []string{"public", "trusted"} { + if _, exists := fields[field]; !exists { + fields[field] = json.RawMessage("false") + } + } + return nil +} + +func applyName(fields map[string]json.RawMessage, req pluginsdk.ExecuteRequest, index int) error { + if req.ChangedFlags["name"] { + if err := setName(fields, req.Flags["name"]); err != nil { + return err + } + } + if len(req.Args) > index { + return setName(fields, req.Args[index]) + } + return nil +} + +func setName(fields map[string]json.RawMessage, name string) error { + if _, exists := fields["name"]; exists { + return fmt.Errorf("provide name using only one of the positional argument, --name or --data") + } + value, err := json.Marshal(name) + if err != nil { + return err + } + fields["name"] = value + return nil +} + +func encodeFlag(flag, value string) (json.RawMessage, error) { + switch flag { + case "public", "trusted": + return json.RawMessage(value), nil // NormalizeRequest validated the boolean. + case "redirect-uri", "post-logout-redirect-uri", "client-scopes": + values := []string{} + if value != "" { + records, err := csv.NewReader(strings.NewReader(value)).ReadAll() + if err != nil || len(records) != 1 { + return nil, fmt.Errorf("--%s must contain one valid CSV record", flag) + } + values = records[0] + } + return json.Marshal(values) + default: + return json.Marshal(value) + } +} + +func validateFields(fields map[string]json.RawMessage, requireName, secret bool) error { + if requireName { + if _, exists := fields["name"]; !exists { + return fmt.Errorf("name is required in the positional argument, --name or --data") + } + } + for field, raw := range fields { + if secret && field != "name" && field != "metadata" { + return fmt.Errorf("unsupported secret option %q", field) + } + if err := validateField(field, raw); err != nil { + return err + } + } + return nil +} + +func validateField(field string, raw json.RawMessage) error { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("%s must not be null", field) + } + switch field { + case "name", "description": + return validateStringField(field, raw) + case "public", "trusted": + var value bool + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be a boolean", field) + } + return nil + case "redirectUris", "postLogoutRedirectUris", "scopes": + return validateStringArray(field, raw) + case "metadata": + return validateMetadata(raw) + default: + return fmt.Errorf("unsupported client option %q", field) + } +} + +func validateStringField(field string, raw json.RawMessage) error { + var value string + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be a string", field) + } + if field == "name" && strings.TrimSpace(value) == "" { + return fmt.Errorf("name must not be empty") + } + return nil +} + +func validateStringArray(field string, raw json.RawMessage) error { + var values []json.RawMessage + if json.Unmarshal(raw, &values) != nil { + return fmt.Errorf("%s must be an array of strings", field) + } + for _, value := range values { + var text string + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) || json.Unmarshal(value, &text) != nil { + return fmt.Errorf("%s must be an array of strings", field) + } + } + return nil +} + +func validateMetadata(raw json.RawMessage) error { + var values map[string]json.RawMessage + if json.Unmarshal(raw, &values) != nil || values == nil { + return fmt.Errorf("metadata must be an object of strings") + } + for _, value := range values { + var text string + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) || json.Unmarshal(value, &text) != nil { + return fmt.Errorf("metadata must be an object of strings") + } + } + return nil +} diff --git a/misc/fctl-plugin/internal/auth/clientpayload_test.go b/misc/fctl-plugin/internal/auth/clientpayload_test.go new file mode 100644 index 00000000..f15f17f0 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/clientpayload_test.go @@ -0,0 +1,28 @@ +package auth_test + +import ( + "encoding/json" + "net/http" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" +) + +func TestPayloadTypes(t *testing.T) { + for _, field := range []string{ + `"name":null`, `"name":9`, `"name":" "`, `"description":null`, `"description":false`, + `"public":"false"`, `"trusted":null`, `"redirectUris":{}`, `"redirectUris":[null]`, + `"scopes":[9]`, `"postLogoutRedirectUris":null`, `"metadata":[]`, `"metadata":{"n":9007199254740993}`, `"metadata":{"n":null}`, + } { + t.Run(field, func(t *testing.T) { + var calls atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); w.WriteHeader(500) }) + req := approve(request("auth clients update", "client")) + req.Body, req.Endpoint = json.RawMessage("{"+field+"}"), server.URL + if _, err := auth.New(server.Client()).Execute(t.Context(), req); err == nil || calls.Load() != 0 { + t.Fatalf("invalid payload was accepted: error = %v; calls = %d", err, calls.Load()) + } + }) + } +} diff --git a/misc/fctl-plugin/internal/auth/clients.go b/misc/fctl-plugin/internal/auth/clients.go new file mode 100644 index 00000000..dfb357d0 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/clients.go @@ -0,0 +1,84 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func executeClients(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest) (json.RawMessage, error) { + action := req.CommandPath[2] + if action == "secrets" { + return executeSecrets(ctx, client, req) + } + path := httpclient.Path("clients") + if action != "create" && action != "list" { + if err := validID(req.Args[0]); err != nil { + return nil, err + } + path = httpclient.Path("clients", req.Args[0]) + } + switch action { + case "list", "show": + return client.Do(ctx, http.MethodGet, path, nil, nil, nil) + case "delete": + return client.Do(ctx, http.MethodDelete, path, nil, nil, nil) + case "create": + body, err := clientFields(req, true) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodPost, path, nil, body, nil) + case "update": + return updateClient(ctx, client, path, req) + default: + return nil, fmt.Errorf("unsupported Auth command") + } +} + +func updateClient(ctx context.Context, client *httpclient.Client, path string, req pluginsdk.ExecuteRequest) (json.RawMessage, error) { + patch, err := clientFields(req, false) + if err != nil { + return nil, err + } + data, err := client.Do(ctx, http.MethodGet, path, nil, nil, nil) + if err != nil { + return nil, err + } + var current struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, ¤t); err != nil || current.Data == nil { + return nil, fmt.Errorf("read client response must contain a data object") + } + // PUT replaces the options. Retain writable fields only, excluding secrets + // and the server's ID; never replace the name with the positional client ID. + fields := make(map[string]json.RawMessage) + for _, field := range clientFieldFlags { + if value, exists := current.Data[field]; exists && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + fields[field] = value + } + } + if value, exists := current.Data["metadata"]; exists && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + fields["metadata"] = value + } + var changes map[string]json.RawMessage + if err := json.Unmarshal(patch, &changes); err != nil { + return nil, err + } + maps.Copy(fields, changes) + if err := validateFields(fields, true, false); err != nil { + return nil, fmt.Errorf("invalid client options after update: %w", err) + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodPut, path, nil, body, nil) +} diff --git a/misc/fctl-plugin/internal/auth/clients_test.go b/misc/fctl-plugin/internal/auth/clients_test.go new file mode 100644 index 00000000..71a4f4ae --- /dev/null +++ b/misc/fctl-plugin/internal/auth/clients_test.go @@ -0,0 +1,91 @@ +package auth_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" +) + +func TestUpdatePreservesOmittedOptions(t *testing.T) { + const current = `{"data":{"id":"client-id","name":"Original","description":"Keep me","public":true,"trusted":true,"redirectUris":["https://example.com"],"postLogoutRedirectUris":["https://example.com/logout"],"scopes":["auth:read"],"metadata":{"id":"9007199254740993"},"secrets":[{"id":"secret","clear":"never-send"}],"serverOnly":9007199254740993}}` + for _, tc := range []struct { + name string + flags map[string]string + changed map[string]bool + body string + changes string + }{ + {name: "omitted host defaults", flags: map[string]string{"description": "Changed", "public": "false", "trusted": "false", "client-scopes": "", "redirect-uri": "", "post-logout-redirect-uri": "", "name": ""}, changed: map[string]bool{"description": true}, changes: `{"description":"Changed"}`}, + {name: "explicit false", flags: map[string]string{"public": "false", "trusted": "false"}, changed: map[string]bool{"public": true, "trusted": true}, changes: `{"public":false,"trusted":false}`}, + {name: "direct SDK false", flags: map[string]string{"public": "false"}, changes: `{"public":false}`}, + {name: "explicit empty", flags: map[string]string{"description": "", "client-scopes": "", "redirect-uri": "", "post-logout-redirect-uri": ""}, changed: map[string]bool{"description": true, "client-scopes": true, "redirect-uri": true, "post-logout-redirect-uri": true}, changes: `{"description":"","scopes":[],"redirectUris":[],"postLogoutRedirectUris":[]}`}, + {name: "direct empty", flags: map[string]string{"description": ""}, changes: `{"description":""}`}, + {name: "rename flag", flags: map[string]string{"name": "Renamed"}, changes: `{"name":"Renamed"}`}, + {name: "false string is a value", flags: map[string]string{"description": "false"}, changed: map[string]bool{}, changes: `{"description":"false"}`}, + {name: "CSV update", flags: map[string]string{"client-scopes": "auth:write,ledger:write"}, changes: `{"scopes":["auth:write","ledger:write"]}`}, + {name: "body patch", body: `{"public":false,"redirectUris":[],"metadata":{}}`, changes: `{"public":false,"redirectUris":[],"metadata":{}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + server, calls := updateFixture(t, current, tc.changes) + req := request("auth clients update", "client-id") + req.Flags, req.ChangedFlags, req.Endpoint = tc.flags, tc.changed, server.URL+"/api/auth" + if tc.body != "" { + req.Body = json.RawMessage(tc.body) + } + req = approve(req) + before := cloneRequest(req) + response, err := auth.New(server.Client()).Execute(t.Context(), req) + if err != nil || calls.Load() != 2 || string(response.Data) != `{"data":{"quantity":9007199254740993}}` { + t.Fatalf("result=%s; err=%v; calls=%d", response.Data, err, calls.Load()) + } + assertRequestUnchanged(t, req, before) + }) + } +} + +func TestCancellationDuringUpdateReadPreventsPut(t *testing.T) { + entered := make(chan struct{}) + var reads, writes atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes.Add(1) + w.WriteHeader(http.StatusInternalServerError) + return + } + reads.Add(1) + close(entered) + <-r.Context().Done() + }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + req := approve(request("auth clients update", "client")) + req.Flags["description"], req.Endpoint = "changed", server.URL + finished := make(chan error, 1) + go func() { + _, err := auth.New(server.Client()).Execute(ctx, req) + finished <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("update never started its read") + } + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context cancellation", err) + } + case <-time.After(5 * time.Second): + t.Fatal("update did not stop after cancellation") + } + if reads.Load() != 1 || writes.Load() != 0 { + t.Fatalf("reads = %d; writes = %d", reads.Load(), writes.Load()) + } +} diff --git a/misc/fctl-plugin/internal/auth/helpers_test.go b/misc/fctl-plugin/internal/auth/helpers_test.go new file mode 100644 index 00000000..7ce3525c --- /dev/null +++ b/misc/fctl-plugin/internal/auth/helpers_test.go @@ -0,0 +1,280 @@ +package auth_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" +) + +func request(path string, args ...string) pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{CommandPath: strings.Fields(path), Args: args} +} + +func approve(req pluginsdk.ExecuteRequest) pluginsdk.ExecuteRequest { + req.Flags = maps.Clone(req.Flags) + if req.Flags == nil { + req.Flags = make(map[string]string) + } + req.Flags["confirm"] = "true" + return req +} + +func object(t *testing.T, data []byte) map[string]json.RawMessage { + t.Helper() + var result map[string]json.RawMessage + if err := json.Unmarshal(data, &result); err != nil || result == nil { + t.Fatalf("expected JSON object, got %s: %v", data, err) + } + return result +} + +func assertJSON(t *testing.T, actual []byte, expected string) { + t.Helper() + if !json.Valid(actual) || !json.Valid([]byte(expected)) { + t.Fatalf("expected valid JSON values, got %s and %s", actual, expected) + } + var got, want any + actualDecoder := json.NewDecoder(bytes.NewReader(actual)) + actualDecoder.UseNumber() + if err := actualDecoder.Decode(&got); err != nil { + t.Fatal(err) + } + expectedDecoder := json.NewDecoder(strings.NewReader(expected)) + expectedDecoder.UseNumber() + if err := expectedDecoder.Decode(&want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("JSON = %s, want %s", actual, expected) + } +} + +func fixture(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} + +func readRequestBody(t *testing.T, r *http.Request) []byte { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + return body +} + +func writeResponse(t *testing.T, w http.ResponseWriter, body string) { + t.Helper() + if _, err := io.WriteString(w, body); err != nil { + t.Errorf("write response: %v", err) + } +} + +func assertRequestRoute(t *testing.T, r *http.Request, method, route string) { + t.Helper() + if r.Method != method || r.URL.EscapedPath() != route || r.URL.RawQuery != "" { + t.Errorf("request = %s %s, want %s %s without query", r.Method, r.URL, method, route) + } +} + +func assertRequestBody(t *testing.T, r *http.Request, want string) { + t.Helper() + body := readRequestBody(t, r) + if want == "" { + if len(body) != 0 { + t.Errorf("unexpected request body: %s", body) + } + return + } + assertJSON(t, body, want) + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q", r.Header.Get("Content-Type")) + } +} + +func historicalRouteFixture(t *testing.T, method, route, body, response string) (*httptest.Server, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assertRequestRoute(t, r, method, "/gateway/api/auth"+route) + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q", r.Header.Get("Accept")) + } + assertRequestBody(t, r, body) + writeResponse(t, w, response) + }) + return server, &calls +} + +func updateFixture(t *testing.T, current, changes string) (*httptest.Server, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + if call == 1 { + assertRequestRoute(t, r, http.MethodGet, "/api/auth/clients/client-id") + writeResponse(t, w, current) + return + } + assertRequestRoute(t, r, http.MethodPut, "/api/auth/clients/client-id") + if call != 2 { + t.Errorf("unexpected call %d", call) + } + assertUpdatedFields(t, readRequestBody(t, r), current, changes) + writeResponse(t, w, `{"data":{"quantity":9007199254740993}}`) + }) + return server, &calls +} + +func assertUpdatedFields(t *testing.T, body []byte, current, changes string) { + t.Helper() + fields := object(t, body) + want := object(t, object(t, []byte(current))["data"]) + delete(want, "id") + delete(want, "secrets") + delete(want, "serverOnly") + maps.Copy(want, object(t, []byte(changes))) + if len(fields) != len(want) { + t.Errorf("PUT fields = %s", body) + } + for key, value := range want { + assertJSON(t, fields[key], string(value)) + } +} + +func cloneRequest(req pluginsdk.ExecuteRequest) pluginsdk.ExecuteRequest { + req.Flags = maps.Clone(req.Flags) + req.ChangedFlags = maps.Clone(req.ChangedFlags) + req.Body = bytes.Clone(req.Body) + return req +} + +func assertRequestUnchanged(t *testing.T, actual, before pluginsdk.ExecuteRequest) { + t.Helper() + if !reflect.DeepEqual(actual.Flags, before.Flags) || !reflect.DeepEqual(actual.ChangedFlags, before.ChangedFlags) || !bytes.Equal(actual.Body, before.Body) { + t.Fatal("execution mutated the caller's request") + } +} + +func manifestCommandPaths(t *testing.T, manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, parent []string) []string { + t.Helper() + path := append(slices.Clone(parent), pluginsdk.CommandName(command)) + var paths []string + if command.Runnable { + paths = append(paths, strings.Join(path, " ")) + assertCommandContract(t, manifest, command, path) + } + for _, child := range command.Subcommands { + paths = append(paths, manifestCommandPaths(t, manifest, child, path)...) + } + return paths +} + +func assertCommandContract(t *testing.T, manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, path []string) { + t.Helper() + service, err := pluginsdk.CommandService(manifest, path) + if err != nil || service != "auth" { + t.Fatalf("service for %v = %q: %v", path, service, err) + } + for _, input := range command.Inputs { + assertInputContract(t, manifest, command, input, path) + } + if command.Confirm { + assertConfirmationRequired(t, manifest, command, path) + } +} + +func assertInputContract(t *testing.T, manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, input pluginsdk.InputSpec, path []string) { + t.Helper() + if input.Argument != nil && (*input.Argument < 0 || *input.Argument >= command.Args.Max) { + t.Fatalf("invalid argument input for %v", path) + } + if input.Source == nil { + return + } + read, err := pluginsdk.FindCommand(manifest, input.Source.CommandPath) + if err != nil || !read.Runnable || pluginsdk.CommandName(read) != "list" { + t.Fatalf("invalid selector source for %v: %v", path, err) + } +} + +func assertConfirmationRequired(t *testing.T, manifest pluginsdk.Manifest, command pluginsdk.CommandSpec, path []string) { + t.Helper() + req := request(strings.Join(path, " "), make([]string, command.Args.Min)...) + if _, err := pluginsdk.NormalizeRequest(manifest, req); err == nil { + t.Fatalf("unconfirmed mutation accepted: %v", path) + } + if _, err := pluginsdk.NormalizeRequest(manifest, approve(req)); err != nil { + t.Fatal(err) + } +} + +type failureCase struct { + name, path, response, message string + status int + args []string +} + +func runFailureCase(t *testing.T, tc failureCase) { + t.Helper() + var calls atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(tc.status) + writeResponse(t, w, tc.response) + }) + req := request(tc.path, tc.args...) + if !strings.HasSuffix(tc.path, "list") { + req = approve(req) + } + if strings.HasSuffix(tc.path, "update") { + req.Flags["description"] = "update" + } + req.Endpoint = server.URL + response, err := auth.New(server.Client()).Execute(t.Context(), req) + assertFailureResponse(t, response, err, tc) + if calls.Load() != 1 { + t.Fatalf("calls = %d; failed operations must not retry", calls.Load()) + } +} + +func assertFailureResponse(t *testing.T, response pluginsdk.ExecuteResponse, err error, tc failureCase) { + t.Helper() + if tc.message == "" { + if err != nil || string(response.Data) != "null" { + t.Fatalf("response = %s: %v", response.Data, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("error = %v, want %s", err, tc.message) + } + if tc.name == "structured failure" { + assertHTTPFailure(t, err, tc.response, tc.status) + } +} + +func assertHTTPFailure(t *testing.T, err error, body string, status int) { + t.Helper() + failure, ok := errors.AsType[*httpclient.Error](err) + if !ok || string(failure.Body) != body || failure.StatusCode != status { + t.Fatalf("structured error = %v", err) + } +} diff --git a/misc/fctl-plugin/internal/auth/manifest.go b/misc/fctl-plugin/internal/auth/manifest.go new file mode 100644 index 00000000..a36aac62 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/manifest.go @@ -0,0 +1,104 @@ +package auth + +import ( + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +func manifest() pluginsdk.Manifest { + return annotateAliases(pluginsdk.Manifest{ + Name: "auth", Version: metadata.Version, Service: "auth", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{ + Use: "auth", Short: "Manage the auth server", Service: "auth", Target: "stack", + Subcommands: []pluginsdk.CommandSpec{clientsCommand(), usersCommand()}, + }, + }) +} + +func clientsCommand() pluginsdk.CommandSpec { + create := leaf("create [name]", "Create an OAuth2 client", 0, 1) + create.Flags = clientFlags() + create.Inputs = []pluginsdk.InputSpec{nameInput("Client name", 0), flagInput("Description", "description"), boolInput("Public client", "public"), boolInput("Trusted client", "trusted"), flagInput("Redirect URIs (CSV)", "redirect-uri"), flagInput("Post logout redirect URIs (CSV)", "post-logout-redirect-uri"), flagInput("Client scopes (CSV)", "client-scopes")} + confirm(&create) + show := leaf("show ", "Show an OAuth2 client", 1, 1) + show.Inputs = []pluginsdk.InputSpec{clientInput()} + update := leaf("update ", "Update an OAuth2 client, preserving omitted options", 1, 1) + update.Flags = clientFlags() + // Optional fields must not acquire defaults in an update form. --data or + // explicit flags select changes; the selector only supplies the client ID. + update.Inputs = []pluginsdk.InputSpec{clientInput()} + confirm(&update) + remove := leaf("delete ", "Delete an OAuth2 client", 1, 1) + remove.Inputs = []pluginsdk.InputSpec{clientInput()} + confirm(&remove) + return pluginsdk.CommandSpec{ + Use: "clients", Short: "Manage Auth clients", + Subcommands: []pluginsdk.CommandSpec{create, leaf("list", "List OAuth2 clients", 0, 0), show, update, remove, secretsCommand(), usersCommand()}, + } +} + +func usersCommand() pluginsdk.CommandSpec { + show := leaf("show ", "Show an Auth user", 1, 1) + show.Inputs = []pluginsdk.InputSpec{{ + Title: "User", Kind: "select", Argument: new(0), Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "users", "list"}, ValueField: "id", LabelFields: []string{"email", "subject", "id"}, EmptyMessage: "No Auth users are available."}, + }} + return pluginsdk.CommandSpec{Use: "users", Short: "Manage Auth users", Subcommands: []pluginsdk.CommandSpec{leaf("list", "List Auth users", 0, 0), show}} +} + +func secretsCommand() pluginsdk.CommandSpec { + create := leaf("create [secret-name]", "Create an OAuth2 client secret", 1, 2) + create.Flags = []pluginsdk.FlagSpec{dataFlag(), {Name: "name", Type: "string", Usage: "Secret name"}} + create.Inputs = []pluginsdk.InputSpec{clientInput(), nameInput("Secret name", 1)} + confirm(&create) + remove := leaf("delete ", "Delete an OAuth2 client secret", 2, 2) + remove.Inputs = []pluginsdk.InputSpec{clientInput(), {Title: "Secret ID", Kind: "input", Argument: new(1), Required: true, Description: "Inspect auth clients show CLIENT_ID for secret IDs."}} + confirm(&remove) + return pluginsdk.CommandSpec{Use: "secrets", Short: "Manage client secrets", Subcommands: []pluginsdk.CommandSpec{create, remove}} +} + +func leaf(use, short string, minArgs, maxArgs int) pluginsdk.CommandSpec { + return pluginsdk.CommandSpec{Use: use, Short: short, Runnable: true, Args: pluginsdk.ArgsSpec{Min: minArgs, Max: maxArgs}} +} + +func dataFlag() pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: "data", Type: "string", Body: true, Usage: "JSON object, @file or - for stdin"} +} + +func clientFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{ + dataFlag(), + {Name: "name", Type: "string", Usage: "Client name"}, + {Name: "public", Type: "bool", Default: "false", Usage: "Mark the client as public"}, + {Name: "trusted", Type: "bool", Default: "false", Usage: "Mark the client as trusted"}, + {Name: "description", Type: "string", Usage: "Client description"}, + {Name: "redirect-uri", Type: "string", Usage: "Comma-separated redirect URIs; empty clears the list"}, + {Name: "post-logout-redirect-uri", Type: "string", Usage: "Comma-separated post logout redirect URIs; empty clears the list"}, + {Name: "client-scopes", Type: "string", Usage: "Comma-separated client scopes; empty clears the list"}, + } +} + +func confirm(command *pluginsdk.CommandSpec) { + command.Confirm = true + command.Flags = append(command.Flags, pluginsdk.FlagSpec{Name: "confirm", Type: "bool", Default: "false", Usage: "Confirm this mutation"}) +} + +func clientInput() pluginsdk.InputSpec { + return pluginsdk.InputSpec{ + Title: "OAuth2 client", Kind: "select", Argument: new(0), Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"auth", "clients", "list"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "No OAuth2 clients are available; create one with auth clients create."}, + } +} + +func nameInput(title string, index int) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", Flag: "name", Required: true, AlternativeArgument: new(index), AlternativeFlag: "data"} +} + +func flagInput(title, flag string) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", Flag: flag, AlternativeFlag: "data"} +} + +func boolInput(title, flag string) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "confirm", ValueType: "bool", Flag: flag, Default: "false", AlternativeFlag: "data"} +} diff --git a/misc/fctl-plugin/internal/auth/manifest_test.go b/misc/fctl-plugin/internal/auth/manifest_test.go new file mode 100644 index 00000000..857327d3 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/manifest_test.go @@ -0,0 +1,36 @@ +package auth_test + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" +) + +func TestManifestOfflineAndSDKContract(t *testing.T) { + manifest, err := auth.New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + var roundTrip pluginsdk.Manifest + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatal(err) + } + if roundTrip.Name != "auth" || roundTrip.Version != "1.0.0" || roundTrip.Service != "auth" || roundTrip.Root.Target != "stack" || roundTrip.Root.Service != "auth" { + t.Fatalf("manifest = %s", encoded) + } + wantPaths := []string{"auth clients create", "auth clients list", "auth clients show", "auth clients update", "auth clients delete", "auth clients secrets create", "auth clients secrets delete", "auth clients users list", "auth clients users show", "auth users list", "auth users show"} + gotPaths := manifestCommandPaths(t, roundTrip, roundTrip.Root, nil) + slices.Sort(gotPaths) + slices.Sort(wantPaths) + if !slices.Equal(gotPaths, wantPaths) { + t.Fatalf("paths = %v, want %v", gotPaths, wantPaths) + } +} diff --git a/misc/fctl-plugin/internal/auth/plugin.go b/misc/fctl-plugin/internal/auth/plugin.go new file mode 100644 index 00000000..5197e2b6 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/plugin.go @@ -0,0 +1,81 @@ +// Package auth implements the historical Auth commands through the public SDK. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type plugin struct{ client *http.Client } + +// New uses the host's HTTP client and service endpoint, including its Auth prefix. +// The manifest version identifies the legacy bundle, not the remote Auth service. +func New(client *http.Client) pluginsdk.Plugin { return &plugin{client: client} } + +func (*plugin) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.Manifest{}, err + } + return manifest(), nil +} + +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + // Remember explicit values before normalization adds defaults. The host's + // ChangedFlags distinguishes defaults from explicit false and empty values. + explicit := maps.Clone(req.Flags) + changed := maps.Clone(req.ChangedFlags) + req, err := pluginsdk.NormalizeRequest(manifest(), req) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + req.ChangedFlags = explicitFlags(explicit, changed) + action := req.CommandPath[len(req.CommandPath)-1] + if req.Body != nil && action != "create" && action != "update" { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("this Auth command does not accept a JSON body") + } + if p.client == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("auth plugin requires an HTTP client") + } + client, err := httpclient.New(req.Endpoint, p.client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + var data json.RawMessage + if req.CommandPath[1] == "users" || (len(req.CommandPath) > 2 && req.CommandPath[2] == "users") { + data, err = executeUsers(ctx, client, req) + } else { + data, err = executeClients(ctx, client, req) + } + return pluginsdk.ExecuteResponse{Data: data}, err +} + +func explicitFlags(flags map[string]string, changed map[string]bool) map[string]bool { + result := maps.Clone(changed) + if result == nil { + result = make(map[string]bool) + } + for name, value := range flags { + isBool := name == "public" || name == "trusted" || name == "confirm" + if changed == nil || (value != "" && (!isBool || value != "false")) { + result[name] = true + } + } + return result +} + +func validID(id string) error { + if strings.TrimSpace(id) == "" || id == "." || id == ".." { + return fmt.Errorf("identifier must be non-empty and cannot be a dot or double dot") + } + return nil +} diff --git a/misc/fctl-plugin/internal/auth/plugin_test.go b/misc/fctl-plugin/internal/auth/plugin_test.go new file mode 100644 index 00000000..26f695fb --- /dev/null +++ b/misc/fctl-plugin/internal/auth/plugin_test.go @@ -0,0 +1,177 @@ +package auth_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" +) + +func TestHistoricalRoutesAndRawResults(t *testing.T) { + // The historical aggregate SDK prefixes these routes with /api/auth. The + // host now supplies that service prefix through ExecuteRequest.Endpoint. + const rawResponse = `{"data":{"quantity":9007199254740993,"metadata":{"identifier":"9007199254740993"}}}` + for _, tc := range []struct { + path, method, route, body string + args []string + }{ + {path: "auth clients list", method: "GET", route: "/clients"}, + {path: "auth clients show", method: "GET", route: "/clients/client%2F100%25", args: []string{"client/100%"}}, + {path: "auth clients create", method: "POST", route: "/clients", args: []string{"Legacy App"}, body: `{"name":"Legacy App","public":false,"trusted":false}`}, + {path: "auth clients delete", method: "DELETE", route: "/clients/client%2F100%25", args: []string{"client/100%"}}, + {path: "auth clients secrets create", method: "POST", route: "/clients/client%2F100%25/secrets", args: []string{"client/100%", "production"}, body: `{"name":"production"}`}, + {path: "auth clients secrets delete", method: "DELETE", route: "/clients/client%2F100%25/secrets/secret%2F100%25", args: []string{"client/100%", "secret/100%"}}, + {path: "auth users list", method: "GET", route: "/users"}, + {path: "auth users show", method: "GET", route: "/users/user%2F100%25", args: []string{"user/100%"}}, + {path: "auth clients users list", method: "GET", route: "/users"}, + {path: "auth clients users show", method: "GET", route: "/users/user%2F100%25", args: []string{"user/100%"}}, + } { + t.Run(tc.path, func(t *testing.T) { + server, calls := historicalRouteFixture(t, tc.method, tc.route, tc.body, rawResponse) + req := request(tc.path, tc.args...) + if tc.method != "GET" { + req = approve(req) + } + req.Endpoint = server.URL + "/gateway/api/auth/" + response, err := auth.New(server.Client()).Execute(t.Context(), req) + if err != nil { + t.Fatal(err) + } + if string(response.Data) != rawResponse || calls.Load() != 1 { + t.Fatalf("result = %s; calls = %d", response.Data, calls.Load()) + } + }) + } +} + +func TestCreateInputs(t *testing.T) { + for _, tc := range []struct { + name, path, body, want string + args []string + flags map[string]string + }{ + {name: "legacy flags", path: "auth clients create", args: []string{"app"}, flags: map[string]string{ + "public": "true", "trusted": "true", "description": "false", "redirect-uri": `"https://example.com/callback?a=1,2",https://second.example/callback`, "post-logout-redirect-uri": "https://example.com/logout", "client-scopes": "auth:read,ledger:read", + }, want: `{"name":"app","description":"false","public":true,"trusted":true,"redirectUris":["https://example.com/callback?a=1,2","https://second.example/callback"],"postLogoutRedirectUris":["https://example.com/logout"],"scopes":["auth:read","ledger:read"]}`}, + {name: "form name flag", path: "auth clients create", flags: map[string]string{"name": "form-app"}, want: `{"name":"form-app","public":false,"trusted":false}`}, + {name: "host body", path: "auth clients create", body: `{"name":"json-app","public":true,"redirectUris":[],"metadata":{"id":"9007199254740993"}}`, flags: map[string]string{"data": "@body.json"}, want: `{"name":"json-app","public":true,"trusted":false,"redirectUris":[],"metadata":{"id":"9007199254740993"}}`}, + {name: "secret form", path: "auth clients secrets create", args: []string{"client"}, flags: map[string]string{"name": "form-secret"}, want: `{"name":"form-secret"}`}, + {name: "secret body", path: "auth clients secrets create", args: []string{"client"}, body: `{"name":"json-secret","metadata":{"owner":"test"}}`, want: `{"name":"json-secret","metadata":{"owner":"test"}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + server := fixture(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.RawQuery != "" { + t.Errorf("request = %s %s", r.Method, r.URL) + } + assertJSON(t, readRequestBody(t, r), tc.want) + writeResponse(t, w, `{"data":{"id":"created"}}`) + }) + req := request(tc.path, tc.args...) + req.Flags, req.Body, req.Endpoint = tc.flags, json.RawMessage(tc.body), server.URL + if tc.body == "" { + req.Body = nil + } + if _, err := auth.New(server.Client()).Execute(t.Context(), approve(req)); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDirectSDKValidationBeforeHTTP(t *testing.T) { + for _, tc := range []struct { + name, path, body, message string + args []string + flags map[string]string + changed map[string]bool + }{ + {name: "unknown command", path: "auth clients rotate", message: "unknown plugin command"}, + {name: "non executable root", path: "auth", message: "not executable"}, + {name: "missing ID", path: "auth clients show", message: "arguments"}, + {name: "extra argument", path: "auth users list", args: []string{"extra"}, message: "arguments"}, + {name: "unknown flag", path: "auth clients list", flags: map[string]string{"page-size": "100"}, message: "unknown plugin flag"}, + {name: "unknown changed flag", path: "auth clients list", changed: map[string]bool{"bogus": true}, message: "unknown plugin flag"}, + {name: "empty ID", path: "auth users show", args: []string{" "}, message: "identifier"}, + {name: "dot ID", path: "auth clients show", args: []string{"."}, message: "identifier"}, + {name: "double dot secret", path: "auth clients secrets delete", args: []string{"client", ".."}, flags: map[string]string{"confirm": "true"}, message: "identifier"}, + {name: "unconfirmed client delete", path: "auth clients delete", args: []string{"client"}, message: "requires --confirm"}, + {name: "refused secret delete", path: "auth clients secrets delete", args: []string{"client", "secret"}, flags: map[string]string{"confirm": "false"}, message: "requires --confirm"}, + {name: "invalid confirm", path: "auth clients delete", args: []string{"client"}, flags: map[string]string{"confirm": "yes"}, message: "invalid --confirm"}, + {name: "invalid bool", path: "auth clients create", args: []string{"app"}, flags: map[string]string{"confirm": "true", "public": "yes"}, message: "invalid --public"}, + {name: "missing name", path: "auth clients create", flags: map[string]string{"confirm": "true"}, message: "name is required"}, + {name: "blank name", path: "auth clients create", args: []string{" "}, flags: map[string]string{"confirm": "true"}, message: "name must not be empty"}, + {name: "duplicate name", path: "auth clients create", args: []string{"app"}, flags: map[string]string{"confirm": "true", "name": "other"}, message: "only one"}, + {name: "duplicate body field", path: "auth clients create", body: `{"name":"app","public":true}`, flags: map[string]string{"confirm": "true", "public": "false"}, message: "either --data or --public"}, + {name: "invalid CSV", path: "auth clients create", args: []string{"app"}, flags: map[string]string{"confirm": "true", "client-scopes": `"unterminated`}, message: "valid CSV"}, + {name: "multiple CSV records", path: "auth clients create", args: []string{"app"}, flags: map[string]string{"confirm": "true", "client-scopes": "one\ntwo"}, message: "valid CSV"}, + {name: "core OAuth scopes rejected", path: "auth clients create", args: []string{"app"}, flags: map[string]string{"confirm": "true", "scopes": "auth:read"}, message: "unknown plugin flag"}, + {name: "host body required", path: "auth clients create", flags: map[string]string{"confirm": "true", "data": "@file"}, message: "decoded into Body"}, + {name: "invalid JSON", path: "auth clients create", body: `{"name":`, flags: map[string]string{"confirm": "true"}, message: "valid JSON"}, + {name: "oversized JSON", path: "auth clients create", body: `{"name":"` + strings.Repeat("a", 4<<20) + `"}`, flags: map[string]string{"confirm": "true"}, message: "4 MiB"}, + {name: "unexpected read body", path: "auth clients list", body: `{}`, message: "does not accept"}, + {name: "unexpected delete body", path: "auth clients delete", args: []string{"client"}, body: `{}`, flags: map[string]string{"confirm": "true"}, message: "does not accept"}, + {name: "null body", path: "auth clients create", body: `null`, flags: map[string]string{"confirm": "true"}, message: "JSON object"}, + {name: "array body", path: "auth clients create", body: `[]`, flags: map[string]string{"confirm": "true"}, message: "JSON object"}, + {name: "empty update", path: "auth clients update", args: []string{"client"}, flags: map[string]string{"confirm": "true"}, message: "at least one"}, + {name: "missing secret name", path: "auth clients secrets create", args: []string{"client"}, flags: map[string]string{"confirm": "true"}, message: "name is required"}, + {name: "secret extra field", path: "auth clients secrets create", args: []string{"client"}, body: `{"name":"secret","public":true}`, flags: map[string]string{"confirm": "true"}, message: "unsupported secret option"}, + {name: "unknown payload field", path: "auth clients create", body: `{"name":"app","id":"cannot-set"}`, flags: map[string]string{"confirm": "true"}, message: "unsupported client option"}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + server := fixture(t, func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); w.WriteHeader(500) }) + req := request(tc.path, tc.args...) + req.Flags, req.ChangedFlags, req.Endpoint = tc.flags, tc.changed, server.URL + if tc.body != "" { + req.Body = json.RawMessage(tc.body) + } + _, err := auth.New(server.Client()).Execute(t.Context(), req) + if err == nil || !strings.Contains(err.Error(), tc.message) || calls.Load() != 0 { + t.Fatalf("error = %v; expected %q; calls = %d", err, tc.message, calls.Load()) + } + }) + } +} + +func TestFailures(t *testing.T) { + for _, tc := range []failureCase{ + {name: "empty delete", path: "auth clients delete", args: []string{"client"}, status: 204}, + {name: "structured failure", path: "auth clients delete", args: []string{"client"}, status: 409, response: `{"errorCode":"IN_USE","errorMessage":"client is in use","quantity":9007199254740993}`, message: "IN_USE"}, + {name: "malformed response", path: "auth users list", status: 200, response: "{", message: "invalid JSON"}, + {name: "read failure blocks update", path: "auth clients update", args: []string{"client"}, status: 404, response: `{"errorCode":"NOT_FOUND","errorMessage":"missing"}`, message: "NOT_FOUND"}, + {name: "invalid read envelope", path: "auth clients update", args: []string{"client"}, status: 200, response: `{"data":[]}`, message: "data object"}, + {name: "missing read envelope", path: "auth clients update", args: []string{"client"}, status: 200, response: `{}`, message: "data object"}, + {name: "invalid read options", path: "auth clients update", args: []string{"client"}, status: 200, response: `{"data":{"name":42}}`, message: "invalid client options"}, + } { + t.Run(tc.name, func(t *testing.T) { + runFailureCase(t, tc) + }) + } +} + +func TestCancellationBeforeExecution(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := auth.New(nil).GetManifest(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("manifest cancellation = %v", err) + } + if _, err := auth.New(nil).Execute(ctx, request("auth users list")); !errors.Is(err, context.Canceled) { + t.Fatalf("execution cancellation = %v", err) + } +} + +func TestInvalidClientAndEndpoint(t *testing.T) { + if _, err := auth.New(nil).Execute(t.Context(), request("auth users list")); err == nil || !strings.Contains(err.Error(), "HTTP client") { + t.Fatalf("nil client = %v", err) + } + req := request("auth users list") + req.Endpoint = "https://user:password@example.com/auth" + if _, err := auth.New(http.DefaultClient).Execute(t.Context(), req); err == nil || !strings.Contains(err.Error(), "endpoint") { + t.Fatalf("invalid endpoint = %v", err) + } +} diff --git a/misc/fctl-plugin/internal/auth/secrets.go b/misc/fctl-plugin/internal/auth/secrets.go new file mode 100644 index 00000000..24ee1d23 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/secrets.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func executeSecrets(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := validID(req.Args[0]); err != nil { + return nil, err + } + path := httpclient.Path("clients", req.Args[0], "secrets") + if req.CommandPath[3] == "delete" { + if err := validID(req.Args[1]); err != nil { + return nil, err + } + return client.Do(ctx, http.MethodDelete, httpclient.Path("clients", req.Args[0], "secrets", req.Args[1]), nil, nil, nil) + } + fields, err := bodyObject(req) + if err != nil { + return nil, err + } + if err := applyName(fields, req, 1); err != nil { + return nil, err + } + if err := validateFields(fields, true, true); err != nil { + return nil, err + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodPost, path, nil, body, nil) +} diff --git a/misc/fctl-plugin/internal/auth/users.go b/misc/fctl-plugin/internal/auth/users.go new file mode 100644 index 00000000..d4e94205 --- /dev/null +++ b/misc/fctl-plugin/internal/auth/users.go @@ -0,0 +1,21 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func executeUsers(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest) (json.RawMessage, error) { + path := httpclient.Path("users") + if req.CommandPath[len(req.CommandPath)-1] == "show" { + if err := validID(req.Args[0]); err != nil { + return nil, err + } + path = httpclient.Path("users", req.Args[0]) + } + return client.Do(ctx, http.MethodGet, path, nil, nil, nil) +} diff --git a/misc/fctl-plugin/internal/command/aliases.go b/misc/fctl-plugin/internal/command/aliases.go new file mode 100644 index 00000000..73b55d35 --- /dev/null +++ b/misc/fctl-plugin/internal/command/aliases.go @@ -0,0 +1,22 @@ +package command + +import ( + "slices" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var commonAliases = map[string][]string{"list": {"ls", "l"}, "get": {"sh", "s"}, "show": {"sh", "s"}, "create": {"cr", "c"}, "delete": {"del", "d"}, "update": {"up"}} + +func applyAliases(command *pluginsdk.CommandSpec, path []string, aliases map[string][]string) { + if values, exists := aliases[strings.Join(path, " ")]; exists { + command.Aliases = slices.Clone(values) + } else if command.Runnable { + command.Aliases = slices.Clone(commonAliases[pluginsdk.CommandName(*command)]) + } + for i := range command.Subcommands { + child := &command.Subcommands[i] + applyAliases(child, append(slices.Clone(path), pluginsdk.CommandName(*child)), aliases) + } +} diff --git a/misc/fctl-plugin/internal/command/command.go b/misc/fctl-plugin/internal/command/command.go new file mode 100644 index 00000000..5efd7d1b --- /dev/null +++ b/misc/fctl-plugin/internal/command/command.go @@ -0,0 +1,130 @@ +// Package command builds and executes declarative SDK commands without service-specific rules. +package command + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type Operation struct { + Command string + Spec pluginsdk.CommandSpec + Method string + Segments []string // $0, $1, ... bind arguments; @name binds a flag. + Query map[string]string // flag to wire name + Body func(pluginsdk.ExecuteRequest) (json.RawMessage, error) + ValidateRoute func(pluginsdk.ExecuteRequest, Operation) error + Run func(context.Context, *httpclient.Client, pluginsdk.ExecuteRequest, Operation, json.RawMessage) (json.RawMessage, error) +} + +type plugin struct { + client *http.Client + manifest pluginsdk.Manifest + operations map[string]Operation +} + +func New(name, version string, client *http.Client, operations []Operation, aliases map[string][]string) pluginsdk.Plugin { + p := &plugin{client: client, operations: make(map[string]Operation)} + p.manifest = pluginsdk.Manifest{Name: name, Version: version, Service: name, + ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{Use: name, Short: "Manage legacy " + name, Target: "stack"}} + for _, op := range operations { + p.operations[op.Command] = op + addCommand(&p.manifest.Root, strings.Fields(op.Command), op.Spec) + } + applyAliases(&p.manifest.Root, nil, aliases) + return p +} + +func addCommand(root *pluginsdk.CommandSpec, path []string, spec pluginsdk.CommandSpec) { + if len(path) == 1 { + root.Subcommands = append(root.Subcommands, spec) + return + } + for i := range root.Subcommands { + if pluginsdk.CommandName(root.Subcommands[i]) == path[0] { + addCommand(&root.Subcommands[i], path[1:], spec) + return + } + } + root.Subcommands = append(root.Subcommands, pluginsdk.CommandSpec{Use: path[0], Short: "Manage " + path[0]}) + addCommand(&root.Subcommands[len(root.Subcommands)-1], path[1:], spec) +} + +func (p *plugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { + // Return independent metadata to callers that modify their local copy. + data, err := json.Marshal(p.manifest) + if err != nil { + return pluginsdk.Manifest{}, err + } + var manifest pluginsdk.Manifest + err = json.Unmarshal(data, &manifest) + return manifest, err +} + +func (p *plugin) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + r, err := pluginsdk.NormalizeRequest(p.manifest, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + op := p.operations[strings.Join(r.CommandPath[1:], " ")] + body, err := prepareOperation(op, r) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if p.client == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("%s requires an injected HTTP client", p.manifest.Name) + } + client, err := httpclient.New(r.Endpoint, p.client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + var data json.RawMessage + if op.Run != nil { + data, err = op.Run(ctx, client, r, op, body) + } else { + data, err = Perform(ctx, client, r, op, body) + } + if err != nil { + var failure *httpclient.Error + if errors.As(err, &failure) && len(failure.Body) > 0 { + data = failure.Body + } + } + return pluginsdk.ExecuteResponse{Data: data}, err +} + +func prepareOperation(op Operation, r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil && !acceptsBody(op.Spec.Flags) { + return nil, fmt.Errorf("this command does not accept a JSON body") + } + if op.ValidateRoute != nil { + if err := op.ValidateRoute(r, op); err != nil { + return nil, err + } + } else if _, err := Route(op.Segments, r); err != nil { + return nil, err + } + if _, err := QueryValues(op.Query, r); err != nil { + return nil, err + } + if op.Body != nil { + return op.Body(r) + } + return nil, nil +} + +func acceptsBody(flags []pluginsdk.FlagSpec) bool { + for _, flag := range flags { + if flag.Body { + return true + } + } + return false +} diff --git a/misc/fctl-plugin/internal/command/command_test.go b/misc/fctl-plugin/internal/command/command_test.go new file mode 100644 index 00000000..c72e1347 --- /dev/null +++ b/misc/fctl-plugin/internal/command/command_test.go @@ -0,0 +1,93 @@ +package command_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func fixturePlugin(client *http.Client) pluginsdk.Plugin { + list := command.Leaf("list", "list", http.MethodGet, "/resources", 0, 0, command.PaginationFlags()...) + list.Query = map[string]string{"page-size": "pageSize"} + return command.New("fixture", "1.0.0", client, []command.Operation{ + list, + command.Confirmed(command.Leaf("delete", "delete ID", http.MethodDelete, "/resources/$0", 1, 1)), + }, nil) +} + +func TestHTTPFailuresAndNumbers(t *testing.T) { + t.Parallel() + for _, status := range []int{400, 404, 409, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + t.Parallel() + failure := `{"errorCode":"VALIDATION","errorMessage":"service rejected request","data":{"amount":9007199254740993}}` + server, verify := testutil.Fixture(t, []testutil.Exchange{{Method: "DELETE", Path: "/resources/id", Status: status, Response: failure}}) + defer verify() + p := fixturePlugin(server.Client()) + response, err := p.Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "delete"}, Args: []string{"id"}, Flags: map[string]string{"confirm": "true"}, Endpoint: server.URL + "/gateway/service"}) + var httpError *httpclient.Error + if !errors.As(err, &httpError) || httpError.StatusCode != status || httpError.Code != "VALIDATION" { + t.Fatalf("HTTP failure: %v", err) + } + if string(response.Data) != failure { + t.Errorf("lost partial response: %s", response.Data) + } + }) + } + server, verify := testutil.Fixture(t, []testutil.Exchange{{Method: "GET", Path: "/resources", Query: "pageSize=100", Response: `{"amount":9007199254740993}`}, {Method: "GET", Path: "/resources", Query: "pageSize=100", Response: `not json`}}) + defer verify() + p := fixturePlugin(server.Client()) + r := pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "list"}, Endpoint: server.URL + "/gateway/service"} + response, err := p.Execute(t.Context(), r) + if err != nil || string(response.Data) != `{"amount":9007199254740993}` { + t.Fatalf("raw response: %s, %v", response.Data, err) + } + if _, err := p.Execute(t.Context(), r); err == nil { + t.Fatal("accepted malformed service JSON") + } +} + +func TestCancellationPropagates(t *testing.T) { + t.Parallel() + server, verify := testutil.Fixture(t, nil) + defer verify() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := fixturePlugin(server.Client()).Execute(ctx, pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "list"}, Endpoint: server.URL + "/gateway/service"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } +} + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []struct { + name, want string + request pluginsdk.ExecuteRequest + }{ + {"route traversal", "dot segment", pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "delete"}, Args: []string{".."}, Flags: map[string]string{"confirm": "true"}}}, + {"missing confirmation", "confirm", pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "delete"}, Args: []string{"id"}}}, + {"unexpected body", "JSON body", pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "list"}, Body: []byte(`{}`)}}, + {"invalid pagination", "page-size", pluginsdk.ExecuteRequest{CommandPath: []string{"fixture", "list"}, Flags: map[string]string{"page-size": "0"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server, verify := testutil.Fixture(t, nil) + defer verify() + tc.request.Endpoint = server.URL + "/gateway/service" + if _, err := fixturePlugin(server.Client()).Execute(t.Context(), tc.request); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q, got %v", tc.want, err) + } + }) + } +} diff --git a/misc/fctl-plugin/internal/command/manifest.go b/misc/fctl-plugin/internal/command/manifest.go new file mode 100644 index 00000000..17fa5cea --- /dev/null +++ b/misc/fctl-plugin/internal/command/manifest.go @@ -0,0 +1,52 @@ +package command + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func StringFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "string", Usage: usage} +} +func BoolFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "bool", Default: "false", Usage: usage} +} +func PaginationFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{StringFlag("cursor", "Opaque pagination cursor"), {Name: "page-size", Type: "uint32", Default: "100", Usage: "Page size (1-1000)"}} +} +func DataFlag() pluginsdk.FlagSpec { + f := StringFlag("data", "JSON payload, @file or - (read by the host)") + f.Body = true + return f +} + +func Leaf(command, use, method, path string, minArgs, maxArgs int, flags ...pluginsdk.FlagSpec) Operation { + op := Operation{Command: command, Method: method, Segments: strings.Split(strings.Trim(path, "/"), "/"), + Spec: pluginsdk.CommandSpec{Use: use, Short: strings.ToUpper(command[:1]) + command[1:], Runnable: true, Args: pluginsdk.ArgsSpec{Min: minArgs, Max: maxArgs}, Flags: flags}} + for i := range minArgs { + op.Spec.Inputs = append(op.Spec.Inputs, pluginsdk.InputSpec{Title: fmt.Sprintf("%s argument %d", command, i+1), Kind: "text", Argument: new(i), Required: true}) + } + return op +} + +func Confirmed(op Operation) Operation { + op.Spec.Confirm = true + op.Spec.Flags = append(op.Spec.Flags, BoolFlag("confirm", "Confirm this operation")) + return op +} + +func Payload(op Operation, fields ...string) Operation { + // Historical file/stdin/URL input stays a host-owned boundary. Direct + // callers and forms can still supply Body without the optional source. + source := op.Spec.Args.Max + op.Spec.Files = &pluginsdk.FileSpec{ReadArgument: &source, ReadFormat: "yaml"} + op.Spec.Args.Max++ + op.Spec.Use += " [|-]" + op.Spec.Flags = append(op.Spec.Flags, DataFlag()) + op.Spec.Inputs = append(op.Spec.Inputs, pluginsdk.InputSpec{Title: "JSON payload", Kind: "text", ValueType: "string", Flag: "data", Required: true}) + op.Body = func(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { return ObjectBody(r.Body, fields...) } + return op +} diff --git a/misc/fctl-plugin/internal/command/payload.go b/misc/fctl-plugin/internal/command/payload.go new file mode 100644 index 00000000..5be28079 --- /dev/null +++ b/misc/fctl-plugin/internal/command/payload.go @@ -0,0 +1,73 @@ +package command + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "strings" +) + +func ObjectBody(body json.RawMessage, required ...string) (json.RawMessage, error) { + var object map[string]json.RawMessage + if len(body) == 0 || json.Unmarshal(body, &object) != nil || object == nil { + return nil, fmt.Errorf("a JSON object payload is required (--data)") + } + for _, field := range required { + v := strings.TrimSpace(string(object[field])) + if v == "" || v == "null" || v == `""` { + return nil, fmt.Errorf("payload field %q is required", field) + } + } + return body, nil +} + +// list accepts the SDK's string flag representation as JSON or CSV. +func List(value string) ([]string, error) { + if value == "" { + return []string{}, nil + } + var values []string + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if err := json.Unmarshal([]byte(value), &values); err != nil { + return nil, fmt.Errorf("expected a JSON string array: %w", err) + } + } else { + var err error + values, err = csv.NewReader(strings.NewReader(value)).Read() + if err != nil { + return nil, fmt.Errorf("expected comma-separated values: %w", err) + } + } + for _, v := range values { + if strings.TrimSpace(v) == "" { + return nil, fmt.Errorf("list entries cannot be empty") + } + } + return values, nil +} + +func Pairs(value string) (map[string]string, error) { + if strings.HasPrefix(strings.TrimSpace(value), "{") { + var out map[string]string + if err := json.Unmarshal([]byte(value), &out); err != nil || out == nil { + return nil, fmt.Errorf("expected a JSON object with string values") + } + return out, nil + } + values, err := List(value) + if err != nil { + return nil, err + } + out := make(map[string]string) + for _, v := range values { + key, val, ok := strings.Cut(v, "=") + if !ok || key == "" { + return nil, fmt.Errorf("expected key=value, got %q", v) + } + if _, exists := out[key]; exists { + return nil, fmt.Errorf("duplicate key %q", key) + } + out[key] = val + } + return out, nil +} diff --git a/misc/fctl-plugin/internal/command/route.go b/misc/fctl-plugin/internal/command/route.go new file mode 100644 index 00000000..ad47372d --- /dev/null +++ b/misc/fctl-plugin/internal/command/route.go @@ -0,0 +1,93 @@ +package command + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func Perform(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op Operation, body json.RawMessage) (json.RawMessage, error) { + path, err := Route(op.Segments, r) + if err != nil { + return nil, err + } + query, err := QueryValues(op.Query, r) + if err != nil { + return nil, err + } + headers := http.Header{} + key := r.Flags["idempotency-key"] + if key == "" { + key = r.Flags["ik"] + } + if key != "" { + headers.Set("Idempotency-Key", key) + } + return client.Do(ctx, op.Method, path, query, body, headers) +} + +func Identifier(value string) error { + if strings.TrimSpace(value) == "" || value == "." || value == ".." { + return fmt.Errorf("identifier must be nonempty and cannot be a dot segment") + } + return nil +} + +func Route(segments []string, r pluginsdk.ExecuteRequest) (string, error) { + resolved := make([]string, len(segments)) + for i, segment := range segments { + value := segment + if strings.HasPrefix(segment, "$") { + n, err := strconv.Atoi(segment[1:]) + if err != nil || n < 0 || n >= len(r.Args) { + return "", fmt.Errorf("missing route argument %s", segment) + } + value = r.Args[n] + } else if strings.HasPrefix(segment, "@") { + value = r.Flags[segment[1:]] + } + if err := Identifier(value); err != nil { + return "", fmt.Errorf("%s: %w", segment, err) + } + resolved[i] = value + } + return httpclient.Path(resolved...), nil +} + +func QueryValues(bindings map[string]string, r pluginsdk.ExecuteRequest) (url.Values, error) { + query := url.Values{} + for flag, wire := range bindings { + value := r.Flags[flag] + if value == "" { + continue + } + if flag == "page-size" { + n, err := strconv.ParseUint(value, 10, 32) + if err != nil || n == 0 || n > 1000 { + return nil, fmt.Errorf("--page-size must be between 1 and 1000") + } + } + if strings.Contains(flag, "created-at") || flag == "at" || flag == "timestamp" { + if err := Timestamp(value); err != nil { + return nil, fmt.Errorf("--%s: %w", flag, err) + } + } + query.Set(wire, value) + } + return query, nil +} + +func Timestamp(value string) error { + if _, err := time.Parse(time.RFC3339Nano, value); err != nil { + return fmt.Errorf("expected RFC3339 timestamp: %w", err) + } + return nil +} diff --git a/misc/fctl-plugin/internal/ledger/README.md b/misc/fctl-plugin/internal/ledger/README.md new file mode 100644 index 00000000..dfa3570c --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/README.md @@ -0,0 +1,153 @@ +# Historical Ledger plugin + +This package ports every executable command in `cmd/ledger` from fctl +`origin/main` pinned at `e00243b3e2e56aae6a09d7010b0c17890134388c`. +HTTP contracts were checked against the generated `formance-sdk-go/v4@v4.0.0` +source used by that revision. The service code uses the public `pluginsdk` and +`pluginsdk/httpclient` packages and the Go standard library. The manifest uses +the bundle's `internal/metadata.Version`. + +The internal `New(*http.Client) pluginsdk.Plugin` constructor exposes an offline manifest with root and +service `ledger`, version `1.0.0` (the legacy bundle version). The parent bundle +owns service-version selection, execution guards and its executable. No server +version guard runs in this package. Standalone callers use the parent module's +public `legacy` API; this internal package is owned by the bundle. + +`plugin.go` owns construction, manifest discovery and SDK execution. `ledger.go` +owns ledger operations, command dispatch, metadata routes and pagination. +`accounts.go` owns account operations and volume filters. `transactions.go` +owns transaction lookup, writes and Numscript payloads. `schemas.go` owns schema +operations and validation. `logs.go` owns bounded unary log import/export; +`stream.go` owns incremental NDJSON import/export. +`manifest.go` contains the command tree, selector and file metadata. + +`ExecuteRequest.Endpoint` must already be the Ledger service base, including +the gateway prefix where needed, for example `https://stack.example/api/ledger`. +The injected client owns authentication. Relative v1 routes have **no `/v1`** +prefix. v2 routes use `/v2`. No modern Ledger v3 routes are used. + +## Command and route inventory + +All 23 historical executable commands are included. `{ledger}` below comes +from the inherited `--ledger` flag (default `default`) unless a ledger name is +an explicit argument. Routes are relative to the service endpoint. + +| Command | Method and route | +| --- | --- | +| `create NAME` | `POST /v2/NAME` | +| `send [SOURCE] DESTINATION AMOUNT ASSET` | `POST /{ledger}/transactions` | +| `stats` | `GET /{ledger}/stats` | +| `server-infos` | `GET /_info` | +| `list` | `GET /v2` | +| `set-metadata NAME KEY=VALUE...` | `PUT /v2/NAME/metadata` | +| `delete-metadata NAME KEY` | `DELETE /v2/NAME/metadata/KEY` | +| `export` | `POST /v2/{ledger}/logs/export` | +| `import NAME [SOURCE]` | `POST /v2/NAME/logs/import` | +| `accounts list` | `GET /v2/{ledger}/accounts` | +| `accounts show ADDRESS` | `GET /{ledger}/accounts/ADDRESS` | +| `accounts set-metadata ADDRESS KEY=VALUE...` | `POST /{ledger}/accounts/ADDRESS/metadata` | +| `accounts delete-metadata ADDRESS KEY` | `DELETE /v2/{ledger}/accounts/ADDRESS/metadata/KEY` | +| `transactions list` | `GET /{ledger}/transactions` | +| `transactions num [SOURCE]` | `POST /{ledger}/transactions` with a script | +| `transactions revert ID` | `POST /{ledger}/transactions/ID/revert?disableChecks=BOOL` | +| `transactions revert ID --at-effective-date` | `POST /v2/{ledger}/transactions/ID/revert?atEffectiveDate=true&force=BOOL` | +| `transactions show ID` | `GET /{ledger}/transactions/ID` | +| `transactions set-metadata ID KEY=VALUE...` | `POST /{ledger}/transactions/ID/metadata` | +| `transactions delete-metadata ID KEY` | `DELETE /v2/{ledger}/transactions/ID/metadata/KEY` | +| `schemas insert VERSION [SOURCE]` | `POST /v2/{ledger}/schemas/VERSION` | +| `schemas get VERSION` | `GET /v2/{ledger}/schemas/VERSION` | +| `schemas list` | `GET /v2/{ledger}/schemas` | +| `volumes list` | `GET /v2/{ledger}/volumes` | + +`send` defaults SOURCE to `world`. Amounts and transaction IDs use arbitrary +precision integers. `last` and `lastN` resolve via the historical v1 transaction +list with `pageSize=1`; `lastN` subtracts N from the final ID, rather than selecting +the Nth row. No automatic mutation retry runs. + +Transactions list preserves the historical `pageSize=5`, `account`, `source`, +`destination`, `reference`, `startTime`, `endTime`, and `metadata[KEY]` queries. +Accounts and volumes filters use a JSON `{"$and":[{"$match":...}]}` body on GET, +as in the historical SDK. Ledger list sends its historical JSON `null` filter +and `includeDeleted=false`. Volumes preserves `pageSize=10`, `groupBy=0`, and +`insertionDate=false` defaults. Schemas preserves `pageSize=15`, `order=desc`, and +`sort=created_at`, including the generated SDK's page-size default with a cursor. +Volumes also retains the historical page-size query with a cursor. + +## Inputs and host integration + +The SDK has string, boolean and uint32 flags. Historical string-slice flags +(`metadata`, `features`, `account-var`, `portion-var`, `amount-var`) are declared +as strings. Supply comma-separated `key=value` pairs or a JSON array of strings: + +```text +--metadata 'owner=alice,environment=test' +--metadata '["description=hello, world","reference=a=b"]' +--amount-var 'payment=123456789012345678901234567890/USD/2' +``` + +Pairs split at the first equals sign; repeated keys keep the last value. Use a +JSON string array for embedded commas or newlines. Flags retain clear help +describing these formats. Explicit `ChangedFlags` allow clearing Numscript body +metadata, reference and timestamp. + +Declarative forms include ledger/account/transaction/schema selectors, metadata +arguments, send arguments, Numscript text and schema chart input. List commands +accept `cursor` and `page-size` for selector enumeration. Ledger/account lists +keep the server's historical page size when omitted. Discovery invokes only GET +operations. The host owns interaction, confirmation and file access. + +The plugin never opens a file, reads stdin, writes a terminal, fetches a schema +source URL, or reads environment variables. The manifest declares file +metadata; the parent host supplies `ExecuteRequest.Body` or raw streams: + +| Command | Historical host source/output | SDK Body/result | +| --- | --- | --- | +| `transactions num` | Filename or `-` argument 0 | Body: JSON string of Numscript, `{ "plain": "...", "vars": {} }`, or `{ "script": { "plain": "...", "vars": {} }, "metadata": {}, "reference": "...", "timestamp": "..." }` | +| `import` | Filename argument 1 or `--file`, or stdin | `ExecutionStreams.Input`: raw NDJSON; unary Body remains a JSON array or JSON string of NDJSON | +| `schemas insert` | JSON/YAML file or URL argument 1 | Body: JSON schema object; the host converts YAML | +| `export` | `--file` or stdout | `ExecutionStreams.Output`: exact backend NDJSON; separate result summary; unary result remains a JSON array | +| `schemas get` | `--format json/yaml/yml` | Result: raw HTTP JSON; host unwraps/presents schema as needed | + +`--data` is the declared body flag for direct JSON input. Source arguments are +optional for direct SDK callers supplying Body. Script bodies retain unknown +JSON fields and exact numbers. Schema insertion preserves its raw JSON body. +HTTP JSON responses retain their original envelope, unknown fields and exact +integers, including nested or direct server-info responses. A 204 returns JSON +`null`. Structured service-error bodies accompany the returned error. + +Streaming import validates each record and sends octet-stream NDJSON batches +of at most 100 logs and 4 MiB including added newlines. `read/bytes` measures +source bytes with optional `InputSize`; `import/logs` measures acknowledged +writes without a total. `--resume-from-last-log` looks up the latest log through +`GET /v2/NAME/logs?pageSize=1` and scans through that exact ID before any write. +A missing checkpoint fails without import writes. Results report `imported`, +`skipped`, and `batches`; failed batches are not counted or retried. Export +validates each bounded record before writing its exact bytes, including unknown +fields, huge integers and a final line without newline. Its response contains +only `exported` and `bytes`. See [streaming logs](../../docs/streaming-logs.md). + +## Compatibility limits and verification + +- Historical command aliases are declared in the SDK manifest and resolve + to canonical paths. +- Environment-derived dates, terminal tables/colors/progress, filesystem + reads/writes, YAML rendering and source URL fetching belong to the host. +- `ExecuteStream` has no total input/output bound. Individual NDJSON records + are limited to 4 MiB; compacted import records plus the added newline must + also fit the 4 MiB batch limit. The host stages file output until success. +- Direct unary `Execute` remains compatible: SDK Body is limited to 4 MiB, + import validates the complete Body before mutation, and export is buffered + up to 32 MiB before returning a JSON array. +- Validation rejects empty/dot path identifiers, malformed pairs, negative + amounts/IDs/lastN offsets and invalid dates before HTTP. This intentionally + avoids historical cases that could fail later at the backend or panic. +- Schema structure and Numscript input shape are validated locally; full + schema semantics and program execution remain backend responsibilities. +- Host integration and sandbox results are recorded in the repository's + `docs/legacy-validation.md`. + +From `misc/fctl-plugin`, run `go test -race ./internal/auth ./internal/ledger`. +Contract tests use `httptest` and +cover methods/routes, query/filter/payload shapes, exact big integers, validation +before HTTP, both revert APIs, last/lastN, raw JSON/error envelopes, cancellation, +import batching/resume/partial results, and export NDJSON conversion. diff --git a/misc/fctl-plugin/internal/ledger/accounts.go b/misc/fctl-plugin/internal/ledger/accounts.go new file mode 100644 index 00000000..18d816cf --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/accounts.go @@ -0,0 +1,80 @@ +package ledger + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "slices" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func prepareAccounts(ctx context.Context, client *httpclient.Client, name string, r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, query: make(url.Values)} + switch name { + case "accounts list": + body, err := filterPayload(r.Flags["metadata"], "") + if err != nil { + return op, err + } + op.path, op.body = httpclient.Path("v2", r.Flags["ledger"], "accounts"), body + if err := paginate(op.query, r); err != nil { + return op, err + } + case "accounts show": + if err := validateSegment(r.Args[0], "account address"); err != nil { + return op, err + } + op.path = httpclient.Path(r.Flags["ledger"], "accounts", r.Args[0]) + case "accounts set-metadata", "accounts delete-metadata": + return metadataOperation(ctx, client, "accounts", r) + default: + return op, fmt.Errorf("unknown ledger command %q", name) + } + return op, nil +} + +func volumesList(r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, path: httpclient.Path("v2", r.Flags["ledger"], "volumes"), query: make(url.Values)} + var err error + op.body, err = filterPayload(r.Flags["metadata"], r.Flags["address"]) + if err != nil { + return op, err + } + if err := paginate(op.query, r); err != nil { + return op, err + } + if r.Flags["cursor"] != "" { + op.query.Set("pageSize", r.Flags["page-size"]) // Historical volumes controller always supplies both. + } + op.query.Set("groupBy", r.Flags["group-by"]) + op.query.Set("insertionDate", r.Flags["insertion-date"]) + if err := dateQuery(op.query, r.Flags, map[string]string{"start-time": "startTime", "end-time": "endTime"}); err != nil { + return op, err + } + return op, nil +} + +func filterPayload(metadata, address string) (json.RawMessage, error) { + values, err := flagPairs(metadata) + if err != nil { + return nil, err + } + // Stable ordering keeps fixtures and request fingerprints reproducible. + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + slices.Sort(keys) + clauses := make([]map[string]any, 0, len(keys)+1) + for _, key := range keys { + clauses = append(clauses, map[string]any{"$match": map[string]string{"metadata[" + key + "]": values[key]}}) + } + if address != "" { + clauses = append(clauses, map[string]any{"$match": map[string]string{"account": address}}) + } + return json.Marshal(map[string]any{"$and": clauses}) +} diff --git a/misc/fctl-plugin/internal/ledger/accounts_test.go b/misc/fctl-plugin/internal/ledger/accounts_test.go new file mode 100644 index 00000000..60b42c4e --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/accounts_test.go @@ -0,0 +1,24 @@ +package ledger + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestEscapesIdentifiersExactlyOnce(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v2/ledger%2F%25/accounts/users%2F%25/metadata/key%2F%25" + if r.URL.EscapedPath() != want { + t.Errorf("escaped path %q, want %q", r.URL.EscapedPath(), want) + } + w.WriteHeader(204) + })) + t.Cleanup(server.Close) + r := executeRequest("accounts delete-metadata", []string{"users/%", "key/%"}, map[string]string{"ledger": "ledger/%"}, "") + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if err != nil || string(result.Data) != "null" { + t.Fatalf("got %s: %v", result.Data, err) + } +} diff --git a/misc/fctl-plugin/internal/ledger/aliases.go b/misc/fctl-plugin/internal/ledger/aliases.go new file mode 100644 index 00000000..9088ce11 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/aliases.go @@ -0,0 +1,24 @@ +package ledger + +import ( + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +var historicalAliases = map[string][]string{ + "ledger": {"l"}, "ledger create": {"c", "cr"}, "ledger list": {"l", "ls"}, "ledger send": {"s", "se"}, "ledger stats": {"st"}, "ledger server-infos": {"si"}, + "ledger set-metadata": {"sm", "set-meta"}, "ledger delete-metadata": {"dm", "del-meta"}, + "ledger accounts": {"acc", "a", "ac", "account"}, "ledger accounts list": {"ls", "l"}, "ledger accounts show": {"sh", "s"}, "ledger accounts set-metadata": {"sm", "set-meta"}, "ledger accounts delete-metadata": {"dm", "del-meta"}, + "ledger transactions": {"t", "txs", "tx"}, "ledger transactions list": {"ls", "l"}, "ledger transactions show": {"sh"}, "ledger transactions set-metadata": {"sm", "set-meta"}, "ledger transactions delete-metadata": {"dm", "del-meta"}, + "ledger schemas": {"schema", "sc"}, "ledger schemas get": {"g", "show"}, "ledger schemas insert": {"i", "create"}, "ledger schemas list": {"ls", "l"}, + "ledger volumes": {"vol", "volume", "vols", "vlm"}, "ledger volumes list": {"ls", "l"}, +} + +func annotateAliases(command *pluginsdk.CommandSpec, path []string) { + path = append(append([]string{}, path...), pluginsdk.CommandName(*command)) + command.Aliases = append([]string{}, historicalAliases[strings.Join(path, " ")]...) + for i := range command.Subcommands { + annotateAliases(&command.Subcommands[i], path) + } +} diff --git a/misc/fctl-plugin/internal/ledger/helpers_test.go b/misc/fctl-plugin/internal/ledger/helpers_test.go new file mode 100644 index 00000000..c6d4997a --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/helpers_test.go @@ -0,0 +1,56 @@ +package ledger + +import ( + "bytes" + "encoding/json" + "io" + "maps" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const huge = "1234567890123456789012345678901234567890" + +func executeRequest(command string, args []string, flags map[string]string, body string) pluginsdk.ExecuteRequest { + values := make(map[string]string) + maps.Copy(values, flags) + path := append([]string{"ledger"}, strings.Fields(command)...) + if spec, err := pluginsdk.FindCommand(manifest(), path); err == nil && spec.Confirm { + if _, exists := values["confirm"]; !exists { + values["confirm"] = "true" + } + } + r := pluginsdk.ExecuteRequest{CommandPath: path, Args: args, Flags: values} + if body != "" { + r.Body = json.RawMessage(body) + } + return r +} + +func writeJSON(t *testing.T, w http.ResponseWriter, data string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, data); err != nil { + t.Error(err) + } +} + +func assertJSON(t *testing.T, got, want []byte) { + t.Helper() + decode := func(raw []byte) any { + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + t.Fatalf("invalid JSON %s: %v", raw, err) + } + return value + } + if !reflect.DeepEqual(decode(got), decode(want)) { + t.Errorf("JSON %s, want %s", got, want) + } +} diff --git a/misc/fctl-plugin/internal/ledger/ledger.go b/misc/fctl-plugin/internal/ledger/ledger.go new file mode 100644 index 00000000..6732959d --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/ledger.go @@ -0,0 +1,202 @@ +package ledger + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type operation struct { + method string + path string + query url.Values + body json.RawMessage +} + +func prepare(ctx context.Context, client *httpclient.Client, name string, r pluginsdk.ExecuteRequest) (operation, error) { + ledgerName := r.Flags["ledger"] + if name != "create" && name != "list" && name != "server-infos" && name != "set-metadata" && name != "delete-metadata" { + if err := validateSegment(ledgerName, "ledger"); err != nil { + return operation{}, err + } + } + op := operation{method: http.MethodGet, query: make(url.Values)} + switch name { + case "server-infos": + op.path = "/_info" + case "stats": + op.path = httpclient.Path(ledgerName, "stats") + case "list": + op.path = "/v2" + op.body = json.RawMessage("null") // The historical generated SDK serializes its nil filter map. + op.query.Set("includeDeleted", "false") + if err := paginate(op.query, r); err != nil { + return op, err + } + case "create": + return createOperation(r) + case "set-metadata", "delete-metadata": + return metadataOperation(ctx, client, "ledger", r) + case "accounts list", "accounts show", "accounts set-metadata", "accounts delete-metadata": + return prepareAccounts(ctx, client, name, r) + case "send", "transactions list", "transactions num", "transactions show", "transactions revert", "transactions set-metadata", "transactions delete-metadata": + return prepareTransactions(ctx, client, name, r) + case "schemas list": + return schemasList(r) + case "schemas get", "schemas insert": + return schemaOperation(name, r) + case "volumes list": + return volumesList(r) + default: + return op, fmt.Errorf("unknown ledger command %q", name) + } + return op, nil +} + +func createOperation(r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodPost} + if err := validateSegment(r.Args[0], "ledger name"); err != nil { + return op, err + } + metadata, err := flagPairs(r.Flags["metadata"]) + if err != nil { + return op, err + } + features, err := flagPairs(r.Flags["features"]) + if err != nil { + return op, err + } + body := map[string]any{"bucket": r.Flags["bucket"]} + if len(metadata) > 0 { + body["metadata"] = metadata + } + if len(features) > 0 { + body["features"] = features + } + op.path = httpclient.Path("v2", r.Args[0]) + op.body, err = json.Marshal(body) + return op, err +} + +func metadataOperation(ctx context.Context, client *httpclient.Client, resource string, r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodPost} + isDelete := r.CommandPath[len(r.CommandPath)-1] == "delete-metadata" + if isDelete { + if err := validateSegment(r.Args[1], "metadata key"); err != nil { + return op, err + } + op.method = http.MethodDelete + } else { + metadata, err := pairs(r.Args[1:]) + if err != nil { + return op, err + } + op.body, err = json.Marshal(metadata) + if err != nil { + return op, err + } + } + segments := []string{r.Flags["ledger"], resource, r.Args[0], "metadata"} + if resource == "transactions" { + id, err := transactionID(ctx, client, r.Flags["ledger"], r.Args[0]) + if err != nil { + return op, err + } + segments[2] = id + } else if err := validateSegment(r.Args[0], resource+" identifier"); err != nil { + return op, err + } + if resource == "ledger" { + segments = []string{"v2", r.Args[0], "metadata"} + if !isDelete { + op.method = http.MethodPut + } + } else if isDelete { + segments = append([]string{"v2"}, segments...) + } + if isDelete { + segments = append(segments, r.Args[1]) + } + op.path = httpclient.Path(segments...) + return op, nil +} + +func paginate(query url.Values, r pluginsdk.ExecuteRequest) error { + size, err := strconv.ParseUint(r.Flags["page-size"], 10, 32) + name := strings.Join(r.CommandPath[1:], " ") + serverDefault := name == "list" || name == "accounts list" + if err != nil || (size == 0 && (!serverDefault || r.ChangedFlags["page-size"])) { + return fmt.Errorf("page size must be greater than 0") + } + if r.Flags["cursor"] != "" { + query.Set("cursor", r.Flags["cursor"]) + } else if size > 0 { + query.Set("pageSize", strconv.FormatUint(size, 10)) + } + return nil +} + +func dateQuery(query url.Values, flags map[string]string, names map[string]string) error { + for flag, param := range names { + if value := flags[flag]; value != "" { + date, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return fmt.Errorf("parsing --%s: %w", flag, err) + } + query.Set(param, date.Format(time.RFC3339Nano)) + } + } + return nil +} + +func validateSegment(value, label string) error { + if strings.TrimSpace(value) == "" || value == "." || value == ".." { + return fmt.Errorf("%s must be nonempty and must not be a dot segment", label) + } + return nil +} + +func flagPairs(value string) (map[string]string, error) { + if value == "" { + return map[string]string{}, nil + } + var entries []string + if strings.HasPrefix(strings.TrimSpace(value), "[") { + if err := json.Unmarshal([]byte(value), &entries); err != nil { + return nil, fmt.Errorf("expected a JSON string array: %w", err) + } + } else { + reader := csv.NewReader(strings.NewReader(value)) + reader.FieldsPerRecord = -1 + var err error + entries, err = reader.Read() + if err != nil { + return nil, fmt.Errorf("parse comma-separated pairs: %w", err) + } + if strings.ContainsAny(value, "\r\n") { + return nil, fmt.Errorf("use a JSON string array for multiline values") + } + } + return pairs(entries) +} + +func pairs(entries []string) (map[string]string, error) { + result := make(map[string]string, len(entries)) + for _, entry := range entries { + key, value, ok := strings.Cut(entry, "=") + if !ok || key == "" { + return nil, fmt.Errorf("malformed key=value pair %q", entry) + } + result[key] = value + } + return result, nil +} diff --git a/misc/fctl-plugin/internal/ledger/ledger_test.go b/misc/fctl-plugin/internal/ledger/ledger_test.go new file mode 100644 index 00000000..24ef1611 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/ledger_test.go @@ -0,0 +1,52 @@ +package ledger + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +//nolint:gocognit // Each cursor endpoint has distinct historical query defaults. +func TestPaginationMaintainsHistoricalDefaults(t *testing.T) { + for _, command := range []string{"list", "accounts list", "transactions list", "schemas list", "volumes list"} { + t.Run(command, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("cursor") != "next+opaque" { + t.Errorf("missing cursor: %s", r.URL) + } + wantPage := "" + switch command { + case "schemas list": + wantPage = "15" + case "volumes list": + wantPage = "10" + } + if r.URL.Query().Get("pageSize") != wantPage { + t.Errorf("cursor page size %s, want %s", r.URL, wantPage) + } + writeJSON(t, w, `{"cursor":{"hasMore":true,"next":"next+2","data":[{"id":`+huge+`}]}}`) + })) + t.Cleanup(server.Close) + r := executeRequest(command, nil, map[string]string{"cursor": "next+opaque"}, "") + r.Endpoint = server.URL + data, err := New(server.Client()).Execute(t.Context(), r) + if err != nil || !strings.Contains(string(data.Data), huge) { + t.Fatalf("got %s: %v", data.Data, err) + } + }) + } +} + +func TestServerInfoPreservesNestedAndDirectEnvelopes(t *testing.T) { + for _, data := range []string{`{"data":{"server":"ledger","version":"v2.4.15","future":true}}`, `{"server":"ledger","version":"v2.4.15"}`} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeJSON(t, w, data) })) + r := executeRequest("server-infos", nil, nil, "") + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if err != nil || string(result.Data) != data { + t.Errorf("info %s: %v", result.Data, err) + } + server.Close() + } +} diff --git a/misc/fctl-plugin/internal/ledger/logs.go b/misc/fctl-plugin/internal/ledger/logs.go new file mode 100644 index 00000000..904ec24d --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/logs.go @@ -0,0 +1,223 @@ +package ledger + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const importBlockSize = 100 + +// Logs cross the plugin transport as an array or a JSON string containing +// NDJSON. The service still receives historical octet-stream NDJSON batches. +func importLogs(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + ledgerName := r.Args[0] + if err := validateSegment(ledgerName, "ledger name"); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + logs, err := decodeLogs(r.Body) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + skipped := 0 + if r.Flags["resume-from-last-log"] == "true" { + skipped, err = resumeOffset(ctx, client, ledgerName, logs) + if err != nil { + return response(nil, err) + } + } + progress := importProgress{Skipped: int64(skipped)} + for start := skipped; start < len(logs); start += importBlockSize { + end := min(start+importBlockSize, len(logs)) + block, err := encodeNDJSON(logs[start:end]) + if err != nil { + return progress.response(), err + } + _, err = client.Do(ctx, http.MethodPost, httpclient.Path("v2", ledgerName, "logs", "import"), nil, block, http.Header{"Content-Type": {"application/octet-stream"}}) + if err != nil { + if failure, ok := errors.AsType[*httpclient.Error](err); ok { + progress.Error = failure.Body + } + return progress.response(), err + } + progress.Imported += int64(end - start) + progress.Batches++ + } + return progress.response(), nil +} + +type importProgress struct { + Imported int64 `json:"imported"` + Skipped int64 `json:"skipped"` + Batches int64 `json:"batches"` + Error json.RawMessage `json:"error,omitempty"` +} + +func (p importProgress) response() pluginsdk.ExecuteResponse { + data := fmt.Appendf(nil, `{"imported":%d,"skipped":%d,"batches":%d`, p.Imported, p.Skipped, p.Batches) + if p.Error != nil { + data = append(data, `,"error":`...) + data = append(data, p.Error...) + } + data = append(data, '}') + return pluginsdk.ExecuteResponse{Data: data} +} + +func encodeNDJSON(logs []json.RawMessage) ([]byte, error) { + var block []byte + for _, log := range logs { + var compact bytes.Buffer + if err := json.Compact(&compact, log); err != nil { + return nil, fmt.Errorf("encode log: %w", err) + } + block = append(block, compact.Bytes()...) + block = append(block, '\n') + } + return block, nil +} + +func resumeOffset(ctx context.Context, client *httpclient.Client, ledgerName string, logs []json.RawMessage) (int, error) { + id, err := latestLogID(ctx, client, ledgerName) + if err != nil || id == "" { + return 0, err + } + for index, log := range logs { + candidate, err := logID(log) + if err != nil { + return 0, err + } + if candidate == id { + return index + 1, nil + } + } + return 0, fmt.Errorf("log %s not found in import Body", id) +} + +func latestLogID(ctx context.Context, client *httpclient.Client, ledgerName string) (string, error) { + data, err := client.Do(ctx, http.MethodGet, httpclient.Path("v2", ledgerName, "logs"), url.Values{"pageSize": {"1"}}, json.RawMessage("null"), nil) + if err != nil { + return "", err + } + var result struct { + Cursor struct { + Data []json.RawMessage `json:"data"` + } `json:"cursor"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("decode last log: %w", err) + } + if result.Cursor.Data == nil { + return "", fmt.Errorf("decode last log: missing cursor data") + } + if len(result.Cursor.Data) == 0 { + return "", nil + } + return logID(result.Cursor.Data[0]) +} + +func decodeLogs(raw json.RawMessage) ([]json.RawMessage, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return nil, fmt.Errorf("import Body is required; the host must read file/stdin logs") + } + var logs []json.RawMessage + var ndjson string + if bytes.TrimSpace(raw)[0] == '"' { + if err := json.Unmarshal(raw, &ndjson); err != nil { + return nil, fmt.Errorf("decode NDJSON string: %w", err) + } + var err error + logs, err = decodeNDJSON(strings.NewReader(ndjson)) + if err != nil { + return nil, err + } + } else if err := json.Unmarshal(raw, &logs); err != nil || logs == nil { + return nil, fmt.Errorf("logs must be a JSON array or a JSON string containing NDJSON") + } + for index, log := range logs { + if _, err := logID(log); err != nil { + return nil, fmt.Errorf("log %d: %w", index, err) + } + } + return logs, nil +} + +func logID(raw json.RawMessage) (string, error) { + var log map[string]json.RawMessage + if err := json.Unmarshal(raw, &log); err != nil || log == nil { + return "", fmt.Errorf("log must be a JSON object") + } + id, err := nonnegativeInteger(string(log["id"]), "log ID") + if err != nil { + return "", err + } + return id.String(), nil +} + +func decodeNDJSON(reader io.Reader) ([]json.RawMessage, error) { + buffer := bufio.NewReader(reader) + logs := make([]json.RawMessage, 0) + for { + line, err := buffer.ReadBytes('\n') + if len(line) > 0 { + line = bytes.TrimSpace(line) + if len(line) == 0 || !json.Valid(line) { + return nil, fmt.Errorf("invalid NDJSON log at line %d", len(logs)+1) + } + logs = append(logs, json.RawMessage(line)) + } + if errors.Is(err, io.EOF) { + return logs, nil + } + if err != nil { + return nil, fmt.Errorf("read logs: %w", err) + } + } +} + +func exportLogs(ctx context.Context, client *httpclient.Client, ledgerName string) (result pluginsdk.ExecuteResponse, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.Endpoint()+httpclient.Path("v2", ledgerName, "logs", "export"), nil) + if err != nil { + return result, err + } + req.Header.Set("Accept", "*/*") + res, err := client.HTTPClient().Do(req) + if err != nil { + return result, fmt.Errorf("export logs: %w", err) + } + defer func() { err = errors.Join(err, res.Body.Close()) }() + data, err := io.ReadAll(io.LimitReader(res.Body, 32<<20+1)) + if err != nil { + return result, fmt.Errorf("read export: %w", err) + } + if len(data) > 32<<20 { + return result, fmt.Errorf("export exceeds the 32 MiB plugin response limit") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + failure := &httpclient.Error{StatusCode: res.StatusCode} + var details struct { + Code string `json:"errorCode"` + Message string `json:"errorMessage"` + } + if json.Unmarshal(data, &details) == nil { + failure.Body, failure.Code, failure.Message = data, details.Code, details.Message + } + return response(nil, failure) + } + logs, err := decodeNDJSON(bytes.NewReader(data)) + if err != nil { + return result, err + } + result.Data, err = json.Marshal(logs) + return result, err +} diff --git a/misc/fctl-plugin/internal/ledger/logs_test.go b/misc/fctl-plugin/internal/ledger/logs_test.go new file mode 100644 index 00000000..15c79368 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/logs_test.go @@ -0,0 +1,235 @@ +package ledger + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +//nolint:gocognit // Keep batch request assertions beside the generated 205-log fixture. +func TestImportNDJSONBatchesAndExactNumbers(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + if r.Method != "POST" || r.URL.EscapedPath() != "/v2/named/logs/import" || r.Header.Get("Content-Type") != "application/octet-stream" { + t.Errorf("unexpected import request %s %s, content type %q", r.Method, r.URL, r.Header.Get("Content-Type")) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + lines := bytes.Split(bytes.TrimSuffix(data, []byte{'\n'}), []byte{'\n'}) + wantLines := 100 + if call == 3 { + wantLines = 5 + } + if len(lines) != wantLines || len(data) == 0 || data[len(data)-1] != '\n' { + t.Errorf("batch %d has %d lines, want %d, ending %q", call, len(lines), wantLines, data[len(data)-1:]) + } + for index, line := range lines { + want := fmt.Sprintf(`{"id":%d,"payload":{"amount":%s,"unknown":true}}`, int(call-1)*100+index, huge) + assertJSON(t, line, []byte(want)) + } + w.WriteHeader(204) + })) + t.Cleanup(server.Close) + var ndjson strings.Builder + for index := range 205 { + if _, err := fmt.Fprintf(&ndjson, `{"id":%d,"payload":{"amount":%s,"unknown":true}}`, index, huge); err != nil { + t.Fatal(err) + } + if index < 204 { + if err := ndjson.WriteByte('\n'); err != nil { + t.Fatal(err) + } + } + } + body, err := json.Marshal(ndjson.String()) + if err != nil { + t.Fatal(err) + } + r := executeRequest("import", []string{"named", "host-read.ndjson"}, map[string]string{"file": "host-only"}, string(body)) + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if err != nil || calls.Load() != 3 { + t.Fatalf("calls %d: %v", calls.Load(), err) + } + assertJSON(t, result.Data, []byte(`{"imported":205,"skipped":0,"batches":3}`)) +} + +//nolint:gocognit // The checkpoint matrix verifies reads, skipped writes and progress in one contract test. +func TestImportArrayResumeAndMissingCheckpoint(t *testing.T) { + for _, checkpoint := range []string{"", huge, "12", "bad"} { + t.Run(checkpoint, func(t *testing.T) { + var lookups, writes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + lookups.Add(1) + if r.URL.Path != "/v2/named/logs" || r.URL.Query().Get("pageSize") != "1" { + t.Errorf("lookup %s", r.URL) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if string(body) != "null" { + t.Errorf("historical nil filter body %s", body) + } + switch checkpoint { + case "": + writeJSON(t, w, `{"cursor":{"data":[]}}`) + case "bad": + writeJSON(t, w, `{"cursor":{"data":[{"id":"bad"}]}}`) + default: + writeJSON(t, w, `{"cursor":{"data":[{"id":`+checkpoint+`}]}}`) + } + return + } + writes.Add(1) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if checkpoint == huge && string(body) != "{\"id\":1234567890123456789012345678901234567891}\n" { + t.Errorf("resume body %s", body) + } + w.WriteHeader(204) + })) + t.Cleanup(server.Close) + r := executeRequest("import", []string{"named"}, map[string]string{"resume-from-last-log": "true"}, `[{"id":`+huge+`},{"id":1234567890123456789012345678901234567891}]`) + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if lookups.Load() != 1 { + t.Errorf("lookups %d", lookups.Load()) + } + if checkpoint == "12" || checkpoint == "bad" { + if err == nil || writes.Load() != 0 { + t.Fatalf("expected checkpoint error, writes %d: %v", writes.Load(), err) + } + return + } + if err != nil || writes.Load() != 1 { + t.Fatalf("writes %d: %v", writes.Load(), err) + } + want := `{"imported":2,"skipped":0,"batches":1}` + if checkpoint == huge { + want = `{"imported":1,"skipped":1,"batches":1}` + } + assertJSON(t, result.Data, []byte(want)) + }) + } +} + +func TestImportPartialResultsNoRetry(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 2 { + w.WriteHeader(409) + writeJSON(t, w, `{"errorCode":"IMPORT","errorMessage":"bad hash","details":{"id":`+huge+`}}`) + return + } + w.WriteHeader(204) + })) + t.Cleanup(server.Close) + logs := make([]json.RawMessage, 105) + for index := range logs { + logs[index] = json.RawMessage(fmt.Sprintf(`{"id":%d}`, index)) + } + body, err := json.Marshal(logs) + if err != nil { + t.Fatal(err) + } + r := executeRequest("import", []string{"named"}, nil, string(body)) + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if err == nil || calls.Load() != 2 { + t.Fatalf("calls %d: %v", calls.Load(), err) + } + assertJSON(t, result.Data, []byte(`{"imported":100,"skipped":0,"batches":1,"error":{"errorCode":"IMPORT","errorMessage":"bad hash","details":{"id":`+huge+`}}}`)) +} + +//nolint:gocognit // Assert the raw export stream and its JSON conversion in the same fixture matrix. +func TestExportNDJSONAndHostOwnsFile(t *testing.T) { + for _, content := range []string{"", `{"id":` + huge + `,"payload":{"amount":` + huge + `}}` + "\n" + `{"id":1234567890123456789012345678901234567891}`} { + t.Run(fmt.Sprint(len(content)), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.EscapedPath() != "/gateway/v2/named/logs/export" || r.Header.Get("Accept") != "*/*" { + t.Errorf("export request %s %s accept %s", r.Method, r.URL, r.Header.Get("Accept")) + } + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := io.WriteString(w, content); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + r := executeRequest("export", nil, map[string]string{"ledger": "named", "file": "/this/path/must/not/be/written"}, "") + r.Endpoint = server.URL + "/gateway" + result, err := New(server.Client()).Execute(t.Context(), r) + if err != nil { + t.Fatal(err) + } + want := "[]" + if content != "" { + want = "[" + strings.ReplaceAll(content, "\n", ",") + "]" + } + assertJSON(t, result.Data, []byte(want)) + }) + } +} + +func TestExportRejectsMalformedStreamAndPreservesError(t *testing.T) { + for _, status := range []int{200, 403} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + body := "malformed\n" + if status == 403 { + body = `{"errorCode":"DENIED","errorMessage":"no export"}` + } + if _, err := io.WriteString(w, body); err != nil { + t.Error(err) + } + })) + r := executeRequest("export", nil, nil, "") + r.Endpoint = server.URL + result, err := New(server.Client()).Execute(t.Context(), r) + if err == nil { + t.Error("expected export error") + } + if status == 403 && string(result.Data) != `{"errorCode":"DENIED","errorMessage":"no export"}` { + t.Errorf("error data %s", result.Data) + } + server.Close() + } +} + +func TestReadLogsUnboundedLinesAndEmptyInput(t *testing.T) { + large := strings.Repeat("a", 128<<10) + body, err := json.Marshal(`{"id":1,"large":"` + large + `"}`) + if err != nil { + t.Fatal(err) + } + logs, err := decodeLogs(body) + if err != nil || len(logs) != 1 || !bytes.Contains(logs[0], []byte(large)) { + t.Fatalf("logs %d: %v", len(logs), err) + } + if _, err := decodeNDJSON(brokenReader{}); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("read error %v", err) + } + for _, body := range []string{`[]`, `""`} { + logs, err := decodeLogs([]byte(body)) + if err != nil || len(logs) != 0 { + t.Fatalf("empty logs %s: %v", body, err) + } + } +} + +type brokenReader struct{} + +func (brokenReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } diff --git a/misc/fctl-plugin/internal/ledger/manifest.go b/misc/fctl-plugin/internal/ledger/manifest.go new file mode 100644 index 00000000..bc7be0b4 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/manifest.go @@ -0,0 +1,178 @@ +package ledger + +import ( + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +func manifest() pluginsdk.Manifest { + m := pluginsdk.Manifest{ + Name: "ledger", Service: "ledger", Version: metadata.Version, ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{ + Use: "ledger", Short: "Manage ledgers with the historical v1/v2 API", Target: "stack", + Flags: []pluginsdk.FlagSpec{{Name: "ledger", Type: "string", Default: "default", Usage: "Specific ledger", Persistent: true}}, + Subcommands: []pluginsdk.CommandSpec{ + command("create ", "Create a ledger (Ledger v2 or later)", 1, 1, true, + []pluginsdk.FlagSpec{stringFlag("bucket", "Bucket in which to install the ledger"), pairsFlag("features", "Features (name=value; Ledger v2.2 or later)"), pairsFlag("metadata", "Ledger metadata")}, + argument("Ledger name", 0)), + withLedger(command("send [] ", "Send from one account to another (source defaults to world)", 3, 4, true, + []pluginsdk.FlagSpec{pairsFlag("metadata", "Transaction metadata"), stringFlag("reference", "Transaction reference")}, + argument("Destination account", 0), argument("Amount (integer)", 1), argument("Asset", 2))), + withLedger(command("stats", "Read ledger stats", 0, 0, false, nil)), + command("server-infos", "Read server info", 0, 0, false, nil), + command("list", "List ledgers (Ledger v2 or later)", 0, 0, false, pagination("0")), + command("set-metadata =...", "Set metadata on a ledger", 2, unlimitedArgs, true, nil, ledgerArgument(0), argument("Metadata key=value", 1)), + command("delete-metadata ", "Delete metadata from a ledger", 2, unlimitedArgs, true, nil, ledgerArgument(0), argument("Metadata key", 1)), + withLedger(command("export", "Stream ledger logs as NDJSON; file output belongs to the host", 0, 0, false, + []pluginsdk.FlagSpec{stringFlag("file", "NDJSON output file handled by the host")})), + command("import []", "Stream NDJSON logs supplied by the host", 1, 2, false, + []pluginsdk.FlagSpec{bodyFlag("NDJSON source; @file/- is streamed by the host; direct unary SDK callers may supply JSON Body"), stringFlag("file", "NDJSON source file handled by the host"), boolFlag("resume-from-last-log", "Resume after the last imported log")}, + ledgerArgument(0), bodyInput("Logs", "JSON array of logs or JSON string containing NDJSON")), + {Use: "accounts", Short: "Manage accounts", Subcommands: []pluginsdk.CommandSpec{ + withLedger(command("list", "List accounts", 0, 0, false, append([]pluginsdk.FlagSpec{pairsFlag("metadata", "Filter account metadata")}, pagination("0")...))), + withLedger(command("show
", "Show an account", 1, 1, false, nil, accountArgument(0))), + withLedger(command("set-metadata
=...", "Set account metadata", 2, unlimitedArgs, true, nil, accountArgument(0), argument("Metadata key=value", 1))), + withLedger(command("delete-metadata
", "Delete account metadata", 2, unlimitedArgs, true, nil, accountArgument(0), argument("Metadata key", 1))), + }}, + {Use: "transactions", Short: "Manage transactions", Subcommands: []pluginsdk.CommandSpec{ + withLedger(command("list", "List transactions", 0, 0, false, append([]pluginsdk.FlagSpec{ + stringFlag("account", "Filter by account"), stringFlag("dst", "Filter by destination"), stringFlag("src", "Filter by source"), + stringFlag("start", "Inclusive start time (RFC3339)"), stringFlag("end", "Exclusive end time (RFC3339)"), + stringFlag("reference", "Filter by reference"), pairsFlag("metadata", "Filter transaction metadata"), + }, pagination("5")...))), + withLedger(command("num [|-]", "Execute Numscript supplied by the host in --data or SDK Body", 0, 1, true, + []pluginsdk.FlagSpec{bodyFlag("JSON string containing Numscript, {\"plain\":...}, or {\"script\":{\"plain\":...,\"vars\":...}}; source files are read by the host"), + pairsFlag("amount-var", "Amount variables name=amount/asset"), pairsFlag("portion-var", "Portion variables name=value"), pairsFlag("account-var", "Account variables name=value"), + pairsFlag("metadata", "Transaction metadata"), stringFlag("reference", "Transaction reference"), stringFlag("timestamp", "Transaction timestamp (RFC3339)")}, + pluginsdk.InputSpec{Title: "Numscript", Kind: "text", BodyPointer: "/script/plain", ValueType: "string", Required: true})), + withLedger(command("revert ", "Revert a transaction; accepts last or lastN", 1, 1, true, + []pluginsdk.FlagSpec{boolFlag("at-effective-date", "Use original timestamp via Ledger v2"), boolFlag("force", "Disable balance checks")}, transactionArgument(0))), + withLedger(command("show ", "Show a transaction; accepts last or lastN", 1, 1, false, nil, transactionArgument(0))), + withLedger(command("set-metadata =...", "Set transaction metadata; accepts last or lastN", 2, unlimitedArgs, true, nil, transactionArgument(0), argument("Metadata key=value", 1))), + withLedger(command("delete-metadata ", "Delete transaction metadata; accepts last or lastN", 2, unlimitedArgs, true, nil, transactionArgument(0), argument("Metadata key", 1))), + }}, + {Use: "schemas", Short: "Manage ledger schemas", Subcommands: []pluginsdk.CommandSpec{ + withLedger(command("insert []", "Insert a schema supplied as JSON Body; host reads JSON/YAML files or URLs", 1, 2, true, + []pluginsdk.FlagSpec{bodyFlag("Schema JSON object, @file or -; host converts YAML to JSON")}, argument("Schema version", 0), + pluginsdk.InputSpec{Title: "Chart of accounts", Kind: "text", BodyPointer: "/chart", ValueType: "json", Required: true})), + withLedger(command("get ", "Get a schema; output presentation belongs to the host", 1, 1, false, + []pluginsdk.FlagSpec{{Name: "format", Type: "string", Default: "json", Usage: "Host presentation hint: json, yaml or yml; SDK output remains JSON"}}, schemaArgument(0))), + withLedger(command("list", "List ledger schemas", 0, 0, false, pagination("15"))), + }}, + {Use: "volumes", Short: "Get volumes and balances", Subcommands: []pluginsdk.CommandSpec{ + withLedger(command("list", "List volumes and balances for a time period", 0, 0, false, append([]pluginsdk.FlagSpec{ + stringFlag("end-time", "PIT (RFC3339)"), stringFlag("start-time", "OOT (RFC3339)"), boolFlag("insertion-date", "Use insertion date"), + {Name: "group-by", Type: "uint32", Default: "0", Usage: "Group by address segment level"}, stringFlag("address", "Filter account address"), pairsFlag("metadata", "Filter account metadata"), + }, pagination("10")...))), + }}, + }, + }, + } + return annotateFiles(m) +} + +const unlimitedArgs = 1<<31 - 1 + +func command(use, short string, minArgs, maxArgs int, confirm bool, flags []pluginsdk.FlagSpec, inputs ...pluginsdk.InputSpec) pluginsdk.CommandSpec { + if confirm { + flags = append(flags, boolFlag("confirm", "Confirm this operation")) + } + return pluginsdk.CommandSpec{Use: use, Short: short, Args: pluginsdk.ArgsSpec{Min: minArgs, Max: maxArgs}, Runnable: true, Confirm: confirm, Flags: flags, Inputs: inputs} +} + +func stringFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "string", Usage: usage} +} + +func pairsFlag(name, usage string) pluginsdk.FlagSpec { + return stringFlag(name, usage+`; use comma-separated key=value pairs or a JSON string array, e.g. ["key=value,with,commas"]`) +} + +func boolFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "bool", Default: "false", Usage: usage} +} + +func bodyFlag(usage string) pluginsdk.FlagSpec { + flag := stringFlag("data", usage) + flag.Body = true + return flag +} + +func pagination(defaultSize string) []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{stringFlag("cursor", "Opaque pagination cursor"), {Name: "page-size", Type: "uint32", Default: defaultSize, Usage: "Page size; 0 uses the server default when omitted"}} +} + +func argument(title string, index int) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", Argument: new(index), ValueType: "string", Required: true} +} + +func bodyInput(title, description string) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Description: description, Kind: "text", Flag: "data", ValueType: "json", Required: true} +} + +func ledgerArgument(index int) pluginsdk.InputSpec { + i := argument("Ledger", index) + i.Kind = "select" + i.Source = &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "list"}, ValueField: "name", LabelFields: []string{"name"}, EmptyMessage: "No ledgers available"} + return i +} + +func withLedger(c pluginsdk.CommandSpec) pluginsdk.CommandSpec { + i := ledgerArgument(0) + i.Argument, i.Flag, i.Default = nil, "ledger", "default" + c.Inputs = append([]pluginsdk.InputSpec{i}, c.Inputs...) + return c +} + +func accountArgument(index int) pluginsdk.InputSpec { + i := argument("Account", index) + i.Kind = "select" + i.Source = &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "accounts", "list"}, Flags: map[string]string{"ledger": "$ledger"}, ValueField: "address", LabelFields: []string{"address"}, EmptyMessage: "No accounts available"} + return i +} + +func transactionArgument(index int) pluginsdk.InputSpec { + i := argument("Transaction ID (or last/lastN)", index) + i.Kind = "select" + i.Source = &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "transactions", "list"}, Flags: map[string]string{"ledger": "$ledger"}, ValueField: "txid", LabelFields: []string{"txid", "reference"}, EmptyMessage: "No transactions available"} + return i +} + +func schemaArgument(index int) pluginsdk.InputSpec { + i := argument("Schema version", index) + i.Kind = "select" + i.Source = &pluginsdk.ChoiceSource{CommandPath: []string{"ledger", "schemas", "list"}, Flags: map[string]string{"ledger": "$ledger"}, ValueField: "version", LabelFields: []string{"version"}, EmptyMessage: "No schemas available"} + return i +} + +// File effects are declarative. SDK callers provide Body and consume Data; +// the host supplies local file/stdin reads and preserves historical exports. +func annotateFiles(manifest pluginsdk.Manifest) pluginsdk.Manifest { + attachFiles(&manifest.Root, nil) + annotateAliases(&manifest.Root, nil) + return manifest +} + +func attachFiles(command *pluginsdk.CommandSpec, parent []string) { + path := append(append([]string{}, parent...), pluginsdk.CommandName(*command)) + switch strings.Join(path, " ") { + case "ledger export": + command.Files = &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"} + command.Streams = &pluginsdk.StreamSpec{Output: true} + case "ledger import": + command.Files = &pluginsdk.FileSpec{ReadArgument: new(1), ReadFlag: "file", ReadFormat: "string"} + command.Streams = &pluginsdk.StreamSpec{Input: true} + case "ledger transactions num": + command.Files = &pluginsdk.FileSpec{ReadArgument: new(0), ReadFormat: "string"} + case "ledger schemas insert": + command.Files = &pluginsdk.FileSpec{ReadArgument: new(1), ReadFormat: "yaml"} + case "ledger schemas get": + command.Files = &pluginsdk.FileSpec{FormatFlag: "format", WriteFormat: "json"} + } + for i := range command.Subcommands { + attachFiles(&command.Subcommands[i], path) + } +} diff --git a/misc/fctl-plugin/internal/ledger/manifest_test.go b/misc/fctl-plugin/internal/ledger/manifest_test.go new file mode 100644 index 00000000..91a80264 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/manifest_test.go @@ -0,0 +1,52 @@ +package ledger + +import ( + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +//nolint:gocognit // This recursive inventory checks the complete command and selector contract. +func TestManifestOfflineAndEveryHistoricalCommand(t *testing.T) { + m, err := New(nil).GetManifest(t.Context()) + if err != nil || m.Name != "ledger" || m.Service != "ledger" || m.Version != "1.0.0" || m.Root.Use != "ledger" || m.ProtocolVersion != pluginsdk.ProtocolVersion { + t.Fatalf("manifest %+v: %v", m, err) + } + want := strings.Fields("create send stats server-infos list set-metadata delete-metadata export import") + want = append(want, "accounts list", "accounts show", "accounts set-metadata", "accounts delete-metadata", "transactions list", "transactions num", "transactions revert", "transactions show", "transactions set-metadata", "transactions delete-metadata", "schemas insert", "schemas get", "schemas list", "volumes list") + var visit func(pluginsdk.CommandSpec) + count := 0 + visit = func(c pluginsdk.CommandSpec) { + if c.Runnable { + count++ + for _, input := range c.Inputs { + if input.Source == nil { + continue + } + source, err := pluginsdk.FindCommand(m, input.Source.CommandPath) + if err != nil || !source.Runnable { + t.Errorf("invalid selector source %v: %v", input.Source.CommandPath, err) + } + } + } + for _, child := range c.Subcommands { + visit(child) + } + } + visit(m.Root) + if count != len(want) { + t.Errorf("got %d executable commands, want %d", count, len(want)) + } + for _, path := range want { + c, err := pluginsdk.FindCommand(m, append([]string{"ledger"}, strings.Fields(path)...)) + if err != nil || !c.Runnable { + t.Errorf("missing historical command %s: %v", path, err) + } + } + m.Root.Subcommands[0].Use = "corrupted" + fresh, err := New(nil).GetManifest(t.Context()) + if err != nil || fresh.Root.Subcommands[0].Use != "create " { + t.Fatalf("manifest was shared: %v", err) + } +} diff --git a/misc/fctl-plugin/internal/ledger/plugin.go b/misc/fctl-plugin/internal/ledger/plugin.go new file mode 100644 index 00000000..8ec4aee2 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/plugin.go @@ -0,0 +1,101 @@ +// Package ledger ports cmd/ledger from fctl's historical main revision +// e00243b3e2e56aae6a09d7010b0c17890134388c onto the public plugin SDK. +package ledger + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type plugin struct{ http *http.Client } + +// New injects the host's HTTP transport. Manifest discovery works with nil. +// Endpoint is the service base URL, e.g. https://stack.example/api/ledger. +func New(client *http.Client) pluginsdk.Plugin { return &plugin{http: client} } + +func (*plugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { return manifest(), nil } + +var _ pluginsdk.StreamingPlugin = (*plugin)(nil) + +func (p *plugin) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + request, err := pluginsdk.NormalizeRequest(manifest(), request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + name := strings.Join(request.CommandPath[1:], " ") + if name != "import" && name != "export" { + if streams.Input != nil || streams.Output != nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("%s does not accept streams", name) + } + return p.Execute(ctx, request) + } + if request.Body != nil { + // Preserve bounded --data/direct SDK imports while files and stdin use + // the streaming path. Inline JSON is never combined with a raw source. + if name == "import" && streams.Input == nil && streams.Output == nil { + return p.Execute(ctx, request) + } + return pluginsdk.ExecuteResponse{}, fmt.Errorf("%s streaming does not accept Body; use ExecutionStreams", name) + } + if p.http == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("ledger execution requires an injected HTTP client") + } + client, err := httpclient.New(request.Endpoint, p.http) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if name == "import" { + return importLogsStream(ctx, client, request, streams) + } + if err := validateSegment(request.Flags["ledger"], "ledger"); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return exportLogsStream(ctx, client, request.Flags["ledger"], streams) +} + +func (p *plugin) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + request, err := pluginsdk.NormalizeRequest(manifest(), request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if p.http == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("ledger execution requires an injected HTTP client") + } + client, err := httpclient.New(request.Endpoint, p.http) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + name := strings.Join(request.CommandPath[1:], " ") + if request.Body != nil && name != "transactions num" && name != "schemas insert" && name != "import" { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("%s does not accept a JSON body", name) + } + if name == "import" { + return importLogs(ctx, client, request) + } + if name == "export" { + if err := validateSegment(request.Flags["ledger"], "ledger"); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return exportLogs(ctx, client, request.Flags["ledger"]) + } + op, err := prepare(ctx, client, name, request) + if err != nil { + return response(nil, err) + } + data, err := client.Do(ctx, op.method, op.path, op.query, op.body, nil) + return response(data, err) +} + +func response(data json.RawMessage, err error) (pluginsdk.ExecuteResponse, error) { + if failure, ok := errors.AsType[*httpclient.Error](err); ok && failure.Body != nil { + data = failure.Body + } + return pluginsdk.ExecuteResponse{Data: data}, err +} diff --git a/misc/fctl-plugin/internal/ledger/plugin_test.go b/misc/fctl-plugin/internal/ledger/plugin_test.go new file mode 100644 index 00000000..9be1f770 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/plugin_test.go @@ -0,0 +1,181 @@ +package ledger + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync/atomic" + "testing" +) + +//nolint:gocognit // Keep the complete HTTP contract fixture and assertions together for review. +func TestHistoricalHTTPContracts(t *testing.T) { + tests := []struct { + command, method, path string + args []string + flags map[string]string + body, payload string + query url.Values + }{ + {command: "create", method: "POST", path: "/v2/new", args: []string{"new"}, flags: map[string]string{"bucket": "bucket", "features": "HASH_LOGS=SYNC", "metadata": `["name=hello, world","key=a=b"]`}, payload: `{"bucket":"bucket","features":{"HASH_LOGS":"SYNC"},"metadata":{"name":"hello, world","key":"a=b"}}`}, + {command: "create", method: "POST", path: "/v2/empty", args: []string{"empty"}, payload: `{"bucket":""}`}, + {command: "send", method: "POST", path: "/default/transactions", args: []string{"users:1", huge, "USD/2"}, flags: map[string]string{"metadata": "owner=alice", "reference": "ref"}, payload: `{"postings":[{"source":"world","destination":"users:1","amount":` + huge + `,"asset":"USD/2"}],"metadata":{"owner":"alice"},"reference":"ref"}`}, + {command: "send", method: "POST", path: "/default/transactions", args: []string{"world:fund", "users:1", "0", "USD"}, payload: `{"postings":[{"source":"world:fund","destination":"users:1","amount":0,"asset":"USD"}],"reference":""}`}, + {command: "stats", method: "GET", path: "/default/stats"}, + {command: "server-infos", method: "GET", path: "/_info"}, + {command: "list", method: "GET", path: "/v2", payload: "null", query: url.Values{"includeDeleted": {"false"}}}, + {command: "set-metadata", method: "PUT", path: "/v2/named/metadata", args: []string{"named", "x=1", "x=2", "empty=", "value=a=b"}, payload: `{"x":"2","empty":"","value":"a=b"}`}, + {command: "delete-metadata", method: "DELETE", path: "/v2/named/metadata/key", args: []string{"named", "key"}}, + {command: "accounts list", method: "GET", path: "/v2/default/accounts", flags: map[string]string{"metadata": "owner=alice"}, payload: `{"$and":[{"$match":{"metadata[owner]":"alice"}}]}`}, + {command: "accounts list", method: "GET", path: "/v2/default/accounts", payload: `{"$and":[]}`}, + {command: "accounts show", method: "GET", path: "/default/accounts/users:1", args: []string{"users:1"}}, + {command: "accounts set-metadata", method: "POST", path: "/default/accounts/users:1/metadata", args: []string{"users:1", "key=value"}, payload: `{"key":"value"}`}, + {command: "accounts delete-metadata", method: "DELETE", path: "/v2/default/accounts/users:1/metadata/key", args: []string{"users:1", "key"}}, + {command: "transactions list", method: "GET", path: "/default/transactions", flags: map[string]string{"account": "users:1", "src": "world", "dst": "users:1", "reference": "ref", "start": "2026-10-01T12:30:00.123Z", "end": "2026-10-02T12:30:00Z", "metadata": "owner=alice"}, query: url.Values{"pageSize": {"5"}, "account": {"users:1"}, "source": {"world"}, "destination": {"users:1"}, "reference": {"ref"}, "startTime": {"2026-10-01T12:30:00.123Z"}, "endTime": {"2026-10-02T12:30:00Z"}, "metadata[owner]": {"alice"}}}, + {command: "transactions show", method: "GET", path: "/default/transactions/" + huge, args: []string{huge}}, + {command: "transactions num", method: "POST", path: "/default/transactions", body: `"send [USD/2 10] ( source = @world destination = @users:1 )"`, payload: `{"script":{"plain":"send [USD/2 10] ( source = @world destination = @users:1 )"},"reference":""}`}, + {command: "transactions revert", method: "POST", path: "/default/transactions/12/revert", args: []string{"12"}, query: url.Values{"disableChecks": {"false"}}}, + {command: "transactions revert", method: "POST", path: "/v2/default/transactions/12/revert", args: []string{"12"}, flags: map[string]string{"at-effective-date": "true", "force": "true"}, query: url.Values{"atEffectiveDate": {"true"}, "force": {"true"}}}, + {command: "transactions set-metadata", method: "POST", path: "/default/transactions/12/metadata", args: []string{"12", "key=value"}, payload: `{"key":"value"}`}, + {command: "transactions delete-metadata", method: "DELETE", path: "/v2/default/transactions/12/metadata/key", args: []string{"12", "key"}}, + {command: "schemas list", method: "GET", path: "/v2/default/schemas", query: url.Values{"pageSize": {"15"}, "sort": {"created_at"}, "order": {"desc"}}}, + {command: "schemas get", method: "GET", path: "/v2/default/schemas/1", args: []string{"1"}, flags: map[string]string{"format": "yaml"}}, + {command: "schemas insert", method: "POST", path: "/v2/default/schemas/1", args: []string{"1", "host-read.yaml"}, body: `{"chart":{"users":{"metadata":{"large":` + huge + `}}},"extension":{"n":` + huge + `}}`, payload: `{"chart":{"users":{"metadata":{"large":` + huge + `}}},"extension":{"n":` + huge + `}}`}, + {command: "volumes list", method: "GET", path: "/v2/default/volumes", flags: map[string]string{"metadata": "owner=alice", "address": "users:", "group-by": "2", "insertion-date": "true", "start-time": "2026-10-01T12:00:00Z", "end-time": "2026-10-02T12:00:00Z"}, payload: `{"$and":[{"$match":{"metadata[owner]":"alice"}},{"$match":{"account":"users:"}}]}`, query: url.Values{"pageSize": {"10"}, "groupBy": {"2"}, "insertionDate": {"true"}, "startTime": {"2026-10-01T12:00:00Z"}, "endTime": {"2026-10-02T12:00:00Z"}}}, + } + for _, test := range tests { + t.Run(test.command+test.path, func(t *testing.T) { + var calls atomic.Int32 + wantData := `{"data":{"id":` + huge + `,"amount":` + huge + `},"extra":{"future":true}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != test.method || r.URL.EscapedPath() != "/gateway/api/ledger"+test.path { + t.Errorf("got %s %s, want %s %s", r.Method, r.URL.EscapedPath(), test.method, test.path) + } + wantQuery := test.query + if wantQuery == nil { + wantQuery = url.Values{} + } + if !reflect.DeepEqual(r.URL.Query(), wantQuery) { + t.Errorf("query %v, want %v", r.URL.Query(), wantQuery) + } + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if test.payload == "" { + if len(payload) > 0 { + t.Errorf("unexpected body: %s", payload) + } + } else { + assertJSON(t, payload, []byte(test.payload)) + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("content type %q", r.Header.Get("Content-Type")) + } + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept %q", r.Header.Get("Accept")) + } + writeJSON(t, w, wantData) + })) + t.Cleanup(server.Close) + r := executeRequest(test.command, test.args, test.flags, test.body) + r.Endpoint = server.URL + "/gateway/api/ledger" + result, err := New(server.Client()).Execute(t.Context(), r) + if err != nil { + t.Fatal(err) + } + if string(result.Data) != wantData || calls.Load() != 1 { + t.Fatalf("data %s; calls %d", result.Data, calls.Load()) + } + }) + } +} + +func TestValidationBeforeHTTP(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); w.WriteHeader(500) })) + t.Cleanup(server.Close) + tests := []struct { + command string + args []string + flags map[string]string + body string + }{ + {"missing", nil, nil, ""}, {"accounts", nil, nil, ""}, {"send", []string{"a", "10"}, nil, ""}, + {"send", []string{"a", "ten", "USD"}, nil, ""}, {"send", []string{"a", "-1", "USD"}, nil, ""}, + {"send", []string{"", "1", "USD"}, nil, ""}, {"send", []string{"a", "1", ""}, nil, ""}, + {"send", []string{"a", "1", "USD"}, map[string]string{"confirm": "false"}, ""}, + {"send", []string{"a", "1", "USD"}, map[string]string{"metadata": "bad"}, ""}, + {"create", []string{".."}, nil, ""}, {"create", []string{"x"}, map[string]string{"features": "bad"}, ""}, + {"create", []string{"x"}, map[string]string{"metadata": `[1]`}, ""}, + {"stats", nil, map[string]string{"ledger": "."}, ""}, {"stats", nil, map[string]string{"unknown": "value"}, ""}, + {"stats", nil, nil, `{}`}, {"accounts show", []string{""}, nil, ""}, + {"accounts list", nil, map[string]string{"metadata": "bad"}, ""}, + {"set-metadata", []string{"x", "bad"}, nil, ""}, {"delete-metadata", []string{"x", ".."}, nil, ""}, + {"transactions show", []string{"abc"}, nil, ""}, {"transactions show", []string{"-1"}, nil, ""}, + {"transactions show", []string{"lastwat"}, nil, ""}, {"transactions show", []string{"last-1"}, nil, ""}, + {"transactions set-metadata", []string{"last", "bad"}, nil, ""}, + {"transactions delete-metadata", []string{"last", "."}, nil, ""}, + {"transactions revert", []string{"12"}, map[string]string{"force": "yes"}, ""}, + {"transactions list", nil, map[string]string{"page-size": "-1"}, ""}, + {"transactions list", nil, map[string]string{"page-size": "0"}, ""}, + {"schemas list", nil, map[string]string{"page-size": "0"}, ""}, + {"volumes list", nil, map[string]string{"page-size": "0"}, ""}, + {"transactions list", nil, map[string]string{"start": "yesterday"}, ""}, + {"transactions list", nil, map[string]string{"end": "yesterday"}, ""}, + {"transactions num", nil, nil, ""}, {"transactions num", nil, nil, `null`}, + {"transactions num", nil, nil, `{"script":{"plain":""}}`}, + {"transactions num", nil, map[string]string{"amount-var": "n=no/USD"}, `"script"`}, + {"transactions num", nil, map[string]string{"amount-var": "n=1"}, `"script"`}, + {"transactions num", nil, map[string]string{"account-var": "bad"}, `"script"`}, + {"transactions num", nil, map[string]string{"timestamp": "today"}, `"script"`}, + {"transactions num", nil, nil, `{"script":{"plain":"script","vars":[]}}`}, + {"transactions num", nil, nil, `{"script":{"plain":"script"},"postings":[]}`}, + {"transactions num", nil, nil, `{"script":{"plain":"script"},"metadata":[]}`}, + {"transactions num", nil, nil, `{"script":{"plain":"script"},"reference":3}`}, + {"schemas get", []string{".."}, nil, ""}, {"schemas get", []string{"1"}, map[string]string{"format": "toml"}, ""}, + {"schemas insert", []string{"1"}, nil, `{}`}, {"schemas insert", []string{"1"}, nil, `{"chart":[]}`}, + {"schemas insert", []string{"1"}, nil, `{"chart":{"a":null}}`}, + {"schemas insert", []string{"1"}, nil, `{"chart":{},"queries":[]}`}, + {"volumes list", nil, map[string]string{"group-by": "-1"}, ""}, + {"volumes list", nil, map[string]string{"start-time": "today"}, ""}, + {"import", []string{"x"}, nil, ""}, {"import", []string{"x"}, nil, `null`}, + {"import", []string{"x"}, nil, `[{"id":1},{"id":-2}]`}, + {"import", []string{"x"}, map[string]string{"resume-from-last-log": "true"}, `[{"id":1},{}]`}, + {"import", []string{"x"}, nil, `"{bad}\n"`}, + } + for _, test := range tests { + t.Run(fmt.Sprint(test), func(t *testing.T) { + r := executeRequest(test.command, test.args, test.flags, test.body) + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(t.Context(), r); err == nil { + t.Fatal("expected validation error") + } + }) + } + if calls.Load() != 0 { + t.Fatalf("validation made %d HTTP calls", calls.Load()) + } + if _, err := New(nil).Execute(t.Context(), executeRequest("stats", nil, nil, "")); err == nil || !strings.Contains(err.Error(), "injected") { + t.Fatalf("missing-client error: %v", err) + } +} + +func TestCallerCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeJSON(t, w, `{}`) })) + t.Cleanup(server.Close) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + r := executeRequest("stats", nil, nil, "") + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(ctx, r); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } +} diff --git a/misc/fctl-plugin/internal/ledger/schemas.go b/misc/fctl-plugin/internal/ledger/schemas.go new file mode 100644 index 00000000..fd2e0e0e --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/schemas.go @@ -0,0 +1,74 @@ +package ledger + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func schemasList(r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, path: httpclient.Path("v2", r.Flags["ledger"], "schemas"), query: url.Values{"order": {"desc"}, "sort": {"created_at"}}} + if err := paginate(op.query, r); err != nil { + return op, err + } + if r.Flags["cursor"] != "" { + // Historical generated SDK default even when the controller supplies only a cursor. + op.query.Set("pageSize", "15") + } + return op, nil +} + +func schemaOperation(name string, r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet} + if err := validateSegment(r.Args[0], "schema version"); err != nil { + return op, err + } + op.path = httpclient.Path("v2", r.Flags["ledger"], "schemas", r.Args[0]) + if name == "schemas insert" { + if err := validateSchema(r.Body); err != nil { + return op, err + } + op.method, op.body = http.MethodPost, r.Body + } else if format := r.Flags["format"]; format != "json" && format != "yaml" && format != "yml" { + return op, fmt.Errorf("unsupported format %q (expected json or yaml)", format) + } + return op, nil +} + +func validateSchema(raw json.RawMessage) error { + var schema map[string]json.RawMessage + if err := json.Unmarshal(raw, &schema); err != nil || schema == nil { + return fmt.Errorf("schema Body must be a JSON object; the host must read and convert the source") + } + for _, name := range []string{"chart", "queries", "transactions"} { + value, exists := schema[name] + if !exists { + if name == "chart" { + return fmt.Errorf("schema chart is required") + } + continue + } + if err := validateSchemaEntries(name, value); err != nil { + return err + } + } + return nil +} + +func validateSchemaEntries(name string, raw json.RawMessage) error { + var entries map[string]json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil || entries == nil { + return fmt.Errorf("schema %s must be a JSON object", name) + } + for key, entry := range entries { + var object map[string]json.RawMessage + if err := json.Unmarshal(entry, &object); err != nil || object == nil { + return fmt.Errorf("schema %s[%s] must be a JSON object", name, key) + } + } + return nil +} diff --git a/misc/fctl-plugin/internal/ledger/stream.go b/misc/fctl-plugin/internal/ledger/stream.go new file mode 100644 index 00000000..b16f0d8a --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/stream.go @@ -0,0 +1,290 @@ +package ledger + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const ( + maxLogRecordBytes = 4 << 20 + maxImportBatchBytes = 4 << 20 // The callback includes the NDJSON delimiter. +) + +var errEndLogs = errors.New("end of NDJSON logs") + +// Import holds at most one bounded batch and one bounded record. Read progress +// measures source bytes; import progress measures only acknowledged log writes. +func importLogsStream(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + progress := importProgress{} + ledgerName := r.Args[0] + if err := validateSegment(ledgerName, "ledger name"); err != nil { + return progress.response(), err + } + if streams.Input == nil || streams.Output != nil { + return progress.response(), fmt.Errorf("import requires an input stream and no output stream") + } + if err := reportProgress(ctx, streams, "read", 0, streams.InputSize, "bytes"); err != nil { + return progress.response(), err + } + if err := reportProgress(ctx, streams, "import", 0, nil, "logs"); err != nil { + return progress.response(), err + } + checkpoint := "" + if r.Flags["resume-from-last-log"] == "true" { + var err error + checkpoint, err = latestLogID(ctx, client, ledgerName) + if err != nil { + return progress.response(), err + } + } + err := importStreamRecords(ctx, client, ledgerName, streams, checkpoint, &progress) + return progress.response(), err +} + +func importStreamRecords(ctx context.Context, client *httpclient.Client, ledgerName string, streams pluginsdk.ExecutionStreams, checkpoint string, progress *importProgress) error { + reader := bufio.NewReader(&progressReader{ctx: ctx, streams: streams}) + batch := logBatch{client: client, ledger: ledgerName, streams: streams} + var scanned int64 + for { + record, err := readLogRecord(ctx, reader) + if errors.Is(err, errEndLogs) { + break + } + if err != nil { + return err + } + id, err := logID(record) + if err != nil { + return fmt.Errorf("log %d: %w", scanned+1, err) + } + scanned++ + if checkpoint != "" { + if id == checkpoint { + checkpoint = "" + progress.Skipped = scanned + } + continue + } + if err := batch.append(ctx, record, progress); err != nil { + return err + } + } + if checkpoint != "" { + return fmt.Errorf("log %s not found in import stream; no logs written", checkpoint) + } + return batch.flush(ctx, progress) +} + +type logBatch struct { + client *httpclient.Client + ledger string + streams pluginsdk.ExecutionStreams + data []byte + count int64 +} + +func (b *logBatch) append(ctx context.Context, record []byte, progress *importProgress) error { + var compact bytes.Buffer + if err := json.Compact(&compact, record); err != nil { + return fmt.Errorf("encode log: %w", err) + } + if compact.Len()+1 > maxImportBatchBytes { + return fmt.Errorf("NDJSON log with newline exceeds the 4 MiB import batch limit") + } + if len(b.data)+compact.Len()+1 > maxImportBatchBytes { + if err := b.flush(ctx, progress); err != nil { + return err + } + } + b.data = append(b.data, compact.Bytes()...) + b.data = append(b.data, '\n') + b.count++ + if b.count == importBlockSize { + return b.flush(ctx, progress) + } + return nil +} + +func (b *logBatch) flush(ctx context.Context, progress *importProgress) error { + if err := ctx.Err(); err != nil { + return err + } + if b.count == 0 { + return nil + } + _, err := b.client.Do(ctx, http.MethodPost, httpclient.Path("v2", b.ledger, "logs", "import"), nil, b.data, http.Header{"Content-Type": {"application/octet-stream"}}) + if err != nil { + if failure, ok := errors.AsType[*httpclient.Error](err); ok { + progress.Error = failure.Body + } + // An unacknowledged batch can have reached the service. Never replay it. + return err + } + progress.Imported += b.count + progress.Batches++ + b.data = b.data[:0] + b.count = 0 + return reportProgress(ctx, b.streams, "import", progress.Imported, nil, "logs") +} + +type progressReader struct { + ctx context.Context + streams pluginsdk.ExecutionStreams + completed int64 +} + +func (r *progressReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + n, err := r.streams.Input.Read(p) + r.completed += int64(n) + if n > 0 { + if reportErr := reportProgress(r.ctx, r.streams, "read", r.completed, r.streams.InputSize, "bytes"); reportErr != nil { + return 0, fmt.Errorf("report read progress: %w", errors.Join(err, reportErr)) + } + } + return n, err +} + +func reportProgress(ctx context.Context, streams pluginsdk.ExecutionStreams, phase string, completed int64, total *int64, unit string) error { + if err := ctx.Err(); err != nil { + return err + } + event := pluginsdk.ProgressEvent{Phase: phase, Completed: completed, Total: total, Unit: unit} + if err := event.Validate(); err != nil { + return err + } + if streams.Report != nil { + return errors.Join(streams.Report(event), ctx.Err()) + } + return nil +} + +// ReadSlice bounds allocation even for a peer that never sends a newline. The +// returned record keeps the exact bytes, including whitespace and its delimiter. +func readLogRecord(ctx context.Context, reader *bufio.Reader) ([]byte, error) { + var record []byte + for { + if err := ctx.Err(); err != nil { + return nil, err + } + fragment, err := reader.ReadSlice('\n') + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if len(record)+len(fragment) > maxLogRecordBytes { + return nil, fmt.Errorf("NDJSON log exceeds the 4 MiB record limit") + } + record = append(record, fragment...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + // Only the Reader's exact EOF sentinel ends a record. In particular, + // a reporter error wrapping EOF must still fail the invocation. + if err != nil && err != io.EOF { + return nil, fmt.Errorf("read logs: %w", err) + } + if len(record) == 0 && err == io.EOF { + return nil, errEndLogs + } + return record, nil + } +} + +func exportLogsStream(ctx context.Context, client *httpclient.Client, ledgerName string, streams pluginsdk.ExecutionStreams) (result pluginsdk.ExecuteResponse, err error) { + if streams.Output == nil || streams.Input != nil || streams.InputSize != nil { + return result, fmt.Errorf("export requires an output stream and no input stream") + } + if err := reportProgress(ctx, streams, "export", 0, nil, "logs"); err != nil { + return result, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.Endpoint()+httpclient.Path("v2", ledgerName, "logs", "export"), nil) + if err != nil { + return result, err + } + req.Header.Set("Accept", "*/*") + res, err := client.HTTPClient().Do(req) + if err != nil { + return result, fmt.Errorf("export logs: %w", err) + } + defer func() { err = errors.Join(err, res.Body.Close()) }() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return exportStreamFailure(res) + } + var progress exportProgress + err = copyExportStream(ctx, res.Body, streams, &progress) + return progress.response(), err +} + +type exportProgress struct{ exported, written int64 } + +func (p exportProgress) response() pluginsdk.ExecuteResponse { + return pluginsdk.ExecuteResponse{Data: fmt.Appendf(nil, `{"exported":%d,"bytes":%d}`, p.exported, p.written)} +} + +func copyExportStream(ctx context.Context, input io.Reader, streams pluginsdk.ExecutionStreams, progress *exportProgress) error { + reader := bufio.NewReader(input) + for { + record, readErr := readLogRecord(ctx, reader) + if errors.Is(readErr, errEndLogs) { + return nil + } + if readErr != nil { + return readErr + } + if _, err := logID(record); err != nil { + return fmt.Errorf("export log %d: %w", progress.exported+1, err) + } + if err := progress.writeRecord(ctx, streams.Output, record); err != nil { + return err + } + if err := reportProgress(ctx, streams, "export", progress.exported, nil, "logs"); err != nil { + return err + } + } +} + +func (p *exportProgress) writeRecord(ctx context.Context, output io.Writer, record []byte) error { + if err := ctx.Err(); err != nil { + return err + } + n, err := output.Write(record) + p.written += int64(n) + if err != nil { + return fmt.Errorf("write export: %w", err) + } + if n != len(record) { + return io.ErrShortWrite + } + p.exported++ + return nil +} + +func exportStreamFailure(res *http.Response) (pluginsdk.ExecuteResponse, error) { + data, err := io.ReadAll(io.LimitReader(res.Body, maxLogRecordBytes+1)) + if err != nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("read export error: %w", err) + } + if len(data) > maxLogRecordBytes { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("export error response exceeds 4 MiB") + } + failure := &httpclient.Error{StatusCode: res.StatusCode} + var details struct { + Code string `json:"errorCode"` + Message string `json:"errorMessage"` + } + if json.Unmarshal(data, &details) == nil { + failure.Body, failure.Code, failure.Message = data, details.Code, details.Message + } + return response(nil, failure) +} diff --git a/misc/fctl-plugin/internal/ledger/stream_test.go b/misc/fctl-plugin/internal/ledger/stream_test.go new file mode 100644 index 00000000..57ea920c --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/stream_test.go @@ -0,0 +1,575 @@ +package ledger + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type streamTransport func(*http.Request) (*http.Response, error) + +func (f streamTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func streamResponse(status int, body io.Reader) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(body)} +} + +func streamPlugin(t *testing.T, transport streamTransport) pluginsdk.StreamingPlugin { + t.Helper() + streaming, ok := New(&http.Client{Transport: transport}).(pluginsdk.StreamingPlugin) + if !ok { + t.Fatal("Ledger must implement StreamingPlugin") + } + return streaming +} + +func streamRequest(command string, args []string, flags map[string]string, body string) pluginsdk.ExecuteRequest { + r := executeRequest(command, args, flags, body) + r.Endpoint = "https://ledger.example" + return r +} + +// The fixture never allocates the whole input. It repeats one bounded record. +type generatedLogs struct { + record []byte + remaining int + offset int + eof bool + endError error +} + +func (g *generatedLogs) Read(p []byte) (int, error) { + if g.remaining == 0 { + g.eof = true + if g.endError != nil { + return 0, g.endError + } + return 0, io.EOF + } + n := 0 + for n < len(p) && g.remaining > 0 { + copied := copy(p[n:], g.record[g.offset:]) + n += copied + g.offset += copied + if g.offset == len(g.record) { + g.offset = 0 + g.remaining-- + } + } + return n, nil +} + +//nolint:gocognit // Verify byte and count bounds, raw fidelity and both progress units beside the generated source. +func TestStreamImportOver64MiBBoundedBatches(t *testing.T) { + const count = 550 + record := []byte(`{"id":` + huge + `,"unknown":{"amount":` + huge + `,"text":"` + strings.Repeat("x", 128<<10) + `"}}` + "\n") + input := &generatedLogs{record: record, remaining: count} + size := int64(len(record) * count) + if size <= 64<<20 { + t.Fatal("fixture must exceed the old total stream limit") + } + var posts, acknowledged int64 + var lastRead, lastImported int64 + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + if r.Method != "POST" || r.URL.Path != "/v2/named/logs/import" || r.Header.Get("Content-Type") != "application/octet-stream" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL) + } + if posts == 0 && input.eof { + t.Fatal("first import waited for source EOF") + } + data, err := io.ReadAll(io.LimitReader(r.Body, maxImportBatchBytes+1)) + if err != nil || len(data) > maxImportBatchBytes { + t.Fatalf("batch bytes %d: %v", len(data), err) + } + logs := bytes.Count(data, []byte{'\n'}) + if logs == 0 || logs > importBlockSize || !bytes.Equal(data, bytes.Repeat(record, logs)) { + t.Fatal("batch lost raw fields, exact numbers or its bound") + } + acknowledged += int64(logs) + posts++ + return streamResponse(204, strings.NewReader("")), nil + }) + r := streamRequest("import", []string{"named"}, nil, "") + result, err := p.ExecuteStream(t.Context(), r, pluginsdk.ExecutionStreams{ + Input: input, InputSize: &size, Report: func(event pluginsdk.ProgressEvent) error { + if err := event.Validate(); err != nil { + return err + } + switch event.Phase { + case "read": + if event.Unit != "bytes" || event.Total == nil || *event.Total != size || event.Completed < lastRead { + t.Fatalf("invalid read progress %+v", event) + } + lastRead = event.Completed + case "import": + if event.Unit != "logs" || event.Total != nil || event.Completed != acknowledged { + t.Fatalf("import progress must count ACKs: %+v, ACKs %d", event, acknowledged) + } + lastImported = event.Completed + default: + t.Fatalf("unexpected phase %+v", event) + } + return nil + }, + }) + if err != nil || lastRead != size || lastImported != count || posts <= count/importBlockSize { + t.Fatalf("read %d imported %d posts %d: %v", lastRead, lastImported, posts, err) + } + assertJSON(t, result.Data, fmt.Appendf(nil, `{"imported":%d,"skipped":0,"batches":%d}`, count, posts)) +} + +//nolint:gocognit // Keep the checkpoint matrix and its no-write assertions together. +func TestStreamImportResumeExactCheckpoint(t *testing.T) { + for _, checkpoint := range []string{"", huge, "42", `"invalid"`} { + t.Run(checkpoint, func(t *testing.T) { + var posts int + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + if r.Method == "GET" { + if r.URL.Path != "/v2/named/logs" || r.URL.Query().Get("pageSize") != "1" { + t.Fatalf("checkpoint request %s", r.URL) + } + body := `{"cursor":{"data":[]}}` + if checkpoint != "" { + body = `{"cursor":{"data":[{"id":` + checkpoint + `}]}}` + } + return streamResponse(200, strings.NewReader(body)), nil + } + posts++ + data, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if checkpoint == huge && string(data) != "{\"id\":0}\n" { + t.Fatalf("replayed checkpoint %s", data) + } + return streamResponse(204, strings.NewReader("")), nil + }) + r := streamRequest("import", []string{"named"}, map[string]string{"resume-from-last-log": "true"}, "") + input := strings.NewReader(`{"id":1}` + "\n" + `{"id":` + huge + `}` + "\n" + `{"id":0}`) + result, err := p.ExecuteStream(t.Context(), r, pluginsdk.ExecutionStreams{Input: input}) + if checkpoint == "42" || checkpoint == `"invalid"` { + if err == nil || posts != 0 { + t.Fatalf("missing checkpoint made %d writes: %v", posts, err) + } + assertJSON(t, result.Data, []byte(`{"imported":0,"skipped":0,"batches":0}`)) + return + } + if err != nil || posts != 1 { + t.Fatalf("posts %d: %v", posts, err) + } + want := `{"imported":3,"skipped":0,"batches":1}` + if checkpoint == huge { + want = `{"imported":1,"skipped":2,"batches":1}` + } + assertJSON(t, result.Data, []byte(want)) + }) + } +} + +//nolint:gocognit // Verify both structured and ambiguous failures against the same ACK counters. +func TestStreamImportFailedBatchNeverReplayed(t *testing.T) { + for _, transportError := range []bool{false, true} { + t.Run(fmt.Sprint(transportError), func(t *testing.T) { + failure := errors.New("lost acknowledgment") + posts := 0 + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + posts++ + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Fatal(err) + } + if posts == 2 { + if transportError { + return nil, failure + } + return streamResponse(409, strings.NewReader(`{"errorCode":"IMPORT","errorMessage":"bad hash"}`)), nil + } + return streamResponse(204, strings.NewReader("")), nil + }) + input := &generatedLogs{record: []byte("{\"id\":1}\n"), remaining: 205} + result, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: input}) + if err == nil || posts != 2 { + t.Fatalf("posts %d: %v", posts, err) + } + if transportError && !errors.Is(err, failure) { + t.Fatal(err) + } + want := `{"imported":100,"skipped":0,"batches":1}` + if !transportError { + want = `{"imported":100,"skipped":0,"batches":1,"error":{"errorCode":"IMPORT","errorMessage":"bad hash"}}` + } + assertJSON(t, result.Data, []byte(want)) + }) + } +} + +func TestStreamImportLateInputFailureCountsOnlyACKs(t *testing.T) { + for _, malformed := range []bool{false, true} { + t.Run(fmt.Sprint(malformed), func(t *testing.T) { + posts := 0 + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + posts++ + return streamResponse(204, strings.NewReader("")), nil + }) + var input io.Reader = &generatedLogs{record: []byte("{\"id\":1}\n"), remaining: 100, endError: io.ErrUnexpectedEOF} + if malformed { + input = io.MultiReader(&generatedLogs{record: []byte("{\"id\":1}\n"), remaining: 100}, strings.NewReader("{bad}\n")) + } + result, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: input}) + if err == nil || posts != 1 { + t.Fatalf("posts %d: %v", posts, err) + } + if !malformed && !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatal(err) + } + assertJSON(t, result.Data, []byte(`{"imported":100,"skipped":0,"batches":1}`)) + }) + } +} + +type finalErrorReader struct { + content string + err error +} + +func (r *finalErrorReader) Read(p []byte) (int, error) { + n := copy(p, r.content) + r.content = r.content[n:] + return n, r.err +} + +//nolint:gocognit // Keep reporter and cancellation outcomes beside their confirmed-write assertions. +func TestStreamImportProgressErrorsAndCancellation(t *testing.T) { + for _, phase := range []string{"read", "import"} { + for _, cancelContext := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%t", phase, cancelContext), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + failure := errors.New("report stopped") + posts := 0 + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + posts++ + return streamResponse(204, strings.NewReader("")), nil + }) + input := &finalErrorReader{content: "{\"id\":1}", err: io.EOF} + result, err := p.ExecuteStream(ctx, streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: input, Report: func(event pluginsdk.ProgressEvent) error { + if event.Phase == phase && event.Completed > 0 { + if cancelContext { + cancel() + return nil + } + return failure + } + return nil + }}) + wantErr := failure + if cancelContext { + wantErr = context.Canceled + } + if !errors.Is(err, wantErr) { + t.Fatalf("got %v want %v", err, wantErr) + } + want := `{"imported":0,"skipped":0,"batches":0}` + if phase == "import" { + want = `{"imported":1,"skipped":0,"batches":1}` + if posts != 1 { + t.Fatalf("posts %d", posts) + } + } else if posts != 0 { + t.Fatalf("read failure made %d writes", posts) + } + assertJSON(t, result.Data, []byte(want)) + }) + } + } +} + +func TestStreamImportEmptyAndZeroTotal(t *testing.T) { + for _, total := range []*int64{nil, new(int64(0)), new(int64(-1))} { + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { t.Fatal("empty import must not write"); return nil, nil }) + var events []pluginsdk.ProgressEvent + result, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: strings.NewReader(""), InputSize: total, Report: func(e pluginsdk.ProgressEvent) error { events = append(events, e); return nil }}) + if total != nil && *total < 0 { + if err == nil { + t.Fatal("negative size accepted") + } + continue + } + if err != nil || len(events) != 2 || events[0].Total != total || events[0].Completed != 0 { + t.Fatalf("events %+v: %v", events, err) + } + assertJSON(t, result.Data, []byte(`{"imported":0,"skipped":0,"batches":0}`)) + } +} + +func TestStreamImportReportEOFIsFailure(t *testing.T) { + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + t.Fatal("report failure must not write") + return nil, nil + }) + input := &finalErrorReader{content: "{\"id\":1}", err: io.EOF} + result, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{ + Input: input, Report: func(e pluginsdk.ProgressEvent) error { + if e.Phase == "read" && e.Completed > 0 { + return io.EOF + } + return nil + }, + }) + if !errors.Is(err, io.EOF) { + t.Fatalf("report EOF swallowed: %v", err) + } + assertJSON(t, result.Data, []byte(`{"imported":0,"skipped":0,"batches":0}`)) +} + +func TestStreamImportZeroTotalRejectsNonemptyInput(t *testing.T) { + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + t.Fatal("invalid total must not write") + return nil, nil + }) + _, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{ + Input: strings.NewReader("{\"id\":1}"), InputSize: new(int64(0)), + }) + if err == nil { + t.Fatal("nonempty source accepted with zero total") + } +} + +func TestStreamImportCancelDuringRequestDoesNotACKOrRetry(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + posts := 0 + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + posts++ + cancel() + return nil, r.Context().Err() + }) + result, err := p.ExecuteStream(ctx, streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: strings.NewReader("{\"id\":1}")}) + if !errors.Is(err, context.Canceled) || posts != 1 { + t.Fatalf("posts %d: %v", posts, err) + } + assertJSON(t, result.Data, []byte(`{"imported":0,"skipped":0,"batches":0}`)) +} + +func TestStreamImportInvalidCheckpointEnvelopeNeverWrites(t *testing.T) { + for _, body := range []string{`{}`, `null`, `{"cursor":{}}`, `{"cursor":{"data":null}}`} { + t.Run(body, func(t *testing.T) { + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + if r.Method != "GET" { + t.Fatal("invalid checkpoint response must not write") + } + return streamResponse(200, strings.NewReader(body)), nil + }) + _, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, map[string]string{"resume-from-last-log": "true"}, ""), pluginsdk.ExecutionStreams{Input: strings.NewReader("{\"id\":1}")}) + if err == nil { + t.Fatal("invalid checkpoint response treated as empty ledger") + } + }) + } +} + +func TestStreamRecordAndBatchByteLimits(t *testing.T) { + for _, length := range []int{maxLogRecordBytes, maxLogRecordBytes + 1} { + input := `{"id":1,"payload":"` + strings.Repeat("x", length-len(`{"id":1,"payload":""}`)) + `"}` + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + t.Fatal("oversize import must not write") + return nil, nil + }) + _, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: strings.NewReader(input)}) + if err == nil || !strings.Contains(err.Error(), "4 MiB") { + t.Fatal(err) + } + } + // Exactly 4 MiB including the added delimiter is accepted. + input := `{"id":1,"payload":"` + strings.Repeat("x", maxLogRecordBytes-1-len(`{"id":1,"payload":""}`)) + `"}` + posts := 0 + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + posts++ + data, err := io.ReadAll(r.Body) + if err != nil || len(data) != maxImportBatchBytes { + t.Fatalf("bytes %d: %v", len(data), err) + } + return streamResponse(204, strings.NewReader("")), nil + }) + _, err := p.ExecuteStream(t.Context(), streamRequest("import", []string{"named"}, nil, ""), pluginsdk.ExecutionStreams{Input: strings.NewReader(input)}) + if err != nil || posts != 1 { + t.Fatalf("posts %d: %v", posts, err) + } +} + +type earlyWriter struct { + writer io.Writer + source *generatedLogs + early bool + bytes int64 +} + +func (w *earlyWriter) Write(p []byte) (int, error) { + if !w.source.eof { + w.early = true + } + n, err := w.writer.Write(p) + w.bytes += int64(n) + return n, err +} + +func TestStreamExportOver64MiBToFileBeforeEOF(t *testing.T) { + const count = 550 + record := []byte(`{"id":` + huge + `,"unknown":"` + strings.Repeat("x", 128<<10) + `"}` + "\n") + source := &generatedLogs{record: record, remaining: count} + p := streamPlugin(t, func(r *http.Request) (*http.Response, error) { + if r.Method != "POST" || r.URL.Path != "/v2/named/logs/export" || r.Header.Get("Accept") != "*/*" { + t.Fatalf("request %s", r.URL) + } + return streamResponse(200, source), nil + }) + file, err := os.Create(filepath.Join(t.TempDir(), "export.ndjson")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Error(err) + } + }) + output := &earlyWriter{writer: file, source: source} + result, err := p.ExecuteStream(t.Context(), streamRequest("export", nil, map[string]string{"ledger": "named"}, ""), pluginsdk.ExecutionStreams{Output: output}) + if err != nil { + t.Fatal(err) + } + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + size := int64(len(record) * count) + if size <= 64<<20 || info.Size() != size || !output.early || !source.eof { + t.Fatalf("size %d early %t eof %t", info.Size(), output.early, source.eof) + } + assertJSON(t, result.Data, fmt.Appendf(nil, `{"exported":%d,"bytes":%d}`, count, size)) +} + +func TestStreamExportPreservesExactRawBytesAndMissingNewline(t *testing.T) { + for _, content := range []string{"", " {\"id\":" + huge + ",\"unknown\":{\"amount\":" + huge + "}}\r\n{\"id\":0}"} { + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + return streamResponse(200, strings.NewReader(content)), nil + }) + var output bytes.Buffer + result, err := p.ExecuteStream(t.Context(), streamRequest("export", nil, nil, ""), pluginsdk.ExecutionStreams{Output: &output}) + if err != nil || output.String() != content { + t.Fatalf("raw bytes %q: %v", output.String(), err) + } + count := 0 + if content != "" { + count = 2 + } + assertJSON(t, result.Data, fmt.Appendf(nil, `{"exported":%d,"bytes":%d}`, count, len(content))) + } +} + +type errorWriter struct { + err error + short bool +} + +func (w errorWriter) Write(p []byte) (int, error) { + if w.short { + return len(p) - 1, nil + } + return 0, w.err +} + +//nolint:gocognit // Keep each injected IO failure beside its output prefix and summary assertions. +func TestStreamExportFailuresAndCancellation(t *testing.T) { + failure := errors.New("output failed") + for _, kind := range []string{"malformed", "read", "write", "short", "report", "cancel", "http", "oversize", "close"} { + t.Run(kind, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var output bytes.Buffer + streams := pluginsdk.ExecutionStreams{Output: &output} + var body io.Reader = strings.NewReader("{\"id\":1}\n{\"id\":2}") + status := 200 + switch kind { + case "malformed": + body = strings.NewReader("{\"id\":1}\n{bad}\n") + case "read": + body = io.MultiReader(strings.NewReader("{\"id\":1}\n"), brokenReader{}) + case "write": + streams.Output = errorWriter{err: failure} + case "short": + streams.Output = errorWriter{short: true} + case "report", "cancel": + streams.Report = func(e pluginsdk.ProgressEvent) error { + if e.Completed > 0 { + if kind == "cancel" { + cancel() + return nil + } + return failure + } + return nil + } + case "http": + status = 403 + body = strings.NewReader(`{"errorCode":"DENIED","errorMessage":"no export"}`) + case "oversize": + body = strings.NewReader(strings.Repeat("x", maxLogRecordBytes+1)) + } + p := streamPlugin(t, func(*http.Request) (*http.Response, error) { + res := streamResponse(status, body) + if kind == "close" { + res.Body = closeErrorBody{Reader: body, err: failure} + } + return res, nil + }) + result, err := p.ExecuteStream(ctx, streamRequest("export", nil, nil, ""), streams) + if err == nil { + t.Fatal("expected failure") + } + switch kind { + case "write", "report", "close": + if !errors.Is(err, failure) { + t.Fatal(err) + } + case "short": + if !errors.Is(err, io.ErrShortWrite) { + t.Fatal(err) + } + case "cancel": + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + case "read": + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatal(err) + } + case "http": + assertJSON(t, result.Data, []byte(`{"errorCode":"DENIED","errorMessage":"no export"}`)) + if output.Len() != 0 { + t.Fatal("HTTP error leaked into NDJSON") + } + } + if kind == "malformed" || kind == "read" || kind == "report" || kind == "cancel" { + if output.String() != "{\"id\":1}\n" { + t.Fatalf("invalid record written %q", output.String()) + } + assertJSON(t, result.Data, []byte(`{"exported":1,"bytes":9}`)) + } + }) + } +} + +type closeErrorBody struct { + io.Reader + err error +} + +func (b closeErrorBody) Close() error { return b.err } diff --git a/misc/fctl-plugin/internal/ledger/testdata/schema.yaml b/misc/fctl-plugin/internal/ledger/testdata/schema.yaml new file mode 100644 index 00000000..cdeeb10b --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/testdata/schema.yaml @@ -0,0 +1 @@ +chart: {} diff --git a/misc/fctl-plugin/internal/ledger/transactions.go b/misc/fctl-plugin/internal/ledger/transactions.go new file mode 100644 index 00000000..65e0b992 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/transactions.go @@ -0,0 +1,329 @@ +package ledger + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func prepareTransactions(ctx context.Context, client *httpclient.Client, name string, r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, query: make(url.Values)} + switch name { + case "send": + body, err := sendPayload(r) + op.method, op.path, op.body = http.MethodPost, httpclient.Path(r.Flags["ledger"], "transactions"), body + return op, err + case "transactions list": + return transactionList(r) + case "transactions num": + body, err := numPayload(r) + op.method, op.path, op.body = http.MethodPost, httpclient.Path(r.Flags["ledger"], "transactions"), body + return op, err + case "transactions show", "transactions revert": + return transactionOperation(ctx, client, name, r) + case "transactions set-metadata", "transactions delete-metadata": + return metadataOperation(ctx, client, "transactions", r) + default: + return op, fmt.Errorf("unknown ledger command %q", name) + } +} + +func transactionOperation(ctx context.Context, client *httpclient.Client, name string, r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, query: make(url.Values)} + id, err := transactionID(ctx, client, r.Flags["ledger"], r.Args[0]) + if err != nil { + return op, err + } + op.path = httpclient.Path(r.Flags["ledger"], "transactions", id) + if name == "transactions show" { + return op, nil + } + op.method = http.MethodPost + op.path = httpclient.Path(r.Flags["ledger"], "transactions", id, "revert") + if r.Flags["at-effective-date"] == "true" { + op.path = httpclient.Path("v2", r.Flags["ledger"], "transactions", id, "revert") + op.query.Set("atEffectiveDate", "true") + op.query.Set("force", r.Flags["force"]) + } else { + op.query.Set("disableChecks", r.Flags["force"]) + } + return op, nil +} + +func transactionID(ctx context.Context, client *httpclient.Client, ledgerName, value string) (string, error) { + if !strings.HasPrefix(value, "last") { + id, err := nonnegativeInteger(value, "transaction ID") + if err != nil { + return "", err + } + return id.String(), nil + } + offset := int64(0) + if suffix := strings.TrimPrefix(value, "last"); suffix != "" { + var err error + offset, err = strconv.ParseInt(suffix, 10, 64) + if err != nil || offset < 0 { + return "", fmt.Errorf("invalid transaction offset %q", value) + } + } + data, err := client.Do(ctx, http.MethodGet, httpclient.Path(ledgerName, "transactions"), url.Values{"pageSize": {"1"}}, nil, nil) + if err != nil { + return "", err + } + var result struct { + Cursor struct { + Data []struct { + ID json.RawMessage `json:"txid"` + } `json:"data"` + } `json:"cursor"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("decode last transaction: %w", err) + } + if len(result.Cursor.Data) == 0 { + return "", fmt.Errorf("no transaction found") + } + id, err := nonnegativeInteger(string(result.Cursor.Data[0].ID), "last transaction ID") + if err != nil { + return "", err + } + id.Sub(id, big.NewInt(offset)) + if id.Sign() < 0 { + return "", fmt.Errorf("transaction offset exceeds last transaction ID") + } + return id.String(), nil +} + +func transactionList(r pluginsdk.ExecuteRequest) (operation, error) { + op := operation{method: http.MethodGet, path: httpclient.Path(r.Flags["ledger"], "transactions"), query: make(url.Values)} + if err := paginate(op.query, r); err != nil { + return op, err + } + metadata, err := flagPairs(r.Flags["metadata"]) + if err != nil { + return op, err + } + if r.Flags["cursor"] != "" { + return op, nil + } + for flag, query := range map[string]string{"account": "account", "src": "source", "dst": "destination", "reference": "reference"} { + if value := r.Flags[flag]; value != "" { + op.query.Set(query, value) + } + } + for key, value := range metadata { + op.query.Set("metadata["+key+"]", value) + } + if err := dateQuery(op.query, r.Flags, map[string]string{"start": "startTime", "end": "endTime"}); err != nil { + return op, err + } + return op, nil +} + +func nonnegativeInteger(value, label string) (*big.Int, error) { + id, ok := new(big.Int).SetString(value, 10) + if !ok || id.Sign() < 0 { + return nil, fmt.Errorf("%s must be a nonnegative integer", label) + } + return id, nil +} + +func sendPayload(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + source := "world" + args := r.Args + if len(args) == 4 { + source, args = args[0], args[1:] + } + if strings.TrimSpace(source) == "" || strings.TrimSpace(args[0]) == "" || strings.TrimSpace(args[2]) == "" { + return nil, fmt.Errorf("source, destination and asset must be nonempty") + } + amount, err := nonnegativeInteger(args[1], "amount") + if err != nil { + return nil, err + } + metadata, err := flagPairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + body := map[string]any{ + "postings": []map[string]any{{"source": source, "destination": args[0], "amount": amount, "asset": args[2]}}, + "reference": r.Flags["reference"], + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + return json.Marshal(body) +} + +func numPayload(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + body, err := scriptBody(r.Body) + if err != nil { + return nil, err + } + script, err := scriptWithVariables(body["script"], r.Flags) + if err != nil { + return nil, err + } + body["script"] = script + if err := transactionBodyFlags(body, r); err != nil { + return nil, err + } + return json.Marshal(body) +} + +func scriptWithVariables(raw json.RawMessage, flags map[string]string) (json.RawMessage, error) { + var script map[string]json.RawMessage + if err := json.Unmarshal(raw, &script); err != nil || script == nil { + return nil, fmt.Errorf("numscript body must contain a script object") + } + var plain string + if err := json.Unmarshal(script["plain"], &plain); err != nil || strings.TrimSpace(plain) == "" { + return nil, fmt.Errorf("numscript plain text must be nonempty; the host must read the source into Body") + } + vars := map[string]json.RawMessage{} + if raw, exists := script["vars"]; exists { + if err := json.Unmarshal(raw, &vars); err != nil || vars == nil { + return nil, fmt.Errorf("script vars must be a JSON object") + } + } + if err := applyVariables(vars, flags); err != nil { + return nil, err + } + if len(vars) > 0 { + data, err := json.Marshal(vars) + if err != nil { + return nil, err + } + script["vars"] = data + } + return json.Marshal(script) +} + +func applyVariables(vars map[string]json.RawMessage, flags map[string]string) error { + for _, flag := range []string{"account-var", "portion-var", "amount-var"} { + entries, err := flagPairs(flags[flag]) + if err != nil { + return fmt.Errorf("--%s: %w", flag, err) + } + for name, value := range entries { + data, err := variableJSON(flag, value) + if err != nil { + return fmt.Errorf("--%s variable %s: %w", flag, name, err) + } + vars[name] = data + } + } + return nil +} + +func variableJSON(flag, value string) (json.RawMessage, error) { + if flag != "amount-var" { + return json.Marshal(value) + } + amountValue, asset, ok := strings.Cut(value, "/") + if !ok || asset == "" { + return nil, fmt.Errorf("expected name=amount/asset") + } + amount, err := nonnegativeInteger(amountValue, "amount variable") + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"amount": amount, "asset": asset}) +} + +func scriptBody(raw json.RawMessage) (map[string]json.RawMessage, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("numscript Body is required; file/stdin reads belong to the host") + } + var plain string + if err := json.Unmarshal(raw, &plain); err == nil { + script, err := json.Marshal(map[string]string{"plain": plain}) + return map[string]json.RawMessage{"script": script}, err + } + var body map[string]json.RawMessage + if err := json.Unmarshal(raw, &body); err != nil || body == nil { + return nil, fmt.Errorf("numscript Body must be a JSON string or object") + } + if _, hasPlain := body["plain"]; hasPlain { + return map[string]json.RawMessage{"script": raw}, nil + } + if _, exists := body["postings"]; exists { + return nil, fmt.Errorf("numscript Body cannot also contain postings") + } + return body, nil +} + +func transactionBodyFlags(body map[string]json.RawMessage, r pluginsdk.ExecuteRequest) error { + if err := metadataBodyFlag(body, r); err != nil { + return err + } + if err := referenceBodyFlag(body, r); err != nil { + return err + } + return timestampBodyFlag(body, r) +} + +func metadataBodyFlag(body map[string]json.RawMessage, r pluginsdk.ExecuteRequest) error { + metadata, err := flagPairs(r.Flags["metadata"]) + if err != nil { + return err + } + if len(metadata) > 0 || r.ChangedFlags["metadata"] { + body["metadata"], err = json.Marshal(metadata) + if err != nil { + return err + } + } else if raw, exists := body["metadata"]; exists { + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil || values == nil { + return fmt.Errorf("transaction metadata must be a JSON object") + } + } + return nil +} + +func referenceBodyFlag(body map[string]json.RawMessage, r pluginsdk.ExecuteRequest) error { + if _, exists := body["reference"]; !exists || r.Flags["reference"] != "" || r.ChangedFlags["reference"] { + data, err := json.Marshal(r.Flags["reference"]) + if err != nil { + return err + } + body["reference"] = data + } + var reference string + if err := json.Unmarshal(body["reference"], &reference); err != nil { + return fmt.Errorf("transaction reference must be a string") + } + return nil +} + +func timestampBodyFlag(body map[string]json.RawMessage, r pluginsdk.ExecuteRequest) error { + if r.Flags["timestamp"] != "" { + data, err := json.Marshal(r.Flags["timestamp"]) + if err != nil { + return err + } + body["timestamp"] = data + } else if r.ChangedFlags["timestamp"] { + delete(body, "timestamp") + } + if raw, exists := body["timestamp"]; exists { + var timestamp string + if err := json.Unmarshal(raw, ×tamp); err != nil { + return fmt.Errorf("transaction timestamp must be a string") + } + if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil { + return fmt.Errorf("parsing transaction timestamp: %w", err) + } + } + return nil +} diff --git a/misc/fctl-plugin/internal/ledger/transactions_test.go b/misc/fctl-plugin/internal/ledger/transactions_test.go new file mode 100644 index 00000000..b78d6231 --- /dev/null +++ b/misc/fctl-plugin/internal/ledger/transactions_test.go @@ -0,0 +1,133 @@ +package ledger + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func TestStructuredErrorsAndNoMutationRetry(t *testing.T) { + var calls atomic.Int32 + want := `{"errorCode":"CONFLICT","errorMessage":"duplicate","details":{"id":` + huge + `}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); w.WriteHeader(409); writeJSON(t, w, want) })) + t.Cleanup(server.Close) + r := executeRequest("send", []string{"a", "1", "USD"}, nil, "") + r.Endpoint = server.URL + data, err := New(server.Client()).Execute(t.Context(), r) + var failure *httpclient.Error + if !errors.As(err, &failure) || failure.StatusCode != 409 || failure.Code != "CONFLICT" || string(data.Data) != want || calls.Load() != 1 { + t.Fatalf("data %s, calls %d, error %v", data.Data, calls.Load(), err) + } +} + +//nolint:gocognit // The matrix checks the two-request lastN contract for each supported operation. +func TestTransactionLastNPreservesBigInteger(t *testing.T) { + for _, command := range []string{"transactions show", "transactions revert", "transactions set-metadata", "transactions delete-metadata"} { + t.Run(command, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + if r.Method != "GET" || r.URL.Path != "/default/transactions" || r.URL.Query().Get("pageSize") != "1" { + t.Errorf("unexpected lastN lookup: %s %s", r.Method, r.URL) + } + writeJSON(t, w, `{"cursor":{"data":[{"txid":`+huge+`}]}}`) + return + } + expected := "1234567890123456789012345678901234567888" + if !strings.Contains(r.URL.Path, "/transactions/"+expected) { + t.Errorf("lost ID precision: %s", r.URL) + } + writeJSON(t, w, `{"data":{"txid":`+expected+`}}`) + })) + t.Cleanup(server.Close) + args := []string{"last2"} + if strings.Contains(command, "metadata") { + args = append(args, "key=value") + } + r := executeRequest(command, args, nil, "") + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(t.Context(), r); err != nil || calls.Load() != 2 { + t.Fatalf("calls %d: %v", calls.Load(), err) + } + }) + } +} + +func TestTransactionLastWithoutOffsetAndBadResults(t *testing.T) { + for _, data := range []string{`{"cursor":{"data":[]}}`, `{"cursor":{"data":[{"txid":1}]}}`, `{"cursor":{"data":[{"txid":"bad"}]}}`} { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); writeJSON(t, w, data) })) + r := executeRequest("transactions show", []string{"last2"}, nil, "") + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(t.Context(), r); err == nil || calls.Load() != 1 { + t.Errorf("bad lastN result %s, calls %d, error %v", data, calls.Load(), err) + } + server.Close() + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/default/transactions" { + writeJSON(t, w, `{"cursor":{"data":[{"txid":`+huge+`}]}}`) + return + } + if r.URL.Path != "/default/transactions/"+huge { + t.Errorf("last path %s", r.URL) + } + writeJSON(t, w, `{"data":{}}`) + })) + t.Cleanup(server.Close) + r := executeRequest("transactions show", []string{"last"}, nil, "") + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(t.Context(), r); err != nil { + t.Fatal(err) + } +} + +func TestNumscriptVariablesAndExplicitClears(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + assertJSON(t, payload, []byte(`{"script":{"plain":"script","vars":{"account":"users:1","portion":"1/2","amount":{"amount":`+huge+`,"asset":"USD/2"},"raw":`+huge+`}},"reference":"","metadata":{},"future":{"id":`+huge+`}}`)) + writeJSON(t, w, `{"data":[]}`) + })) + t.Cleanup(server.Close) + r := executeRequest("transactions num", []string{"host-read.num"}, map[string]string{"account-var": "account=users:1", "portion-var": "portion=1/2", "amount-var": "amount=" + huge + "/USD/2"}, `{"script":{"plain":"script","vars":{"raw":`+huge+`}},"reference":"old","metadata":{"a":"b"},"timestamp":"2026-10-01T00:00:00Z","future":{"id":`+huge+`}}`) + r.Flags["reference"], r.Flags["metadata"], r.Flags["timestamp"] = "", "", "" + r.ChangedFlags = map[string]bool{"reference": true, "metadata": true, "timestamp": true} + r.Endpoint = server.URL + if _, err := New(server.Client()).Execute(t.Context(), r); err != nil { + t.Fatal(err) + } +} + +func TestScriptObjectAndPairFormats(t *testing.T) { + r := executeRequest("transactions num", nil, map[string]string{"reference": "ref", "timestamp": "2026-10-01T00:00:00Z", "metadata": "key=value"}, `{"plain":"script","vars":{"raw":`+huge+`}}`) + normalized, err := pluginsdk.NormalizeRequest(manifest(), r) + if err != nil { + t.Fatal(err) + } + body, err := numPayload(normalized) + if err != nil { + t.Fatal(err) + } + assertJSON(t, body, []byte(`{"script":{"plain":"script","vars":{"raw":`+huge+`}},"reference":"ref","timestamp":"2026-10-01T00:00:00Z","metadata":{"key":"value"}}`)) + for _, value := range []string{`"a=hello, world",b=a=b`, `["a=hello, world","b=a=b"]`} { + pairs, err := flagPairs(value) + if err != nil || pairs["a"] != "hello, world" || pairs["b"] != "a=b" { + t.Errorf("pairs %v: %v", pairs, err) + } + } + for _, value := range []string{"a=b\nc=d", "=empty-key", "[invalid", `"unclosed`} { + if _, err := flagPairs(value); err == nil { + t.Errorf("expected invalid pairs: %s", value) + } + } +} diff --git a/misc/fctl-plugin/internal/metadata/metadata.go b/misc/fctl-plugin/internal/metadata/metadata.go new file mode 100644 index 00000000..00b402b2 --- /dev/null +++ b/misc/fctl-plugin/internal/metadata/metadata.go @@ -0,0 +1,5 @@ +// Package metadata identifies the historical plugin bundle, independently of service versions. +package metadata + +const Version = "1.0.0" +const SourceCommit = "e00243b3e2e56aae6a09d7010b0c17890134388c" diff --git a/misc/fctl-plugin/internal/orchestration/aliases.go b/misc/fctl-plugin/internal/orchestration/aliases.go new file mode 100644 index 00000000..db399755 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/aliases.go @@ -0,0 +1,3 @@ +package orchestration + +var aliases = map[string][]string{"": {"orch", "or"}, "instances": {"ins", "i"}, "workflows": {"w", "work"}, "triggers": {"trig", "t"}, "triggers occurrences": {"oc", "o"}, "workflows run": {"r"}} diff --git a/misc/fctl-plugin/internal/orchestration/contract_test.go b/misc/fctl-plugin/internal/orchestration/contract_test.go new file mode 100644 index 00000000..e9887af0 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/contract_test.go @@ -0,0 +1,33 @@ +package orchestration + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestOrchestrationContracts(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "workflows list", Command: "orchestration workflows list", Exchanges: []testutil.Exchange{{Method: "GET", Path: "/workflows"}}}, + {Name: "workflow show", Command: "orchestration workflows show", Args: []string{"flow/a"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/workflows/flow%2Fa"}}}, + {Name: "workflow create", Command: "orchestration workflows create", Body: `{"name":"flow","stages":[{"send":{"amount":9007199254740993}}]}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/workflows", Body: `{"name":"flow","stages":[{"send":{"amount":9007199254740993}}]}`}}}, + {Name: "workflow run", Command: "orchestration workflows run", Args: []string{"flow"}, Flags: map[string]string{"wait": "true", "variable": "user=a=1,asset=USD/2"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/workflows/flow/instances", Query: "wait=true", Body: `{"user":"a=1","asset":"USD/2"}`, Response: `{"data":{"id":"inst","terminated":true}}`}}}, + {Name: "workflow run raw", Command: "orchestration workflows run", Args: []string{"flow"}, Body: `{"quantity":9007199254740993}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/workflows/flow/instances", Query: "wait=false", Body: `{"quantity":9007199254740993}`}}}, + {Name: "workflow delete", Command: "orchestration workflows delete", Args: []string{"flow"}, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "DELETE", Path: "/workflows/flow"}}}, + {Name: "instances list", Command: "orchestration instances list", Flags: map[string]string{"workflow": "flow", "running": "true"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/instances", Query: "workflowID=flow&running=true"}}}, + {Name: "instance show", Command: "orchestration instances show", Args: []string{"inst"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/instances/inst", Response: `{"data":{"id":"inst","workflowID":"flow"}}`}, {Method: "GET", Path: "/workflows/flow"}}}, + {Name: "instance describe", Command: "orchestration instances describe", Args: []string{"inst"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/instances/inst/history", Response: `{"data":[{"stage":{"type":"send"}},{"stage":{"type":"delay"}}]}`}, {Method: "GET", Path: "/instances/inst/stages/0/history"}}}, + {Name: "send event", Command: "orchestration instances send-event", Args: []string{"inst", "event/a"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/instances/inst/events", Body: `{"name":"event/a"}`}}}, + {Name: "abort", Command: "orchestration instances stop", Args: []string{"inst"}, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/instances/inst/abort"}}}, + {Name: "triggers list", Command: "orchestration triggers list", Flags: map[string]string{"name": "trigger"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/triggers", Query: "name=trigger"}}}, + {Name: "trigger show", Command: "orchestration triggers show", Args: []string{"trigger"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/triggers/trigger"}}}, + {Name: "trigger create", Command: "orchestration triggers create", Args: []string{"event", "flow"}, Flags: map[string]string{"name": "trigger", "filter": "data.amount > 0", "vars": "value=data.amount"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/triggers", Body: `{"name":"trigger","event":"event","workflowID":"flow","filter":"data.amount > 0","vars":{"value":"data.amount"}}`}}}, + {Name: "trigger test", Command: "orchestration triggers test", Args: []string{"trigger", `{"amount":9007199254740993}`}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/v2/triggers/trigger/test", Body: `{"amount":9007199254740993}`}}}, + {Name: "trigger delete", Command: "orchestration triggers delete", Args: []string{"trigger"}, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "DELETE", Path: "/triggers/trigger"}}}, + {Name: "occurrences", Command: "orchestration triggers occurrences list", Args: []string{"trigger"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/triggers/trigger/occurrences"}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/orchestration/files_test.go b/misc/fctl-plugin/internal/orchestration/files_test.go new file mode 100644 index 00000000..c61bb2fb --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/files_test.go @@ -0,0 +1,40 @@ +package orchestration + +import ( + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +// The host decodes historical file inputs and leaves their source argument in place. +func TestHistoricalFileArguments(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Command: "orchestration workflows create", Body: `{"stages":[{"delay":{"duration":"1s"}}]}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/workflows", Body: `{"stages":[{"delay":{"duration":"1s"}}]}`}}}, + } + for _, tc := range cases { + t.Run(tc.Command, func(t *testing.T) { + t.Parallel() + path := strings.Fields(tc.Command) + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + command, err := pluginsdk.FindCommand(manifest, path) + if err != nil { + t.Fatal(err) + } + if command.Files == nil || command.Files.ReadArgument == nil || *command.Files.ReadArgument != len(tc.Args) || command.Files.ReadFormat != "yaml" { + t.Fatalf("missing historical file input: %+v", command.Files) + } + if command.Args.Min != len(tc.Args) || command.Args.Max != len(tc.Args)+1 || !strings.Contains(command.Use, "[|-]") { + t.Fatalf("wrong file signature: %+v", command) + } + tc.Args = append(append([]string{}, tc.Args...), "/does-not-exist/fixture.yaml") + testutil.RunCase(t, New, tc) + }) + } +} diff --git a/misc/fctl-plugin/internal/orchestration/instances.go b/misc/fctl-plugin/internal/orchestration/instances.go new file mode 100644 index 00000000..656b9257 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/instances.go @@ -0,0 +1,85 @@ +package orchestration + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func instanceOperations() []command.Operation { + instances := command.Leaf("instances list", "list", http.MethodGet, "instances", 0, 0, command.StringFlag("workflow", "Workflow ID filter"), command.BoolFlag("running", "Only running instances")) + instances.Query = map[string]string{"workflow": "workflowID", "running": "running"} + show := command.Leaf("instances show", "show ", http.MethodGet, "instances/$0", 1, 1) + show.Run = instanceWithWorkflow + describe := command.Leaf("instances describe", "describe ", http.MethodGet, "instances/$0/history", 1, 1) + describe.Run = instanceHistory + event := command.Leaf("instances send-event", "send-event ", http.MethodPost, "instances/$0/events", 2, 2) + event.Body = eventBody + return []command.Operation{ + instances, show, describe, event, + command.Confirmed(command.Leaf("instances stop", "stop ", http.MethodPut, "instances/$0/abort", 1, 1)), + } +} + +func instanceWithWorkflow(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op command.Operation, body json.RawMessage) (json.RawMessage, error) { + instance, err := command.Perform(ctx, client, r, op, body) + if err != nil { + return instance, err + } + var envelope struct { + Data struct { + WorkflowID string `json:"workflowID"` + } `json:"data"` + } + if json.Unmarshal(instance, &envelope) != nil || command.Identifier(envelope.Data.WorkflowID) != nil { + return nil, fmt.Errorf("instance response is missing data.workflowID") + } + workflow, err := client.Do(ctx, http.MethodGet, httpclient.Path("workflows", envelope.Data.WorkflowID), nil, nil, nil) + if err != nil { + return instance, err + } + return json.Marshal(map[string]json.RawMessage{"instance": instance, "workflow": workflow}) +} + +func instanceHistory(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op command.Operation, body json.RawMessage) (json.RawMessage, error) { + history, err := command.Perform(ctx, client, r, op, body) + if err != nil { + return history, err + } + var envelope struct { + Data []struct { + Stage struct { + Type string `json:"type"` + } `json:"stage"` + } `json:"data"` + } + if json.Unmarshal(history, &envelope) != nil || envelope.Data == nil { + return nil, fmt.Errorf("instance history response is missing a data array") + } + stages := map[string]json.RawMessage{} + for i, entry := range envelope.Data { + if entry.Stage.Type != "send" { + continue + } + stage, err := client.Do(ctx, http.MethodGet, httpclient.Path("instances", r.Args[0], "stages", strconv.Itoa(i), "history"), nil, nil, nil) + if err != nil { + return history, err + } + stages[strconv.Itoa(i)] = stage + } + return json.Marshal(map[string]any{"history": history, "stages": stages}) +} + +func eventBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := command.Identifier(r.Args[1]); err != nil { + return nil, fmt.Errorf("event: %w", err) + } + return json.Marshal(map[string]string{"name": r.Args[1]}) +} diff --git a/misc/fctl-plugin/internal/orchestration/plugin.go b/misc/fctl-plugin/internal/orchestration/plugin.go new file mode 100644 index 00000000..0a8984a6 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/plugin.go @@ -0,0 +1,19 @@ +// Package orchestration supplies the historical orchestration commands through the plugin SDK. +package orchestration + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +// New builds an independent plugin using the HTTP client supplied by the host. +func New(client *http.Client) pluginsdk.Plugin { + operations := instanceOperations() + operations = append(operations, workflowOperations()...) + operations = append(operations, triggerOperations()...) + return command.New("orchestration", metadata.Version, client, operations, aliases) +} diff --git a/misc/fctl-plugin/internal/orchestration/triggers.go b/misc/fctl-plugin/internal/orchestration/triggers.go new file mode 100644 index 00000000..24d15af6 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/triggers.go @@ -0,0 +1,55 @@ +package orchestration + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func triggerOperations() []command.Operation { + trigger := command.Leaf("triggers create", "create ", http.MethodPost, "triggers", 2, 2, + command.StringFlag("name", "Trigger name"), command.StringFlag("filter", "Event filter expression"), command.StringFlag("vars", "Variables as JSON object or CSV key=value")) + trigger.Body = triggerCreateBody + triggers := command.Leaf("triggers list", "list", http.MethodGet, "triggers", 0, 0, command.StringFlag("name", "Trigger name filter")) + triggers.Query = map[string]string{"name": "name"} + test := command.Leaf("triggers test", "test [event-json]", http.MethodPost, "v2/triggers/$0/test", 1, 2, command.DataFlag()) + test.Body = triggerTestBody + test.Spec.Inputs = append(test.Spec.Inputs, pluginsdk.InputSpec{Title: "Event JSON", Kind: "text", Flag: "data", Required: true, AlternativeArgument: new(1)}) + return []command.Operation{ + triggers, trigger, test, + command.Leaf("triggers show", "show ", http.MethodGet, "triggers/$0", 1, 1), + command.Confirmed(command.Leaf("triggers delete", "delete ", http.MethodDelete, "triggers/$0", 1, 1)), + command.Leaf("triggers occurrences list", "list ", http.MethodGet, "triggers/$0/occurrences", 1, 1), + } +} + +func triggerCreateBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + for _, v := range r.Args { + if err := command.Identifier(v); err != nil { + return nil, err + } + } + vars, err := command.Pairs(r.Flags["vars"]) + if err != nil { + return nil, err + } + body := map[string]any{"event": r.Args[0], "workflowID": r.Args[1], "name": r.Flags["name"], "vars": vars} + if r.Flags["filter"] != "" { + body["filter"] = r.Flags["filter"] + } + return json.Marshal(body) +} + +func triggerTestBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil && len(r.Args) == 2 { + return nil, fmt.Errorf("provide event JSON once, as an argument or --data") + } + if len(r.Args) == 2 { + return command.ObjectBody(json.RawMessage(r.Args[1])) + } + return command.ObjectBody(r.Body) +} diff --git a/misc/fctl-plugin/internal/orchestration/validation_test.go b/misc/fctl-plugin/internal/orchestration/validation_test.go new file mode 100644 index 00000000..93d9d2e0 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/validation_test.go @@ -0,0 +1,44 @@ +package orchestration + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "empty stages", Command: "orchestration workflows create", Body: `{"stages":[]}`}, + {Name: "scalar stage", Command: "orchestration workflows create", Body: `{"stages":[1]}`}, + {Name: "null variables", Command: "orchestration workflows run", Args: []string{"flow"}, Body: `null`}, + {Name: "duplicate variables", Command: "orchestration workflows run", Args: []string{"flow"}, Flags: map[string]string{"variable": "x=1,x=2"}}, + {Name: "bad variables", Command: "orchestration triggers create", Args: []string{"event", "flow"}, Flags: map[string]string{"vars": "x"}}, + {Name: "duplicate event payload", Command: "orchestration triggers test", Args: []string{"trigger", `{}`}, Body: `{}`}, + {Name: "missing event payload", Command: "orchestration triggers test", Args: []string{"trigger"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + testutil.RunFailure(t, New, tc, "") + }) + } +} + +func TestDependentReadValidation(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "missing workflow reference", Command: "orchestration instances show", Args: []string{"id"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/instances/id", Response: `{"data":{}}`}}}, + {Name: "invalid instance history", Command: "orchestration instances describe", Args: []string{"id"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/instances/id/history", Response: `{"data":{}}`}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + want := "workflowID" + if tc.Name == "invalid instance history" { + want = "data array" + } + testutil.RunFailure(t, New, tc, want) + }) + } +} diff --git a/misc/fctl-plugin/internal/orchestration/workflow_timing_test.go b/misc/fctl-plugin/internal/orchestration/workflow_timing_test.go new file mode 100644 index 00000000..b54e7308 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/workflow_timing_test.go @@ -0,0 +1,99 @@ +package orchestration + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestWorkflowWaitPollInterval(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + transport := &workflowClockTransport{t: t, terminalAfter: 2} + started := time.Now() + response, err := New(&http.Client{Transport: transport}).Execute(t.Context(), waitRequest()) + if err != nil { + t.Fatal(err) + } + if elapsed := time.Since(started); elapsed != time.Second || transport.polls != 2 { + t.Fatalf("polled %d times in %s; expected two polls in 1s", transport.polls, elapsed) + } + if string(response.Data) != `{"data":{"id":"inst/a","terminated":true,"amount":9007199254740993}}` { + t.Fatalf("lost terminal response: %s", response.Data) + } + }) +} + +func TestWorkflowWaitFiveMinuteDeadline(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + // Response time separates the read timer from the deadline so this tests + // cancellation while waiting, rather than two simultaneous timer events. + transport := &workflowClockTransport{t: t, readLatency: time.Millisecond} + started := time.Now() + response, err := New(&http.Client{Transport: transport}).Execute(t.Context(), waitRequest()) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("lost deadline: %v", err) + } + if elapsed := time.Since(started); elapsed != 5*time.Minute { + t.Fatalf("wait deadline is %s; expected 5m", elapsed) + } + if err.Error() != "waiting for workflow instance inst/a: context deadline exceeded" { + t.Fatalf("lost instance context: %v", err) + } + if string(response.Data) != `{"data":{"id":"inst/a","terminated":false,"amount":9007199254740993}}` { + t.Fatalf("lost last workflow response: %s", response.Data) + } + }) +} + +func waitRequest() pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{ + CommandPath: []string{"orchestration", "workflows", "run"}, + Args: []string{"flow"}, Flags: map[string]string{"wait": "true"}, + Endpoint: "https://example.invalid/gateway/service", + } +} + +// This transport stays inside the virtual clock so the five-minute deadline +// can be checked without real time or an external server. +type workflowClockTransport struct { + t *testing.T + terminalAfter int + readLatency time.Duration + polls int + lastResponse time.Time +} + +func (c *workflowClockTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c.t.Helper() + if err := r.Context().Err(); err != nil { + return nil, err + } + if r.Method == http.MethodPost { + if c.polls != 0 || !c.lastResponse.IsZero() || r.URL.EscapedPath() != "/gateway/service/workflows/flow/instances" || r.URL.RawQuery != "wait=true" { + c.t.Fatalf("unexpected workflow start: %s %s", r.Method, r.URL) + } + } else { + if r.Method != http.MethodGet || r.URL.EscapedPath() != "/gateway/service/instances/inst%2Fa" || r.URL.RawQuery != "" { + c.t.Fatalf("unexpected workflow poll: %s %s", r.Method, r.URL) + } + if interval := time.Since(c.lastResponse); interval != 500*time.Millisecond { + c.t.Fatalf("poll interval is %s; expected 500ms", interval) + } + c.polls++ + time.Sleep(c.readLatency) + } + c.lastResponse = time.Now() + terminal := c.terminalAfter > 0 && c.polls >= c.terminalAfter + body := fmt.Sprintf(`{"data":{"id":"inst/a","terminated":%t,"amount":9007199254740993}}`, terminal) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil +} diff --git a/misc/fctl-plugin/internal/orchestration/workflow_wait_test.go b/misc/fctl-plugin/internal/orchestration/workflow_wait_test.go new file mode 100644 index 00000000..53e8d619 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/workflow_wait_test.go @@ -0,0 +1,110 @@ +package orchestration + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestWorkflowWaitReadsTerminalState(t *testing.T) { + t.Parallel() + terminal := `{"data":{"id":"inst/a","terminated":true,"amount":9007199254740993}}` + response, err := executeWait(t.Context(), t, []testutil.Exchange{ + {Method: "POST", Path: "/workflows/flow/instances", Query: "wait=true", Body: `{}`, Response: `{"data":{"id":"inst/a","terminated":false}}`}, + {Method: "GET", Path: "/instances/inst%2Fa", Response: `{"data":{"id":"inst/a","terminated":false}}`}, + {Method: "GET", Path: "/instances/inst%2Fa", Response: terminal}, + }) + if err != nil || string(response.Data) != terminal { + t.Fatalf("terminal response lost: %s, %v", response.Data, err) + } +} + +func TestWorkflowWaitRefusesInvalidState(t *testing.T) { + t.Parallel() + for _, response := range []string{ + `{"data":{"id":"inst"}}`, + `{"data":{"terminated":false}}`, + `{"data":{"id":"inst","terminated":false,"error":"workflow failed"}}`, + } { + t.Run(response, func(t *testing.T) { + t.Parallel() + _, err := executeWait(t.Context(), t, []testutil.Exchange{workflowStart(response)}) + if err == nil { + t.Fatal("accepted incomplete or failed workflow") + } + }) + } +} + +func TestWorkflowWaitCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + state := `{"data":{"id":"inst","terminated":false}}` + response, err := executeWait(ctx, t, []testutil.Exchange{workflowStart(state)}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waiting did not preserve cancellation: %v", err) + } + if err.Error() != "waiting for workflow instance inst: context deadline exceeded" || string(response.Data) != state { + t.Fatalf("waiting lost its instance context: %s, %v", response.Data, err) + } +} + +func TestWorkflowWaitReadFailure(t *testing.T) { + t.Parallel() + failureBody := `{"errorMessage":"unavailable","amount":9007199254740993}` + response, err := executeWait(t.Context(), t, []testutil.Exchange{ + workflowStart(`{"data":{"id":"inst","terminated":false}}`), + {Method: http.MethodGet, Path: "/instances/inst", Status: http.StatusServiceUnavailable, Response: failureBody}, + }) + var failure *httpclient.Error + if !errors.As(err, &failure) || failure.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("read failure lost: %v", err) + } + if string(response.Data) != failureBody { + t.Fatalf("lost service failure body: %s", response.Data) + } +} + +func TestWorkflowWaitReportsPolledFailure(t *testing.T) { + t.Parallel() + state := `{"data":{"id":"inst","terminated":true,"error":"workflow failed","amount":9007199254740993}}` + response, err := executeWait(t.Context(), t, []testutil.Exchange{ + workflowStart(`{"data":{"id":"inst","terminated":false}}`), + {Method: http.MethodGet, Path: "/instances/inst", Response: state}, + }) + if err == nil || err.Error() != "workflow instance inst: workflow failed" || string(response.Data) != state { + t.Fatalf("workflow failure lost: %s, %v", response.Data, err) + } +} + +func TestWorkflowWaitRefusesAnotherInstance(t *testing.T) { + t.Parallel() + _, err := executeWait(t.Context(), t, []testutil.Exchange{ + workflowStart(`{"data":{"id":"inst","terminated":false}}`), + {Method: http.MethodGet, Path: "/instances/inst", Response: `{"data":{"id":"another","terminated":true}}`}, + }) + if err == nil || !strings.Contains(err.Error(), "requested id") { + t.Fatalf("accepted another instance: %v", err) + } +} + +func workflowStart(response string) testutil.Exchange { + return testutil.Exchange{Method: http.MethodPost, Path: "/workflows/flow/instances", Query: "wait=true", Body: `{}`, Response: response} +} + +func executeWait(ctx context.Context, t *testing.T, exchanges []testutil.Exchange) (pluginsdk.ExecuteResponse, error) { + t.Helper() + server, verify := testutil.Fixture(t, exchanges) + defer verify() + p := New(server.Client()) + return p.Execute(ctx, pluginsdk.ExecuteRequest{CommandPath: []string{"orchestration", "workflows", "run"}, Args: []string{"flow"}, Flags: map[string]string{"wait": "true"}, Endpoint: server.URL + "/gateway/service"}) +} diff --git a/misc/fctl-plugin/internal/orchestration/workflows.go b/misc/fctl-plugin/internal/orchestration/workflows.go new file mode 100644 index 00000000..ae212cb1 --- /dev/null +++ b/misc/fctl-plugin/internal/orchestration/workflows.go @@ -0,0 +1,111 @@ +package orchestration + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func workflowOperations() []command.Operation { + workflowCreate := command.Payload(command.Leaf("workflows create", "create", http.MethodPost, "workflows", 0, 0), "stages") + workflowCreate.Body = workflowCreateBody + run := command.Leaf("workflows run", "run ", http.MethodPost, "workflows/$0/instances", 1, 1, command.StringFlag("variable", "Variables as JSON object or CSV key=value"), command.BoolFlag("wait", "Wait for completion"), command.DataFlag()) + run.Query = map[string]string{"wait": "wait"} + run.Body = workflowRunBody + run.Run = runWorkflow + return []command.Operation{ + command.Leaf("workflows list", "list", http.MethodGet, "workflows", 0, 0), + command.Leaf("workflows show", "show ", http.MethodGet, "workflows/$0", 1, 1), workflowCreate, run, + command.Confirmed(command.Leaf("workflows delete", "delete ", http.MethodDelete, "workflows/$0", 1, 1)), + } +} + +func workflowCreateBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + body, err := command.ObjectBody(r.Body, "stages") + if err != nil { + return nil, err + } + var config struct { + Stages []json.RawMessage `json:"stages"` + } + if json.Unmarshal(body, &config) != nil || len(config.Stages) == 0 { + return nil, fmt.Errorf("workflow stages must be a nonempty array") + } + for _, stage := range config.Stages { + if _, err := command.ObjectBody(stage); err != nil { + return nil, fmt.Errorf("invalid workflow stage: %w", err) + } + } + return body, nil +} + +func workflowRunBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil { + return command.ObjectBody(r.Body) + } + vars, err := command.Pairs(r.Flags["variable"]) + if err != nil { + return nil, err + } + return json.Marshal(vars) +} + +// Some historical servers return before the persisted instance is terminal, +// even with wait=true. Read its state without starting another workflow. +func runWorkflow(ctx context.Context, client *httpclient.Client, request pluginsdk.ExecuteRequest, op command.Operation, body json.RawMessage) (json.RawMessage, error) { + data, err := command.Perform(ctx, client, request, op, body) + if err != nil || request.Flags["wait"] != "true" { + return data, err + } + id, terminated, err := workflowState(data, "") + if err != nil || terminated { + return data, err + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + for { + timer := time.NewTimer(500 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return data, fmt.Errorf("waiting for workflow instance %s: %w", id, ctx.Err()) + case <-timer.C: + } + data, err = client.Do(ctx, http.MethodGet, httpclient.Path("instances", id), nil, nil, nil) + if err != nil { + return data, err + } + _, terminated, err = workflowState(data, id) + if err != nil || terminated { + return data, err + } + } +} + +func workflowState(data json.RawMessage, expectedID string) (string, bool, error) { + var envelope struct { + Data struct { + ID string `json:"id"` + Terminated *bool `json:"terminated"` + Error string `json:"error"` + } `json:"data"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return "", false, fmt.Errorf("invalid workflow instance response: %w", err) + } + state := envelope.Data + if command.Identifier(state.ID) != nil || state.Terminated == nil || (expectedID != "" && state.ID != expectedID) { + return "", false, fmt.Errorf("workflow instance response must contain the requested id and terminated state") + } + if state.Error != "" { + return state.ID, false, fmt.Errorf("workflow instance %s: %s", state.ID, state.Error) + } + return state.ID, *state.Terminated, nil +} diff --git a/misc/fctl-plugin/internal/payments/accounts.go b/misc/fctl-plugin/internal/payments/accounts.go new file mode 100644 index 00000000..af0a1c71 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/accounts.go @@ -0,0 +1,14 @@ +package payments + +import ( + "net/http" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func accountOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("accounts create", "create", http.MethodPost, "accounts", 0, 0)), "connectorID", "createdAt", "reference", "type"), + commandapi.Leaf("accounts get", "get ", http.MethodGet, "accounts/$0", 1, 1), + } +} diff --git a/misc/fctl-plugin/internal/payments/aliases.go b/misc/fctl-plugin/internal/payments/aliases.go new file mode 100644 index 00000000..ccdfac54 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/aliases.go @@ -0,0 +1,9 @@ +package payments + +var aliases = map[string][]string{"accounts": {"acc", "a", "ac", "account"}, "bank-accounts": {"bank_accounts", "bacc", "ba", "bac", "baccount"}, "payments": {"p"}, "pools": {"pool"}, + "transfer-initiation": {"transfer_initiation", "payment_initiations", "payment-initiations", "ti"}, "transfer-initiation update-status": {"update_status", "u"}, "connectors": {"c", "co", "con"}, + "connectors get-config": {"getconfig", "getconf", "gc", "get", "g"}, "connectors update-config": {"uc"}, "connectors list-available": {"la"}, "connectors schedules": {"sch"}, + "connectors schedules instances": {"inst"}, "connectors install": {"i"}, "connectors uninstall": {"u", "un"}, + "bank-accounts forward": {"fo", "f"}, "bank-accounts update-metadata": {"um", "update-meta"}, "payments set-metadata": {"sm", "set-meta"}, + "pools add-account": {"add", "a"}, "pools remove-account": {"remove", "rm"}, "pools update-query": {"uq"}, + "transfer-initiation approve": {"a"}, "transfer-initiation reject": {"rj"}, "transfer-initiation retry": {"r"}, "transfer-initiation reverse": {"re"}, "orders": {"o"}, "conversions": {"cv"}, "tasks": {"t"}} diff --git a/misc/fctl-plugin/internal/payments/bank_accounts.go b/misc/fctl-plugin/internal/payments/bank_accounts.go new file mode 100644 index 00000000..c6539e67 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/bank_accounts.go @@ -0,0 +1,30 @@ +package payments + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func bankAccountOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("bank-accounts create", "create", http.MethodPost, "bank-accounts", 0, 0)), "name"), + commandapi.Leaf("bank-accounts get", "get ", http.MethodGet, "bank-accounts/$0", 1, 1), + } +} + +func forwardBankAccountOperation() commandapi.Operation { + op := commandapi.Confirmed(commandapi.Leaf("bank-accounts forward", "forward ", http.MethodPost, "bank-accounts/$0/forward", 2, 2)) + op.Body = forwardBankAccountBody + return op +} + +func forwardBankAccountBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := commandapi.Identifier(r.Args[1]); err != nil { + return nil, err + } + return json.Marshal(map[string]string{"connectorID": r.Args[1]}) +} diff --git a/misc/fctl-plugin/internal/payments/connectors.go b/misc/fctl-plugin/internal/payments/connectors.go new file mode 100644 index 00000000..2208a815 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/connectors.go @@ -0,0 +1,267 @@ +package payments + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func connectorOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Leaf("connectors list-available", "list-available", http.MethodGet, "connectors/configs", 0, 0), + commandapi.Confirmed(commandapi.Payload(commandapi.Leaf("connectors install", "install ", http.MethodPost, "connectors/$0", 1, 1))), + commandapi.Confirmed(commandapi.Payload(commandapi.Leaf("connectors update-config", "update-config ", http.MethodPost, "connectors/$0/config", 1, 1, commandapi.StringFlag("connector-id", "Connector ID")))), + commandapi.Leaf("connectors get-config", "get-config", http.MethodGet, "connectors", 0, 0, commandapi.StringFlag("provider", "Legacy provider name"), commandapi.StringFlag("connector-id", "Connector ID")), + commandapi.Confirmed(commandapi.Leaf("connectors uninstall", "uninstall", http.MethodDelete, "connectors", 0, 0, commandapi.StringFlag("provider", "Legacy provider name"), commandapi.StringFlag("connector-id", "Connector ID"))), + commandapi.Leaf("connectors schedules get", "get ", http.MethodGet, "connectors/$0/schedules/$1", 2, 2), + } +} + +func legacyProvider(value string) (string, error) { + name := strings.ToLower(strings.ReplaceAll(value, "-", "")) + providers := map[string]string{"adyen": "ADYEN", "atlar": "ATLAR", "bankingcircle": "BANKING-CIRCLE", "currencycloud": "CURRENCY-CLOUD", "mangopay": "MANGOPAY", "modulr": "MODULR", "moneycorp": "MONEYCORP", "stripe": "STRIPE", "wise": "WISE", "generic": "GENERIC", "dummypay": "DUMMY-PAY"} + if provider := providers[name]; provider != "" { + return provider, nil + } + return "", fmt.Errorf("provider %q is unsupported by the legacy Payments API", value) +} + +func paymentConnector(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, body json.RawMessage, major int) (json.RawMessage, error) { + query, err := commandapi.QueryValues(op.Query, r) + if err != nil { + return nil, err + } + if strings.HasPrefix(op.Command, "connectors schedules ") { + if major != 3 { + return nil, fmt.Errorf("connector schedules require Payments >= 3.0.0") + } + op.Segments = append([]string{"v3"}, op.Segments...) + return cursorOperation(ctx, client, r, op, body) + } + if major == 3 { + op, body, err = prepareConnectorV3(ctx, client, r, op, body) + } else { + op, err = prepareConnectorV1(ctx, client, r, op, major) + if op.Command == "connectors list" { + query = nil + } + } + if err != nil { + return nil, err + } + if cursor := query.Get("cursor"); cursor != "" { + query = url.Values{"cursor": {cursor}} + } + path, err := commandapi.Route(op.Segments, r) + if err != nil { + return nil, err + } + data, err := client.Do(ctx, op.Method, path, query, body, nil) + if err == nil && op.Command == "connectors list" && major < 3 { + return connectorChoiceIDs(data) + } + return data, err +} + +func connectorChoiceIDs(data json.RawMessage) (json.RawMessage, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err + } + var rows []map[string]json.RawMessage + if err := json.Unmarshal(envelope["data"], &rows); err != nil || rows == nil { + return nil, fmt.Errorf("connectors response is missing a data array") + } + for _, row := range rows { + if _, exists := row["id"]; !exists { + row["id"] = row["connectorID"] + } + } + encoded, err := json.Marshal(rows) + if err != nil { + return nil, err + } + envelope["data"] = encoded + return json.Marshal(envelope) +} + +func canonicalProvider(ctx context.Context, client *httpclient.Client, requested string) (string, error) { + configs, err := client.Do(ctx, http.MethodGet, httpclient.Path("v3", "connectors", "configs"), nil, nil, nil) + if err != nil { + return "", err + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if json.Unmarshal(configs, &envelope) != nil || envelope.Data == nil { + return "", fmt.Errorf("connector configs response is missing a data object") + } + for provider := range envelope.Data { + if strings.EqualFold(provider, requested) { + return provider, nil + } + } + // The historical CLI falls back to the requested provider when configs + // omits it (for example a development connector). The install/update API + // remains authoritative and its rejection is returned intact. + return requested, commandapi.Identifier(requested) +} + +func discoverLegacyConnector(ctx context.Context, client *httpclient.Client, provider, id string) (string, string, error) { + data, err := client.Do(ctx, http.MethodGet, httpclient.Path("connectors"), nil, nil, nil) + if err != nil { + return "", "", err + } + var envelope struct { + Data []struct { + ID string `json:"connectorID"` + Provider string `json:"provider"` + } `json:"data"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return "", "", err + } + if provider != "" { + provider, err = legacyProvider(provider) + if err != nil { + return "", "", err + } + } + count := 0 + foundProvider, foundID := "", "" + for _, connector := range envelope.Data { + if id != "" && id != connector.ID { + continue + } + if provider != "" && !strings.EqualFold(provider, connector.Provider) { + continue + } + count++ + foundProvider, foundID = connector.Provider, connector.ID + } + if count != 1 { + return "", "", fmt.Errorf("expected one connector, found %d; specify --provider and --connector-id", count) + } + return foundProvider, foundID, nil +} + +func prepareConnectorV3(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, body json.RawMessage) (commandapi.Operation, json.RawMessage, error) { + op.Segments = append([]string{"v3"}, op.Segments...) + switch op.Command { + case "connectors get-config", "connectors uninstall", "connectors update-config": + id := r.Flags["connector-id"] + if err := commandapi.Identifier(id); err != nil { + return op, nil, fmt.Errorf("--connector-id is required: %w", err) + } + op.Segments = []string{"v3", "connectors", id} + if op.Command != "connectors uninstall" { + op.Segments = append(op.Segments, "config") + } + if op.Command == "connectors update-config" { + op.Method = http.MethodPatch + } + } + if op.Command != "connectors install" && op.Command != "connectors update-config" { + return op, body, nil + } + provider, err := canonicalProvider(ctx, client, r.Args[0]) + if err != nil { + return op, nil, err + } + body, err = connectorConfig(body, provider) + if err != nil { + return op, nil, err + } + if op.Command == "connectors install" { + op.Segments = []string{"v3", "connectors", "install", strings.ToLower(provider)} + } + return op, body, nil +} + +func connectorConfig(body json.RawMessage, provider string) (json.RawMessage, error) { + var config map[string]json.RawMessage + if err := json.Unmarshal(body, &config); err != nil { + return nil, err + } + value, err := json.Marshal(provider) + if err != nil { + return nil, err + } + config["provider"] = value + return json.Marshal(config) +} + +func prepareConnectorV1(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, major int) (commandapi.Operation, error) { + switch op.Command { + case "connectors install", "connectors update-config": + provider, err := legacyProvider(r.Args[0]) + if err != nil { + return op, err + } + op.Segments = []string{"connectors", provider} + if op.Command == "connectors update-config" { + id := r.Flags["connector-id"] + if err := commandapi.Identifier(id); err != nil { + return op, fmt.Errorf("--connector-id is required") + } + op.Segments = append(op.Segments, id, "config") + } + case "connectors get-config", "connectors uninstall": + path, err := legacyConnectorPath(ctx, client, r, op.Command, major) + if err != nil { + return op, err + } + op.Segments = path + } + return op, nil +} + +func legacyConnectorPath(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, command string, major int) ([]string, error) { + provider, id := r.Flags["provider"], r.Flags["connector-id"] + var err error + if major != 0 && command == "connectors get-config" && (provider == "" || id == "") { + provider, id, err = discoverLegacyConnector(ctx, client, provider, id) + if err != nil { + return nil, err + } + } + provider, err = legacyProvider(provider) + if err != nil { + return nil, err + } + path := []string{"connectors", provider} + if major != 0 { + if err := commandapi.Identifier(id); err != nil { + return nil, fmt.Errorf("--connector-id is required") + } + path = append(path, id) + } + if command == "connectors get-config" { + path = append(path, "config") + } + return path, nil +} + +func cursorOperation(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, body json.RawMessage) (json.RawMessage, error) { + query, err := commandapi.QueryValues(op.Query, r) + if err != nil { + return nil, err + } + if cursor := query.Get("cursor"); cursor != "" { + query = url.Values{"cursor": {cursor}} + body = nil + } + path, err := commandapi.Route(op.Segments, r) + if err != nil { + return nil, err + } + return client.Do(ctx, op.Method, path, query, body, nil) +} diff --git a/misc/fctl-plugin/internal/payments/connectors_contract_test.go b/misc/fctl-plugin/internal/payments/connectors_contract_test.go new file mode 100644 index 00000000..9f6dc123 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/connectors_contract_test.go @@ -0,0 +1,38 @@ +package payments + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestConnectorContracts(t *testing.T) { + t.Parallel() + configs := `{"data":{"Stripe":{},"GENERIC":{},"NewProvider":{}}}` + cases := []testutil.Case{ + payCase("connectors install", []string{"dummypay"}, map[string]string{"confirm": "true"}, `{"name":"qa-hidden","directory":"/tmp"}`, "3.4.8", + testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs", Response: `{"data":{"stripe":{}}}`}, + testutil.Exchange{Method: "POST", Path: "/v3/connectors/install/dummypay", Body: `{"name":"qa-hidden","directory":"/tmp","provider":"dummypay"}`}), + payCase("connectors list", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors", Query: "pageSize=10"}), + payCase("connectors list", nil, nil, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/connectors", Response: `{"data":[{"connectorID":"conn","provider":"STRIPE","name":"stripe"}]}`}), + payCase("connectors list-available", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs"}), + payCase("connectors list-available", nil, nil, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/connectors/configs"}), + payCase("connectors install", []string{"newprovider"}, map[string]string{"confirm": "true"}, `{"name":"test","provider":"stale","amount":9007199254740993}`, "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs", Response: configs}, testutil.Exchange{Method: "POST", Path: "/v3/connectors/install/newprovider", Body: `{"name":"test","provider":"NewProvider","amount":9007199254740993}`}), + payCase("connectors install", []string{"bankingcircle"}, map[string]string{"confirm": "true"}, `{"name":"test"}`, "2.0.0", testutil.Exchange{Method: "POST", Path: "/connectors/BANKING-CIRCLE", Body: `{"name":"test"}`}), + payCase("connectors update-config", []string{"stripe"}, map[string]string{"confirm": "true", "connector-id": "conn/%"}, `{"name":"test"}`, "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs", Response: configs}, testutil.Exchange{Method: "PATCH", Path: "/v3/connectors/conn%2F%25/config", Body: `{"name":"test","provider":"Stripe"}`}), + payCase("connectors update-config", []string{"stripe"}, map[string]string{"confirm": "true", "connector-id": "conn"}, `{"name":"test"}`, "2.0.0", testutil.Exchange{Method: "POST", Path: "/connectors/STRIPE/conn/config", Body: `{"name":"test"}`}), + payCase("connectors get-config", nil, map[string]string{"connector-id": "conn"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/conn/config"}), + payCase("connectors get-config", nil, map[string]string{"provider": "stripe", "connector-id": "conn"}, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/connectors/STRIPE/conn/config"}), + payCase("connectors get-config", nil, map[string]string{"provider": "stripe"}, "", "0.9.0", testutil.Exchange{Method: "GET", Path: "/connectors/STRIPE/config"}), + payCase("connectors get-config", nil, map[string]string{"connector-id": "conn"}, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/connectors", Response: `{"data":[{"connectorID":"conn","provider":"STRIPE"},{"connectorID":"other","provider":"WISE"}]}`}, testutil.Exchange{Method: "GET", Path: "/connectors/STRIPE/conn/config"}), + payCase("connectors uninstall", nil, map[string]string{"confirm": "true", "connector-id": "conn"}, "", "3.4.8", testutil.Exchange{Method: "DELETE", Path: "/v3/connectors/conn"}), + payCase("connectors uninstall", nil, map[string]string{"confirm": "true", "connector-id": "conn", "provider": "stripe"}, "", "2.0.0", testutil.Exchange{Method: "DELETE", Path: "/connectors/STRIPE/conn"}), + payCase("connectors uninstall", nil, map[string]string{"confirm": "true", "provider": "stripe"}, "", "0.9.0", testutil.Exchange{Method: "DELETE", Path: "/connectors/STRIPE"}), + payCase("connectors schedules list", []string{"conn"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/conn/schedules", Query: "pageSize=100"}), + payCase("connectors schedules get", []string{"conn", "schedule"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/conn/schedules/schedule"}), + payCase("connectors schedules instances list", []string{"conn", "schedule"}, map[string]string{"cursor": "next"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/conn/schedules/schedule/instances", Query: "cursor=next"}), + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/payments/exchanges.go b/misc/fctl-plugin/internal/payments/exchanges.go new file mode 100644 index 00000000..def7ac20 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/exchanges.go @@ -0,0 +1,70 @@ +package payments + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func orderOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Leaf("orders get", "get ", http.MethodGet, "orders/$0", 1, 1), + } +} + +func conversionOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Leaf("conversions get", "get ", http.MethodGet, "conversions/$0", 1, 1), + } +} + +func exchangeQuery(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + group := r.CommandPath[1] + allowed := map[string][]string{"direction": {"BUY", "SELL", "UNKNOWN"}} + if group == "orders" { + allowed["status"] = []string{"PENDING", "OPEN", "PARTIALLY_FILLED", "FILLED", "CANCELLED", "FAILED", "EXPIRED", "UNKNOWN"} + allowed["type"] = []string{"MARKET", "LIMIT", "STOP", "STOP_LIMIT", "TWAP", "VWAP", "PEG", "BLOCK", "RFQ", "TRAILING_STOP", "TRAILING_STOP_LIMIT", "TAKE_PROFIT", "TAKE_PROFIT_LIMIT", "LIMIT_MAKER", "UNKNOWN"} + } else { + allowed["status"] = []string{"PENDING", "COMPLETED", "FAILED", "UNKNOWN"} + } + for flag, values := range allowed { + if v := r.Flags[flag]; v != "" && !slices.Contains(values, v) { + return nil, fmt.Errorf("invalid --%s %q", flag, v) + } + } + if r.Flags["cursor"] != "" { + return nil, nil + } + matches := []any{} + for _, flag := range []string{"connector-id", "reference", "direction", "status", "type", "source-asset", "destination-asset"} { + if value := r.Flags[flag]; value != "" { + matches = append(matches, map[string]any{"$match": map[string]string{strings.ReplaceAll(flag, "-", "_"): value}}) + } + } + if len(matches) == 0 { + return nil, nil + } + return json.Marshal(map[string]any{"$and": matches}) +} + +func paymentExchangeOperations() []commandapi.Operation { + operations := []commandapi.Operation{} + for _, group := range []string{"orders", "conversions"} { + flags := append(commandapi.PaginationFlags(), commandapi.StringFlag("connector-id", "Connector ID filter"), commandapi.StringFlag("reference", "Provider reference filter"), + commandapi.StringFlag("status", "Status filter"), commandapi.StringFlag("source-asset", "Source asset filter"), commandapi.StringFlag("destination-asset", "Destination asset filter")) + if group == "orders" { + flags = append(flags, commandapi.StringFlag("direction", "BUY, SELL or UNKNOWN"), commandapi.StringFlag("type", "Order type")) + } + op := commandapi.Leaf(group+" list", "list", http.MethodGet, group, 0, 0, flags...) + op.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor"} + op.Body = exchangeQuery + operations = append(operations, op) + } + return operations +} diff --git a/misc/fctl-plugin/internal/payments/execute.go b/misc/fctl-plugin/internal/payments/execute.go new file mode 100644 index 00000000..46eeb8b4 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/execute.go @@ -0,0 +1,49 @@ +package payments + +import ( + "context" + "encoding/json" + "net/url" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func paymentsOperation(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, body json.RawMessage) (json.RawMessage, error) { + major, minor, err := paymentsVersion(ctx, client) + if err != nil { + return nil, err + } + if strings.HasPrefix(op.Command, "connectors ") { + return paymentConnector(ctx, client, r, op, body, major) + } + v3, err := paymentV3Route(op.Command, major, minor) + if err != nil { + return nil, err + } + if err := validatePaymentPayload(op.Command, body, major, minor); err != nil { + return nil, err + } + if v3 { + op.Segments = append([]string{"v3"}, op.Segments...) + } + query, err := commandapi.QueryValues(op.Query, r) + if err != nil { + return nil, err + } + if op.Command == "pools balances" { + query.Set("at", r.Args[1]) + } + if cursor := query.Get("cursor"); cursor != "" { + query = url.Values{"cursor": {cursor}} + body = nil + } + path, err := commandapi.Route(op.Segments, r) + if err != nil { + return nil, err + } + return client.Do(ctx, op.Method, path, query, body, nil) +} diff --git a/misc/fctl-plugin/internal/payments/files_test.go b/misc/fctl-plugin/internal/payments/files_test.go new file mode 100644 index 00000000..8db1b813 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/files_test.go @@ -0,0 +1,75 @@ +package payments + +import ( + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestHistoricalFileArguments(t *testing.T) { + t.Parallel() + confirm := map[string]string{"confirm": "true"} + account := `{"connectorID":"conn","createdAt":"2026-01-01T00:00:00Z","reference":"qa","type":"INTERNAL"}` + payment := `{"amount":9007199254740993,"asset":"USD/2","connectorID":"conn","createdAt":"2026-01-01T00:00:00Z","reference":"qa","scheme":"OTHER","status":"SUCCEEDED","type":"TRANSFER"}` + transfer := `{"amount":1,"asset":"USD/2","description":"qa","destinationAccountID":"dst","reference":"qa","scheduledAt":"2026-01-01T00:00:00Z","sourceAccountID":"src","type":"TRANSFER","validated":true}` + reverse := `{"amount":1,"asset":"USD/2","description":"qa","metadata":{},"reference":"qa"}` + cases := []testutil.Case{ + payCase("accounts create", nil, confirm, account, "3.4.8", testutil.Exchange{Method: "POST", Path: "/accounts", Body: account}), + payCase("bank-accounts create", nil, confirm, `{"name":"qa"}`, "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/bank-accounts", Body: `{"name":"qa"}`}), + payCase("payments create", nil, confirm, payment, "3.4.8", testutil.Exchange{Method: "POST", Path: "/payments", Body: payment}), + payCase("pools create", nil, confirm, `{"name":"qa","accountIDs":[]}`, "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/pools", Body: `{"name":"qa","accountIDs":[]}`}), + payCase("pools update-query", []string{"pool"}, confirm, `{"query":{"$match":{"type":"INTERNAL"}}}`, "3.4.8", testutil.Exchange{Method: "PATCH", Path: "/v3/pools/pool/query", Body: `{"query":{"$match":{"type":"INTERNAL"}}}`}), + payCase("transfer-initiation create", nil, confirm, transfer, "3.4.8", testutil.Exchange{Method: "POST", Path: "/transfer-initiations", Body: transfer}), + payCase("transfer-initiation reverse", []string{"transfer"}, confirm, reverse, "3.4.8", testutil.Exchange{Method: "POST", Path: "/transfer-initiations/transfer/reverse", Body: reverse}), + payCase("connectors install", []string{"stripe"}, confirm, `{"name":"qa"}`, "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs", Response: `{"data":{"stripe":{}}}`}, testutil.Exchange{Method: "POST", Path: "/v3/connectors/install/stripe", Body: `{"name":"qa","provider":"stripe"}`}), + payCase("connectors update-config", []string{"stripe"}, map[string]string{"confirm": "true", "connector-id": "conn"}, `{"name":"qa"}`, "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/connectors/configs", Response: `{"data":{"stripe":{}}}`}, testutil.Exchange{Method: "PATCH", Path: "/v3/connectors/conn/config", Body: `{"name":"qa","provider":"stripe"}`}), + } + for _, tc := range cases { + t.Run(tc.Command, func(t *testing.T) { + t.Parallel() + path := strings.Fields(tc.Command) + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + command, err := pluginsdk.FindCommand(manifest, path) + if err != nil { + t.Fatal(err) + } + if command.Files == nil || command.Files.ReadArgument == nil || *command.Files.ReadArgument != len(tc.Args) || command.Files.ReadFormat != "yaml" { + t.Fatalf("missing historical file input: %+v", command.Files) + } + if command.Args.Min != len(tc.Args) || command.Args.Max != len(tc.Args)+1 || !strings.Contains(command.Use, "[|-]") { + t.Fatalf("wrong file signature: %+v", command) + } + // This deliberately nonexistent source proves the module itself never reads it. + tc.Args = append(append([]string{}, tc.Args...), "/does-not-exist/fixture.yaml") + testutil.RunCase(t, New, tc) + }) + } +} + +func TestFileInputsOnlyOnHistoricalPayloadCommands(t *testing.T) { + t.Parallel() + cases := []string{"payments pools add-account"} + for _, path := range cases { + t.Run(path, func(t *testing.T) { + t.Parallel() + names := strings.Fields(path) + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + command, err := pluginsdk.FindCommand(manifest, names) + if err != nil { + t.Fatal(err) + } + if command.Files != nil { + t.Fatal("non-file historical command gained a file source") + } + }) + } +} diff --git a/misc/fctl-plugin/internal/payments/metadata.go b/misc/fctl-plugin/internal/payments/metadata.go new file mode 100644 index 00000000..8173155e --- /dev/null +++ b/misc/fctl-plugin/internal/payments/metadata.go @@ -0,0 +1,72 @@ +package payments + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func metadataFlag() pluginsdk.FlagSpec { + return commandapi.StringFlag("metadata", "Metadata as JSON object or CSV key=value") +} + +func paymentMetadata(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if len(r.Args) > 1 && r.Flags["metadata"] != "" { + return nil, fmt.Errorf("provide metadata once") + } + var metadata map[string]string + if len(r.Args) > 1 { + var err error + metadata, err = positionalMetadata(r.Args[1:]) + if err != nil { + return nil, err + } + } else { + var err error + metadata, err = commandapi.Pairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + } + if len(metadata) == 0 { + return nil, fmt.Errorf("metadata is required") + } + if r.CommandPath[1] == "bank-accounts" { + return json.Marshal(map[string]any{"metadata": metadata}) + } + return json.Marshal(metadata) +} + +func paymentMetadataOperations() []commandapi.Operation { + operations := []commandapi.Operation{} + for _, group := range []string{"payments", "bank-accounts"} { + command, path := "set-metadata", "payments/$0/metadata" + if group == "bank-accounts" { + command, path = "update-metadata", "bank-accounts/$0/metadata" + } + op := commandapi.Confirmed(commandapi.Leaf(group+" "+command, command+" [key=value...]", http.MethodPatch, path, 1, 1000, metadataFlag())) + op.Body = paymentMetadata + operations = append(operations, op) + } + return operations +} + +func positionalMetadata(args []string) (map[string]string, error) { + metadata := map[string]string{} + for _, v := range args { + key, value, ok := strings.Cut(v, "=") + if !ok || key == "" { + return nil, fmt.Errorf("expected metadata key=value") + } + if _, duplicate := metadata[key]; duplicate { + return nil, fmt.Errorf("duplicate metadata key %q", key) + } + metadata[key] = value + } + return metadata, nil +} diff --git a/misc/fctl-plugin/internal/payments/numbers.go b/misc/fctl-plugin/internal/payments/numbers.go new file mode 100644 index 00000000..d9e2067c --- /dev/null +++ b/misc/fctl-plugin/internal/payments/numbers.go @@ -0,0 +1,15 @@ +package payments + +import ( + "encoding/json" + "fmt" + "math/big" +) + +func integer(value string, nonnegative bool) (json.Number, error) { + n, ok := new(big.Int).SetString(value, 10) + if !ok || (nonnegative && n.Sign() < 0) { + return "", fmt.Errorf("expected %sinteger, got %q", map[bool]string{true: "nonnegative ", false: ""}[nonnegative], value) + } + return json.Number(n.String()), nil +} diff --git a/misc/fctl-plugin/internal/payments/pagination.go b/misc/fctl-plugin/internal/payments/pagination.go new file mode 100644 index 00000000..fb5bfb78 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/pagination.go @@ -0,0 +1,35 @@ +package payments + +import ( + "fmt" + "net/http" + "strings" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func paymentListOperations() []commandapi.Operation { + operations := []commandapi.Operation{} + for _, pair := range [][2]string{ + {"accounts list", "accounts"}, {"accounts balances", "accounts/$0/balances"}, {"payments list", "payments"}, + {"bank-accounts list", "bank-accounts"}, {"pools list", "pools"}, {"transfer-initiation list", "transfer-initiations"}, + {"connectors list", "connectors"}, {"connectors schedules list", "connectors/$0/schedules"}, + {"connectors schedules instances list", "connectors/$0/schedules/$1/instances"}, + } { + args := strings.Count(pair[1], "$") + use := "list" + if pair[0] == "accounts balances" { + use = "balances" + } + for i := range args { + use += fmt.Sprintf(" ", i+1) + } + op := commandapi.Leaf(pair[0], use, http.MethodGet, pair[1], args, args, commandapi.PaginationFlags()...) + op.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor"} + if pair[0] == "connectors list" { + op.Spec.Flags[1].Default = "10" + } + operations = append(operations, op) + } + return operations +} diff --git a/misc/fctl-plugin/internal/payments/payload.go b/misc/fctl-plugin/internal/payments/payload.go new file mode 100644 index 00000000..a4473fad --- /dev/null +++ b/misc/fctl-plugin/internal/payments/payload.go @@ -0,0 +1,120 @@ +package payments + +import ( + "encoding/json" + "fmt" + "slices" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func validatePaymentPayload(command string, body json.RawMessage, major, minor int) error { + if body == nil { + return nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(body, &object); err != nil || object == nil { + return fmt.Errorf("expected a JSON object") + } + for _, validate := range []func(map[string]json.RawMessage) error{validatePaymentNumbers, validatePaymentTimes, validatePaymentStrings, validatePaymentMetadata} { + if err := validate(object); err != nil { + return err + } + } + if err := validatePaymentEnums(command, object); err != nil { + return err + } + if command == "bank-accounts create" && major < 3 { + if _, err := commandapi.ObjectBody(body, "country"); err != nil { + return err + } + } + if command == "pools create" || command == "pools update-query" { + return validatePoolPayload(object, major, minor) + } + return nil +} + +func rawEnum(object map[string]json.RawMessage, field string, allowed []string) error { + var value string + if json.Unmarshal(object[field], &value) != nil || !slices.Contains(allowed, value) { + return fmt.Errorf("invalid payload %s", field) + } + return nil +} + +func validatePaymentNumbers(object map[string]json.RawMessage) error { + for _, field := range []string{"amount", "initialAmount"} { + if raw, exists := object[field]; exists { + if _, err := integer(string(raw), true); err != nil { + return fmt.Errorf("%s: %w", field, err) + } + } + } + return nil +} + +func validatePaymentTimes(object map[string]json.RawMessage) error { + for _, field := range []string{"createdAt", "scheduledAt"} { + if raw, exists := object[field]; exists { + var value string + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be an RFC3339 string", field) + } + if err := commandapi.Timestamp(value); err != nil { + return err + } + } + } + return nil +} + +func validatePaymentStrings(object map[string]json.RawMessage) error { + for _, field := range []string{"name", "connectorID", "reference", "asset", "description", "sourceAccountID", "destinationAccountID", "scheme"} { + if raw, exists := object[field]; exists && string(raw) != "null" { + var value string + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be a string", field) + } + } + } + return nil +} + +func validatePaymentMetadata(object map[string]json.RawMessage) error { + if raw, exists := object["metadata"]; exists && string(raw) != "null" { + var values map[string]string + if json.Unmarshal(raw, &values) != nil || values == nil { + return fmt.Errorf("metadata must be an object with string values") + } + } + if raw, exists := object["validated"]; exists { + var value bool + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("validated must be a boolean") + } + } + return nil +} + +func validatePaymentEnums(command string, object map[string]json.RawMessage) error { + if command == "accounts create" { + if err := rawEnum(object, "type", []string{"UNKNOWN", "INTERNAL", "EXTERNAL"}); err != nil { + return err + } + } + if command == "payments create" { + if err := rawEnum(object, "type", []string{"PAY-IN", "PAYOUT", "TRANSFER", "OTHER"}); err != nil { + return err + } + if err := rawEnum(object, "status", []string{"PENDING", "SUCCEEDED", "CANCELLED", "FAILED", "EXPIRED", "REFUNDED", "REFUNDED_FAILURE", "DISPUTE", "DISPUTE_WON", "DISPUTE_LOST", "OTHER"}); err != nil { + return err + } + } + if command == "transfer-initiation create" { + if err := rawEnum(object, "type", []string{"TRANSFER", "PAYOUT"}); err != nil { + return err + } + } + return nil +} diff --git a/misc/fctl-plugin/internal/payments/payments.go b/misc/fctl-plugin/internal/payments/payments.go new file mode 100644 index 00000000..af623216 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/payments.go @@ -0,0 +1,14 @@ +package payments + +import ( + "net/http" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func paymentOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("payments create", "create", http.MethodPost, "payments", 0, 0)), "amount", "asset", "connectorID", "createdAt", "reference", "scheme", "status", "type"), + commandapi.Leaf("payments get", "get ", http.MethodGet, "payments/$0", 1, 1), + } +} diff --git a/misc/fctl-plugin/internal/payments/payments_contract_test.go b/misc/fctl-plugin/internal/payments/payments_contract_test.go new file mode 100644 index 00000000..08e36400 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/payments_contract_test.go @@ -0,0 +1,69 @@ +package payments + +import ( + "fmt" + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func payCase(command string, args []string, flags map[string]string, body, version string, steps ...testutil.Exchange) testutil.Case { + return testutil.Case{Name: command + " " + version, Command: "payments " + command, Args: args, Flags: flags, Body: body, + Exchanges: append([]testutil.Exchange{{Method: "GET", Path: "/_info", Response: fmt.Sprintf(`{"data":{"version":%q}}`, version)}}, steps...)} +} + +func TestPaymentsHistoricalContracts(t *testing.T) { + t.Parallel() + confirm := map[string]string{"confirm": "true"} + account := `{"connectorID":"conn","createdAt":"2026-01-01T00:00:00Z","reference":"ref","type":"INTERNAL"}` + payment := `{"amount":9007199254740993,"asset":"USD/2","connectorID":"conn","createdAt":"2026-01-01T00:00:00Z","reference":"ref","scheme":"other","status":"SUCCEEDED","type":"PAYOUT"}` + initiation := `{"amount":9007199254740993,"asset":"USD/2","description":"transfer","destinationAccountID":"dst","reference":"ref","scheduledAt":"2026-01-01T00:00:00Z","sourceAccountID":"src","type":"TRANSFER","validated":false}` + reversal := `{"amount":9007199254740993,"asset":"USD/2","description":"reverse","metadata":{"reason":"test"},"reference":"reverse-ref"}` + cases := []testutil.Case{ + payCase("accounts create", nil, confirm, account, "3.4.8", testutil.Exchange{Method: "POST", Path: "/accounts", Body: account}), + payCase("accounts get", []string{"a/%"}, nil, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/accounts/a%2F%25"}), + payCase("accounts list", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/accounts", Query: "pageSize=100"}), + payCase("accounts balances", []string{"a"}, map[string]string{"cursor": "next"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/accounts/a/balances", Query: "cursor=next"}), + payCase("payments create", nil, confirm, payment, "3.4.8", testutil.Exchange{Method: "POST", Path: "/payments", Body: payment}), + payCase("payments get", []string{"p"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/payments/p"}), + payCase("payments list", nil, map[string]string{"page-size": "2"}, "", "2.5.0", testutil.Exchange{Method: "GET", Path: "/payments", Query: "pageSize=2"}), + payCase("payments set-metadata", []string{"p", "key=value=extra"}, confirm, "", "3.4.8", testutil.Exchange{Method: "PATCH", Path: "/payments/p/metadata", Body: `{"key":"value=extra"}`}), + payCase("bank-accounts create", nil, confirm, `{"name":"bank"}`, "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/bank-accounts", Body: `{"name":"bank"}`}), + payCase("bank-accounts create", nil, confirm, `{"name":"bank","country":"FR"}`, "2.5.0", testutil.Exchange{Method: "POST", Path: "/bank-accounts", Body: `{"name":"bank","country":"FR"}`}), + payCase("bank-accounts get", []string{"b"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/bank-accounts/b"}), + payCase("bank-accounts get", []string{"b"}, nil, "", "2.5.0", testutil.Exchange{Method: "GET", Path: "/bank-accounts/b"}), + payCase("bank-accounts list", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/bank-accounts", Query: "pageSize=100"}), + payCase("bank-accounts forward", []string{"b", "conn"}, confirm, "", "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/bank-accounts/b/forward", Body: `{"connectorID":"conn"}`}), + payCase("bank-accounts forward", []string{"b", "conn"}, confirm, "", "2.5.0", testutil.Exchange{Method: "POST", Path: "/bank-accounts/b/forward", Body: `{"connectorID":"conn"}`}), + payCase("bank-accounts update-metadata", []string{"b"}, map[string]string{"confirm": "true", "metadata": "key=value"}, "", "3.4.8", testutil.Exchange{Method: "PATCH", Path: "/v3/bank-accounts/b/metadata", Body: `{"metadata":{"key":"value"}}`}), + payCase("pools create", nil, confirm, `{"name":"pool","accountIDs":["a"]}`, "2.0.0", testutil.Exchange{Method: "POST", Path: "/pools", Body: `{"name":"pool","accountIDs":["a"]}`}), + payCase("pools create", nil, confirm, `{"name":"pool","query":{"$match":{"amount":9007199254740993}}}`, "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/pools", Body: `{"name":"pool","query":{"$match":{"amount":9007199254740993}}}`}), + payCase("pools get", []string{"pool"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/pools/pool"}), + payCase("pools list", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/pools", Query: "pageSize=100"}), + payCase("pools delete", []string{"pool"}, confirm, "", "3.4.8", testutil.Exchange{Method: "DELETE", Path: "/pools/pool", Status: 204}), + payCase("pools balances", []string{"pool", "2026-01-01T00:00:00Z"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/pools/pool/balances", Query: "at=2026-01-01T00%3A00%3A00Z"}), + payCase("pools latest-balances", []string{"pool"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/pools/pool/balances/latest"}), + payCase("pools latest-balances", []string{"pool"}, nil, "", "2.0.0", testutil.Exchange{Method: "GET", Path: "/pools/pool/balances/latest"}), + payCase("pools add-account", []string{"pool", "a"}, nil, "", "3.4.8", testutil.Exchange{Method: "POST", Path: "/pools/pool/accounts", Body: `{"accountID":"a"}`}), + payCase("pools remove-account", []string{"pool", "a"}, confirm, "", "3.4.8", testutil.Exchange{Method: "DELETE", Path: "/pools/pool/accounts/a"}), + payCase("pools update-query", []string{"pool"}, confirm, `{"query":{"$match":{"type":"INTERNAL"}}}`, "3.4.8", testutil.Exchange{Method: "PATCH", Path: "/v3/pools/pool/query", Body: `{"query":{"$match":{"type":"INTERNAL"}}}`}), + payCase("transfer-initiation create", nil, confirm, initiation, "3.4.8", testutil.Exchange{Method: "POST", Path: "/transfer-initiations", Body: initiation}), + payCase("transfer-initiation get", []string{"ti"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/transfer-initiations/ti"}), + payCase("transfer-initiation list", nil, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/transfer-initiations", Query: "pageSize=100"}), + payCase("transfer-initiation approve", []string{"ti"}, confirm, "", "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/payment-initiations/ti/approve"}), + payCase("transfer-initiation reject", []string{"ti"}, confirm, "", "3.4.8", testutil.Exchange{Method: "POST", Path: "/v3/payment-initiations/ti/reject"}), + payCase("transfer-initiation retry", []string{"ti"}, confirm, "", "3.4.8", testutil.Exchange{Method: "POST", Path: "/transfer-initiations/ti/retry"}), + payCase("transfer-initiation reverse", []string{"ti"}, confirm, reversal, "3.4.8", testutil.Exchange{Method: "POST", Path: "/transfer-initiations/ti/reverse", Body: reversal}), + payCase("transfer-initiation delete", []string{"ti"}, confirm, "", "3.4.8", testutil.Exchange{Method: "DELETE", Path: "/transfer-initiations/ti"}), + payCase("transfer-initiation update-status", []string{"ti", "VALIDATED"}, confirm, "", "2.0.0", testutil.Exchange{Method: "POST", Path: "/transfer-initiations/ti/status", Body: `{"status":"VALIDATED"}`}), + payCase("tasks get", []string{"task"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/tasks/task"}), + payCase("orders get", []string{"order"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/orders/order"}), + payCase("conversions get", []string{"conv"}, nil, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/conversions/conv"}), + payCase("orders list", nil, map[string]string{"connector-id": "c", "reference": "ref", "direction": "BUY", "status": "FILLED", "type": "MARKET", "source-asset": "USD/2", "destination-asset": "BTC/8", "page-size": "2"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/orders", Query: "pageSize=2", Body: `{"$and":[{"$match":{"connector_id":"c"}},{"$match":{"reference":"ref"}},{"$match":{"direction":"BUY"}},{"$match":{"status":"FILLED"}},{"$match":{"type":"MARKET"}},{"$match":{"source_asset":"USD/2"}},{"$match":{"destination_asset":"BTC/8"}}]}`}), + payCase("conversions list", nil, map[string]string{"cursor": "next", "reference": "ignored-on-next-page"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/conversions", Query: "cursor=next"}), + payCase("conversions list", nil, map[string]string{"status": "COMPLETED"}, "", "3.4.8", testutil.Exchange{Method: "GET", Path: "/v3/conversions", Query: "pageSize=100", Body: `{"$and":[{"$match":{"status":"COMPLETED"}}]}`}), + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/payments/plugin.go b/misc/fctl-plugin/internal/payments/plugin.go new file mode 100644 index 00000000..c3425beb --- /dev/null +++ b/misc/fctl-plugin/internal/payments/plugin.go @@ -0,0 +1,45 @@ +// Package payments implements the historical payments commands. +package payments + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +// New builds the Payments SDK plugin using the injected HTTP client. +func New(client *http.Client) pluginsdk.Plugin { + operations := accountOperations() + operations = append(operations, paymentOperations()...) + operations = append(operations, bankAccountOperations()...) + operations = append(operations, poolOperations()...) + operations = append(operations, transferOperations()...) + operations = append(operations, taskOperations()...) + operations = append(operations, connectorOperations()...) + operations = append(operations, orderOperations()...) + operations = append(operations, conversionOperations()...) + operations = append(operations, paymentListOperations()...) + operations = append(operations, paymentExchangeOperations()...) + operations = append(operations, paymentMetadataOperations()...) + operations = append(operations, + forwardBankAccountOperation(), + addPoolAccountOperation(), + poolBalancesOperation(), + transferStatusOperation(), + ) + for i := range operations { + operations[i].Run = paymentsOperation + if operations[i].Command == "connectors get-config" || operations[i].Command == "connectors uninstall" || operations[i].Command == "connectors update-config" { + alternative := "provider" + if operations[i].Command == "connectors update-config" { + alternative = "" + } + operations[i].Spec.Inputs = append(operations[i].Spec.Inputs, pluginsdk.InputSpec{Title: "Connector", Kind: "select", Flag: "connector-id", AlternativeFlag: alternative, Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"payments", "connectors", "list"}, ValueField: "id", LabelFields: []string{"name", "provider", "id"}, EmptyMessage: "No connectors available"}}) + } + } + return commandapi.New("payments", metadata.Version, client, operations, aliases) +} diff --git a/misc/fctl-plugin/internal/payments/pools.go b/misc/fctl-plugin/internal/payments/pools.go new file mode 100644 index 00000000..5799bff5 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/pools.go @@ -0,0 +1,76 @@ +package payments + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func poolOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("pools create", "create", http.MethodPost, "pools", 0, 0)), "name"), + commandapi.Leaf("pools get", "get ", http.MethodGet, "pools/$0", 1, 1), + commandapi.Confirmed(commandapi.Leaf("pools delete", "delete ", http.MethodDelete, "pools/$0", 1, 1)), + commandapi.Leaf("pools latest-balances", "latest-balances ", http.MethodGet, "pools/$0/balances/latest", 1, 1), + commandapi.Confirmed(commandapi.Leaf("pools remove-account", "remove-account ", http.MethodDelete, "pools/$0/accounts/$1", 2, 2)), + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("pools update-query", "update-query ", http.MethodPatch, "pools/$0/query", 1, 1)), "query"), + } +} + +func addPoolAccountOperation() commandapi.Operation { + op := commandapi.Leaf("pools add-account", "add-account ", http.MethodPost, "pools/$0/accounts", 2, 2) + op.Body = addPoolAccountBody + return op +} + +func poolBalancesOperation() commandapi.Operation { + op := commandapi.Leaf("pools balances", "balances ", http.MethodGet, "pools/$0/balances", 2, 2) + op.Body = poolBalancesBody + return op +} + +func addPoolAccountBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := commandapi.Identifier(r.Args[1]); err != nil { + return nil, err + } + return json.Marshal(map[string]string{"accountID": r.Args[1]}) +} + +func poolBalancesBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + return nil, commandapi.Timestamp(r.Args[1]) +} + +func validatePoolPayload(object map[string]json.RawMessage, major, minor int) error { + + if raw, exists := object["query"]; exists && string(raw) != "null" { + if major != 3 || minor < 1 { + return fmt.Errorf("dynamic pools require Payments >= 3.1.0") + } + if _, err := commandapi.ObjectBody(raw); err != nil { + return fmt.Errorf("query: %w", err) + } + } + if raw, exists := object["accountIDs"]; exists && string(raw) != "null" { + var ids []string + if json.Unmarshal(raw, &ids) != nil { + return fmt.Errorf("accountIDs must be a string array") + } + if err := validateIdentifiers(ids); err != nil { + return err + } + } + return nil +} + +func validateIdentifiers(ids []string) error { + for _, id := range ids { + if err := commandapi.Identifier(id); err != nil { + return err + } + } + return nil +} diff --git a/misc/fctl-plugin/internal/payments/tasks.go b/misc/fctl-plugin/internal/payments/tasks.go new file mode 100644 index 00000000..84964d30 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/tasks.go @@ -0,0 +1,13 @@ +package payments + +import ( + "net/http" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func taskOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Leaf("tasks get", "get ", http.MethodGet, "tasks/$0", 1, 1), + } +} diff --git a/misc/fctl-plugin/internal/payments/transfers.go b/misc/fctl-plugin/internal/payments/transfers.go new file mode 100644 index 00000000..05e0d702 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/transfers.go @@ -0,0 +1,40 @@ +package payments + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func transferOperations() []commandapi.Operation { + return []commandapi.Operation{ + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("transfer-initiation create", "create", http.MethodPost, "transfer-initiations", 0, 0)), "amount", "asset", "description", "destinationAccountID", "reference", "scheduledAt", "sourceAccountID", "type", "validated"), + commandapi.Leaf("transfer-initiation get", "get ", http.MethodGet, "transfer-initiations/$0", 1, 1), + commandapi.Confirmed(commandapi.Leaf("transfer-initiation delete", "delete ", http.MethodDelete, "transfer-initiations/$0", 1, 1)), + commandapi.Confirmed(commandapi.Leaf("transfer-initiation retry", "retry ", http.MethodPost, "transfer-initiations/$0/retry", 1, 1)), + commandapi.Payload(commandapi.Confirmed(commandapi.Leaf("transfer-initiation reverse", "reverse ", http.MethodPost, "transfer-initiations/$0/reverse", 1, 1)), "amount", "asset", "description", "metadata", "reference"), + commandapi.Confirmed(commandapi.Leaf("transfer-initiation approve", "approve ", http.MethodPost, "payment-initiations/$0/approve", 1, 1)), + commandapi.Confirmed(commandapi.Leaf("transfer-initiation reject", "reject ", http.MethodPost, "payment-initiations/$0/reject", 1, 1)), + } +} + +func transferStatusOperation() commandapi.Operation { + op := commandapi.Confirmed(commandapi.Leaf("transfer-initiation update-status", "update-status ", http.MethodPost, "transfer-initiations/$0/status", 2, 2)) + op.Body = transferStatusBody + return op +} + +func transferStatusBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if !slices.Contains([]string{"REJECTED", "VALIDATED"}, r.Args[1]) { + return nil, fmt.Errorf("status must be REJECTED or VALIDATED") + } + return json.Marshal(map[string]string{"status": r.Args[1]}) +} + +// Unversioned SDK V1 routes remain available on Payments 3. Apply version +// prefixes only to operations that the historical CLI delegated to SDK V3. diff --git a/misc/fctl-plugin/internal/payments/validation_test.go b/misc/fctl-plugin/internal/payments/validation_test.go new file mode 100644 index 00000000..08e7b928 --- /dev/null +++ b/misc/fctl-plugin/internal/payments/validation_test.go @@ -0,0 +1,77 @@ +package payments + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "empty account payload", Command: "payments accounts create", Body: `{}`, Flags: map[string]string{"confirm": "true"}}, + {Name: "duplicate payment metadata", Command: "payments payments set-metadata", Args: []string{"id", "x=1", "x=2"}, Flags: map[string]string{"confirm": "true"}}, + {Name: "missing payment metadata", Command: "payments payments set-metadata", Args: []string{"id"}, Flags: map[string]string{"confirm": "true"}}, + {Name: "bad transfer status", Command: "payments transfer-initiation update-status", Args: []string{"id", "other"}, Flags: map[string]string{"confirm": "true"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunFailure(t, New, tc, "") }) + } +} + +func TestDependentReadFailuresPreventMutation(t *testing.T) { + t.Parallel() + cases := []struct { + tc testutil.Case + want string + }{ + {testutil.Case{Command: "payments connectors install", Args: []string{"DUMMYPAY"}, Body: `{"name":"qa"}`, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/_info", Response: `{"version":"3.4.8"}`}, {Method: "GET", Path: "/v3/connectors/configs", Response: `{"data":[]}`}}}, "missing a data object"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { t.Parallel(); testutil.RunFailure(t, New, tc.tc, tc.want) }) + } +} + +func TestPaymentsVersionAndSchemaFailures(t *testing.T) { + t.Parallel() + cases := []struct { + tc testutil.Case + version, want string + }{ + {testutil.Case{Command: "payments orders list"}, "3.2.9", "3.3.0"}, + {testutil.Case{Command: "payments pools update-query", Args: []string{"id"}, Body: `{"query":{}}`, Flags: map[string]string{"confirm": "true"}}, "3.0.0", "3.1.0"}, + {testutil.Case{Command: "payments tasks get", Args: []string{"id"}}, "2.4.0", "3.0.0"}, + {testutil.Case{Command: "payments transfer-initiation update-status", Args: []string{"id", "VALIDATED"}, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "unavailable"}, + {testutil.Case{Command: "payments payments list"}, "4.0.0", "unsupported"}, + {testutil.Case{Command: "payments payments list"}, "development", "unrecognized"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","query":{}}`, Flags: map[string]string{"confirm": "true"}}, "2.0.0", "dynamic pools"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","accountIDs":[""]}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "identifier"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","accountIDs":1}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "array"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","query":[]}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "query"}, + {testutil.Case{Command: "payments bank-accounts create", Body: `{"name":"bank"}`, Flags: map[string]string{"confirm": "true"}}, "2.4.0", "country"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":1}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "string"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","metadata":{"n":1}}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "metadata"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","validated":"true"}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "boolean"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","amount":-1}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "nonnegative"}, + {testutil.Case{Command: "payments pools create", Body: `{"name":"pool","createdAt":5}`, Flags: map[string]string{"confirm": "true"}}, "3.4.8", "RFC3339"}, + } + for _, tc := range cases { + t.Run(tc.tc.Command+" "+tc.version+" "+tc.want, func(t *testing.T) { + t.Parallel() + tc.tc.Exchanges = []testutil.Exchange{{Method: "GET", Path: "/_info", Response: `{"version":"` + tc.version + `"}`}} + testutil.RunFailure(t, New, tc.tc, tc.want) + }) + } +} + +func TestHistoricalUnderscoreAliases(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Command: "payments bank_accounts get", Args: []string{"id"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/_info", Response: `{"version":"3.4.8"}`}, {Method: "GET", Path: "/v3/bank-accounts/id"}}}, + {Command: "payments transfer_initiation update_status", Args: []string{"id", "VALIDATED"}, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/_info", Response: `{"version":"2.4.0"}`}, {Method: "POST", Path: "/transfer-initiations/id/status", Body: `{"status":"VALIDATED"}`}}}, + {Command: "payments payment_initiations approve", Args: []string{"id"}, Flags: map[string]string{"confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/_info", Response: `{"version":"3.4.8"}`}, {Method: "POST", Path: "/v3/payment-initiations/id/approve"}}}, + } + for _, tc := range cases { + t.Run(tc.Command, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/payments/version.go b/misc/fctl-plugin/internal/payments/version.go new file mode 100644 index 00000000..c87aec6e --- /dev/null +++ b/misc/fctl-plugin/internal/payments/version.go @@ -0,0 +1,76 @@ +package payments + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +var paymentSemver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.+-]+)?$`) + +var paymentCommit = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`) + +func paymentsVersion(ctx context.Context, client *httpclient.Client) (major, minor int, err error) { + info, err := client.Do(ctx, http.MethodGet, httpclient.Path("_info"), nil, nil, nil) + if err != nil { + return 0, 0, err + } + version, err := pluginsdk.ServiceVersion(info) + if err != nil { + return 0, 0, err + } + match := paymentSemver.FindStringSubmatch(version) + if match == nil { + if paymentCommit.MatchString(version) { + return 3, int(^uint(0) >> 1), nil + } + return 0, 0, fmt.Errorf("unrecognized Payments version %q", version) + } + major, err = strconv.Atoi(match[1]) + if err != nil { + return 0, 0, err + } + minor, err = strconv.Atoi(match[2]) + if err != nil { + return 0, 0, err + } + if major > 3 { + return 0, 0, fmt.Errorf("unsupported Payments major %d", major) + } + return major, minor, nil +} + +func paymentV3Route(command string, major, minor int) (bool, error) { + if strings.HasPrefix(command, "bank-accounts ") || command == "pools create" || command == "pools latest-balances" { + return major == 3, nil + } + if strings.HasPrefix(command, "orders ") || strings.HasPrefix(command, "conversions ") { + if major != 3 || minor < 3 { + return false, fmt.Errorf("%s requires Payments >= 3.3.0", strings.Fields(command)[0]) + } + return true, nil + } + switch command { + case "tasks get", "transfer-initiation approve", "transfer-initiation reject": + if major != 3 { + return false, fmt.Errorf("%s requires Payments >= 3.0.0", command) + } + return true, nil + case "pools update-query": + if major != 3 || minor < 1 { + return false, fmt.Errorf("update-query requires Payments >= 3.1.0") + } + return true, nil + case "transfer-initiation update-status": + if major == 3 { + return false, fmt.Errorf("update-status is unavailable on Payments 3; use approve or reject") + } + } + return false, nil +} diff --git a/misc/fctl-plugin/internal/reconciliation/alerts.go b/misc/fctl-plugin/internal/reconciliation/alerts.go new file mode 100644 index 00000000..9ab1d800 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/alerts.go @@ -0,0 +1,79 @@ +package reconciliation + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func alertOperations() []command.Operation { + operations := []command.Operation{ + command.Leaf("alerts get", "get ", http.MethodGet, "alerts/$0", 1, 1), + paginatedList("alerts events", "events ", "alerts/$0/events", 1), + clarityList("alerts"), + } + for _, transition := range []string{"ack", "resolve", "accept", "snooze", "unsnooze"} { + by := command.StringFlag("by", "Actor") + by.Required = true + flags := []pluginsdk.FlagSpec{by} + if transition != "unsnooze" { + note := command.StringFlag("note", "Note or business justification") + note.Required = transition == "accept" + flags = append(flags, note) + } + if transition == "resolve" { + flags = append(flags, command.StringFlag("transaction-ref", "Transaction references as CSV or JSON string array")) + } + if transition == "snooze" { + until := command.StringFlag("until", "Future RFC3339 deadline") + until.Required = true + flags = append(flags, until) + } + op := command.Confirmed(command.Leaf("alerts "+transition, transition+" ", http.MethodPost, "alerts/$0/"+transition, 1, 1, flags...)) + op.Body = alertTransition + for _, f := range flags { + if f.Required { + op.Spec.Inputs = append(op.Spec.Inputs, pluginsdk.InputSpec{Title: f.Usage, Kind: "input", Flag: f.Name, Required: true}) + } + } + operations = append(operations, op) + } + return operations +} + +func alertTransition(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if strings.TrimSpace(r.Flags["by"]) == "" { + return nil, fmt.Errorf("--by must be nonempty") + } + body := map[string]any{"by": r.Flags["by"]} + if note := r.Flags["note"]; note != "" { + body["note"] = note + } + if r.CommandPath[2] == "accept" && strings.TrimSpace(r.Flags["note"]) == "" { + return nil, fmt.Errorf("--note is required for accept") + } + if v := r.Flags["until"]; v != "" { + deadline, err := time.Parse(time.RFC3339Nano, v) + if err != nil { + return nil, fmt.Errorf("--until must be RFC3339: %w", err) + } + if !deadline.After(time.Now()) { + return nil, fmt.Errorf("--until must be in the future") + } + body["until"] = v + } + if v := r.Flags["transaction-ref"]; v != "" { + refs, err := command.List(v) + if err != nil { + return nil, err + } + body["transactionRefs"] = refs + } + return json.Marshal(body) +} diff --git a/misc/fctl-plugin/internal/reconciliation/aliases.go b/misc/fctl-plugin/internal/reconciliation/aliases.go new file mode 100644 index 00000000..1ff08307 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/aliases.go @@ -0,0 +1,3 @@ +package reconciliation + +var aliases = map[string][]string{"policies": {"p"}, "rules": {"rule"}, "rules evaluate": {"run"}, "rules update": {"patch"}, "rules get": {"show", "sh", "s"}, "evaluations get": {"show", "sh", "s"}, "alerts get": {"show", "sh", "s"}, "policies reconcile": {"r"}, "evaluations": {"evaluation", "evals"}, "alerts": {"alert"}} diff --git a/misc/fctl-plugin/internal/reconciliation/contract_test.go b/misc/fctl-plugin/internal/reconciliation/contract_test.go new file mode 100644 index 00000000..4cae4fd6 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/contract_test.go @@ -0,0 +1,40 @@ +package reconciliation + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestReconciliationContracts(t *testing.T) { + t.Parallel() + confirm := map[string]string{"confirm": "true"} + cases := []testutil.Case{ + {Name: "list", Command: "reconciliation list", Flags: map[string]string{"page-size": "2"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/reconciliations", Query: "pageSize=2"}}}, + {Name: "get", Command: "reconciliation get", Args: []string{"r/1"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/reconciliations/r%2F1"}}}, + {Name: "policies list", Command: "reconciliation policies list", Flags: map[string]string{"cursor": "next"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/policies", Query: "cursor=next"}}}, + {Name: "policy get", Command: "reconciliation policies get", Args: []string{"p"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/policies/p"}}}, + {Name: "create policy", Command: "reconciliation policies create", Flags: confirm, Body: `{"name":"p","ledgerName":"book","paymentsPoolID":"pool","ledgerQuery":{"$match":{"amount":9007199254740993}}}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/policies", Body: `{"name":"p","ledgerName":"book","paymentsPoolID":"pool","ledgerQuery":{"$match":{"amount":9007199254740993}}}`}}}, + {Name: "delete policy", Command: "reconciliation policies delete", Args: []string{"p"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "DELETE", Path: "/policies/p", Status: 204}}}, + {Name: "reconcile", Command: "reconciliation policies reconcile", Args: []string{"p", "2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/policies/p/reconciliation", Body: `{"reconciledAtLedger":"2026-01-01T00:00:00Z","reconciledAtPayments":"2026-01-02T00:00:00Z"}`}}}, + {Name: "rules query", Command: "reconciliation rules list", Flags: map[string]string{"query": `{"$match":{"enabled":true,"amount":9007199254740993}}`}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/rules", Query: "pageSize=100", Body: `{"$match":{"enabled":true,"amount":9007199254740993}}`}}}, + {Name: "rule get", Command: "reconciliation rules get", Args: []string{"rule"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/rules/rule"}}}, + {Name: "rule create", Command: "reconciliation rules create", Flags: confirm, Body: `{"name":"rule","templateSpec":{"amount":9007199254740993}}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/rules", Body: `{"name":"rule","templateSpec":{"amount":9007199254740993}}`}}}, + {Name: "rule patch false", Command: "reconciliation rules update", Args: []string{"rule"}, Flags: confirm, Body: `{"enabled":false,"labels":{}}`, Exchanges: []testutil.Exchange{{Method: "PATCH", Path: "/rules/rule", Body: `{"enabled":false,"labels":{}}`}}}, + {Name: "rule delete", Command: "reconciliation rules delete", Args: []string{"rule"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "DELETE", Path: "/rules/rule"}}}, + {Name: "rule evaluate", Command: "reconciliation rules evaluate", Args: []string{"rule"}, Flags: map[string]string{"confirm": "true", "at": "2026-01-01T00:00:00Z", "safety-margin": "30s", "source-pit": "ledger=2026-01-01T00:00:00Z,payments=2026-01-02T00:00:00Z"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/rules/rule/evaluate", Body: `{"at":"2026-01-01T00:00:00Z","safetyMargin":"30s","sourcePITs":{"ledger":"2026-01-01T00:00:00Z","payments":"2026-01-02T00:00:00Z"}}`}}}, + {Name: "evaluations query", Command: "reconciliation evaluations list", Body: `{"$match":{"ruleID":"rule"}}`, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/evaluations", Query: "pageSize=100", Body: `{"$match":{"ruleID":"rule"}}`}}}, + {Name: "evaluation get", Command: "reconciliation evaluations get", Args: []string{"ev"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/evaluations/ev"}}}, + {Name: "alerts cursor", Command: "reconciliation alerts list", Flags: map[string]string{"cursor": "next", "query": `{"$match":{"status":"open"}}`}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/alerts", Query: "cursor=next"}}}, + {Name: "alert get", Command: "reconciliation alerts get", Args: []string{"al"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/alerts/al"}}}, + {Name: "alert events", Command: "reconciliation alerts events", Args: []string{"al"}, Flags: map[string]string{"page-size": "10"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/alerts/al/events", Query: "pageSize=10"}}}, + {Name: "ack", Command: "reconciliation alerts ack", Args: []string{"al"}, Flags: map[string]string{"confirm": "true", "by": "engineer", "note": "investigating"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/alerts/al/ack", Body: `{"by":"engineer","note":"investigating"}`}}}, + {Name: "resolve", Command: "reconciliation alerts resolve", Args: []string{"al"}, Flags: map[string]string{"confirm": "true", "by": "engineer", "transaction-ref": "ref1,ref2"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/alerts/al/resolve", Body: `{"by":"engineer","transactionRefs":["ref1","ref2"]}`}}}, + {Name: "accept", Command: "reconciliation alerts accept", Args: []string{"al"}, Flags: map[string]string{"confirm": "true", "by": "engineer", "note": "approved adjustment"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/alerts/al/accept", Body: `{"by":"engineer","note":"approved adjustment"}`}}}, + {Name: "snooze", Command: "reconciliation alerts snooze", Args: []string{"al"}, Flags: map[string]string{"confirm": "true", "by": "engineer", "until": "2099-01-01T00:00:00Z"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/alerts/al/snooze", Body: `{"by":"engineer","until":"2099-01-01T00:00:00Z"}`}}}, + {Name: "unsnooze", Command: "reconciliation alerts unsnooze", Args: []string{"al"}, Flags: map[string]string{"confirm": "true", "by": "engineer"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/alerts/al/unsnooze", Body: `{"by":"engineer"}`}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/reconciliation/files_test.go b/misc/fctl-plugin/internal/reconciliation/files_test.go new file mode 100644 index 00000000..6e6063e7 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/files_test.go @@ -0,0 +1,43 @@ +package reconciliation + +import ( + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +// The host decodes historical file inputs and leaves their source argument in place. +func TestHistoricalFileArguments(t *testing.T) { + t.Parallel() + confirm := map[string]string{"confirm": "true"} + cases := []testutil.Case{ + {Command: "reconciliation policies create", Flags: confirm, Body: `{"name":"qa","ledgerName":"qa","paymentsPoolID":"pool","ledgerQuery":{}}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/policies", Body: `{"name":"qa","ledgerName":"qa","paymentsPoolID":"pool","ledgerQuery":{}}`}}}, + {Command: "reconciliation rules create", Flags: confirm, Body: `{"name":"qa","templateKind":"ledger_invariant","templateSpec":{"terms":[],"tolerance":{"USD/2":0}}}`, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/rules", Body: `{"name":"qa","templateKind":"ledger_invariant","templateSpec":{"terms":[],"tolerance":{"USD/2":0}}}`}}}, + {Command: "reconciliation rules update", Args: []string{"rule"}, Flags: confirm, Body: `{"enabled":false}`, Exchanges: []testutil.Exchange{{Method: "PATCH", Path: "/rules/rule", Body: `{"enabled":false}`}}}, + } + for _, tc := range cases { + t.Run(tc.Command, func(t *testing.T) { + t.Parallel() + path := strings.Fields(tc.Command) + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + command, err := pluginsdk.FindCommand(manifest, path) + if err != nil { + t.Fatal(err) + } + if command.Files == nil || command.Files.ReadArgument == nil || *command.Files.ReadArgument != len(tc.Args) || command.Files.ReadFormat != "yaml" { + t.Fatalf("missing historical file input: %+v", command.Files) + } + if command.Args.Min != len(tc.Args) || command.Args.Max != len(tc.Args)+1 || !strings.Contains(command.Use, "[|-]") { + t.Fatalf("wrong file signature: %+v", command) + } + tc.Args = append(append([]string{}, tc.Args...), "/does-not-exist/fixture.yaml") + testutil.RunCase(t, New, tc) + }) + } +} diff --git a/misc/fctl-plugin/internal/reconciliation/plugin.go b/misc/fctl-plugin/internal/reconciliation/plugin.go new file mode 100644 index 00000000..eba3cddf --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/plugin.go @@ -0,0 +1,27 @@ +// Package reconciliation supplies the historical reconciliation commands through the plugin SDK. +package reconciliation + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +// New builds an independent plugin using the HTTP client supplied by the host. +func New(client *http.Client) pluginsdk.Plugin { + operations := []command.Operation{ + command.Leaf("get", "get ", http.MethodGet, "reconciliations/$0", 1, 1), + } + operations = append(operations, policyOperations()...) + operations = append(operations, ruleOperations()...) + operations = append(operations, + command.Leaf("evaluations get", "get ", http.MethodGet, "evaluations/$0", 1, 1), + clarityList("evaluations"), + ) + operations = append(operations, alertOperations()...) + operations = append(operations, paginatedList("list", "list", "reconciliations", 0)) + return command.New("reconciliation", metadata.Version, client, operations, aliases) +} diff --git a/misc/fctl-plugin/internal/reconciliation/policies.go b/misc/fctl-plugin/internal/reconciliation/policies.go new file mode 100644 index 00000000..622aae7b --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/policies.go @@ -0,0 +1,31 @@ +package reconciliation + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func policyOperations() []command.Operation { + reconcile := command.Leaf("policies reconcile", "reconcile ", http.MethodPost, "policies/$0/reconciliation", 3, 3) + reconcile.Body = reconcileBody + return []command.Operation{ + command.Leaf("policies get", "get ", http.MethodGet, "policies/$0", 1, 1), + command.Confirmed(command.Payload(command.Leaf("policies create", "create", http.MethodPost, "policies", 0, 0), "name", "ledgerName", "paymentsPoolID", "ledgerQuery")), + command.Confirmed(command.Leaf("policies delete", "delete ", http.MethodDelete, "policies/$0", 1, 1)), + paginatedList("policies list", "list", "policies", 0), + reconcile, + } +} + +func reconcileBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + for _, value := range r.Args[1:] { + if err := command.Timestamp(value); err != nil { + return nil, err + } + } + return json.Marshal(map[string]string{"reconciledAtLedger": r.Args[1], "reconciledAtPayments": r.Args[2]}) +} diff --git a/misc/fctl-plugin/internal/reconciliation/queries.go b/misc/fctl-plugin/internal/reconciliation/queries.go new file mode 100644 index 00000000..948b3387 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/queries.go @@ -0,0 +1,61 @@ +package reconciliation + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +// Continuation cursors replace the query and body, as in the historical CLI. +func paginatedList(name, use, path string, args int) command.Operation { + op := command.Leaf(name, use, http.MethodGet, path, args, args, command.PaginationFlags()...) + op.Query = map[string]string{"cursor": "cursor", "page-size": "pageSize"} + op.Run = cursorOperation + return op +} + +func clarityList(group string) command.Operation { + op := paginatedList(group+" list", "list", group, 0) + op.Spec.Flags = append(op.Spec.Flags, command.StringFlag("query", "JSON query-builder expression"), command.DataFlag()) + op.Body = clarityListBody + return op +} + +func cursorOperation(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op command.Operation, body json.RawMessage) (json.RawMessage, error) { + query, err := command.QueryValues(op.Query, r) + if err != nil { + return nil, err + } + if cursor := query.Get("cursor"); cursor != "" { + query = url.Values{"cursor": {cursor}} + body = nil + } + path, err := command.Route(op.Segments, r) + if err != nil { + return nil, err + } + return client.Do(ctx, op.Method, path, query, body, nil) +} + +func clarityListBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Flags["cursor"] != "" { + return nil, nil + } + if r.Body != nil && r.Flags["query"] != "" { + return nil, fmt.Errorf("provide --query or --data once") + } + if r.Body != nil { + return command.ObjectBody(r.Body) + } + if r.Flags["query"] != "" { + return command.ObjectBody(json.RawMessage(r.Flags["query"])) + } + return nil, nil +} diff --git a/misc/fctl-plugin/internal/reconciliation/rules.go b/misc/fctl-plugin/internal/reconciliation/rules.go new file mode 100644 index 00000000..71c36ed8 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/rules.go @@ -0,0 +1,56 @@ +package reconciliation + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func ruleOperations() []command.Operation { + evaluate := command.Confirmed(command.Leaf("rules evaluate", "evaluate ", http.MethodPost, "rules/$0/evaluate", 1, 1, + command.StringFlag("at", "Canonical RFC3339 point in time"), command.StringFlag("safety-margin", "Nonnegative Go duration, e.g. 30s"), command.StringFlag("source-pit", "JSON object or CSV source=RFC3339"))) + evaluate.Body = evaluateRuleBody + return []command.Operation{ + command.Leaf("rules get", "get ", http.MethodGet, "rules/$0", 1, 1), + command.Confirmed(command.Payload(command.Leaf("rules create", "create", http.MethodPost, "rules", 0, 0))), + command.Confirmed(command.Payload(command.Leaf("rules update", "update ", http.MethodPatch, "rules/$0", 1, 1))), + command.Confirmed(command.Leaf("rules delete", "delete ", http.MethodDelete, "rules/$0", 1, 1)), + clarityList("rules"), + evaluate, + } +} + +func evaluateRuleBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + body := map[string]any{} + if v := r.Flags["at"]; v != "" { + if err := command.Timestamp(v); err != nil { + return nil, err + } + body["at"] = v + } + if v := r.Flags["safety-margin"]; v != "" { + duration, err := time.ParseDuration(v) + if err != nil || duration < 0 { + return nil, fmt.Errorf("--safety-margin must be a nonnegative duration") + } + body["safetyMargin"] = v + } + if v := r.Flags["source-pit"]; v != "" { + values, err := command.Pairs(v) + if err != nil { + return nil, err + } + for _, value := range values { + if err := command.Timestamp(value); err != nil { + return nil, err + } + } + body["sourcePITs"] = values + } + return json.Marshal(body) +} diff --git a/misc/fctl-plugin/internal/reconciliation/validation_test.go b/misc/fctl-plugin/internal/reconciliation/validation_test.go new file mode 100644 index 00000000..e7ced301 --- /dev/null +++ b/misc/fctl-plugin/internal/reconciliation/validation_test.go @@ -0,0 +1,29 @@ +package reconciliation + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "policy required ledger", Command: "reconciliation policies create", Body: `{"name":"policy","paymentsPoolID":"pool","ledgerQuery":{}}`, Flags: map[string]string{"confirm": "true"}}, + {Name: "policy required query", Command: "reconciliation policies create", Body: `{"name":"policy","ledgerName":"ledger","paymentsPoolID":"pool","ledgerQuery":null}`, Flags: map[string]string{"confirm": "true"}}, + {Name: "invalid reconciliation time", Command: "reconciliation policies reconcile", Args: []string{"policy", "yesterday", "2026-01-01T00:00:00Z"}}, + {Name: "conflicting query", Command: "reconciliation rules list", Flags: map[string]string{"query": `{}`}, Body: `{}`}, + {Name: "query scalar", Command: "reconciliation alerts list", Flags: map[string]string{"query": `[]`}}, + {Name: "negative margin", Command: "reconciliation rules evaluate", Args: []string{"rule"}, Flags: map[string]string{"safety-margin": "-1s", "confirm": "true"}}, + {Name: "invalid source PIT", Command: "reconciliation rules evaluate", Args: []string{"rule"}, Flags: map[string]string{"source-pit": "ledger=yesterday", "confirm": "true"}}, + {Name: "empty actor", Command: "reconciliation alerts ack", Args: []string{"alert"}, Flags: map[string]string{"by": " ", "confirm": "true"}}, + {Name: "empty acceptance note", Command: "reconciliation alerts accept", Args: []string{"alert"}, Flags: map[string]string{"by": "qa", "note": " ", "confirm": "true"}}, + {Name: "past snooze", Command: "reconciliation alerts snooze", Args: []string{"alert"}, Flags: map[string]string{"by": "qa", "until": "2000-01-01T00:00:00Z", "confirm": "true"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + testutil.RunFailure(t, New, tc, "") + }) + } +} diff --git a/misc/fctl-plugin/internal/testutil/http.go b/misc/fctl-plugin/internal/testutil/http.go new file mode 100644 index 00000000..33f9af41 --- /dev/null +++ b/misc/fctl-plugin/internal/testutil/http.go @@ -0,0 +1,162 @@ +// Package testutil contains strict HTTP fixtures shared by legacy service contract tests. +package testutil + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type Exchange struct { + Method, Path, Query, Body, Key, Response string + Status int +} + +func Fixture(t *testing.T, exchanges []Exchange) (*httptest.Server, func()) { + t.Helper() + var mu sync.Mutex + index := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if index >= len(exchanges) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL) + w.WriteHeader(500) + return + } + expected := exchanges[index] + index++ + verifyRequest(t, r, expected) + status := expected.Status + if status == 0 { + status = 200 + } + w.WriteHeader(status) + response := expected.Response + if response == "" && status != 204 { + response = `{"data":{"id":"result","amount":9007199254740993}}` + } + if _, err := io.WriteString(w, response); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + return server, func() { + t.Helper() + mu.Lock() + defer mu.Unlock() + if index != len(exchanges) { + t.Errorf("performed %d of %d requests", index, len(exchanges)) + } + } +} + +func AssertJSON(t *testing.T, actual, expected []byte) { + t.Helper() + decode := func(raw []byte) any { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + t.Errorf("invalid JSON %s: %v", raw, err) + } + return value + } + if !reflect.DeepEqual(decode(actual), decode(expected)) { + t.Errorf("JSON: %s; expected %s", actual, expected) + } +} + +type Case struct { + Name, Command string + Args []string + Flags map[string]string + Changed map[string]bool + Body string + Exchanges []Exchange +} + +func RunCase(t *testing.T, factory func(*http.Client) pluginsdk.Plugin, tc Case) { + t.Helper() + server, verify := Fixture(t, tc.Exchanges) + defer verify() + path := strings.Fields(tc.Command) + p := factory(server.Client()) + var body json.RawMessage + if tc.Body != "" { + body = json.RawMessage(tc.Body) + } + response, err := p.Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: path, Args: tc.Args, Flags: tc.Flags, ChangedFlags: tc.Changed, Body: body, Endpoint: server.URL + "/gateway/service"}) + if err != nil { + t.Fatal(err) + } + if !json.Valid(response.Data) { + t.Errorf("invalid response JSON: %s", response.Data) + } +} + +func RunFailure(t *testing.T, factory func(*http.Client) pluginsdk.Plugin, tc Case, want string) { + t.Helper() + server, verify := Fixture(t, tc.Exchanges) + defer verify() + var body json.RawMessage + if tc.Body != "" { + body = json.RawMessage(tc.Body) + } + _, err := factory(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{ + CommandPath: strings.Fields(tc.Command), Args: tc.Args, Flags: tc.Flags, + ChangedFlags: tc.Changed, Body: body, Endpoint: server.URL + "/gateway/service", + }) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("expected failure containing %q, got %v", want, err) + } +} + +func verifyRequest(t *testing.T, r *http.Request, expected Exchange) { + t.Helper() + if r.Method != expected.Method || r.URL.EscapedPath() != "/gateway/service"+expected.Path { + t.Errorf("request: %s %s; expected %s /gateway/service%s", r.Method, r.URL.EscapedPath(), expected.Method, expected.Path) + } + query, err := url.ParseQuery(expected.Query) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(r.URL.Query(), query) { + t.Errorf("query: %v; expected %v", r.URL.Query(), query) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if expected.Body == "" { + if len(body) > 0 { + t.Errorf("unexpected body: %s", body) + } + } else { + AssertJSON(t, body, []byte(expected.Body)) + } + verifyHeaders(t, r, expected.Key, len(body) > 0) +} + +func verifyHeaders(t *testing.T, r *http.Request, key string, hasBody bool) { + t.Helper() + if r.Header.Get("Idempotency-Key") != key { + t.Errorf("idempotency key %q; expected %q", r.Header.Get("Idempotency-Key"), key) + } + if r.Header.Get("Accept") != "application/json" { + t.Error("missing JSON accept header") + } + if hasBody && r.Header.Get("Content-Type") != "application/json" { + t.Error("missing JSON content type") + } +} diff --git a/misc/fctl-plugin/internal/wallets/aliases.go b/misc/fctl-plugin/internal/wallets/aliases.go new file mode 100644 index 00000000..ec061a70 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/aliases.go @@ -0,0 +1,3 @@ +package wallets + +var aliases = map[string][]string{"": {"wal", "wa", "wallet"}, "balances": {"balance", "bls", "bal"}, "holds": {"hold", "h"}, "holds confirm": {"c", "conf"}, "holds void": {"v"}, "debit": {"deb"}, "transactions": {"transaction", "tx", "txs"}} diff --git a/misc/fctl-plugin/internal/wallets/balances.go b/misc/fctl-plugin/internal/wallets/balances.go new file mode 100644 index 00000000..098b0359 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/balances.go @@ -0,0 +1,40 @@ +package wallets + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func balanceOperations() []commandapi.Operation { + balanceCreate := commandapi.Confirmed(targetWallet(commandapi.Leaf("balances create", "create ", http.MethodPost, "wallets/@id/balances", 1, 1, commandapi.StringFlag("expires-at", "RFC3339 expiry"), commandapi.StringFlag("priority", "Integer priority")), true)) + balanceCreate.Body = balanceCreateBody + balanceList := targetWallet(commandapi.Leaf("balances list", "list", http.MethodGet, "wallets/@id/balances", 0, 0, commandapi.PaginationFlags()...), true) + balanceList.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor"} + balanceShow := targetWallet(commandapi.Leaf("balances show", "show ", http.MethodGet, "wallets/@id/balances/$0", 1, 1), true) + return []commandapi.Operation{balanceCreate, balanceList, balanceShow} +} + +func balanceCreateBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := commandapi.Identifier(r.Args[0]); err != nil { + return nil, err + } + body := map[string]any{"name": r.Args[0]} + if v := r.Flags["expires-at"]; v != "" { + if err := commandapi.Timestamp(v); err != nil { + return nil, err + } + body["expiresAt"] = v + } + if v := r.Flags["priority"]; v != "" { + n, err := integer(v, false) + if err != nil { + return nil, err + } + body["priority"] = n + } + return json.Marshal(body) +} diff --git a/misc/fctl-plugin/internal/wallets/execute.go b/misc/fctl-plugin/internal/wallets/execute.go new file mode 100644 index 00000000..8ec940da --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/execute.go @@ -0,0 +1,62 @@ +package wallets + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func walletOperation(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, op commandapi.Operation, body json.RawMessage) (json.RawMessage, error) { + var err error + if op.Command != "list" { + r, err = resolveWalletTarget(ctx, client, r) + if err != nil { + return nil, err + } + } + if op.Command == "credit" || op.Command == "debit" { + body, err = walletSubjectBody(ctx, client, r, body) + if err != nil { + return nil, err + } + } + query, err := walletQuery(op, r) + if err != nil { + return nil, err + } + path, err := commandapi.Route(op.Segments, r) + if err != nil { + return nil, err + } + headers := http.Header{} + if r.Flags["ik"] != "" { + headers.Set("Idempotency-Key", r.Flags["ik"]) + } + return client.Do(ctx, op.Method, path, query, body, headers) +} + +func walletQuery(op commandapi.Operation, r pluginsdk.ExecuteRequest) (url.Values, error) { + query, err := commandapi.QueryValues(op.Query, r) + if err != nil { + return nil, err + } + if cursor := query.Get("cursor"); cursor != "" { + return url.Values{"cursor": {cursor}}, nil + } + if op.Method == http.MethodGet && r.Flags["metadata"] != "" { + metadata, err := commandapi.Pairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + for key, value := range metadata { + query.Set("metadata["+key+"]", value) + } + } + return query, nil +} diff --git a/misc/fctl-plugin/internal/wallets/files_test.go b/misc/fctl-plugin/internal/wallets/files_test.go new file mode 100644 index 00000000..d9ec96fe --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/files_test.go @@ -0,0 +1,30 @@ +package wallets + +import ( + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestFileInputsOnlyOnHistoricalPayloadCommands(t *testing.T) { + t.Parallel() + cases := []string{"wallets create"} + for _, path := range cases { + t.Run(path, func(t *testing.T) { + t.Parallel() + names := strings.Fields(path) + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + command, err := pluginsdk.FindCommand(manifest, names) + if err != nil { + t.Fatal(err) + } + if command.Files != nil { + t.Fatal("non-file historical command gained a file source") + } + }) + } +} diff --git a/misc/fctl-plugin/internal/wallets/holds.go b/misc/fctl-plugin/internal/wallets/holds.go new file mode 100644 index 00000000..45b07f40 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/holds.go @@ -0,0 +1,34 @@ +package wallets + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func holdOperations() []commandapi.Operation { + holds := targetWallet(commandapi.Leaf("holds list", "list", http.MethodGet, "holds", 0, 0, append(commandapi.PaginationFlags(), metadataFlag())...), false) + holds.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor", "id": "walletID"} + holds.Body = validateMetadata + confirm := commandapi.Confirmed(commandapi.Leaf("holds confirm", "confirm ", http.MethodPost, "holds/$0/confirm", 1, 1, commandapi.BoolFlag("final", "Close hold after confirming"), commandapi.StringFlag("amount", "Integer amount (default 0)"), ikFlag())) + confirm.Body = confirmHoldBody + return []commandapi.Operation{holds, confirm, + commandapi.Leaf("holds show", "show ", http.MethodGet, "holds/$0", 1, 1), + commandapi.Confirmed(commandapi.Leaf("holds void", "void ", http.MethodPost, "holds/$0/void", 1, 1, ikFlag())), + } +} + +func confirmHoldBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + amount := r.Flags["amount"] + if amount == "" { + amount = "0" + } + n, err := integer(amount, true) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"amount": n, "final": r.Flags["final"] == "true"}) +} diff --git a/misc/fctl-plugin/internal/wallets/metadata.go b/misc/fctl-plugin/internal/wallets/metadata.go new file mode 100644 index 00000000..1bb8d306 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/metadata.go @@ -0,0 +1,20 @@ +package wallets + +import ( + "encoding/json" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func metadataFlag() pluginsdk.FlagSpec { + return commandapi.StringFlag("metadata", "Metadata as JSON object or CSV key=value") +} + +func ikFlag() pluginsdk.FlagSpec { return commandapi.StringFlag("ik", "Idempotency key") } + +func validateMetadata(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + _, err := commandapi.Pairs(r.Flags["metadata"]) + return nil, err +} diff --git a/misc/fctl-plugin/internal/wallets/movements.go b/misc/fctl-plugin/internal/wallets/movements.go new file mode 100644 index 00000000..86135f32 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/movements.go @@ -0,0 +1,61 @@ +package wallets + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func movementOperations() []commandapi.Operation { + credit := commandapi.Confirmed(targetWallet(commandapi.Leaf("credit", "credit ", http.MethodPost, "wallets/@id/credit", 2, 2, + metadataFlag(), ikFlag(), commandapi.StringFlag("balance", "Balance to credit"), commandapi.StringFlag("source", "Sources as CSV or JSON string array: account=ADDRESS or wallet=id:ID/BALANCE or wallet=name:NAME/BALANCE")), true)) + credit.Body = walletMovement + debit := commandapi.Confirmed(targetWallet(commandapi.Leaf("debit", "debit ", http.MethodPost, "wallets/@id/debit", 2, 2, + metadataFlag(), ikFlag(), commandapi.StringFlag("balance", "Balances as CSV or JSON string array"), commandapi.BoolFlag("pending", "Create a pending hold"), commandapi.StringFlag("description", "Debit description"), + commandapi.StringFlag("destination", "account=ADDRESS or wallet=id:ID/BALANCE or wallet=name:NAME/BALANCE")), true)) + debit.Body = walletMovement + return []commandapi.Operation{credit, debit} +} + +func walletMovement(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + n, err := integer(r.Args[0], true) + if err != nil { + return nil, err + } + if err := commandapi.Identifier(r.Args[1]); err != nil { + return nil, fmt.Errorf("asset: %w", err) + } + metadata, err := commandapi.Pairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + body := map[string]any{"amount": map[string]any{"amount": n, "asset": r.Args[1]}, "metadata": metadata} + if r.CommandPath[1] == "credit" { + body["balance"] = r.Flags["balance"] + sources, err := commandapi.List(r.Flags["source"]) + if err != nil { + return nil, err + } + if err := validateSubjects(sources); err != nil { + return nil, err + } + } else { + body["pending"] = r.Flags["pending"] == "true" + body["description"] = r.Flags["description"] + balances, err := commandapi.List(r.Flags["balance"]) + if err != nil { + return nil, err + } + body["balances"] = balances + if value := r.Flags["destination"]; value != "" { + if _, err := parseSubject(value); err != nil { + return nil, err + } + } + } + return json.Marshal(body) +} diff --git a/misc/fctl-plugin/internal/wallets/numbers.go b/misc/fctl-plugin/internal/wallets/numbers.go new file mode 100644 index 00000000..e6e47437 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/numbers.go @@ -0,0 +1,15 @@ +package wallets + +import ( + "encoding/json" + "fmt" + "math/big" +) + +func integer(value string, nonnegative bool) (json.Number, error) { + n, ok := new(big.Int).SetString(value, 10) + if !ok || (nonnegative && n.Sign() < 0) { + return "", fmt.Errorf("expected %sinteger, got %q", map[bool]string{true: "nonnegative ", false: ""}[nonnegative], value) + } + return json.Number(n.String()), nil +} diff --git a/misc/fctl-plugin/internal/wallets/plugin.go b/misc/fctl-plugin/internal/wallets/plugin.go new file mode 100644 index 00000000..d8fde750 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/plugin.go @@ -0,0 +1,21 @@ +// Package wallets implements the historical wallets commands. +package wallets + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +// New builds the Wallets SDK plugin using the injected HTTP client. +func New(client *http.Client) pluginsdk.Plugin { + operations := walletOperations() + operations = append(operations, balanceOperations()...) + operations = append(operations, movementOperations()...) + operations = append(operations, holdOperations()...) + operations = append(operations, transactionOperations()...) + return commandapi.New("wallets", metadata.Version, client, operations, aliases) +} diff --git a/misc/fctl-plugin/internal/wallets/subjects.go b/misc/fctl-plugin/internal/wallets/subjects.go new file mode 100644 index 00000000..3506352f --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/subjects.go @@ -0,0 +1,113 @@ +package wallets + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +type subject struct{ kind, id, name, balance string } + +func parseSubject(value string) (subject, error) { + if id, found := strings.CutPrefix(value, "account="); found { + if err := commandapi.Identifier(id); err != nil { + return subject{}, err + } + return subject{kind: "ACCOUNT", id: id}, nil + } + if !strings.HasPrefix(value, "wallet=") { + return subject{}, fmt.Errorf("invalid subject %q", value) + } + definition, balance, hasBalance := strings.Cut(strings.TrimPrefix(value, "wallet="), "/") + if !hasBalance { + balance = "main" + } + if err := commandapi.Identifier(balance); err != nil { + return subject{}, err + } + kind, id, found := strings.Cut(definition, ":") + if !found || (kind != "id" && kind != "name") || commandapi.Identifier(id) != nil { + return subject{}, fmt.Errorf("wallet subject requires id:ID or name:NAME") + } + out := subject{kind: "WALLET", balance: balance} + if kind == "name" { + out.name = id + } else { + out.id = id + } + return out, nil +} + +func resolveSubject(ctx context.Context, client *httpclient.Client, value string) (map[string]string, error) { + s, err := parseSubject(value) + if err != nil { + return nil, err + } + if s.name != "" { + s.id, err = walletByName(ctx, client, s.name) + if err != nil { + return nil, err + } + } + out := map[string]string{"type": s.kind, "identifier": s.id} + if s.balance != "" { + out["balance"] = s.balance + } + return out, nil +} + +func validateSubjects(values []string) error { + for _, value := range values { + if _, err := parseSubject(value); err != nil { + return err + } + } + return nil +} + +func walletSubjectBody(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest, body json.RawMessage) (json.RawMessage, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(body, &object); err != nil { + return nil, err + } + if r.CommandPath[1] == "credit" { + sources, err := walletSources(ctx, client, r.Flags["source"]) + if err != nil { + return nil, err + } + object["sources"] = sources + } else if r.Flags["destination"] != "" { + destination, err := resolveSubject(ctx, client, r.Flags["destination"]) + if err != nil { + return nil, err + } + encoded, err := json.Marshal(destination) + if err != nil { + return nil, err + } + object["destination"] = encoded + } + return json.Marshal(object) +} + +func walletSources(ctx context.Context, client *httpclient.Client, value string) (json.RawMessage, error) { + values, err := commandapi.List(value) + if err != nil { + return nil, err + } + sources := []map[string]string{} + for _, value := range values { + s, err := resolveSubject(ctx, client, value) + if err != nil { + return nil, err + } + sources = append(sources, s) + } + return json.Marshal(sources) +} diff --git a/misc/fctl-plugin/internal/wallets/target.go b/misc/fctl-plugin/internal/wallets/target.go new file mode 100644 index 00000000..0d1225ac --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/target.go @@ -0,0 +1,80 @@ +package wallets + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func walletFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{commandapi.StringFlag("id", "Wallet ID"), commandapi.StringFlag("name", "Exact wallet name")} +} + +func targetWallet(op commandapi.Operation, required bool) commandapi.Operation { + op.Spec.Flags = append(op.Spec.Flags, walletFlags()...) + if required { + op.Spec.Inputs = append(op.Spec.Inputs, pluginsdk.InputSpec{Title: "Wallet", Kind: "select", Flag: "id", AlternativeFlag: "name", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"wallets", "list"}, ValueField: "id", LabelFields: []string{"name", "id"}, EmptyMessage: "No wallets available"}}) + } + op.ValidateRoute = validateWalletRoute + op.Run = walletOperation + return op +} + +func walletByName(ctx context.Context, client *httpclient.Client, name string) (string, error) { + data, err := client.Do(ctx, http.MethodGet, httpclient.Path("wallets"), url.Values{"name": {name}, "pageSize": {"2"}}, nil, nil) + if err != nil { + return "", err + } + var envelope struct { + Cursor struct { + HasMore bool `json:"hasMore"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + } `json:"cursor"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return "", err + } + if len(envelope.Cursor.Data) == 0 { + return "", fmt.Errorf("wallet %q not found", name) + } + if len(envelope.Cursor.Data) != 1 || envelope.Cursor.HasMore { + return "", fmt.Errorf("wallet name %q is ambiguous", name) + } + id := envelope.Cursor.Data[0].ID + return id, commandapi.Identifier(id) +} + +func resolveWalletTarget(ctx context.Context, client *httpclient.Client, r pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + if r.Flags["id"] != "" && r.Flags["name"] != "" { + return r, fmt.Errorf("provide --id or --name, not both") + } + if r.Flags["name"] != "" { + id, err := walletByName(ctx, client, r.Flags["name"]) + if err != nil { + return r, err + } + r.Flags["id"] = id + } + return r, nil +} + +// Validate the dynamic wallet ID locally before body validation or HTTP lookup. +func validateWalletRoute(r pluginsdk.ExecuteRequest, op commandapi.Operation) error { + if r.Flags["id"] == "" && r.Flags["name"] != "" { + r.Flags = maps.Clone(r.Flags) + r.Flags["id"] = "resolved-wallet" + } + _, err := commandapi.Route(op.Segments, r) + return err +} diff --git a/misc/fctl-plugin/internal/wallets/target_contract_test.go b/misc/fctl-plugin/internal/wallets/target_contract_test.go new file mode 100644 index 00000000..c3854a5a --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/target_contract_test.go @@ -0,0 +1,62 @@ +package wallets + +import ( + "maps" + "reflect" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestWalletNameValidationMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "negative credit by name", Command: "wallets credit", Args: []string{"-1", "USD/2"}, Flags: map[string]string{"name": "Alice", "confirm": "true"}}, + {Name: "invalid source by name", Command: "wallets credit", Args: []string{"1", "USD/2"}, Flags: map[string]string{"name": "Alice", "source": "wallet=unknown:id", "confirm": "true"}}, + {Name: "invalid destination by name", Command: "wallets debit", Args: []string{"1", "USD/2"}, Flags: map[string]string{"name": "Alice", "destination": "account=", "confirm": "true"}}, + {Name: "invalid balance route by name", Command: "wallets balances show", Args: []string{".."}, Flags: map[string]string{"name": "Alice"}}, + {Name: "invalid expiry by name", Command: "wallets balances create", Args: []string{"promo"}, Flags: map[string]string{"name": "Alice", "expires-at": "tomorrow", "confirm": "true"}}, + {Name: "missing wallet", Command: "wallets show"}, + {Name: "dot wallet", Command: "wallets show", Flags: map[string]string{"id": ".."}}, + {Name: "conflicting target", Command: "wallets show", Flags: map[string]string{"id": "w", "name": "Alice"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + testutil.RunFailure(t, New, tc, "") + }) + } +} + +func TestWalletRouteValidationDoesNotMutateFlags(t *testing.T) { + t.Parallel() + op := targetWallet(commandapi.Leaf("show", "show", "GET", "wallets/@id", 0, 0), true) + request := pluginsdk.ExecuteRequest{Flags: map[string]string{"name": "Alice"}} + original := maps.Clone(request.Flags) + if err := op.ValidateRoute(request, op); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(request.Flags, original) { + t.Fatalf("route validation changed the target flags: %v", request.Flags) + } + if _, err := commandapi.Route(op.Segments, request); err == nil { + t.Fatal("generic route accepted an unresolved wallet name") + } +} + +func TestWalletResolvedIDsAreValidated(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "invalid resolved ID", Command: "wallets show", Flags: map[string]string{"name": "Alice"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=Alice&pageSize=2", Response: `{"cursor":{"data":[{"id":".."}]}}`}}}, + {Name: "ambiguous next page", Command: "wallets show", Flags: map[string]string{"name": "Alice"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=Alice&pageSize=2", Response: `{"cursor":{"data":[{"id":"w"}],"hasMore":true}}`}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + testutil.RunFailure(t, New, tc, "") + }) + } +} diff --git a/misc/fctl-plugin/internal/wallets/transactions.go b/misc/fctl-plugin/internal/wallets/transactions.go new file mode 100644 index 00000000..3bff6faa --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/transactions.go @@ -0,0 +1,13 @@ +package wallets + +import ( + "net/http" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func transactionOperations() []commandapi.Operation { + transactions := targetWallet(commandapi.Leaf("transactions list", "list", http.MethodGet, "transactions", 0, 0, commandapi.PaginationFlags()...), false) + transactions.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor", "id": "walletID"} + return []commandapi.Operation{transactions} +} diff --git a/misc/fctl-plugin/internal/wallets/validation_test.go b/misc/fctl-plugin/internal/wallets/validation_test.go new file mode 100644 index 00000000..07fa93d2 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/validation_test.go @@ -0,0 +1,39 @@ +package wallets + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "unexpected body", Command: "wallets show", Flags: map[string]string{"id": "id"}, Body: `{}`}, + {Name: "negative credit", Command: "wallets credit", Args: []string{"-1", "USD/2"}, Flags: map[string]string{"id": "wallet", "confirm": "true"}}, + {Name: "fractional debit", Command: "wallets debit", Args: []string{"1.5", "USD/2"}, Flags: map[string]string{"id": "wallet", "confirm": "true"}}, + {Name: "empty wallet asset", Command: "wallets debit", Args: []string{"1", " "}, Flags: map[string]string{"id": "wallet", "confirm": "true"}}, + {Name: "invalid source", Command: "wallets credit", Args: []string{"1", "USD/2"}, Flags: map[string]string{"id": "wallet", "source": "wallet=unknown:id", "confirm": "true"}}, + {Name: "invalid destination", Command: "wallets debit", Args: []string{"1", "USD/2"}, Flags: map[string]string{"id": "wallet", "destination": "account=", "confirm": "true"}}, + {Name: "bad metadata", Command: "wallets create", Args: []string{"wallet"}, Flags: map[string]string{"metadata": `{"amount":1}`, "confirm": "true"}}, + {Name: "expired timestamp format", Command: "wallets balances create", Args: []string{"balance"}, Flags: map[string]string{"id": "wallet", "expires-at": "tomorrow", "confirm": "true"}}, + {Name: "negative hold amount", Command: "wallets holds confirm", Args: []string{"hold"}, Flags: map[string]string{"amount": "-1", "confirm": "true"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunFailure(t, New, tc, "") }) + } +} + +func TestDependentReadFailuresPreventMutation(t *testing.T) { + t.Parallel() + cases := []struct { + tc testutil.Case + want string + }{ + {testutil.Case{Command: "wallets credit", Args: []string{"1", "USD/2"}, Flags: map[string]string{"name": "missing", "confirm": "true"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=missing&pageSize=2", Response: `{"cursor":{"data":[]}}`}}}, "not found"}, + {testutil.Case{Command: "wallets show", Flags: map[string]string{"name": "duplicate"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=duplicate&pageSize=2", Response: `{"cursor":{"data":[{"id":"1"},{"id":"2"}]}}`}}}, "ambiguous"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { t.Parallel(); testutil.RunFailure(t, New, tc.tc, tc.want) }) + } +} diff --git a/misc/fctl-plugin/internal/wallets/wallets.go b/misc/fctl-plugin/internal/wallets/wallets.go new file mode 100644 index 00000000..1636d428 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/wallets.go @@ -0,0 +1,42 @@ +package wallets + +import ( + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + commandapi "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func walletOperations() []commandapi.Operation { + create := commandapi.Confirmed(commandapi.Leaf("create", "create ", http.MethodPost, "wallets", 1, 1, metadataFlag(), ikFlag())) + create.Body = walletCreateBody + update := commandapi.Confirmed(commandapi.Leaf("update", "update ", http.MethodPatch, "wallets/$0", 1, 1, metadataFlag(), ikFlag())) + update.Body = walletUpdateBody + listWallets := commandapi.Leaf("list", "list", http.MethodGet, "wallets", 0, 0, append(commandapi.PaginationFlags(), metadataFlag(), commandapi.StringFlag("name", "Exact name filter"))...) + listWallets.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor", "name": "name"} + listWallets.Body = validateMetadata + listWallets.Run = walletOperation + show := targetWallet(commandapi.Leaf("show", "show", http.MethodGet, "wallets/@id", 0, 0), true) + return []commandapi.Operation{create, update, listWallets, show} +} + +func walletCreateBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if err := commandapi.Identifier(r.Args[0]); err != nil { + return nil, err + } + metadata, err := commandapi.Pairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"name": r.Args[0], "metadata": metadata}) +} + +func walletUpdateBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + metadata, err := commandapi.Pairs(r.Flags["metadata"]) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"metadata": metadata}) +} diff --git a/misc/fctl-plugin/internal/wallets/wallets_contract_test.go b/misc/fctl-plugin/internal/wallets/wallets_contract_test.go new file mode 100644 index 00000000..0cf26666 --- /dev/null +++ b/misc/fctl-plugin/internal/wallets/wallets_contract_test.go @@ -0,0 +1,32 @@ +package wallets + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestWalletContracts(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "create", Command: "wallets create", Args: []string{"Alice"}, Flags: map[string]string{"confirm": "true", "metadata": "customer=42", "ik": "create-1"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/wallets", Key: "create-1", Body: `{"name":"Alice","metadata":{"customer":"42"}}`}}}, + {Name: "update", Command: "wallets update", Args: []string{"w/1"}, Flags: map[string]string{"confirm": "true", "metadata": "{\"customer\":\"43\"}", "ik": "update-1"}, Exchanges: []testutil.Exchange{{Method: "PATCH", Path: "/wallets/w%2F1", Key: "update-1", Body: `{"metadata":{"customer":"43"}}`}}}, + {Name: "metadata list", Command: "wallets list", Flags: map[string]string{"name": "Alice", "metadata": "customer=42", "page-size": "2"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=Alice&metadata%5Bcustomer%5D=42&pageSize=2"}}}, + {Name: "cursor list", Command: "wallets list", Flags: map[string]string{"cursor": "opaque +/=", "metadata": "customer=42"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "cursor=opaque+%2B%2F%3D"}}}, + {Name: "show id", Command: "wallets show", Flags: map[string]string{"id": "w/1"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets/w%2F1"}}}, + {Name: "show name", Command: "wallets show", Flags: map[string]string{"name": "Alice"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=Alice&pageSize=2", Response: `{"cursor":{"data":[{"id":"w/1"}],"hasMore":false}}`}, {Method: "GET", Path: "/wallets/w%2F1"}}}, + {Name: "create balance", Command: "wallets balances create", Args: []string{"promo"}, Flags: map[string]string{"confirm": "true", "id": "w", "expires-at": "2027-01-01T00:00:00Z", "priority": "-9007199254740993"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/wallets/w/balances", Body: `{"name":"promo","expiresAt":"2027-01-01T00:00:00Z","priority":-9007199254740993}`}}}, + {Name: "list balances", Command: "wallets balances list", Flags: map[string]string{"id": "w", "page-size": "2"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets/w/balances", Query: "pageSize=2"}}}, + {Name: "show balance", Command: "wallets balances show", Args: []string{"promo/1"}, Flags: map[string]string{"id": "w"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets/w/balances/promo%2F1"}}}, + {Name: "credit exact integer and subjects", Command: "wallets credit", Args: []string{"9007199254740993", "USD/2"}, Flags: map[string]string{"confirm": "true", "id": "w", "source": "account=world,wallet=name:Bob/promo", "balance": "main", "metadata": "reference=credit-1", "ik": "credit-1"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/wallets", Query: "name=Bob&pageSize=2", Response: `{"cursor":{"data":[{"id":"bob"}],"hasMore":false}}`}, {Method: "POST", Path: "/wallets/w/credit", Key: "credit-1", Body: `{"amount":{"amount":9007199254740993,"asset":"USD/2"},"balance":"main","metadata":{"reference":"credit-1"},"sources":[{"type":"ACCOUNT","identifier":"world"},{"type":"WALLET","identifier":"bob","balance":"promo"}]}`}}}, + {Name: "debit pending", Command: "wallets debit", Args: []string{"100", "USD/2"}, Flags: map[string]string{"confirm": "true", "id": "w", "pending": "true", "balance": "main,promo", "destination": "wallet=id:target", "description": "pending debit", "ik": "debit-1"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/wallets/w/debit", Key: "debit-1", Body: `{"amount":{"amount":100,"asset":"USD/2"},"pending":true,"balances":["main","promo"],"metadata":{},"description":"pending debit","destination":{"type":"WALLET","identifier":"target","balance":"main"}}`}}}, + {Name: "holds list", Command: "wallets holds list", Flags: map[string]string{"id": "w", "metadata": "customer=42"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/holds", Query: "walletID=w&metadata%5Bcustomer%5D=42&pageSize=100"}}}, + {Name: "hold show", Command: "wallets holds show", Args: []string{"h"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/holds/h"}}}, + {Name: "confirm hold", Command: "wallets holds confirm", Args: []string{"h"}, Flags: map[string]string{"confirm": "true", "amount": "9007199254740993", "final": "true", "ik": "confirm-1"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/holds/h/confirm", Key: "confirm-1", Body: `{"amount":9007199254740993,"final":true}`}}}, + {Name: "void hold", Command: "wallets holds void", Args: []string{"h"}, Flags: map[string]string{"confirm": "true", "ik": "void-1"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/holds/h/void", Key: "void-1"}}}, + {Name: "transactions list", Command: "wallets transactions list", Flags: map[string]string{"id": "w"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/transactions", Query: "walletID=w&pageSize=100"}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/webhooks/aliases.go b/misc/fctl-plugin/internal/webhooks/aliases.go new file mode 100644 index 00000000..3d0ead80 --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/aliases.go @@ -0,0 +1,3 @@ +package webhooks + +var aliases = map[string][]string{"": {"web", "wh"}, "deliveries": {"delivery", "dlvs"}, "change-secret": {"cs"}, "activate": {"ac", "a"}, "deactivate": {"deac"}, "deliveries show": {"get"}} diff --git a/misc/fctl-plugin/internal/webhooks/configs.go b/misc/fctl-plugin/internal/webhooks/configs.go new file mode 100644 index 00000000..1c7c9210 --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/configs.go @@ -0,0 +1,82 @@ +package webhooks + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func configOperations() []command.Operation { + listOp := command.Leaf("list", "list", http.MethodGet, "configs", 0, 0, command.StringFlag("config-id", "Config ID filter"), command.StringFlag("endpoint", "Endpoint URL filter")) + listOp.Query = map[string]string{"config-id": "id", "endpoint": "endpoint"} + create := command.Confirmed(command.Leaf("create", "create ...", http.MethodPost, "configs", 2, 1000, command.StringFlag("secret", "Optional base64 signing secret (24 bytes)"))) + create.Body = webhookConfig + update := command.Confirmed(command.Leaf("update", "update ...", http.MethodPut, "configs/$0", 3, 1001, command.StringFlag("secret", "Optional signing secret"))) + update.Body = webhookConfig + secret := command.Confirmed(command.Leaf("change-secret", "change-secret [secret]", http.MethodPut, "configs/$0/secret/change", 1, 2)) + secret.Body = changeSecretBody + return []command.Operation{listOp, create, update, secret, + command.Confirmed(command.Leaf("activate", "activate ", http.MethodPut, "configs/$0/activate", 1, 1)), + command.Confirmed(command.Leaf("deactivate", "deactivate ", http.MethodPut, "configs/$0/deactivate", 1, 1)), + command.Confirmed(command.Leaf("delete", "delete ", http.MethodDelete, "configs/$0", 1, 1)), + command.Confirmed(command.Leaf("test", "test ", http.MethodGet, "configs/$0/test", 1, 1)), + } +} + +func webhookConfig(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil { + return nil, fmt.Errorf("use endpoint and event-type arguments") + } + offset := 0 + if r.CommandPath[len(r.CommandPath)-1] == "update" { + offset = 1 + } + endpoint := r.Args[offset] + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil { + return nil, fmt.Errorf("webhook endpoint must be an absolute HTTP(S) URL") + } + for _, event := range r.Args[offset+1:] { + if strings.TrimSpace(event) == "" { + return nil, fmt.Errorf("event types cannot be empty") + } + } + body := map[string]any{"endpoint": endpoint, "eventTypes": r.Args[offset+1:]} + secret := r.Flags["secret"] + if err := signingSecret(secret); err != nil { + return nil, err + } + if offset == 0 || secret != "" || r.ChangedFlags["secret"] { + body["secret"] = secret + } + return json.Marshal(body) +} + +func signingSecret(value string) error { + if value == "" { + return nil + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(decoded) != 24 { + return fmt.Errorf("signing secret must be base64 encoding of 24 bytes") + } + return nil +} + +func changeSecretBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + value := "" + if len(r.Args) == 2 { + value = r.Args[1] + } + if err := signingSecret(value); err != nil { + return nil, err + } + return json.Marshal(map[string]string{"secret": value}) +} diff --git a/misc/fctl-plugin/internal/webhooks/contract_test.go b/misc/fctl-plugin/internal/webhooks/contract_test.go new file mode 100644 index 00000000..48cbe069 --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/contract_test.go @@ -0,0 +1,32 @@ +package webhooks + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestWebhooksContracts(t *testing.T) { + t.Parallel() + confirm := map[string]string{"confirm": "true"} + cases := []testutil.Case{ + {Name: "config filters", Command: "webhooks list", Flags: map[string]string{"config-id": "config/a", "endpoint": "https://example.org/hook"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/configs", Query: "id=config%2Fa&endpoint=https%3A%2F%2Fexample.org%2Fhook"}}}, + {Name: "create", Command: "webhooks create", Args: []string{"https://example.org/hook", "ledger.transaction.created", "wallets.created"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/configs", Body: `{"endpoint":"https://example.org/hook","eventTypes":["ledger.transaction.created","wallets.created"],"secret":""}`}}}, + {Name: "update omit secret", Command: "webhooks update", Args: []string{"id/%", "https://example.org/new", "event"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/configs/id%2F%25", Body: `{"endpoint":"https://example.org/new","eventTypes":["event"]}`}}}, + {Name: "update explicit empty secret", Command: "webhooks update", Args: []string{"id", "https://example.org/new", "event"}, Flags: map[string]string{"confirm": "true", "secret": ""}, Changed: map[string]bool{"secret": true}, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/configs/id", Body: `{"endpoint":"https://example.org/new","eventTypes":["event"],"secret":""}`}}}, + {Name: "rotate secret", Command: "webhooks change-secret", Args: []string{"id"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/configs/id/secret/change", Body: `{"secret":""}`}}}, + {Name: "activate", Command: "webhooks activate", Args: []string{"id"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/configs/id/activate"}}}, + {Name: "deactivate", Command: "webhooks deactivate", Args: []string{"id"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "PUT", Path: "/configs/id/deactivate"}}}, + {Name: "delete", Command: "webhooks delete", Args: []string{"id"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "DELETE", Path: "/configs/id", Status: 204}}}, + {Name: "send test", Command: "webhooks test", Args: []string{"id"}, Flags: confirm, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/configs/id/test"}}}, + {Name: "delivery filters", Command: "webhooks deliveries list", Flags: map[string]string{"status": "failed", "config-id": "cfg", "created-at-from": "2026-01-01T00:00:00Z", "created-at-to": "2026-02-01T00:00:00Z", "page-size": "20", "cursor": "opaque +/="}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/deliveries", Query: "status=failed&configId=cfg&createdAtFrom=2026-01-01T00%3A00%3A00Z&createdAtTo=2026-02-01T00%3A00%3A00Z&pageSize=20&cursor=opaque+%2B%2F%3D"}}}, + {Name: "delivery show", Command: "webhooks deliveries show", Args: []string{"delivery/a"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/deliveries/delivery%2Fa"}}}, + {Name: "attempts", Command: "webhooks deliveries attempts", Args: []string{"delivery"}, Flags: map[string]string{"cursor": "next", "page-size": "2"}, Exchanges: []testutil.Exchange{{Method: "GET", Path: "/deliveries/delivery/attempts", Query: "cursor=next&pageSize=2"}}}, + {Name: "replay", Command: "webhooks deliveries replay", Args: []string{"delivery"}, Flags: map[string]string{"confirm": "true", "idempotency-key": "replay-1"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/deliveries/delivery/replay", Key: "replay-1"}}}, + {Name: "replay page", Command: "webhooks deliveries replay-bulk", Flags: map[string]string{"confirm": "true", "idempotency-key": "bulk-1", "created-at-from": "2026-01-01T00:00:00Z", "created-at-to": "2026-02-01T00:00:00Z", "status": "FAILED,pending", "config-id": "[\"cfg/1\",\"cfg2\"]", "cursor": "next", "page-size": "2"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/deliveries/replay", Key: "bulk-1", Body: `{"createdAtFrom":"2026-01-01T00:00:00Z","createdAtTo":"2026-02-01T00:00:00Z","statuses":["failed","pending"],"configIds":["cfg/1","cfg2"],"cursor":"next","pageSize":2}`}}}, + {Name: "replay defaults", Command: "webhooks deliveries replay-bulk", Flags: map[string]string{"confirm": "true", "idempotency-key": "bulk-2", "created-at-from": "2026-01-01T00:00:00Z"}, Exchanges: []testutil.Exchange{{Method: "POST", Path: "/deliveries/replay", Key: "bulk-2", Body: `{"createdAtFrom":"2026-01-01T00:00:00Z","statuses":["failed","pending"],"pageSize":100}`}}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { t.Parallel(); testutil.RunCase(t, New, tc) }) + } +} diff --git a/misc/fctl-plugin/internal/webhooks/deliveries.go b/misc/fctl-plugin/internal/webhooks/deliveries.go new file mode 100644 index 00000000..2e93a29d --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/deliveries.go @@ -0,0 +1,113 @@ +package webhooks + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" +) + +func deliveryOperations() []command.Operation { + deliveries := command.Leaf("deliveries list", "list", http.MethodGet, "deliveries", 0, 0, append(command.PaginationFlags(), + command.StringFlag("config-id", "Config ID filter"), command.StringFlag("status", "pending, delivering, succeeded, failed or cancelled"), + command.StringFlag("created-at-from", "Inclusive RFC3339 start"), command.StringFlag("created-at-to", "Inclusive RFC3339 end"))...) + deliveries.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor", "config-id": "configId", "status": "status", "created-at-from": "createdAtFrom", "created-at-to": "createdAtTo"} + deliveries.Body = deliveryListBody + attempts := command.Leaf("deliveries attempts", "attempts ", http.MethodGet, "deliveries/$0/attempts", 1, 1, command.PaginationFlags()...) + attempts.Query = map[string]string{"page-size": "pageSize", "cursor": "cursor"} + keyFlag := command.StringFlag("idempotency-key", "Required replay idempotency key") + keyFlag.Required = true + replay := command.Confirmed(command.Leaf("deliveries replay", "replay ", http.MethodPost, "deliveries/$0/replay", 1, 1, keyFlag)) + bulk := command.Confirmed(command.Leaf("deliveries replay-bulk", "replay-bulk", http.MethodPost, "deliveries/replay", 0, 0, append(command.PaginationFlags(), keyFlag, + command.StringFlag("created-at-from", "Required inclusive RFC3339 start"), command.StringFlag("created-at-to", "Inclusive RFC3339 end"), + command.StringFlag("status", "CSV or JSON array; failed and pending by default"), command.StringFlag("config-id", "CSV or JSON array of config IDs"))...)) + bulk.Body = replayDeliveriesBody + bulk.Spec.Inputs = []pluginsdk.InputSpec{{Title: "Replay from (RFC3339)", Kind: "input", Flag: "created-at-from", Required: true}, {Title: "Idempotency key", Kind: "input", Flag: "idempotency-key", Required: true}} + return []command.Operation{ + command.Leaf("deliveries show", "show ", http.MethodGet, "deliveries/$0", 1, 1), + deliveries, attempts, replay, bulk, + } + +} + +func deliveryListBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil { + return nil, fmt.Errorf("list does not accept a body") + } + if v := r.Flags["status"]; v != "" && !slices.Contains([]string{"pending", "delivering", "succeeded", "failed", "cancelled"}, v) { + return nil, fmt.Errorf("invalid delivery status %q", v) + } + return nil, timeRange(r.Flags["created-at-from"], r.Flags["created-at-to"]) +} + +func timeRange(from, to string) error { + var start, end time.Time + var err error + if from != "" { + start, err = time.Parse(time.RFC3339Nano, from) + if err != nil { + return fmt.Errorf("start must be RFC3339: %w", err) + } + } + if to != "" { + end, err = time.Parse(time.RFC3339Nano, to) + if err != nil { + return fmt.Errorf("end must be RFC3339: %w", err) + } + } + if from != "" && to != "" && end.Before(start) { + return fmt.Errorf("end timestamp precedes start timestamp") + } + return nil +} + +func replayDeliveriesBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.Body != nil { + return nil, fmt.Errorf("replay-bulk uses its declared flags") + } + from, to := r.Flags["created-at-from"], r.Flags["created-at-to"] + if from == "" { + return nil, fmt.Errorf("--created-at-from is required") + } + if err := timeRange(from, to); err != nil { + return nil, err + } + statuses, err := command.List(r.Flags["status"]) + if err != nil { + return nil, err + } + if len(statuses) == 0 { + statuses = []string{"failed", "pending"} + } + for i, status := range statuses { + statuses[i] = strings.ToLower(status) + if statuses[i] != "failed" && statuses[i] != "pending" { + return nil, fmt.Errorf("replay status must be failed or pending") + } + } + ids, err := command.List(r.Flags["config-id"]) + if err != nil { + return nil, err + } + page := json.Number(r.Flags["page-size"]) + if _, err := command.QueryValues(map[string]string{"page-size": "pageSize"}, r); err != nil { + return nil, err + } + body := map[string]any{"createdAtFrom": from, "statuses": statuses, "pageSize": page} + if to != "" { + body["createdAtTo"] = to + } + if len(ids) > 0 { + body["configIds"] = ids + } + if r.Flags["cursor"] != "" { + body["cursor"] = r.Flags["cursor"] + } + return json.Marshal(body) +} diff --git a/misc/fctl-plugin/internal/webhooks/plugin.go b/misc/fctl-plugin/internal/webhooks/plugin.go new file mode 100644 index 00000000..daa33bbc --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/plugin.go @@ -0,0 +1,18 @@ +// Package webhooks supplies the historical webhooks commands through the plugin SDK. +package webhooks + +import ( + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/command" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/metadata" +) + +// New builds an independent plugin using the HTTP client supplied by the host. +func New(client *http.Client) pluginsdk.Plugin { + operations := configOperations() + operations = append(operations, deliveryOperations()...) + return command.New("webhooks", metadata.Version, client, operations, aliases) +} diff --git a/misc/fctl-plugin/internal/webhooks/validation_test.go b/misc/fctl-plugin/internal/webhooks/validation_test.go new file mode 100644 index 00000000..55d30c45 --- /dev/null +++ b/misc/fctl-plugin/internal/webhooks/validation_test.go @@ -0,0 +1,31 @@ +package webhooks + +import ( + "testing" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/testutil" +) + +func TestInvalidInputMakesNoRequests(t *testing.T) { + t.Parallel() + cases := []testutil.Case{ + {Name: "delete confirmation", Command: "webhooks delete", Args: []string{"id"}}, + {Name: "route traversal", Command: "webhooks delete", Args: []string{".."}, Flags: map[string]string{"confirm": "true"}}, + {Name: "webhook userinfo", Command: "webhooks create", Args: []string{"https://user@example.invalid/hook", "event"}, Flags: map[string]string{"confirm": "true"}}, + {Name: "webhook scheme", Command: "webhooks create", Args: []string{"ftp://example.invalid/hook", "event"}, Flags: map[string]string{"confirm": "true"}}, + {Name: "empty event", Command: "webhooks create", Args: []string{"https://example.invalid/hook", " "}, Flags: map[string]string{"confirm": "true"}}, + {Name: "invalid secret", Command: "webhooks change-secret", Args: []string{"id", "not-base64"}, Flags: map[string]string{"confirm": "true"}}, + {Name: "reverse replay range", Command: "webhooks deliveries replay-bulk", Flags: map[string]string{"confirm": "true", "idempotency-key": "k", "created-at-from": "2026-02-01T00:00:00Z", "created-at-to": "2026-01-01T00:00:00Z"}}, + {Name: "missing replay start", Command: "webhooks deliveries replay-bulk", Flags: map[string]string{"confirm": "true", "idempotency-key": "k"}}, + {Name: "invalid replay status", Command: "webhooks deliveries replay-bulk", Flags: map[string]string{"confirm": "true", "idempotency-key": "k", "created-at-from": "2026-01-01T00:00:00Z", "status": "succeeded"}}, + {Name: "invalid delivery status", Command: "webhooks deliveries list", Flags: map[string]string{"status": "other"}}, + {Name: "invalid delivery timestamp", Command: "webhooks deliveries list", Flags: map[string]string{"created-at-from": "yesterday"}}, + {Name: "zero page", Command: "webhooks deliveries list", Flags: map[string]string{"page-size": "0"}}, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + testutil.RunFailure(t, New, tc, "") + }) + } +} diff --git a/misc/fctl-plugin/legacy.go b/misc/fctl-plugin/legacy.go new file mode 100644 index 00000000..64ff96ba --- /dev/null +++ b/misc/fctl-plugin/legacy.go @@ -0,0 +1,99 @@ +package legacy + +import ( + "context" + "fmt" + "maps" + "net/http" + "slices" + + "github.com/formancehq/fctl/pkg/pluginsdk" + + "github.com/formancehq/fctl/misc/fctl-plugin/internal/auth" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/ledger" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/orchestration" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/payments" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/reconciliation" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/wallets" + "github.com/formancehq/fctl/misc/fctl-plugin/internal/webhooks" +) + +type Factory func(*http.Client) pluginsdk.Plugin + +func Factories() map[string]Factory { + return map[string]Factory{ + "auth": auth.New, + "ledger": ledger.New, + "orchestration": orchestration.New, + "payments": payments.New, + "reconciliation": reconciliation.New, + "wallets": wallets.New, + "webhooks": webhooks.New, + } +} + +type bundle struct{ http *http.Client } + +// New exposes all legacy services in one independently distributable plugin. +func New(client *http.Client) pluginsdk.Plugin { return &bundle{http: client} } + +func (p *bundle) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + manifest := pluginsdk.Manifest{Name: "legacy", Service: "stack", Version: Version, ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{Use: "legacy", Short: "Historical service commands for stacks before v4", Target: "stack"}} + factories := Factories() + for _, service := range slices.Sorted(maps.Keys(factories)) { + metadata, err := NewService(service, nil).GetManifest(ctx) + if err != nil { + return pluginsdk.Manifest{}, err + } + metadata.Root.Service = service + prefixSources(&metadata.Root) + manifest.Root.Subcommands = append(manifest.Root.Subcommands, metadata.Root) + } + return manifest, nil +} + +func prefixSources(command *pluginsdk.CommandSpec) { + for i := range command.Inputs { + if source := command.Inputs[i].Source; source != nil { + source.CommandPath = append([]string{"legacy"}, source.CommandPath...) + } + } + for i := range command.Subcommands { + prefixSources(&command.Subcommands[i]) + } +} + +func (p *bundle) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + request, err := p.serviceRequest(ctx, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return NewService(request.CommandPath[0], p.http).Execute(ctx, request) +} + +var _ pluginsdk.StreamingPlugin = (*bundle)(nil) + +func (p *bundle) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + request, err := p.serviceRequest(ctx, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return newService(request.CommandPath[0], p.http).ExecuteStream(ctx, request, streams) +} + +func (p *bundle) serviceRequest(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + manifest, err := p.GetManifest(ctx) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + request.CommandPath = normalized.CommandPath + if len(request.CommandPath) < 2 { + return pluginsdk.ExecuteRequest{}, fmt.Errorf("select a legacy service") + } + request.CommandPath = slices.Clone(request.CommandPath[1:]) + return request, nil +} diff --git a/misc/fctl-plugin/legacy_test.go b/misc/fctl-plugin/legacy_test.go new file mode 100644 index 00000000..4a9f4e70 --- /dev/null +++ b/misc/fctl-plugin/legacy_test.go @@ -0,0 +1,249 @@ +package legacy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) + +func TestMain(m *testing.M) { + if os.Getenv("FCTL_PLUGIN_V4") == "formance-fctl-v4" { + transport.Serve(New) + return + } + os.Exit(m.Run()) +} + +func TestBundleMetadataAndServiceBoundaries(t *testing.T) { + manifest, err := New(nil).GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if manifest.Name != "legacy" || manifest.Version != Version || len(manifest.Root.Subcommands) != 7 { + t.Fatalf("unexpected manifest %#v", manifest) + } + for _, test := range []struct { + path []string + service string + }{ + {[]string{"legacy", "ledger", "list"}, "ledger"}, + {[]string{"legacy", "auth", "clients", "list"}, "auth"}, + {[]string{"legacy", "payments", "accounts", "list"}, "payments"}, + {[]string{"legacy", "wallets", "list"}, "wallets"}, + } { + service, err := pluginsdk.CommandService(manifest, test.path) + if err != nil || service != test.service { + t.Fatalf("%v service=%s error=%v", test.path, service, err) + } + } +} + +func TestServiceMetadataIsOfflineAndIndependent(t *testing.T) { + t.Parallel() + for name, factory := range Factories() { + t.Run(name, func(t *testing.T) { + t.Parallel() + p := factory(nil) + manifest, err := p.GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if manifest.Version != Version || manifest.Service != name || manifest.Root.Target != "stack" { + t.Fatalf("invalid manifest: %+v", manifest) + } + manifest.Root.Subcommands[0].Use = "modified" + again, err := p.GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if again.Root.Subcommands[0].Use == "modified" { + t.Fatal("manifest metadata aliases internal state") + } + }) + } +} + +func TestStandaloneBundleHTTPAndVersionGuard(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + fixture := &ledgerVersionFixture{t: t, version: "2.4.15"} + server := httptest.NewServer(fixture) + defer server.Close() + httpClient := server.Client() + httpClient.Transport = headerTransport{base: httpClient.Transport} + client, err := transport.Open(t.Context(), binary, httpClient, server.URL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Error(err) + } + }) + manifest, err := client.GetManifest(t.Context()) + if err != nil || manifest.Name != "legacy" { + t.Fatalf("manifest: %v", err) + } + result, err := client.Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"legacy", "ledger", "list"}, Endpoint: server.URL, Context: map[string]string{"stack-version": "v3.2"}}) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Cursor struct { + Data []struct { + Name string + ID json.Number + } + } + } + if err = json.Unmarshal(result.Data, &decoded); err != nil || len(decoded.Cursor.Data) != 1 || decoded.Cursor.Data[0].ID.String() != "9007199254740993" { + t.Fatalf("number preservation: %s %v", result.Data, err) + } + fixture.setVersion("3.0.0-beta.10") + _, err = client.Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"legacy", "ledger", "create"}, Args: []string{"forbidden"}, Flags: map[string]string{"confirm": "true"}, Endpoint: server.URL, Context: map[string]string{"stack-version": "v3.2"}}) + if err == nil { + t.Fatal("version drift allowed legacy mutation") + } + if fixture.mutationCount() != 0 { + t.Fatal("legacy guard sent mutation") + } +} + +type headerTransport struct{ base http.RoundTripper } + +func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer synthetic-fixture") + return h.base.RoundTrip(r) +} + +func TestBundlePreservesOmittedAuthUpdateFlags(t *testing.T) { + fixture := &authUpdateFixture{t: t} + server := httptest.NewServer(fixture) + defer server.Close() + _, err := New(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{Endpoint: server.URL, CommandPath: []string{"legacy", "auth", "clients", "update"}, Args: []string{"owned"}, Flags: map[string]string{"description": "changed", "confirm": "true"}}) + if err != nil { + t.Fatal(err) + } + if fixture.updated["description"] != "changed" || fixture.updated["public"] != true || fixture.updated["trusted"] != true || fixture.updated["name"] != "keep" { + t.Fatalf("omitted options overwritten: %#v", fixture.updated) + } +} + +type ledgerVersionFixture struct { + t *testing.T + mu sync.Mutex + version string + mutations int +} + +func (f *ledgerVersionFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Header.Get("Authorization") != "Bearer synthetic-fixture" { + f.t.Error("HTTP did not use host client") + } + if r.URL.Path == "/_info" { + fixtureJSON(f.t, w, map[string]any{"data": map[string]string{"version": f.version}}) + return + } + if r.Method != http.MethodGet { + f.mutations++ + } + if r.URL.Path != "/v2" { + f.t.Errorf("unexpected route %s", r.URL.Path) + } + fixtureJSON(f.t, w, json.RawMessage(`{"cursor":{"data":[{"name":"synthetic","id":9007199254740993}],"hasMore":false}}`)) +} +func (f *ledgerVersionFixture) setVersion(version string) { + f.mu.Lock() + defer f.mu.Unlock() + f.version = version +} +func (f *ledgerVersionFixture) mutationCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.mutations +} + +type authUpdateFixture struct { + t *testing.T + updated map[string]any +} + +func (f *authUpdateFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/_info": + fixtureJSON(f.t, w, json.RawMessage(`{"version":"v2.5.1"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/clients/owned": + fixtureJSON(f.t, w, json.RawMessage(`{"data":{"id":"owned","name":"keep","description":"old","public":true,"trusted":true,"scopes":["ledger:read"]}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/clients/owned": + if err := json.NewDecoder(r.Body).Decode(&f.updated); err != nil { + f.t.Error(err) + } + fixtureJSON(f.t, w, map[string]any{"data": f.updated}) + default: + f.t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } +} +func fixtureJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Error(err) + } +} + +func TestLegacyRejectsUnverifiedServiceBeforeOperations(t *testing.T) { + for _, tc := range []struct { + name, info, line string + status int + }{ + {"invalid info", `{"data":{}}`, "v3.2", 200}, + {"HTTP failure", `{"error":"unavailable"}`, "v3.2", 503}, + {"modern Auth on modern Stack", `{"version":"v2.5.1"}`, "v4.0-beta", 200}, + {"unknown version", `{"version":"dev"}`, "v3.2", 200}, + } { + t.Run(tc.name, func(t *testing.T) { + operations := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_info" { + operations++ + } + w.WriteHeader(tc.status) + fixtureJSON(t, w, json.RawMessage(tc.info)) + })) + defer server.Close() + _, err := NewService("auth", server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"auth", "clients", "list"}, Endpoint: server.URL, Context: map[string]string{"stack-version": tc.line}}) + if err == nil || operations != 0 { + t.Fatalf("unverified service ran operation: err=%v operations=%d", err, operations) + } + }) + } +} + +func TestLegacyInvalidRequestAndMissingHostClient(t *testing.T) { + for _, tc := range []struct { + service string + path []string + }{ + {"unknown", []string{"unknown", "list"}}, + {"ledger", []string{"ledger", "unknown"}}, + {"ledger", []string{"ledger", "list"}}, + } { + _, err := NewService(tc.service, nil).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: tc.path, Endpoint: "https://example.invalid"}) + if err == nil { + t.Fatalf("accepted invalid or unbound service %s", tc.service) + } + } + if _, err := New(nil).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"legacy"}}); err == nil { + t.Fatal("accepted bundle without service") + } +} diff --git a/misc/fctl-plugin/service.go b/misc/fctl-plugin/service.go new file mode 100644 index 00000000..69667f1a --- /dev/null +++ b/misc/fctl-plugin/service.go @@ -0,0 +1,97 @@ +package legacy + +import ( + "context" + "fmt" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +// NewService exposes one service facade with the same SDK contract as New. +func NewService(service string, client *http.Client) pluginsdk.Plugin { + return newService(service, client) +} + +func newService(service string, client *http.Client) *servicePlugin { + factory, ok := Factories()[service] + if !ok { + return &servicePlugin{service: service, http: client} + } + return &servicePlugin{service: service, http: client, plugin: factory(client)} +} + +type servicePlugin struct { + service string + http *http.Client + plugin pluginsdk.Plugin +} + +func (p *servicePlugin) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if p.plugin == nil { + return pluginsdk.Manifest{}, fmt.Errorf("unknown legacy service %q", p.service) + } + manifest, err := p.plugin.GetManifest(ctx) + if err == nil { + manifest.Version = Version + manifest.Root.Short += " (legacy API)" + } + return manifest, err +} + +func (p *servicePlugin) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + request, err := p.prepareRequest(ctx, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return p.plugin.Execute(ctx, request) +} + +var _ pluginsdk.StreamingPlugin = (*servicePlugin)(nil) + +func (p *servicePlugin) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + request, err := p.prepareRequest(ctx, request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if streaming, ok := p.plugin.(pluginsdk.StreamingPlugin); ok { + return streaming.ExecuteStream(ctx, request, streams) + } + if streams.Input != nil || streams.Output != nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("legacy %s does not accept streams", p.service) + } + return p.plugin.Execute(ctx, request) +} + +// Both entry points verify compatibility before consuming streams or mutating. +func (p *servicePlugin) prepareRequest(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + manifest, err := p.GetManifest(ctx) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + request.CommandPath = normalized.CommandPath + if p.http == nil { + return pluginsdk.ExecuteRequest{}, fmt.Errorf("legacy plugin requires an injected HTTP client") + } + client, err := httpclient.New(request.Endpoint, p.http) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + data, err := client.Do(ctx, http.MethodGet, "/_info", nil, nil, nil) + if err != nil { + return pluginsdk.ExecuteRequest{}, fmt.Errorf("verify legacy %s version: %w", p.service, err) + } + version, err := pluginsdk.ServiceVersion(data) + if err != nil { + return pluginsdk.ExecuteRequest{}, err + } + if !Supported(p.service, version, request.Context["stack-version"]) { + return pluginsdk.ExecuteRequest{}, Unsupported(p.service, version, request.Context["stack-version"]) + } + return request, nil +} diff --git a/misc/fctl-plugin/stream_test.go b/misc/fctl-plugin/stream_test.go new file mode 100644 index 00000000..10ca5e8d --- /dev/null +++ b/misc/fctl-plugin/stream_test.go @@ -0,0 +1,218 @@ +package legacy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type streamRoundTripper func(*http.Request) (*http.Response, error) + +func (f streamRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func streamHTTPResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func requireStreaming(t *testing.T, p pluginsdk.Plugin) pluginsdk.StreamingPlugin { + t.Helper() + streaming, ok := p.(pluginsdk.StreamingPlugin) + if !ok { + t.Fatal("wrapper must implement StreamingPlugin") + } + return streaming +} + +type guardedInput struct { + t *testing.T + checked *bool + reads int + reader io.Reader +} + +func (g *guardedInput) Read(p []byte) (int, error) { + g.reads++ + if !*g.checked { + g.t.Fatal("input consumed before compatibility check") + } + return g.reader.Read(p) +} + +type guardedOutput struct { + t *testing.T + checked *bool + writes int + bytes.Buffer +} + +func (g *guardedOutput) Write(p []byte) (int, error) { + g.writes++ + if !*g.checked { + g.t.Fatal("output used before compatibility check") + } + return g.Buffer.Write(p) +} + +//nolint:gocognit // Exercise both public facades and directions against the compatibility matrix. +func TestStreamWrappersCompatibilityBeforeIO(t *testing.T) { + for _, bundle := range []bool{false, true} { + for _, command := range []string{"import", "export"} { + for _, target := range []struct { + version, line string + allowed bool + }{{"2.4.15", "v3.2", true}, {"3.0.0-beta.10", "v3.2", false}, {"2.4.15", "v4.0-beta", false}, {"dev", "", false}} { + t.Run(fmt.Sprintf("bundle=%t/%s/%s/%s", bundle, command, target.version, target.line), func(t *testing.T) { + checked := false + calls := 0 + client := &http.Client{Transport: streamRoundTripper(func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.Path == "/_info" { + checked = true + return streamHTTPResponse(200, `{"version":"`+target.version+`"}`), nil + } + if !target.allowed { + t.Fatal("compatibility guard allowed a service operation") + } + if command == "import" { + data, err := io.ReadAll(r.Body) + if err != nil || string(data) != "{\"id\":1}\n" { + t.Fatalf("import %s: %v", data, err) + } + return streamHTTPResponse(204, ""), nil + } + return streamHTTPResponse(200, "{\"id\":1}"), nil + })} + p := NewService("ledger", client) + path := []string{"l", command} // Preserve the historical service alias. + if bundle { + p = New(client) + path = append([]string{"legacy"}, path...) + } + r := pluginsdk.ExecuteRequest{Endpoint: "https://ledger.example", CommandPath: path, Context: map[string]string{"stack-version": target.line}} + input := &guardedInput{t: t, checked: &checked, reader: strings.NewReader("{\"id\":1}")} + output := &guardedOutput{t: t, checked: &checked} + reports := 0 + streams := pluginsdk.ExecutionStreams{Report: func(pluginsdk.ProgressEvent) error { + if !checked { + t.Fatal("reported before compatibility check") + } + reports++ + return nil + }} + if command == "import" { + r.Args = []string{"named"} + streams.Input = input + } else { + streams.Output = output + } + result, err := requireStreaming(t, p).ExecuteStream(t.Context(), r, streams) + if !target.allowed { + if err == nil || calls != 1 || input.reads != 0 || output.writes != 0 || reports != 0 { + t.Fatalf("calls %d reads %d writes %d reports %d: %v", calls, input.reads, output.writes, reports, err) + } + return + } + if err != nil || calls != 2 || !json.Valid(result.Data) { + t.Fatalf("result %s calls %d: %v", result.Data, calls, err) + } + if command == "export" && output.String() != "{\"id\":1}" { + t.Fatal("export was rendered instead of streamed") + } + }) + } + } + } +} + +//nolint:gocognit // Keep fallback and boundary validation beside their HTTP call counts. +func TestStreamWrappersUnaryFallbackAndValidation(t *testing.T) { + for _, bundle := range []bool{false, true} { + t.Run(fmt.Sprint(bundle), func(t *testing.T) { + calls := 0 + client := &http.Client{Transport: streamRoundTripper(func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.Path == "/_info" { + return streamHTTPResponse(200, `{"version":"2.5.1"}`), nil + } + return streamHTTPResponse(200, `{"data":[]}`), nil + })} + p := NewService("auth", client) + path := []string{"auth", "clients", "list"} + if bundle { + p = New(client) + path = append([]string{"legacy"}, path...) + } + r := pluginsdk.ExecuteRequest{Endpoint: "https://auth.example", CommandPath: path} + result, err := requireStreaming(t, p).ExecuteStream(t.Context(), r, pluginsdk.ExecutionStreams{}) + if err != nil || calls != 2 || string(result.Data) != `{"data":[]}` { + t.Fatalf("fallback %s calls %d: %v", result.Data, calls, err) + } + r.Flags = map[string]string{"unknown": "true"} + _, err = requireStreaming(t, p).ExecuteStream(t.Context(), r, pluginsdk.ExecutionStreams{Input: strings.NewReader("must not read")}) + if err == nil || calls != 2 { + t.Fatalf("invalid request made HTTP call: %v", err) + } + }) + } + _, err := requireStreaming(t, NewService("unknown", nil)).ExecuteStream(t.Context(), pluginsdk.ExecuteRequest{}, pluginsdk.ExecutionStreams{}) + if err == nil { + t.Fatal("unknown service accepted") + } +} + +//nolint:gocognit // Walk both manifests and assert exact metadata for the only streaming commands. +func TestStreamManifestOnlyLedgerImportExport(t *testing.T) { + for _, p := range []pluginsdk.Plugin{New(nil), NewService("ledger", nil)} { + m, err := p.GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if m.ProtocolVersion != 1 { + t.Fatal("protocol version changed") + } + count := 0 + var walk func(pluginsdk.CommandSpec) + walk = func(c pluginsdk.CommandSpec) { + if c.Streams != nil { + count++ + switch pluginsdk.CommandName(c) { + case "import": + if !reflect.DeepEqual(c.Streams, &pluginsdk.StreamSpec{Input: true}) || !reflect.DeepEqual(c.Files, &pluginsdk.FileSpec{ReadArgument: new(1), ReadFlag: "file", ReadFormat: "string"}) { + t.Fatalf("import metadata %+v %+v", c.Streams, c.Files) + } + case "export": + if !reflect.DeepEqual(c.Streams, &pluginsdk.StreamSpec{Output: true}) || !reflect.DeepEqual(c.Files, &pluginsdk.FileSpec{WriteFlag: "file", WriteFormat: "ndjson"}) { + t.Fatalf("export metadata %+v %+v", c.Streams, c.Files) + } + default: + t.Fatalf("unexpected stream command %s", c.Use) + } + } + for _, child := range c.Subcommands { + walk(child) + } + } + walk(m.Root) + if count != 2 { + t.Fatalf("%d stream commands", count) + } + } +} + +func TestStreamWrappersInfoFailureDoesNotRead(t *testing.T) { + failure := errors.New("info unavailable") + client := &http.Client{Transport: streamRoundTripper(func(*http.Request) (*http.Response, error) { return nil, failure })} + checked := false + input := &guardedInput{t: t, checked: &checked, reader: strings.NewReader("{\"id\":1}")} + _, err := requireStreaming(t, NewService("ledger", client)).ExecuteStream(t.Context(), pluginsdk.ExecuteRequest{Endpoint: "https://ledger.example", CommandPath: []string{"ledger", "import"}, Args: []string{"named"}}, pluginsdk.ExecutionStreams{Input: input}) + if !errors.Is(err, failure) || input.reads != 0 { + t.Fatalf("read %d: %v", input.reads, err) + } +} diff --git a/openapi/deployserver.yaml b/openapi/deployserver.yaml deleted file mode 100644 index 6ea50aaa..00000000 --- a/openapi/deployserver.yaml +++ /dev/null @@ -1,1179 +0,0 @@ -openapi: 3.1.0 -info: - title: Formance Apps API - contact: {} - version: 0.1.0 -servers: - - url: https://deploy.formance.cloud - description: Production server - - url: https://deploy-server.staging.formance.cloud - description: Staging server - - url: http://localhost:8080 - description: Local server -security: - - bearerAuth: [] -paths: - /apps: - get: - summary: List organization apps - operationId: listApps - parameters: - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: Apps retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ListAppsResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - post: - summary: Create a new app - operationId: createApp - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAppRequest' - responses: - '201': - description: App created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/AppResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /apps/{id}: - put: - summary: Update an app - operationId: updateApp - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAppRequest' - responses: - '204': - description: App updated successfully - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - get: - summary: read app details - operationId: readApp - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: include - in: query - required: false - description: | - Comma-separated list of related resources to include. - - `state`: Include the live stack state reported by Formance Cloud - (a projection of the bound stack from Membership, not a snapshot - persisted by the proxy). - schema: - type: array - items: - type: string - enum: - - state - responses: - '200': - description: App details retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/AppResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - delete: - summary: Delete an app - description: | - Soft-deletes the app immediately and enqueues a destroy deployment to - clean up the app's resources on Formance Cloud via the openapi - reconciler's tear-down path. The app becomes invisible to all - subsequent reads. The destroy runs through the normal worker - pipeline; once it reaches a successful terminal status the worker - hard-deletes the app row. - - By default this returns 202 Accepted as soon as the destroy is - enqueued (or 202 with no body if no destroy was needed). Pass - `?wait=true` to block until the destroy reaches a terminal status. - operationId: deleteApp - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: wait - in: query - required: false - description: | - When `true`, the call blocks until the destroy deployment reaches a - terminal status. Default `false` (returns 202 Accepted). - schema: - type: boolean - default: false - responses: - '202': - description: | - Soft-delete accepted. Cleanup runs asynchronously. The - `Location` header points at the destroy deployment if one was - enqueued. - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteAppResponse' - '204': - description: | - Returned when `wait=true` and the destroy reached a terminal - status successfully (or when no destroy was needed at all). - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /apps/{id}/manifest: - put: - summary: Bind an app to a manifest - operationId: attachAppManifest - description: | - Updates the app's `manifest_id` pointer. Idempotent — re-binding to the - same id is a no-op; rebinding to a different id moves the pointer. - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AttachManifestRequest' - responses: - '204': - description: App bound to manifest - '400': - description: manifestId missing or empty - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '404': - description: Manifest not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - delete: - summary: Unbind an app from its manifest - operationId: detachAppManifest - description: Clears the app's `manifest_id` pointer. Idempotent. - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '204': - description: App unbound from manifest - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /apps/{id}/variables: - get: - summary: Get all variables of an app - operationId: readAppVariables - parameters: - - name: id - in: path - required: true - schema: - type: string - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: App variables retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ReadVariablesResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - post: - summary: Create variable for an app - operationId: createAppVariable - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateVariableRequest' - responses: - '201': - description: Variable created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/CreateVariableResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /apps/{id}/variables/{variableId}: - delete: - summary: Delete a variable from an app - operationId: deleteAppVariable - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: variableId - in: path - required: true - schema: - type: string - responses: - '204': - description: Variable deleted successfully - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /manifests: - post: - summary: Create a new manifest - operationId: createManifest - parameters: - - name: name - in: query - required: true - schema: - type: string - description: Name for the manifest - requestBody: - required: true - content: - application/yaml: - schema: - type: string - format: binary - description: YAML manifest content - application/json: - schema: - type: object - description: JSON manifest content - responses: - '201': - description: Manifest created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/CreateManifestResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - get: - summary: List manifests in the organization - operationId: listManifests - parameters: - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: Manifests retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ListManifestsResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /manifests/{manifestId}: - get: - summary: Read a manifest - operationId: readManifest - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - - name: include - in: query - required: false - schema: - type: string - description: Comma-separated includes (e.g. "latest" to embed latest version content) - responses: - '200': - description: Manifest retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ManifestResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - patch: - summary: Update manifest metadata - operationId: updateManifest - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateManifestRequest' - responses: - '200': - description: Manifest updated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ManifestResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - delete: - summary: Delete a manifest and all its versions - operationId: deleteManifest - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - responses: - '204': - description: Manifest deleted successfully - '409': - description: Manifest is still referenced by deployments - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /manifests/{manifestId}/versions: - post: - summary: Push a new version of a manifest - operationId: pushManifestVersion - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/yaml: - schema: - type: string - format: binary - description: YAML manifest content - application/json: - schema: - type: object - description: JSON manifest content - responses: - '201': - description: Manifest version created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ManifestVersionResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - get: - summary: List versions of a manifest - operationId: listManifestVersions - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: Manifest versions retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ListManifestVersionsResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /manifests/{manifestId}/versions/{version}: - get: - summary: Get a specific manifest version with content - operationId: readManifestVersion - parameters: - - name: manifestId - in: path - required: true - schema: - type: string - - name: version - in: path - required: true - description: Version number or "latest" - schema: - type: string - - name: accept - in: header - required: false - description: | - The desired response format. - - `application/json`: Returns the version metadata and content in JSON format. - - `application/x-yaml`: Returns the raw manifest content as YAML. - schema: - type: string - enum: - - application/json - - application/x-yaml - responses: - '200': - description: Manifest version retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ManifestVersionResponse' - application/x-yaml: - schema: - type: string - format: binary - description: Raw manifest YAML content - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /deployments: - post: - summary: Create a deployment (triggers a run) - operationId: createDeployment - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateDeploymentRequest' - responses: - '201': - description: Deployment created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/DeploymentResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - get: - summary: List deployments - operationId: listDeployments - parameters: - - name: appId - in: query - schema: - type: string - - $ref: '#/components/parameters/PageSize' - - $ref: '#/components/parameters/Cursor' - responses: - '200': - description: Deployments retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ListDeploymentsResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /deployments/{deploymentId}: - get: - summary: Get a single deployment - operationId: readDeployment - parameters: - - name: deploymentId - in: path - required: true - schema: - type: string - - name: accept - in: header - required: false - description: | - The desired response format. - - `application/json`: Returns the deployment metadata in JSON format. - - `application/yaml`: Returns the original application manifest content. - schema: - type: string - enum: - - application/json - - application/yaml - - name: include - in: query - required: false - description: | - Comma-separated list of related resources to include. - - `state`: Include the live stack state reported by Formance Cloud - for this deployment's bound stack. Note: this is the **live** - projection from Membership, not a frozen per-deployment snapshot. - schema: - type: array - items: - type: string - enum: - - state - responses: - '200': - description: Deployment retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/DeploymentResponse' - application/yaml: - schema: - type: string - format: binary - description: Original application manifest content - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /deployments/{deploymentId}/logs: - get: - summary: Get run logs for a deployment - operationId: readDeploymentLogs - parameters: - - name: deploymentId - in: path - required: true - schema: - type: string - responses: - '200': - description: Deployment logs retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ReadLogsResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: | - Formance Cloud bearer token (JWT) issued by Membership. The proxy - validates the token and enforces scopes per operation; see the - per-operation `security` declarations (when present) for required - scopes. - parameters: - PageSize: - name: pageSize - in: query - required: false - description: | - Maximum number of items to return on the first page. Capped at 100; - ignored when `cursor` is supplied (subsequent pages reuse the page - size baked into the cursor token). Defaults to 100. - schema: - type: integer - minimum: 1 - maximum: 100 - Cursor: - name: cursor - in: query - required: false - description: | - Opaque pagination token returned in `cursor.next` / `cursor.previous` - on the previous page. Pass it back unchanged to fetch the adjacent - page. Mutually exclusive with `pageSize` after the first request. - schema: - type: string - schemas: - State: - type: object - required: - - stack - properties: - stack: - type: object - description: | - The live stack details reported by Formance Cloud (a projection of - the bound stack from Membership). Shape is a free-form object - mirroring the upstream stack resource. - additionalProperties: true - DeleteAppResponse: - type: object - properties: - destroyDeploymentId: - type: string - description: | - ID of the destroy deployment enqueued to clean up the app's - resources. Empty when no destroy was needed (e.g. no prior - applied deployment). Use `GET /deployments/{id}` to poll its - status. - ReadManifestResponse: - type: object - required: - - data - properties: - data: - type: string - format: binary - description: YAML manifest content - ReadLogsResponse: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/Log' - Log: - type: object - required: - - message - - timestamp - - module - properties: - message: - type: string - description: Log message - timestamp: - type: string - format: date-time - description: Timestamp when the log was created - module: - type: string - description: Module or component that generated the log - diagnostic: - type: object - description: Detailed log message - required: - - severity - - detail - - summary - properties: - severity: - type: string - description: Severity of the log message - detail: - type: string - summary: - type: string - description: Brief summary of the log message - Cursor: - type: object - description: | - Cursor pagination envelope. `next` and `previous` are opaque tokens - produced by the server; pass them back as `?cursor=` to fetch the - adjacent page. `hasMore` is `true` when more results exist after the - current page. `pageSize` echoes the page size used for this page. - required: - - hasMore - - data - properties: - pageSize: - type: integer - description: Number of items requested for this page - hasMore: - type: boolean - description: True when more results exist after this page - previous: - type: string - description: Opaque cursor token for the previous page (empty when none) - next: - type: string - description: Opaque cursor token for the next page (empty when none) - ListAppsResponse: - type: object - required: - - cursor - properties: - cursor: - allOf: - - $ref: '#/components/schemas/Cursor' - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/App' - AppResponse: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/App' - App: - type: object - required: - - id - - name - properties: - id: - type: string - description: Unique identifier for the app - name: - type: string - description: Name of the app - stackId: - type: string - description: Optional existing stack ID claimed by this app - stackRegionId: - type: string - description: Stack region ID (set when the app's stack lives in a specific region) - state: - $ref: '#/components/schemas/State' - currentManifest: - $ref: '#/components/schemas/AppCurrentManifest' - AppCurrentManifest: - type: object - description: | - The manifest the app is currently bound to (apps.manifest_id), with - catalog metadata and divergence vs the most recent applied deployment. - Populated by GET /apps/{id} when a manifest is bound. - required: - - id - - name - - latestVersion - properties: - id: - type: string - description: ID of the manifest the app is bound to - name: - type: string - description: Name of the bound manifest - latestVersion: - type: integer - description: Latest version available in the manifest's lineage - divergence: - $ref: '#/components/schemas/AppManifestDivergence' - AppManifestDivergence: - type: object - description: | - Relationship between the app's bound manifest and what's currently - deployed (last `applied` deployment, ignoring destroys). - required: - - kind - - latestVersion - properties: - kind: - type: string - enum: - - undeployed - - synced - - behind - - rebound - description: | - - `undeployed`: the app has a manifest bound but no successful deployment yet. - - `synced`: deployed (manifestId, version) matches the bound manifest's latest version. - - `behind`: deployed manifest matches the bound manifest but on an older version. - - `rebound`: the app was rebound; deployed manifest is a different manifest. - deployedManifestId: - type: string - description: ID of the manifest reflected in the most recent applied deployment - deployedVersion: - type: integer - description: Version reflected in the most recent applied deployment - latestVersion: - type: integer - description: Latest version available in the bound manifest's lineage - AttachManifestRequest: - type: object - required: - - manifestId - properties: - manifestId: - type: string - description: ID of the manifest to bind to the app - CreateAppRequest: - type: object - required: - - name - properties: - name: - type: string - description: Name of the app - stackId: - type: string - description: Optional existing stack ID to claim - UpdateAppRequest: - type: object - required: - - name - properties: - name: - type: string - description: Updated name for the app - ReadVariablesResponse: - type: object - required: - - cursor - properties: - cursor: - allOf: - - $ref: '#/components/schemas/Cursor' - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/Variable' - CreateVariableRequest: - type: object - required: - - variable - properties: - variable: - $ref: '#/components/schemas/VariableData' - CreateVariableResponse: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Variable' - VariableData: - type: object - required: - - key - - value - properties: - key: - type: string - description: | - Snake-case identifier (case-insensitive at the API boundary, - normalized to lowercase on storage to match `var.` in the - generated HCL). Either `MONEYCORP_API_KEY` or - `moneycorp_api_key` is accepted. - value: - type: string - description: | - Value of the variable. Always treated as sensitive: `value` is - redacted (`REDACTED`) on every read endpoint. - description: - type: string - description: Description of the variable - Variable: - allOf: - - $ref: '#/components/schemas/VariableData' - - type: object - required: - - id - properties: - id: - type: string - description: Unique identifier for the variable - Manifest: - type: object - required: - - id - - name - - latestVersion - - createdAt - - updatedAt - properties: - id: - type: string - description: Unique identifier for the manifest - name: - type: string - description: Name of the manifest - latestVersion: - type: integer - description: Latest version number - createdAt: - type: string - format: date-time - description: Timestamp when the manifest was created - updatedAt: - type: string - format: date-time - description: Timestamp when the manifest was last updated - ManifestVersion: - type: object - required: - - manifestId - - version - - createdAt - properties: - manifestId: - type: string - description: ID of the manifest this version belongs to - version: - type: integer - description: Version number - content: - type: string - format: byte - description: Manifest content (base64-encoded). Null when listing versions, present when reading a specific version. - createdAt: - type: string - format: date-time - description: Timestamp when the version was created - CreateManifestResponse: - type: object - required: - - data - properties: - data: - type: object - required: - - id - - version - - name - - createdAt - properties: - id: - type: string - version: - type: integer - name: - type: string - createdAt: - type: string - format: date-time - ManifestResponse: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/Manifest' - ManifestVersionResponse: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/ManifestVersion' - ListManifestsResponse: - type: object - required: - - cursor - properties: - cursor: - allOf: - - $ref: '#/components/schemas/Cursor' - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/Manifest' - ListManifestVersionsResponse: - type: object - required: - - cursor - properties: - cursor: - allOf: - - $ref: '#/components/schemas/Cursor' - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/ManifestVersion' - DeploymentResource: - type: object - required: - - id - - appId - - status - - createdAt - - updatedAt - properties: - id: - type: string - appId: - type: string - manifestId: - type: string - manifestVersion: - type: integer - status: - type: string - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - state: - $ref: '#/components/schemas/State' - CreateDeploymentRequest: - type: object - required: - - appId - - manifestId - - manifestVersion - properties: - appId: - type: string - description: ID of the app to deploy to - manifestId: - type: string - description: Manifest catalog ID - manifestVersion: - type: integer - description: Manifest version - DeploymentResponse: - type: object - required: - - data - properties: - data: - $ref: '#/components/schemas/DeploymentResource' - ListDeploymentsResponse: - type: object - required: - - cursor - properties: - cursor: - allOf: - - $ref: '#/components/schemas/Cursor' - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: '#/components/schemas/DeploymentResource' - UpdateManifestRequest: - type: object - required: - - name - properties: - name: - type: string - description: Updated name for the manifest - Error: - type: object - properties: - errorCode: - type: string - errorMessage: - type: string - required: - - errorCode diff --git a/openapi/membership.yaml b/openapi/membership.yaml deleted file mode 100644 index 26168a87..00000000 --- a/openapi/membership.yaml +++ /dev/null @@ -1,3491 +0,0 @@ -openapi: 3.0.3 -info: - title: Membership API - contact: {} - version: "0.1.0" - -servers: - - url: http://localhost:8080 - description: Local server - -paths: - /_info: - get: - summary: Get server info - operationId: getServerInfo - responses: - 200: - description: Server information - content: - application/json: - schema: - $ref: "#/components/schemas/ServerInfo" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations: - get: - summary: List organizations of the connected user - operationId: listOrganizations - security: - - oauth2: [] - parameters: - - name: expand - in: query - schema: - type: boolean - required: false - responses: - 200: - description: List of organizations - content: - application/json: - schema: - $ref: "#/components/schemas/ListOrganizationExpandedResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create organization - operationId: createOrganization - security: - - oauth2: [] - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateOrganizationRequest" - responses: - 201: - description: Organization created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateOrganizationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /organizations/expanded: - get: - summary: List organizations of the connected user with expanded data - operationId: listOrganizationsExpanded - security: - - oauth2: [] - deprecated: true - responses: - 200: - description: List of organizations with expanded data - content: - application/json: - schema: - $ref: "#/components/schemas/ListOrganizationExpandedResponse" - /organizations/{organizationId}: - get: - summary: Read organization - operationId: readOrganization - security: - - oauth2: - - organization:Read - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: expand - in: query - schema: - type: boolean - required: false - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ReadOrganizationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - put: - summary: Update organization - operationId: updateOrganization - security: - - oauth2: - - organization:Update - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/OrganizationData" - responses: - 200: - description: Organization updated - content: - application/json: - schema: - $ref: "#/components/schemas/ReadOrganizationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete organization - operationId: deleteOrganization - security: - - oauth2: - - organization:Delete - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Organization deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/authentication-provider: - get: - summary: Read authentication provider - operationId: readAuthenticationProvider - security: - - oauth2: - - organization:ReadAuthProvider - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 200: - description: Authentication provider details - content: - application/json: - schema: - $ref: "#/components/schemas/AuthenticationProviderResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Upsert an authentication provider - operationId: upsertAuthenticationProvider - security: - - oauth2: - - organization:UpdateAuthProvider - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpsertAuthenticationProviderRequest" - responses: - 200: - description: Authentication provider created or updated - content: - application/json: - schema: - $ref: "#/components/schemas/AuthenticationProviderResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete authentication provider - operationId: deleteAuthenticationProvider - security: - - oauth2: - - organization:DeleteAuthProvider - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Authentication provider deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /organizations/{organizationId}/features: - get: - summary: List features - operationId: listFeatures - security: - - oauth2: - - organization:ListFeatures - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 200: - description: List of features - content: - application/json: - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/OrganizationFeature" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Add Features - operationId: addFeatures - security: - - oauth2: - - organization:UpdateFeatures - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - type: object - required: - - features - properties: - features: - type: array - items: - type: string - responses: - 204: - description: Features added - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/features/{name}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: name - in: path - schema: - type: string - required: true - delete: - summary: Delete feature - operationId: deleteFeature - security: - - oauth2: - - organization:UpdateFeatures - responses: - 204: - description: Feature deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/client: - get: - summary: Read organization client (DEPRECATED) (until 12/31/2025) - operationId: readOrganizationClient - security: - - oauth2: - - organization:ReadClient - deprecated: true - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 200: - description: Organization client - content: - application/json: - schema: - $ref: "#/components/schemas/CreateClientResponseResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Create organization client (DEPRECATED) (until 12/31/2025) - operationId: createOrganizationClient - security: - - oauth2: - - organization:CreateClient - deprecated: true - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 201: - description: Client organization created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateClientResponseResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete organization client (DEPRECATED) (until 12/31/2025) - operationId: deleteOrganizationClient - security: - - oauth2: - - organization:DeleteClient - deprecated: true - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Client deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/clients: - get: - summary: Read organization clients - operationId: organizationClientsRead - security: - - oauth2: - - organization:ListClients - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: cursor - in: query - schema: - type: string - - name: pageSize - in: query - schema: - type: integer - minimum: 1 - responses: - 200: - description: Organization client - content: - application/json: - schema: - $ref: "#/components/schemas/ReadOrganizationClientsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create organization client - operationId: organizationClientCreate - security: - - oauth2: - - organization:CreateClient - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateOrganizationClientRequest" - responses: - 201: - description: Organization client created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateOrganizationClientResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/clients/{clientId}: - get: - summary: Read organization client - operationId: organizationClientRead - security: - - oauth2: - - organization:ReadClient - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: clientId - in: path - schema: - type: string - required: true - responses: - 200: - description: Organization client details - content: - application/json: - schema: - $ref: "#/components/schemas/ReadOrganizationClientResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete organization client - operationId: organizationClientDelete - security: - - oauth2: - - organization:DeleteClient - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: clientId - in: path - schema: - type: string - required: true - responses: - 204: - description: Client deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update organization client - operationId: organizationClientUpdate - security: - - oauth2: - - organization:UpdateClient - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: clientId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateOrganizationClientRequest" - responses: - 204: - description: Client updated - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/logs: - get: - summary: List logs - operationId: listLogs - security: - - oauth2: - - organization:ReadLogs - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: query - schema: - type: string - required: false - - name: cursor - in: query - schema: - type: string - required: false - - name: pageSize - in: query - schema: - type: integer - minimum: 1 - required: false - - name: action - in: query - schema: - $ref: "#/components/schemas/Action" - required: false - - name: userId - in: query - schema: - type: string - required: false - - name: key - in: query - schema: - type: string - required: false - - name: value - in: query - schema: - type: string - required: false - responses: - 200: - description: Cursor of logs - content: - application/json: - schema: - $ref: "#/components/schemas/LogCursor" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/users: - get: - summary: List users of organization - operationId: listUsersOfOrganization - security: - - oauth2: - - organization:ListUsers - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 200: - description: List of users - content: - application/json: - schema: - $ref: "#/components/schemas/ListUsersResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/users/{userId}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: userId - in: path - schema: - type: string - required: true - get: - summary: Read user of organization - operationId: readUserOfOrganization - security: - - oauth2: - - organization:ReadUser - responses: - 200: - description: Read a user - content: - application/json: - schema: - $ref: "#/components/schemas/ReadOrganizationUserResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update user within an organization - operationId: upsertOrganizationUser - security: - - oauth2: - - organization:UpdateUser - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateOrganizationUserRequest" - responses: - 204: - description: Updated user - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: delete user from organization - operationId: deleteUserFromOrganization - security: - - oauth2: - - organization:DeleteUser - description: | - The owner of the organization can remove anyone while each user can leave any organization where it is not owner. - responses: - 204: - description: User deleted from organization - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/policies: - get: - summary: List policies of organization - operationId: listPolicies - security: - - oauth2: - - organization:ListPolicies - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - responses: - 200: - description: List of policies - content: - application/json: - schema: - $ref: "#/components/schemas/ListPoliciesResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create policy - operationId: createPolicy - security: - - oauth2: - - organization:CreatePolicy - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreatePolicyRequest" - responses: - 201: - description: Policy created - content: - application/json: - schema: - $ref: "#/components/schemas/CreatePolicyResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/policies/{policyId}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: policyId - in: path - schema: - type: integer - required: true - get: - summary: Read policy with scopes - operationId: readPolicy - security: - - oauth2: - - organization:ReadPolicy - responses: - 200: - description: Policy with scopes - content: - application/json: - schema: - $ref: "#/components/schemas/ReadPolicyResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update policy - operationId: updatePolicy - security: - - oauth2: - - organization:UpdatePolicy - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpdatePolicyRequest" - responses: - 200: - description: Policy updated - content: - application/json: - schema: - $ref: "#/components/schemas/UpdatePolicyResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete policy - operationId: deletePolicy - security: - - oauth2: - - organization:DeletePolicy - responses: - 204: - description: Policy deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/policies/{policyId}/scopes/{scopeId}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: policyId - in: path - schema: - type: integer - required: true - - name: scopeId - in: path - schema: - type: integer - required: true - put: - summary: Add scope to policy - operationId: addScopeToPolicy - security: - - oauth2: - - organization:UpdatePolicy - responses: - 204: - description: Scope added to policy - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Remove scope from policy - operationId: removeScopeFromPolicy - security: - - oauth2: - - organization:UpdatePolicy - responses: - 204: - description: Scope removed from policy - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks: - get: - summary: List stacks - operationId: listStacks - security: - - oauth2: - - organization:ListStacks - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: all - in: query - description: Include deleted and disabled stacks - schema: - type: boolean - required: false - - name: deleted - in: query - description: Include deleted stacks - deprecated: true - schema: - type: boolean - required: false - responses: - 200: - description: List of stacks - content: - application/json: - schema: - $ref: "#/components/schemas/ListStacksResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create stack - operationId: createStack - security: - - oauth2: - - organization:CreateStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateStackRequest" - responses: - 202: - description: Stack created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateStackResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/modules: - get: - summary: List modules of a stack - operationId: listModules - security: - - oauth2: - - organization:ListStackModules - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 200: - description: List of modules - content: - application/json: - schema: - $ref: "#/components/schemas/ListModulesResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: enable module - operationId: enableModule - security: - - oauth2: - - organization:EnableStackModulesEnable - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: name - in: query - schema: - type: string - required: true - responses: - 202: - description: Module enabled - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: disable module - operationId: disableModule - security: - - oauth2: - - organization:DisableStackModule - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: name - in: query - schema: - type: string - required: true - responses: - 202: - description: Module disabled - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /organizations/{organizationId}/stacks/{stackId}/upgrade: - put: - summary: Upgrade stack - operationId: upgradeStack - security: - - oauth2: - - organization:UpgradeStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/StackVersion" - responses: - 202: - description: Stack upgrade accepted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}: - get: - summary: Find stack - operationId: getStack - security: - - oauth2: - - organization:ReadStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ReadStackResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update stack - operationId: updateStack - security: - - oauth2: - - organization:UpdateStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateStackRequest" - responses: - 200: - description: Updated stack - content: - application/json: - schema: - $ref: "#/components/schemas/ReadStackResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete stack - operationId: deleteStack - security: - - oauth2: - - organization:DeleteStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: force - in: query - schema: - type: boolean - required: false - responses: - 204: - description: Stack deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/users: - get: - summary: List stack users accesses within an organization - operationId: listStackUsersAccesses - security: - - oauth2: - - organization:ListStackUsers - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 200: - description: Stack users access - content: - application/json: - schema: - $ref: "#/components/schemas/StackUserAccessResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/users/{userId}: - get: - summary: Read stack user access within an organization - operationId: readStackUserAccess - security: - - oauth2: - - organization:ReadStackUser - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: userId - in: path - schema: - type: string - required: true - responses: - 200: - description: Stack user access - content: - application/json: - schema: - $ref: "#/components/schemas/ReadStackUserAccess" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete stack user access within an organization - operationId: deleteStackUserAccess - security: - - oauth2: - - organization:DeleteStackUser - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: userId - in: path - schema: - type: string - required: true - responses: - 204: - description: Updated user - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update stack user access within an organization - operationId: upsertStackUserAccess - security: - - oauth2: - - organization:CreateStackUser - - organization:UpdateStackUser - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - - name: userId - in: path - schema: - type: string - required: true - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateStackUserRequest" - responses: - 204: - description: Updated user - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/disable: - put: - summary: Disable stack - operationId: disableStack - security: - - oauth2: - - organization:DisableStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 202: - description: Stack disabled - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/enable: - put: - summary: Enable stack - operationId: enableStack - security: - - oauth2: - - organization:EnableStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 202: - description: Stack enabled - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/restore: - put: - summary: Restore stack - operationId: restoreStack - security: - - oauth2: - - organization:RestoreStack - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - responses: - 202: - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/ReadStackResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/stargate/enable: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - put: - summary: Enable stargate on a stack - operationId: enableStargate - security: - - oauth2: - - organization:EnableStackModule - responses: - 202: - description: OK - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/stacks/{stackId}/stargate/disable: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: stackId - in: path - schema: - type: string - required: true - put: - summary: Disable stargate on a stack - operationId: disableStargate - security: - - oauth2: - - organization:DisableStackModule - responses: - 202: - description: OK - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /me/invitations: - get: - summary: List invitations of the user - operationId: listInvitations - security: - - oauth2: [] - parameters: - - in: query - name: status - required: false - description: Status of organizations - schema: - type: string - - in: query - name: organization - required: false - description: Status of organizations - schema: - type: string - responses: - 200: - description: List of the invitations for the connected user - content: - application/json: - schema: - $ref: "#/components/schemas/ListInvitationsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /me/invitations/{invitationId}/accept: - post: - summary: Accept invitation - operationId: acceptInvitation - security: - - oauth2: [] - parameters: - - name: invitationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Invitation accepted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /me/invitations/{invitationId}/reject: - post: - summary: Decline invitation - operationId: declineInvitation - security: - - oauth2: [] - parameters: - - name: invitationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Invitation declined - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/invitations: - get: - summary: List invitations of the organization - operationId: listOrganizationInvitations - security: - - oauth2: - - organization:ListInvitations - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - in: query - name: status - required: false - description: Status of organizations - schema: - type: string - responses: - 200: - description: List of the invitations for the organization - content: - application/json: - schema: - $ref: "#/components/schemas/ListInvitationsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create invitation - operationId: createInvitation - security: - - oauth2: - - organization:CreateInvitation - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: email - in: query - schema: - type: string - required: true - responses: - 200: - description: Invitation already sent - content: - application/json: - schema: - $ref: "#/components/schemas/CreateInvitationResponse" - 201: - description: Invitation created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateInvitationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/invitations/{invitationId}: - delete: - summary: Delete invitation - operationId: deleteInvitation - security: - - oauth2: - - organization:DeleteInvitation - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: invitationId - in: path - schema: - type: string - required: true - responses: - 204: - description: Invitation deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/regions: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - get: - summary: List regions - operationId: listRegions - security: - - oauth2: - - organization:ListRegions - responses: - 200: - description: List of the regions - content: - application/json: - schema: - $ref: "#/components/schemas/ListRegionsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create a private region - operationId: createPrivateRegion - security: - - oauth2: - - organization:CreateRegion - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreatePrivateRegionRequest" - responses: - 201: - description: Created region - content: - application/json: - schema: - $ref: "#/components/schemas/CreatedPrivateRegionResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/regions/{regionID}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: regionID - in: path - schema: - type: string - required: true - get: - summary: Get region - operationId: getRegion - security: - - oauth2: - - organization:ReadRegion - responses: - 200: - description: Get a specific region - content: - application/json: - schema: - $ref: "#/components/schemas/GetRegionResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete region - operationId: deleteRegion - security: - - oauth2: - - organization:DeleteRegion - responses: - 204: - description: Delete a specific region - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/regions/{regionID}/versions: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: regionID - in: path - schema: - type: string - required: true - get: - summary: Get region versions - operationId: getRegionVersions - security: - - oauth2: - - organization:ReadRegion - responses: - 200: - description: Get a specific region - content: - application/json: - schema: - $ref: "#/components/schemas/GetRegionVersionsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/applications: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - get: - summary: List applications enabled for organization - operationId: listOrganizationApplications - security: - - oauth2: - - organization:Read - parameters: - - name: pageSize - in: query - schema: - type: integer - default: 15 - required: false - - name: page - in: query - schema: - type: integer - default: 0 - required: false - responses: - 200: - description: List of applications enabled for organization - content: - application/json: - schema: - $ref: "#/components/schemas/ListApplicationsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /organizations/{organizationId}/applications/{applicationId}: - parameters: - - name: organizationId - in: path - schema: - type: string - required: true - - name: applicationId - in: path - schema: - type: string - required: true - get: - summary: Get application for organization - operationId: getOrganizationApplication - security: - - oauth2: - - organization:Read - responses: - 200: - description: Application details - content: - application/json: - schema: - $ref: "#/components/schemas/GetApplicationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Enable application for organization - operationId: enableApplicationForOrganization - security: - - oauth2: - - system:Admin - responses: - 201: - description: Application enabled for organization - content: - application/json: - schema: - $ref: "#/components/schemas/EnableApplicationForOrganizationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Disable application for organization - operationId: disableApplicationForOrganization - security: - - oauth2: - - system:Admin - responses: - 204: - description: Application disabled for organization - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /applications: - get: - summary: List applications - operationId: listApplications - security: - - oauth2: - - system:Admin - responses: - 200: - description: List of applications - content: - application/json: - schema: - $ref: "#/components/schemas/ListApplicationsResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - post: - summary: Create application - operationId: createApplication - security: - - oauth2: - - system:Admin - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/ApplicationData" - responses: - 201: - description: Application created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateApplicationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /applications/{applicationId}: - parameters: - - name: applicationId - in: path - schema: - type: string - required: true - get: - summary: Get application - operationId: getApplication - security: - - oauth2: - - system:Admin - responses: - 200: - description: Application details - content: - application/json: - schema: - $ref: "#/components/schemas/GetApplicationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - put: - summary: Update application - operationId: updateApplication - security: - - oauth2: - - system:Admin - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/ApplicationData" - responses: - 200: - description: Application updated - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateApplicationResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete application - operationId: deleteApplication - security: - - oauth2: - - system:Admin - responses: - 204: - description: Application deleted - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /applications/{applicationId}/scopes: - parameters: - - name: applicationId - in: path - description: The unique identifier of the application (UUID format) - schema: - type: string - format: uuid - required: true - example: "550e8400-e29b-41d4-a716-446655440000" - post: - summary: Create application scope - operationId: createApplicationScope - security: - - oauth2: - - system:Admin - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreateApplicationScopeRequest" - responses: - 201: - description: Application scope created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateApplicationScopeResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /applications/{applicationId}/scopes/{scopeID}: - parameters: - - name: applicationId - in: path - description: The unique identifier of the application (UUID format) - schema: - type: string - format: uuid - required: true - example: "550e8400-e29b-41d4-a716-446655440000" - - name: scopeID - in: path - description: The unique identifier of the scope to operate on - schema: - type: integer - required: true - - delete: - summary: Delete application scope - description: Delete a specific scope from an application. This operation requires system administrator privileges. - operationId: deleteApplicationScope - security: - - oauth2: - - system:Admin - responses: - 204: - description: Application scope deleted successfully - 400: - description: Invalid request (invalid UUID or empty scope ID) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - 404: - description: Application or scope not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - 500: - description: Internal server error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /users: - post: - summary: Create user - description: Create a new user in the system. This operation requires system administrator privileges. - operationId: createUser - security: - - oauth2: - - system:Admin - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateUserRequest" - responses: - 201: - description: User created successfully - content: - application/json: - schema: - $ref: "#/components/schemas/CreateUserResponse" - 400: - description: Invalid request (missing or invalid email) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - 500: - description: Internal server error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /me: - get: - summary: Read user - operationId: readConnectedUser - security: - - oauth2: [] - responses: - 200: - description: Read a user - content: - application/json: - schema: - $ref: "#/components/schemas/ReadUserResponse" - default: - description: Error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" -security: - - oauth2: [] - -components: - securitySchemes: - oauth2: - type: oauth2 - flows: - authorizationCode: - authorizationUrl: /authorize - tokenUrl: /oauth/token - scopes: - # OIDC Standard Scopes - openid: Enable OIDC - profile: Access to user profile information - email: Access to email address - phone: Access to phone number - address: Access to postal address - offline_access: Allows obtaining refresh token - # Extended OIDC Scopes - accesses: Includes organization and stack access information in tokens - keep_refresh_token: Keep refresh token across sessions - remember_me: Enable remember me functionality - on_behalf: Allow to provide id_token usable as id_token_hint for quick reauthentication - # System Scopes - system:Admin: Full system administration access - # Stack Scopes - stack:Read: Read-only access to stack services - stack:Write: Write access to stack services - # Organization Scopes - organization:Read: Read organization details - organization:Create: Create organization - organization:Update: Update organization details - organization:Delete: Delete organization - # User Management Scopes - organization:ListUsers: List organization users - organization:ReadUser: Read user details - organization:CreateUser: Create/Invite users to organization - organization:UpdateUser: Update user details - organization:DeleteUser: Remove users from organization - # Policy Management Scopes - organization:ListPolicies: List organization policies - organization:ReadPolicy: Read policy details - organization:CreatePolicy: Create policies - organization:UpdatePolicy: Update policies and manage their scopes - organization:DeletePolicy: Delete policies - # Invitation Management Scopes - organization:ListInvitations: List organization invitations - organization:ReadInvitation: Read invitation details - organization:CreateInvitation: Create invitations - organization:UpdateInvitation: Update invitations - organization:AcceptInvitation: Accept invitations - organization:RejectInvitation: Reject invitations - organization:DeleteInvitation: Delete invitations - # Region Management Scopes - organization:ListRegions: List organization regions - organization:ReadRegion: Read region details - organization:CreateRegion: Create regions - organization:UpdateRegion: Update regions - organization:DeleteRegion: Delete regions - # Stack Management Scopes - organization:ListStacks: List organization stacks - organization:ReadStack: Read stack details - organization:CreateStack: Create stacks - organization:UpdateStack: Update stacks - organization:DeleteStack: Delete stacks - organization:EnableStack: Enable stacks - organization:DisableStack: Disable stacks - organization:RestoreStack: Restore stacks - organization:UpgradeStack: Upgrade stacks - # Stack Users Management Scopes - organization:ListStackUsers: List stack users - organization:ReadStackUser: Read stack user details - organization:CreateStackUser: Add users to stack - organization:UpdateStackUser: Update stack user access - organization:DeleteStackUser: Remove users from stack - # Stack Modules Management Scopes - organization:ListStackModules: List stack modules - organization:EnableStackModule: Enable stack modules - organization:DisableStackModule: Disable stack modules - # Client Management Scopes - organization:ListClients: List organization clients - organization:ReadClient: Read client details - organization:CreateClient: Create clients - organization:UpdateClient: Update clients - organization:DeleteClient: Delete clients - # Authentication Provider Scopes - organization:ReadAuthProvider: Read authentication provider configuration - organization:UpdateAuthProvider: Update authentication provider configuration - organization:DeleteAuthProvider: Delete authentication provider configuration - # Logs Scopes - organization:ReadLogs: Read organization logs - # Features Scopes - organization:ListFeatures: List organization features - organization:ReadFeature: Read feature details - organization:UpdateFeatures: Update organization features - schemas: - Module: - type: object - required: - - name - - state - - status - - lastStatusUpdate - - lastStateUpdate - properties: - name: - type: string - state: - type: string - enum: - - ENABLED - - DISABLED - status: - type: string - enum: - - UNKNOWN - - PROGRESSING - - READY - - DELETED - lastStatusUpdate: - type: string - format: date-time - lastStateUpdate: - type: string - format: date-time - clusterStatus: - type: object - - ListModulesResponse: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Module" - Action: - type: string - enum: - - agents.connected - - agents.disconnected - - invitations.created - - invitations.accepted - - invitations.rejected - - invitations.cancelled - - organizations.created - - organizations.updated - - organizations.deleted - - organizations.user.deleted - - organizations.user.updated - - regions.created - - regions.deleted - - users.created - - users.deleted - - stacks.disposal - - stacks.disposal-reset - - stacks.warned - - stacks.pruned - - stacks.status.updated - - stacks.created - - stacks.updated - - stacks.deleted - - stacks.restored - - stacks.disabled - - stacks.enabled - - stacks.upgraded - - stacks.stargate.enabled - - stacks.stargate.disabled - - stacks.user.updated - - stacks.user.deleted - - stacks.reachness.updated - - stacks.module.enabled - - stacks.module.disabled - - stacks.module.status.updated - - policies.created - - policies.updated - - policies.deleted - - policies.scope.added - - policies.scope.removed - LogCursor: - properties: - data: - allOf: - - $ref: "#/components/schemas/Cursor" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Log" - required: - - data - Log: - type: object - required: - - seq - - organizationId - - userId - - action - - date - - data - properties: - seq: - type: string - example: 1e6f8b6e-1b6b-4b3b-8b3b-1b6b4b3b8b3b - organizationId: - type: string - userId: - type: string - action: - type: string - date: - type: string - format: date-time - data: - type: object - Cursor: - type: object - required: - - pageSize - - hasMore - - data - properties: - pageSize: - type: integer - format: int64 - minimum: 1 - maximum: 1000 - example: 15 - hasMore: - type: boolean - example: false - previous: - type: string - example: YXVsdCBhbmQgYSBtYXhpbXVtIG1heF9yZXN1bHRzLol= - next: - type: string - example: "" - OrganizationFeature: - type: object - required: - - name - - organizationID - - createdAt - properties: - organizationID: - type: string - name: - type: string - createdAt: - type: string - format: date-time - CreatePrivateRegionRequest: - type: object - properties: - name: - type: string - required: - - name - CreatedPrivateRegionResponse: - properties: - data: - $ref: "#/components/schemas/PrivateRegion" - required: - - data - PrivateRegion: - allOf: - - $ref: "#/components/schemas/Region" - - type: object - properties: - organizationID: - type: string - creatorID: - type: string - secret: - $ref: "#/components/schemas/PrivateRegionSecret" - required: - - organizationID - - creatorID - PublicRegion: - allOf: - - $ref: "#/components/schemas/Region" - - type: object - properties: - production: - type: boolean - required: - - production - AnyRegion: - allOf: - - $ref: "#/components/schemas/Region" - - type: object - properties: - clientID: - type: string - organizationID: - type: string - creator: - $ref: "#/components/schemas/User" - production: - type: boolean - public: - type: boolean - version: - type: string - agentID: - type: string - outdated: - type: boolean - secret: - $ref: "#/components/schemas/PrivateRegionSecret" - - required: - - public - - agentID - - outdated - PrivateRegionSecret: - type: object - required: - - lastDigits - properties: - lastDigits: - type: string - clear: - type: string - OrganizationData: - type: object - required: - - name - - defaultPolicyID - properties: - name: - type: string - description: Organization name - domain: - type: string - description: Organization domain - defaultPolicyID: - type: integer - description: Default policy ID applied to new users - nullable: true - CreateOrganizationClientRequest: - type: object - properties: - name: - type: string - description: Name of the client - description: - type: string - description: Description of the client - UpdateOrganizationClientRequest: - type: object - required: - - name - properties: - name: - type: string - description: Name of the client - description: - type: string - description: Description of the client - CreateOrganizationClientResponse: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/OrganizationClient" - ReadOrganizationClientResponse: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/OrganizationClient" - ReadOrganizationClientsResponse: - type: object - required: - - data - properties: - data: - allOf: - - $ref: "#/components/schemas/Cursor" - - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/OrganizationClient" - OrganizationClient: - type: object - required: - - id - - secret - - createdAt - - updatedAt - - description - - name - properties: - id: - type: string - description: Organization ID - secret: - type: object - required: - - lastDigits - properties: - lastDigits: - type: string - clear: - type: string - name: - type: string - description: Name of the client - description: - type: string - description: Description of the client - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - Organization: - allOf: - - $ref: "#/components/schemas/OrganizationData" - - type: object - required: - - id - - ownerId - properties: - id: - type: string - description: Organization ID - ownerId: - type: string - description: Owner ID - availableStacks: - type: integer - description: Number of available stacks - availableSandboxes: - type: integer - description: Number of available sandboxes - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - StackData: - type: object - required: - - name - properties: - name: - type: string - description: Stack name - metadata: - type: object - additionalProperties: - type: string - StackVersion: - type: object - properties: - version: - type: string - description: Supported only with agent version >= v0.7.0 - StackLifeCycle: - type: object - required: - - status - - state - - expectedStatus - - lastStateUpdate - - lastExpectedStatusUpdate - - lastStatusUpdate - properties: - status: - type: string - enum: - - UNKNOWN - - PROGRESSING - - READY - - DISABLED - - DELETED - state: - type: string - enum: - - ACTIVE - - DISABLED - - DELETED - expectedStatus: - type: string - enum: - - READY - - DISABLED - - DELETED - lastStateUpdate: - type: string - format: date-time - lastExpectedStatusUpdate: - type: string - format: date-time - lastStatusUpdate: - type: string - format: date-time - warnedAt: - type: string - format: date-time - disposableSince: - type: string - format: date-time - - StackReachability: - properties: - reachable: - description: Stack is reachable through Stargate - type: boolean - lastReachableUpdate: - description: Last time the stack was reachable - format: date-time - type: string - required: - - reachable - type: object - Stack: - allOf: - - $ref: "#/components/schemas/StackData" - - $ref: "#/components/schemas/StackVersion" - - $ref: "#/components/schemas/StackLifeCycle" - - $ref: "#/components/schemas/StackReachability" - - type: object - required: - - id - - organizationId - - uri - - regionID - - stargateEnabled - - synchronised - - modules - properties: - id: - type: string - description: Stack ID - organizationId: - type: string - description: Organization ID - uri: - type: string - description: Base stack uri - regionID: - type: string - description: The region where the stack is installed - region: - $ref: "#/components/schemas/Region" - stargateEnabled: - type: boolean - createdAt: - type: string - format: date-time - deletedAt: - type: string - format: date-time - disabledAt: - type: string - format: date-time - auditEnabled: - type: boolean - synchronised: - type: boolean - updatedAt: - type: string - format: date-time - modules: - type: array - items: - $ref: "#/components/schemas/Module" - - UserData: - allOf: - - type: object - properties: - email: - type: string - required: - - email - SystemRole: - type: string - enum: - - USER - - SYSTEM - User: - allOf: - - $ref: "#/components/schemas/UserData" - - type: object - required: - - id - properties: - id: - type: string - description: User ID - - type: object - properties: - role: - $ref: "#/components/schemas/SystemRole" - CreateUserRequest: - type: object - required: - - email - properties: - email: - type: string - format: email - description: Email address of the user to create - example: "user@example.com" - CreateUserResponse: - type: object - properties: - data: - $ref: "#/components/schemas/User" - OrganizationUserArray: - type: array - items: - allOf: - - $ref: "#/components/schemas/OrganizationUser" - ReadOrganizationUserResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationUser" - OrganizationUser: - allOf: - - type: object - required: - - email - - id - - policyId - properties: - email: - type: string - id: - type: string - policyId: - type: integer - description: Policy ID applied to the user - - OrganizationArray: - type: array - items: - $ref: "#/components/schemas/Organization" - StackArray: - type: array - items: - $ref: "#/components/schemas/Stack" - UserArray: - type: array - items: - $ref: "#/components/schemas/User" - ReadStackUserAccess: - type: object - properties: - data: - $ref: "#/components/schemas/StackUserAccess" - StackUserAccess: - allOf: - - type: object - required: - - stackId - - userId - - email - - policyId - properties: - stackId: - type: string - description: Stack ID - userId: - type: string - description: User ID - email: - type: string - description: User email - policyId: - type: integer - description: Policy ID applied to the user for the stack - StackUserAccessResponse: - type: object - properties: - data: - $ref: "#/components/schemas/StackUserAccessArray" - StackUserAccessArray: - type: array - items: - $ref: "#/components/schemas/StackUserAccess" - ListOrganizationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationArray" - CreateOrganizationRequest: - type: object - required: - - name - properties: - name: - type: string - description: Organization name - domain: - type: string - description: Organization domain - defaultPolicyID: - type: integer - description: Default policy ID applied to new users - ownerID: - type: string - description: User ID of the owner of the organization (only when used with a non user agent) - CreateOrganizationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationExpanded" - ReadOrganizationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationExpanded" - ListStacksResponse: - type: object - properties: - data: - $ref: "#/components/schemas/StackArray" - ListUsersResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationUserArray" - CreateStackRequest: - allOf: - - $ref: "#/components/schemas/StackData" - - $ref: "#/components/schemas/StackVersion" - - type: object - properties: - regionID: - type: string - required: - - regionID - UpdateStackRequest: - allOf: - - $ref: "#/components/schemas/StackData" - CreateStackResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Stack" - ReadStackResponse: - $ref: "#/components/schemas/CreateStackResponse" - ReadUserResponse: - type: object - properties: - data: - $ref: "#/components/schemas/User" - ListInvitationsResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Invitation" - CreateInvitationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Invitation" - Error: - type: object - properties: - errorCode: - type: string - errorMessage: - type: string - required: - - errorCode - Invitation: - type: object - properties: - id: - type: string - organizationId: - type: string - userEmail: - type: string - status: - type: string - enum: - - PENDING - - ACCEPTED - - REJECTED - - CANCELLED - creationDate: - type: string - format: date-time - updatedAt: - type: string - format: date-tim - userId: - type: string - organizationAccess: - $ref: "#/components/schemas/OrganizationUser" - expiresAt: - type: string - format: date-time - creatorId: - type: string - lastUpdate: - type: string - format: date-time - required: - - creationDate - - status - - creatorID - - userEmail - - organizationId - - id - - policyId - Region: - type: object - required: - - id - - baseUrl - - active - - createdAt - - name - - capabilities - - agentID - - outdated - properties: - id: - type: string - baseUrl: - type: string - createdAt: - type: string - format: datetime - active: - type: boolean - lastPing: - type: string - format: date-time - name: - type: string - capabilities: - $ref: "#/components/schemas/RegionCapability" - agentID: - type: string - outdated: - type: boolean - creatorId: - type: string - version: - type: string - - RegionCapability: - type: object - properties: - MODULE_LIST: - type: array - items: - type: string - EE: - type: array - items: - type: string - RegionCapabilityKeys: - type: string - enum: - - MODULE_LIST - - EE - ListOrganizationExpandedResponse: - type: object - properties: - data: - type: array - items: - allOf: - - $ref: "#/components/schemas/OrganizationExpanded" - CreateClientResponseResponse: - type: object - properties: - data: - allOf: - - $ref: "#/components/schemas/OrganizationClient" - - type: object - properties: - ownerId: - type: string - description: The ID of the owner of the client (DEPRECATED will be empty) - required: - - ownerId - OrganizationExpanded: - allOf: - - $ref: "#/components/schemas/Organization" - - type: object - properties: - totalStacks: - type: integer - totalUsers: - type: integer - owner: - $ref: "#/components/schemas/User" - ListRegionsResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/AnyRegion" - required: - - data - GetRegionResponse: - type: object - properties: - data: - $ref: "#/components/schemas/AnyRegion" - required: - - data - - ServerInfo: - type: object - required: - - version - properties: - version: - type: string - capabilities: - type: array - items: - allOf: - - $ref: "#/components/schemas/Capability" - consoleURL: - type: string - Capability: - type: string - enum: - - MODULE_SELECTION - Version: - type: object - required: - - name - - versions - - regionID - properties: - name: - type: string - versions: - type: object - additionalProperties: - type: string - regionID: - type: string - deprecated: - type: boolean - description: Indicates if this version is deprecated and should not be used - UpdateOrganizationUserRequest: - type: object - properties: - policyId: - type: integer - description: Policy ID applied to the user - UpdateStackUserRequest: - type: object - required: - - policyId - properties: - policyId: - type: integer - - GetRegionVersionsResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Version" - required: - - data - - AuthenticationProviderData: - type: object - oneOf: - - $ref: '#/components/schemas/GoogleIDPConfig' - - $ref: '#/components/schemas/MicrosoftIDPConfig' - - $ref: '#/components/schemas/GithubIDPConfig' - - $ref: '#/components/schemas/OIDCConfig' - - BaseProviderConfig: - type: object - required: - - type - - name - - clientID - - clientSecret - properties: - type: - type: string - enum: - - oidc - - google - - github - - microsoft - description: Type of the authentication provider - name: - type: string - description: Name of the authentication provider - clientID: - type: string - description: Client ID for the authentication provider - clientSecret: - type: string - description: Client secret for the authentication provider - - AuthenticationProvider: - type: object - allOf: - - $ref: '#/components/schemas/AuthenticationProviderData' - - type: object - properties: - organizationId: - type: string - description: Unique identifier for the authentication provider - createdAt: - type: string - format: date-time - description: Creation date of the authentication provider - updatedAt: - type: string - format: date-time - description: Last update date of the authentication provider - required: - - organizationId - - createdAt - - updatedAt - - GoogleIDPConfig: - allOf: - - $ref: '#/components/schemas/BaseProviderConfig' - - type: object - required: - - config - properties: - config: - type: object - additionalProperties: false - - MicrosoftIDPConfig: - allOf: - - $ref: '#/components/schemas/BaseProviderConfig' - - type: object - required: - - config - properties: - config: - type: object - properties: - tenant: - type: string - description: Tenant ID for Microsoft authentication - default: 'common' - - GithubIDPConfig: - allOf: - - $ref: '#/components/schemas/BaseProviderConfig' - - type: object - required: - - config - properties: - config: - type: object - additionalProperties: false - - OIDCConfig: - allOf: - - $ref: '#/components/schemas/BaseProviderConfig' - - type: object - required: - - config - properties: - config: - type: object - required: - - issuer - properties: - issuer: - type: string - description: OIDC issuer URL - discoveryPath: - type: string - default: '/.well-known/openid-configuration' - - UpsertAuthenticationProviderRequest: - $ref: "#/components/schemas/AuthenticationProviderData" - - AuthenticationProviderResponse: - type: object - properties: - data: - allOf: - - $ref: "#/components/schemas/AuthenticationProvider" - - type: object - properties: - redirectURI: - type: string - required: - - redirectURI - - PolicyData: - type: object - required: - - name - properties: - name: - type: string - description: Policy name - description: - type: string - description: Policy description - - Policy: - allOf: - - $ref: "#/components/schemas/PolicyData" - - type: object - required: - - id - - protected - - createdAt - - updatedAt - properties: - id: - type: integer - description: Policy ID - organizationId: - type: string - description: Organization ID - protected: - type: boolean - description: Whether the policy is protected from modification - scopes: - type: array - items: - $ref: "#/components/schemas/Scope" - description: List of scopes assigned to the policy (only present when fetched via GET endpoint) - createdAt: - type: string - format: date-time - description: Creation date - updatedAt: - type: string - format: date-time - description: Last update date - - CreatePolicyRequest: - $ref: "#/components/schemas/PolicyData" - - CreatePolicyResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Policy" - - ReadPolicyResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Policy" - - UpdatePolicyRequest: - $ref: "#/components/schemas/PolicyData" - - UpdatePolicyResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Policy" - - ListPoliciesResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Policy" - - ApplicationData: - type: object - required: - - name - - url - - alias - properties: - name: - type: string - description: Application name - description: - type: string - description: Application description - url: - type: string - format: uri - description: Application URL (must be unique) - alias: - type: string - description: Application alias - - Application: - allOf: - - $ref: "#/components/schemas/ApplicationData" - - type: object - required: - - id - - createdAt - - updatedAt - properties: - id: - type: string - description: Application ID - createdAt: - type: string - format: date-time - description: Creation date - updatedAt: - type: string - format: date-time - description: Last update date - ApplicationWithScope: - allOf: - - $ref: "#/components/schemas/Application" - - type: object - required: - - scopes - properties: - scopes: - type: array - description: List of scopes associated with this application - items: - $ref: "#/components/schemas/Scope" - - ListApplicationsResponse: - type: object - properties: - cursor: - allOf: - - $ref: "#/components/schemas/Cursor" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Application" - required: - - data - - CreateApplicationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Application" - - GetApplicationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/ApplicationWithScope" - - UpdateApplicationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Application" - - CreateApplicationScopeRequest: - type: object - required: - - label - properties: - description: - type: string - description: Scope description - label: - type: string - description: The OAuth2 scope label (e.g., "custom:read") - - CreateApplicationScopeResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Scope" - - UpdateApplicationScopeRequest: - type: object - properties: - description: - type: string - description: Scope description - - UpdateApplicationScopeResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Scope" - - Scope: - type: object - required: - - id - - label - - protected - - createdAt - - updatedAt - properties: - id: - type: integer - description: Scope ID - label: - type: string - description: The OAuth2 scope label (e.g., "custom:read") - description: - type: string - description: Scope description - applicationId: - type: string - description: Application ID (null for global scopes) - protected: - type: boolean - description: Whether the scope is protected - default: false - createdAt: - type: string - format: date-time - description: Creation timestamp - updatedAt: - type: string - format: date-time - description: Last update timestamp - - OrganizationApplication: - type: object - required: - - organizationId - - applicationId - - createdAt - properties: - organizationId: - type: string - description: Organization ID - applicationId: - type: string - description: Application ID - createdAt: - type: string - format: date-time - description: Creation date - - EnableApplicationForOrganizationResponse: - type: object - properties: - data: - $ref: "#/components/schemas/OrganizationApplication" diff --git a/openapi/schemas/application.json b/openapi/schemas/application.json deleted file mode 100644 index 23f9b078..00000000 --- a/openapi/schemas/application.json +++ /dev/null @@ -1,350 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema", - "required": [ - "stack" - ], - "definitions": { - "DotSelf": { - "type": "object" - }, - "Ledger": { - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "schema": { - "additionalProperties": { - "$ref": "#/definitions/V2SchemaData" - }, - "type": [ - "object", - "null" - ] - } - }, - "type": "object" - }, - "Ledgers": { - "items": { - "$ref": "#/definitions/Ledger" - }, - "type": [ - "null", - "array" - ] - }, - "Payments": { - "properties": { - "connectors": { - "items": { - "required": [ - "name", - "provider" - ], - "properties": { - "configuration": { - "additionalProperties": {}, - "type": "object" - }, - "credentials": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - }, - "pools": { - "additionalProperties": { - "$ref": "#/definitions/Pool" - }, - "type": "object" - } - }, - "type": "object" - }, - "Pool": { - "properties": { - "accountIds": { - "items": { - "type": "string" - }, - "type": "array" - }, - "query": { - "additionalProperties": {}, - "type": "object" - } - }, - "type": "object" - }, - "Reconciliation": { - "properties": { - "policies": { - "items": { - "$ref": "#/definitions/ReconciliationPolicy" - }, - "type": "array" - } - }, - "type": "object" - }, - "ReconciliationLedger": { - "required": [ - "name", - "query" - ], - "properties": { - "name": { - "type": "string" - }, - "query": { - "additionalProperties": {}, - "type": [ - "object", - "null" - ] - } - }, - "type": "object" - }, - "ReconciliationPolicy": { - "required": [ - "name", - "ledger", - "pool" - ], - "properties": { - "ledger": { - "$ref": "#/definitions/ReconciliationLedger" - }, - "name": { - "type": "string" - }, - "pool": { - "type": "string" - } - }, - "type": "object" - }, - "RegionSelector": { - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "type": "object" - }, - "Stack": { - "required": [ - "name", - "region" - ], - "properties": { - "name": { - "type": "string" - }, - "region": { - "$ref": "#/definitions/RegionSelector" - }, - "version": { - "type": "string" - } - }, - "type": "object" - }, - "V2ChartAccountMetadata": { - "properties": { - "default": { - "type": [ - "null", - "string" - ] - } - }, - "type": "object" - }, - "V2ChartAccountRules": { - "type": "object" - }, - "V2ChartSegment": { - "additionalProperties": true, - "properties": { - ".metadata": { - "additionalProperties": { - "$ref": "#/definitions/V2ChartAccountMetadata" - }, - "type": "object" - }, - ".pattern": { - "type": [ - "null", - "string" - ] - }, - ".rules": { - "$ref": "#/definitions/V2ChartAccountRules" - }, - ".self": { - "$ref": "#/definitions/DotSelf" - } - }, - "type": "object" - }, - "V2QueryTemplate": { - "properties": { - "body": { - "additionalProperties": {}, - "type": "object" - }, - "description": { - "type": [ - "null", - "string" - ] - }, - "params": { - "additionalProperties": {}, - "type": "object" - }, - "resource": { - "type": [ - "null", - "string" - ] - }, - "vars": { - "additionalProperties": { - "$ref": "#/definitions/V2QueryTemplateVar" - }, - "type": "object" - } - }, - "type": "object" - }, - "V2QueryTemplateVar": { - "properties": { - "default": {}, - "type": { - "type": "string" - } - }, - "type": "object" - }, - "V2SchemaData": { - "properties": { - "chart": { - "additionalProperties": { - "$ref": "#/definitions/V2ChartSegment" - }, - "type": [ - "object", - "null" - ] - }, - "queries": { - "additionalProperties": { - "$ref": "#/definitions/V2QueryTemplate" - }, - "type": "object" - }, - "transactions": { - "additionalProperties": { - "$ref": "#/definitions/V2TransactionTemplate" - }, - "type": "object" - } - }, - "type": "object" - }, - "V2TransactionTemplate": { - "properties": { - "description": { - "type": [ - "null", - "string" - ] - }, - "runtime": { - "type": [ - "null", - "string" - ] - }, - "script": { - "type": [ - "null", - "string" - ] - } - }, - "type": "object" - }, - "Webhook": { - "required": [ - "name", - "endpoint" - ], - "properties": { - "endpoint": { - "type": "string" - }, - "events": { - "items": { - "type": "string" - }, - "type": "array" - }, - "name": { - "type": "string" - }, - "secret": { - "type": "string" - } - }, - "type": "object" - }, - "Webhooks": { - "items": { - "$ref": "#/definitions/Webhook" - }, - "type": [ - "null", - "array" - ] - } - }, - "properties": { - "ledgers": { - "$ref": "#/definitions/Ledgers" - }, - "payments": { - "$ref": "#/definitions/Payments" - }, - "reconciliation": { - "$ref": "#/definitions/Reconciliation" - }, - "stack": { - "$ref": "#/definitions/Stack" - }, - "webhooks": { - "$ref": "#/definitions/Webhooks" - } - }, - "type": "object" -} \ No newline at end of file diff --git a/pkg/approval.go b/pkg/approval.go deleted file mode 100644 index dd012e9d..00000000 --- a/pkg/approval.go +++ /dev/null @@ -1,77 +0,0 @@ -package fctl - -import ( - "context" - "fmt" - "strings" - - "github.com/pkg/errors" - "github.com/pterm/pterm" - "github.com/spf13/cobra" -) - -var ErrMissingApproval = errors.New("Missing approval.") - -var interactiveContinue = pterm.InteractiveContinuePrinter{ - DefaultValueIndex: 0, - DefaultText: "Do you want to continue", - TextStyle: &pterm.ThemeDefault.PrimaryStyle, - Options: []string{"y", "n"}, - OptionsStyle: &pterm.ThemeDefault.SuccessMessageStyle, - SuffixStyle: &pterm.ThemeDefault.SecondaryStyle, -} - -const ( - confirmFlag = "confirm" -) - -type approvalPromptKey struct{} - -// WithApprovalPrompt replaces the interactive approval prompt for commands using ctx. -// The prompt returns the user's answer; --confirm still bypasses it. -func WithApprovalPrompt(ctx context.Context, prompt func(string) (string, error)) context.Context { - return context.WithValue(ctx, approvalPromptKey{}, prompt) -} - -func showApprovalPrompt(cmd *cobra.Command, disclaimer string) (string, error) { - text := disclaimer + ".\r\n" + pterm.DefaultInteractiveContinue.DefaultText - if ctx := cmd.Context(); ctx != nil { - if prompt, ok := ctx.Value(approvalPromptKey{}).(func(string) (string, error)); ok && prompt != nil { - return prompt(text) - } - } - return interactiveContinue.WithDefaultText(text).Show() -} - -func NeedConfirm(cmd *cobra.Command) bool { - if GetBool(cmd, confirmFlag) { - return false - } - return true -} - -func CheckStackApprobation(cmd *cobra.Command, disclaimer string, args ...any) bool { - if GetBool(cmd, confirmFlag) { - return true - } - - disclaimer = fmt.Sprintf(disclaimer, args...) - - result, err := showApprovalPrompt(cmd, disclaimer) - if err != nil { - panic(err) - } - return strings.ToLower(result) == "y" -} - -func CheckOrganizationApprobation(cmd *cobra.Command, disclaimer string, args ...any) bool { - if GetBool(cmd, confirmFlag) { - return true - } - - result, err := showApprovalPrompt(cmd, disclaimer) - if err != nil { - panic(err) - } - return strings.ToLower(result) == "y" -} diff --git a/pkg/approval_test.go b/pkg/approval_test.go deleted file mode 100644 index 4689d34a..00000000 --- a/pkg/approval_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package fctl - -import ( - "context" - "errors" - "testing" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" -) - -func TestApprobation(t *testing.T) { - checks := map[string]func(*cobra.Command, string, ...any) bool{ - "stack": CheckStackApprobation, - "organization": CheckOrganizationApprobation, - } - for name, check := range checks { - t.Run(name, func(t *testing.T) { - for _, tc := range []struct { - name string - args []string - answer string - approved bool - prompts int - }{ - {name: "accept", answer: "y", approved: true, prompts: 1}, - {name: "accept uppercase", answer: "Y", approved: true, prompts: 1}, - {name: "refuse", answer: "n", prompts: 1}, - {name: "refuse uppercase", answer: "N", prompts: 1}, - {name: "empty answer", prompts: 1}, - {name: "unexpected answer", answer: "yes", prompts: 1}, - {name: "confirm bypasses prompt", args: []string{"--confirm"}, approved: true}, - {name: "explicit false still prompts", args: []string{"--confirm=false"}, answer: "n", prompts: 1}, - } { - t.Run(tc.name, func(t *testing.T) { - cmd := NewCommand("action", WithConfirmFlag()) - require.NoError(t, cmd.ParseFlags(tc.args)) - prompts := 0 - cmd.SetContext(WithApprovalPrompt(context.Background(), func(text string) (string, error) { - prompts++ - require.Equal(t, "Disable stack.\r\n"+pterm.DefaultInteractiveContinue.DefaultText, text) - return tc.answer, nil - })) - - require.Equal(t, tc.prompts != 0, NeedConfirm(cmd)) - require.Equal(t, tc.approved, check(cmd, "Disable stack")) - require.Equal(t, tc.prompts, prompts) - }) - } - }) - } -} - -func TestStackApprobationFormatsDisclaimer(t *testing.T) { - cmd := NewCommand("action", WithConfirmFlag()) - cmd.SetContext(WithApprovalPrompt(context.Background(), func(text string) (string, error) { - require.Equal(t, "Disable stack 'sandbox'.\r\n"+pterm.DefaultInteractiveContinue.DefaultText, text) - return "n", nil - })) - require.False(t, CheckStackApprobation(cmd, "Disable stack '%s'", "sandbox")) -} - -func TestApprobationPromptError(t *testing.T) { - for name, check := range map[string]func(*cobra.Command, string, ...any) bool{ - "stack": CheckStackApprobation, - "organization": CheckOrganizationApprobation, - } { - t.Run(name, func(t *testing.T) { - cmd := NewCommand("action", WithConfirmFlag()) - errPrompt := errors.New("terminal unavailable") - cmd.SetContext(WithApprovalPrompt(context.Background(), func(string) (string, error) { - return "y", errPrompt - })) - require.PanicsWithValue(t, errPrompt, func() { check(cmd, "Disable stack") }) - }) - } -} diff --git a/pkg/authentication.go b/pkg/authentication.go deleted file mode 100644 index 088effc3..00000000 --- a/pkg/authentication.go +++ /dev/null @@ -1,441 +0,0 @@ -package fctl - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/oauth2" - - "github.com/formancehq/go-libs/v4/collectionutils" - "github.com/formancehq/go-libs/v4/oidc" - "github.com/formancehq/go-libs/v4/oidc/client" - "github.com/formancehq/go-libs/v4/time" -) - -var ( - OrganizationScopes = []string{ - "organization:Read", - "organization:Create", - "organization:Update", - "organization:Delete", - "organization:ListUsers", - "organization:ReadUser", - "organization:CreateUser", - "organization:UpdateUser", - "organization:DeleteUser", - "organization:ListPolicies", - "organization:ReadPolicy", - "organization:CreatePolicy", - "organization:UpdatePolicy", - "organization:DeletePolicy", - "organization:ListInvitations", - "organization:ReadInvitation", - "organization:CreateInvitation", - "organization:UpdateInvitation", - "organization:AcceptInvitation", - "organization:RejectInvitation", - "organization:DeleteInvitation", - "organization:ListRegions", - "organization:ReadRegion", - "organization:CreateRegion", - "organization:UpdateRegion", - "organization:DeleteRegion", - "organization:ListStacks", - "organization:ReadStack", - "organization:CreateStack", - "organization:UpdateStack", - "organization:DeleteStack", - "organization:EnableStack", - "organization:DisableStack", - "organization:RestoreStack", - "organization:UpgradeStack", - "organization:ListStackUsers", - "organization:ReadStackUser", - "organization:CreateStackUser", - "organization:UpdateStackUser", - "organization:DeleteStackUser", - "organization:ListStackModules", - "organization:EnableStackModule", - "organization:DisableStackModule", - "organization:ListClients", - "organization:ReadClient", - "organization:CreateClient", - "organization:UpdateClient", - "organization:DeleteClient", - "organization:ReadAuthProvider", - "organization:UpdateAuthProvider", - "organization:DeleteAuthProvider", - "organization:ReadLogs", - "organization:ListFeatures", - "organization:ReadFeature", - } - StackScopes = []string{ - "stack:Read", - "stack:Write", - } -) - -type AuthenticationOption func(url.Values) - -func AuthenticateWithScopes(scopes ...string) AuthenticationOption { - return func(values url.Values) { - values.Set("scope", strings.Join(scopes, " ")) - } -} - -func AuthenticateWithIDTokenHint(idToken string) AuthenticationOption { - return func(values url.Values) { - values.Set("id_token_hint", idToken) - } -} - -func AuthenticateWithPrompt(prompt ...string) AuthenticationOption { - return func(values url.Values) { - values.Set("prompt", strings.Join(prompt, " ")) - } -} - -func AuthenticateWithOrganizationID(organization string) AuthenticationOption { - return func(values url.Values) { - values.Set("organization_id", organization) - } -} - -func AuthenticateWithResource(resource string) AuthenticationOption { - return func(values url.Values) { - values.Set("resource", resource) - } -} - -type TokenOption func(url.Values) - -func RequestResource(resource string) TokenOption { - return func(values url.Values) { - values.Set("resource", resource) - } -} - -func initiateDeviceAuthorizationFlow(relyingParty client.RelyingParty, options ...AuthenticationOption) (*oidc.DeviceAuthorizationResponse, error) { - form := url.Values{} - for _, option := range options { - option(form) - } - - body := strings.NewReader(form.Encode()) - req, err := http.NewRequest(http.MethodPost, relyingParty.GetDeviceAuthorizationEndpoint(), body) - if err != nil { - return nil, err - } - req.SetBasicAuth(AuthClient, "") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - httpResponse, err := relyingParty.HttpClient().Do(req) - if err != nil { - return nil, err - } - defer func() { - _ = httpResponse.Body.Close() - }() - - oidcResponse := oidc.DeviceAuthorizationResponse{} - if err := json.NewDecoder(httpResponse.Body).Decode(&oidcResponse); err != nil { - return nil, err - } - - return &oidcResponse, nil -} - -func Authenticate( - ctx context.Context, - relyingParty client.RelyingParty, - dialog Dialog, - authenticationOptions []AuthenticationOption, - tokenOptions []TokenOption, -) (*Tokens, error) { - - deviceCode, err := initiateDeviceAuthorizationFlow(relyingParty, authenticationOptions...) - if err != nil { - return nil, fmt.Errorf("failed to initiate device authorization flow: %w", err) - } - - uri, err := url.Parse(deviceCode.VerificationURI) - if err != nil { - panic(err) - } - query := uri.Query() - query.Set("user_code", deviceCode.UserCode) - uri.RawQuery = query.Encode() - - if err := OpenURL(uri.String()); err != nil { - if !errors.Is(err, ErrOpeningBrowser) { - return nil, err - } - - dialog.Info("No browser detected") - dialog.Info("Please open the following URL in your browser: %s", uri.String()) - } else { - dialog.Info("A browser window has been opened on %s", uri.String()) - } - dialog.Info("Waiting for authentication...") - - rsp, err := client.DeviceAccessToken[*IDTokenClaims]( - ctx, - deviceCode.DeviceCode, - time.Duration(deviceCode.Interval)*time.Second, - relyingParty, - collectionutils.Map(tokenOptions, func(option TokenOption) func(url.Values) { - return option - })..., - ) - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - - idTokenClaims := &IDTokenClaims{} - if _, err := oidc.ParseToken(rsp.IDToken, idTokenClaims); err != nil { - return nil, fmt.Errorf("failed to parse id token: %w", err) - } - - accessTokenClaims := AccessTokenClaims{} - if _, err := oidc.ParseToken(rsp.AccessToken, &accessTokenClaims); err != nil { - return nil, fmt.Errorf("failed to parse access token: %w", err) - } - - return &Tokens{ - Access: AccessToken{ - TokenWithClaims: TokenWithClaims[AccessTokenClaims]{ - Token: rsp.AccessToken, - Claims: accessTokenClaims, - }, - Refresh: rsp.RefreshToken, - }, - ID: IDToken{ - Token: rsp.IDToken, - Claims: *idTokenClaims, - }, - }, nil -} - -func Refresh(ctx context.Context, relyingParty client.RelyingParty, token AccessToken) (*AccessToken, error) { - newToken, err := client.RefreshTokens[*IDTokenClaims](ctx, relyingParty, token.Refresh, "", "") - if err != nil { - return nil, newErrInvalidAuthentication(err) - } - - claims := AccessTokenClaims{} - _, err = oidc.ParseToken(newToken.AccessToken, &claims) - if err != nil { - return nil, newErrInvalidAuthentication(err) - } - - token.Token = newToken.AccessToken - // The provider may omit refresh_token when the existing token remains valid. - if newToken.RefreshToken != "" { - token.Refresh = newToken.RefreshToken - } - token.Claims = claims - - return &token, nil -} - -func FetchStackToken(ctx context.Context, httpClient *http.Client, stackURI, token string) (*oauth2.Token, error) { - form := url.Values{ - "grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"}, - "assertion": []string{token}, - "scope": []string{strings.Join([]string{oidc.ScopeOpenID, oidc.ScopeEmail}, " ")}, - } - - stackDiscoveryConfiguration, err := client.Discover[oidc.DiscoveryConfiguration](ctx, stackURI+"/api/auth", httpClient) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, stackDiscoveryConfiguration.TokenEndpoint, - bytes.NewBufferString(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - now := time.Now() - ret, err := httpClient.Do(req) - if err != nil { - return nil, err - } - - switch ret.StatusCode { - case http.StatusUnauthorized: - return nil, newErrUnauthorized() - case http.StatusForbidden: - return nil, newErrForbidden() - case http.StatusOK: - stackToken := &oauth2.Token{} - if err := json.NewDecoder(ret.Body).Decode(stackToken); err != nil { - return nil, err - } - - if stackToken.Expiry.IsZero() { - stackToken.Expiry = now.Add(time.Duration(stackToken.ExpiresIn) * time.Second).Time - } - - return stackToken, nil - default: - return nil, newUnexpectedStatusCodeError(ret.StatusCode) - } -} - -func UserInfo(cmd *cobra.Command, relyingParty client.RelyingParty, token AccessToken) (*UserClaims, error) { - ui, err := client.Userinfo[*oidc.UserInfo](cmd.Context(), token.Token, "Bearer", relyingParty) - if err != nil { - return nil, err - } - - claims := &UserClaims{} - claims.Email = ui.Email - claims.Subject = ui.Subject - - return claims, nil -} - -type TokenWithClaims[T any] struct { - Token string `json:"token"` - Claims T `json:"claims"` -} - -type AccessTokenClaims struct { - oidc.TokenClaims - Scopes oidc.SpaceDelimitedArray `json:"scope,omitempty"` - OrganizationID string `json:"organization_id"` -} - -type AccessToken struct { - TokenWithClaims[AccessTokenClaims] - Refresh string `json:"refreshToken"` -} - -func (t AccessToken) ToOAuth2() *oauth2.Token { - return &oauth2.Token{ - AccessToken: t.Token, - TokenType: "Bearer", - Expiry: t.Claims.Expiration.AsTime().Time, - } -} - -func (t AccessToken) Expired() bool { // todo: define a delta on time - return t.Claims.Expiration.AsTime().Before(time.Now()) -} - -type IDToken = TokenWithClaims[IDTokenClaims] - -type Tokens struct { - Access AccessToken `json:"accessToken"` - ID IDToken `json:"idToken"` -} - -type IDTokenClaims struct { - oidc.TokenClaims - NotBefore oidc.Time `json:"nbf,omitempty"` - AccessTokenHash string `json:"at_hash,omitempty"` - CodeHash string `json:"c_hash,omitempty"` - SessionID string `json:"sid,omitempty"` - oidc.UserInfoProfile - oidc.UserInfoEmail - oidc.UserInfoPhone - Address *oidc.UserInfoAddress `json:"address,omitempty"` - Organizations []OrganizationAccess `json:"org"` -} - -func (i IDTokenClaims) GetExpiration() time.Time { - return i.Expiration.AsTime() -} - -func (i IDTokenClaims) GetIssuedAt() time.Time { - return i.IssuedAt.AsTime() -} - -func (i IDTokenClaims) GetAuthTime() time.Time { - return i.AuthTime.AsTime() -} - -func (i IDTokenClaims) GetAccessTokenHash() string { - return i.AccessTokenHash -} - -func (i IDTokenClaims) GetOrganizationAccess(id string) *OrganizationAccess { - for _, organization := range i.Organizations { - if organization.ID == id { - return &organization - } - } - return nil -} - -func (i IDTokenClaims) HasOrganizationAccess(id string) bool { - return i.GetOrganizationAccess(id) != nil -} - -func (i IDTokenClaims) HasApplicationsAccess(organizationID string, alias string) bool { - organizationAccess := i.GetOrganizationAccess(organizationID) - if organizationAccess == nil { - return false - } - for _, application := range organizationAccess.Applications { - if application.Alias == alias { - return true - } - } - return false -} - -func (i IDTokenClaims) HasStackAccess(organizationID string, stackID string) bool { - organizationAccess := i.GetOrganizationAccess(organizationID) - if organizationAccess == nil { - return false - } - return organizationAccess.GetStackAccess(stackID) != nil -} - -type StackAccess struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - URI string `json:"uri"` - Scopes []string `json:"scopes"` -} -type OrganizationAccess struct { - ID string `json:"id"` - DisplayName string `json:"displayName"` - Stacks []StackAccess `json:"stacks"` - Applications []ApplicationAccess `json:"applications"` -} - -func (o *OrganizationAccess) GetStackAccess(stackID string) *StackAccess { - if o == nil { - return nil - } - for _, stack := range o.Stacks { - if stack.ID == stackID { - return &stack - } - } - return nil -} - -type OrganizationsClaim []OrganizationAccess -type UserClaims struct { - Email string `json:"email"` - Subject string `json:"sub"` - Org OrganizationsClaim `json:"org"` -} - -type ApplicationAccess struct { - Alias string `json:"alias"` - ID string `json:"id"` - Name string `json:"name"` -} diff --git a/pkg/authentication_test.go b/pkg/authentication_test.go deleted file mode 100644 index 297397c9..00000000 --- a/pkg/authentication_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package fctl - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/formancehq/go-libs/v4/oidc" - "github.com/formancehq/go-libs/v4/oidc/client" -) - -type authenticationTransport func(*http.Request) (*http.Response, error) - -func (f authenticationTransport) RoundTrip(r *http.Request) (*http.Response, error) { - return f(r) -} - -func authenticationResponse(t *testing.T, status int, body any) *http.Response { - t.Helper() - data, err := json.Marshal(body) - require.NoError(t, err) - return &http.Response{ - StatusCode: status, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(string(data))), - } -} - -// All OIDC requests stay in this transport, including discovery. Unexpected -// device authorization requests fail before Authenticate could open a browser. -func authenticationRelyingParty(t *testing.T, tokenEndpoint authenticationTransport) client.RelyingParty { - t.Helper() - const issuer = "https://oidc.example.test" - httpClient := &http.Client{Transport: authenticationTransport(func(r *http.Request) (*http.Response, error) { - switch r.URL.String() { - case issuer + "/.well-known/openid-configuration": - return authenticationResponse(t, http.StatusOK, map[string]string{ - "issuer": issuer, - "token_endpoint": issuer + "/token", - "device_authorization_endpoint": issuer + "/device", - }), nil - case issuer + "/token": - require.Equal(t, http.MethodPost, r.Method) - require.NoError(t, r.ParseForm()) - require.Equal(t, "refresh_token", r.Form.Get("grant_type")) - require.Equal(t, AuthClient, r.Form.Get("client_id")) - return tokenEndpoint(r) - default: - t.Errorf("unexpected OIDC request: %s", r.URL) - return nil, errors.New("unexpected OIDC request") - } - })} - rp, err := GetAuthRelyingParty(context.Background(), httpClient, issuer) - require.NoError(t, err) - return rp -} - -func authenticationToken(t *testing.T, subject string, expires time.Time) AccessToken { - t.Helper() - claims := AccessTokenClaims{ - TokenClaims: oidc.TokenClaims{ - Subject: subject, - Expiration: oidc.Time(expires.Unix()), - }, - Scopes: oidc.SpaceDelimitedArray{"accesses", "on_behalf"}, - OrganizationID: "synthetic-organization", - } - payload, err := json.Marshal(claims) - require.NoError(t, err) - // Refresh only decodes access-token claims; signature verification belongs to - // the OIDC library. No ID token is included in these refresh responses. - jwt := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + - base64.RawURLEncoding.EncodeToString(payload) + "." - return AccessToken{ - TokenWithClaims: TokenWithClaims[AccessTokenClaims]{Token: jwt, Claims: claims}, - Refresh: "synthetic-refresh-token", - } -} - -func TestRefreshUpdatesAccessToken(t *testing.T) { - for _, rotated := range []bool{true, false} { - name := "preserves refresh token when omitted" - if rotated { - name = "replaces rotated refresh token" - } - t.Run(name, func(t *testing.T) { - original := authenticationToken(t, "old-subject", time.Now().Add(-time.Hour)) - want := authenticationToken(t, "new-subject", time.Now().Add(time.Hour)) - response := map[string]any{"access_token": want.Token, "token_type": "Bearer", "expires_in": 3600} - if rotated { - want.Refresh = "rotated-refresh-token" - response["refresh_token"] = want.Refresh - } - calls := 0 - rp := authenticationRelyingParty(t, func(r *http.Request) (*http.Response, error) { - calls++ - require.Equal(t, original.Refresh, r.Form.Get("refresh_token")) - return authenticationResponse(t, http.StatusOK, response), nil - }) - - got, err := Refresh(context.Background(), rp, original) - - require.NoError(t, err) - require.Equal(t, &want, got) - require.Equal(t, 1, calls) - require.Equal(t, "old-subject", original.Claims.Subject) - require.Equal(t, "synthetic-refresh-token", original.Refresh) - }) - } -} - -func TestRefreshPreservesOAuthErrors(t *testing.T) { - for _, errorType := range []string{"invalid_token", "invalid_request", "invalid_client", "server_error"} { - t.Run(errorType, func(t *testing.T) { - rp := authenticationRelyingParty(t, func(*http.Request) (*http.Response, error) { - return authenticationResponse(t, http.StatusBadRequest, map[string]string{ - "error": errorType, "error_description": "synthetic OAuth failure", - }), nil - }) - - got, err := Refresh(context.Background(), rp, AccessToken{Refresh: "synthetic-refresh-token"}) - - require.Nil(t, got) - require.True(t, IsInvalidAuthentication(err)) - var oauthErr *oidc.Error - require.ErrorAs(t, err, &oauthErr) - require.Equal(t, errorType, string(oauthErr.ErrorType)) - require.Equal(t, "synthetic OAuth failure", oauthErr.Description) - }) - } -} - -func TestRefreshRejectsMalformedAccessToken(t *testing.T) { - rp := authenticationRelyingParty(t, func(*http.Request) (*http.Response, error) { - return authenticationResponse(t, http.StatusOK, map[string]string{ - "access_token": "not-a-jwt", "refresh_token": "rotated-refresh-token", - }), nil - }) - - got, err := Refresh(context.Background(), rp, AccessToken{Refresh: "synthetic-refresh-token"}) - - require.Nil(t, got) - require.ErrorIs(t, err, oidc.ErrParse) - require.True(t, IsInvalidAuthentication(err)) - var oauthErr *oidc.Error - require.False(t, errors.As(err, &oauthErr)) -} - -func TestRefreshPreservesTransportError(t *testing.T) { - transportErr := errors.New("synthetic connection failure") - rp := authenticationRelyingParty(t, func(*http.Request) (*http.Response, error) { - return nil, transportErr - }) - - got, err := Refresh(context.Background(), rp, AccessToken{Refresh: "synthetic-refresh-token"}) - - require.Nil(t, got) - require.ErrorIs(t, err, transportErr) - require.True(t, IsInvalidAuthentication(err)) -} - -func TestRefreshCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - rp := authenticationRelyingParty(t, func(r *http.Request) (*http.Response, error) { - cancel() - select { - case <-r.Context().Done(): - return nil, r.Context().Err() - case <-time.After(5 * time.Second): - return nil, errors.New("refresh request did not observe cancellation") - } - }) - - got, err := Refresh(ctx, rp, AccessToken{Refresh: "synthetic-refresh-token"}) - - require.Nil(t, got) - require.ErrorIs(t, err, context.Canceled) - require.True(t, IsInvalidAuthentication(err)) -} diff --git a/pkg/clients.go b/pkg/clients.go deleted file mode 100644 index 4ae31f4f..00000000 --- a/pkg/clients.go +++ /dev/null @@ -1,755 +0,0 @@ -package fctl - -import ( - "context" - "errors" - "fmt" - "net/http" - "slices" - "strings" - "sync" - "time" - - "github.com/spf13/cobra" - "golang.org/x/oauth2" - - "github.com/formancehq/fctl/internal/deployserverclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3" - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - formance "github.com/formancehq/formance-sdk-go/v4" - "github.com/formancehq/go-libs/v4/collectionutils" - "github.com/formancehq/go-libs/v4/oidc" - "github.com/formancehq/go-libs/v4/oidc/client" -) - -func getVersion(cmd *cobra.Command) string { - for cmd != nil { - if cmd.Version != "" { - return cmd.Version - } - cmd = cmd.Parent() - } - return "cmd.Version" -} - -func EnsureMembershipAccess( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (*AccessToken, error) { - authenticate := func() (*Tokens, error) { - return Authenticate( - cmd.Context(), - relyingParty, - dialog, - []AuthenticationOption{ - AuthenticateWithScopes( - oidc.ScopeOpenID, - oidc.ScopeOfflineAccess, - "accesses", - "on_behalf", - ), - AuthenticateWithPrompt("no-org"), - AuthenticateWithIDTokenHint(profile.RootTokens.ID.Token), - }, - []TokenOption{}, - ) - } - - return ensureMembershipAccess(cmd, relyingParty, dialog, profileName, profile, authenticate) -} - -func ensureMembershipAccess( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - authenticate func() (*Tokens, error), -) (*AccessToken, error) { - if !profile.IsConnected() { - return nil, fmt.Errorf("profile %s is not connected, please log in", profileName) - } - - originalToken := &profile.RootTokens.Access - - token := originalToken - if token == nil { - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for organization: %w", err) - } - - token = &tokens.Access - } else if token.Expired() { // todo: define a delta on time - dialog.Info("Refreshing membership access token...") - refreshed, err := Refresh(cmd.Context(), relyingParty, *token) - if err != nil { - oidcErr := &oidc.Error{} - if !errors.As(err, &oidcErr) { - return nil, fmt.Errorf("failed to refresh membership token: %w", err) - } - - if oidcErr.ErrorType != oidc.InvalidToken && oidcErr.ErrorType != oidc.InvalidRequest { - return nil, fmt.Errorf("received unexpected oauth2 error while refreshing token: %w", err) - } - - dialog.Info("Membership token expired, requesting new authentication...") - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for membership: %w", err) - } - - token = &tokens.Access - } else { - token = refreshed - } - } - - if token != originalToken { - profile.RootTokens.Access = *token - if err := WriteProfile(cmd, profileName, profile); err != nil { - return nil, err - } - } - - return token, nil -} - -func EnsureOrganizationAccess( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID string, -) (*AccessToken, error) { - if !profile.RootTokens.ID.Claims.HasOrganizationAccess(organizationID) { - return nil, fmt.Errorf("no access to organization %s found in your authentication profile, "+ - "please log in again and/or check you still have access to the organization", organizationID) - } - - authenticate := func() (*Tokens, error) { - return Authenticate( - cmd.Context(), - relyingParty, - dialog, - []AuthenticationOption{ - AuthenticateWithOrganizationID(organizationID), - AuthenticateWithIDTokenHint(profile.RootTokens.ID.Token), - AuthenticateWithScopes(append(OrganizationScopes, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)...), - }, - []TokenOption{}, - ) - } - - originalOrganizationToken, err := ReadOrganizationToken(cmd, profileName, organizationID) - if err != nil { - return nil, err - } - organizationToken := originalOrganizationToken - if organizationToken == nil { - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for organization: %w", err) - } - - organizationToken = &tokens.Access - } else if organizationToken.Expired() { // todo: define a delta on time - dialog.Info("Refreshing organization access token...") - refreshed, err := Refresh(cmd.Context(), relyingParty, *organizationToken) - if err != nil { - oidcErr := &oidc.Error{} - if !errors.As(err, &oidcErr) { - return nil, fmt.Errorf("failed to refresh organization token: %w", err) - } - - if oidcErr.ErrorType != oidc.InvalidToken && oidcErr.ErrorType != oidc.InvalidRequest { - return nil, fmt.Errorf("received unexpected oauth2 error while refreshing token: %w", err) - } - - dialog.Info("Organization token expired, requesting new authentication...") - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for organization: %w", err) - } - - organizationToken = &tokens.Access - } else { - organizationToken = refreshed - } - } - - if organizationToken != originalOrganizationToken { - if err := WriteOrganizationToken(cmd, profileName, *organizationToken); err != nil { - return nil, err - } - } - - return organizationToken, nil -} - -func EnsureStackAccess( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID, stackID string, -) (*AccessToken, *StackAccess, error) { - if !profile.RootTokens.ID.Claims.HasOrganizationAccess(organizationID) { - return nil, nil, fmt.Errorf("no access to organization %s found in your authentication profile, "+ - "please log in again and/or check you still have access to the organization", organizationID) - } - - if !profile.RootTokens.ID.Claims.HasStackAccess(organizationID, stackID) { - return nil, nil, fmt.Errorf("no access to stack %s on organization %s found in your authentication profile, "+ - "please log in again and/or check you still have access to the organization or that the stack exists or is not deleted", stackID, organizationID) - } - - stackAccess := profile.RootTokens.ID.Claims. - GetOrganizationAccess(organizationID). - GetStackAccess(stackID) - stackScopes := collectionutils.Filter(stackAccess.Scopes, func(s string) bool { - return slices.Contains(StackScopes, s) - }) - if len(stackScopes) == 0 { - return nil, nil, fmt.Errorf("no access to stack %s on organization %s found in your authentication profile, "+ - "please log in again and/or check you still have access to the organization", stackID, organizationID) - } - resource := "stack://" + organizationID + "/" + stackID + "|" + strings.Join(stackScopes, " ") - - authenticate := func() (*Tokens, error) { - return Authenticate( - cmd.Context(), - relyingParty, - dialog, - []AuthenticationOption{ - AuthenticateWithOrganizationID(organizationID), - AuthenticateWithIDTokenHint(profile.RootTokens.ID.Token), - AuthenticateWithResource(resource), - AuthenticateWithScopes(oidc.ScopeOpenID, oidc.ScopeOfflineAccess), - }, - []TokenOption{ - RequestResource(resource), - }, - ) - } - - originalToken, err := ReadStackToken(cmd, profileName, organizationID, stackID) - if err != nil { - return nil, nil, err - } - - stackToken := originalToken - if stackToken == nil { - tokens, err := authenticate() - if err != nil { - return nil, nil, fmt.Errorf("failed to authenticate for stack: %w", err) - } - - stackToken = &tokens.Access - } else if stackToken.Expired() { - dialog.Info("Refreshing stack access token...") - refreshed, err := Refresh(cmd.Context(), relyingParty, *stackToken) - if err != nil { - oidcErr := &oidc.Error{} - if !errors.As(err, &oidcErr) { - return nil, nil, fmt.Errorf("failed to refresh stack token: %w", err) - } - - // Handle invalid_token, invalid_request (refresh_token not found), and other refresh errors - // by requesting a new authentication with current profile credentials - if oidcErr.ErrorType != oidc.InvalidToken && oidcErr.ErrorType != oidc.InvalidRequest { - return nil, nil, fmt.Errorf("received unexpected oauth2 error while refreshing token: %w", err) - } - - // Refresh token is invalid/expired/not found, reauthenticate with current profile - dialog.Info("Stack refresh token expired, requesting new authentication...") - tokens, err := authenticate() - if err != nil { - return nil, nil, fmt.Errorf("failed to authenticate for stack: %w", err) - } - - stackToken = &tokens.Access - } else { - stackToken = refreshed - } - } - - if stackToken != originalToken { - if err := WriteStackToken(cmd, profileName, stackID, *stackToken); err != nil { - return nil, nil, err - } - } - - return stackToken, stackAccess, nil -} - -func EnsureAppAccess( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID string, - appAlias string, - appScopes []string, -) (*AccessToken, error) { - if !profile.RootTokens.ID.Claims.HasApplicationsAccess(organizationID, appAlias) { - return nil, fmt.Errorf("no access to application '%s' on organization %s found in your authentication profile, "+ - "please log in again and/or check you still have access to the organization", appAlias, organizationID) - } - - resource := "app://" + appAlias + "|" + strings.Join(appScopes, " ") - - authenticate := func() (*Tokens, error) { - return Authenticate( - cmd.Context(), - relyingParty, - dialog, - []AuthenticationOption{ - AuthenticateWithOrganizationID(organizationID), - AuthenticateWithIDTokenHint(profile.RootTokens.ID.Token), - AuthenticateWithResource(resource), - AuthenticateWithScopes(oidc.ScopeOpenID, oidc.ScopeOfflineAccess), - }, - []TokenOption{ - RequestResource(resource), - }, - ) - } - - originalAppToken, err := ReadAppToken(cmd, profileName, organizationID, appAlias) - if err != nil { - return nil, err - } - - appToken := originalAppToken - if appToken == nil { - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for organization: %w", err) - } - - appToken = &tokens.Access - } else if appToken.Expired() { // todo: define a delta on time - dialog.Info("Refreshing app access token...") - refreshed, err := Refresh(cmd.Context(), relyingParty, *appToken) - if err != nil { - oidcErr := &oidc.Error{} - if !errors.As(err, &oidcErr) { - return nil, fmt.Errorf("failed to refresh app token: %w", err) - } - - if oidcErr.ErrorType != oidc.InvalidToken && oidcErr.ErrorType != oidc.InvalidRequest { - return nil, fmt.Errorf("received unexpected oauth2 error while refreshing token: %w", err) - } - - dialog.Info("App token expired, requesting new authentication...") - tokens, err := authenticate() - if err != nil { - return nil, fmt.Errorf("failed to authenticate for app: %w", err) - } - - appToken = &tokens.Access - } else { - appToken = refreshed - } - } - - if appToken != originalAppToken { - if err := WriteAppToken(cmd, profileName, appAlias, *appToken); err != nil { - return nil, err - } - } - - return appToken, nil -} - -func NewMembershipClient( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (*membershipclient.SDK, error) { - - accessToken, err := EnsureMembershipAccess( - cmd, - relyingParty, - dialog, - profileName, - profile, - ) - if err != nil { - return nil, err - } - - return membershipclient.New( - membershipclient.WithServerURL(profile.GetMembershipURI()), - membershipclient.WithClient(relyingParty.HttpClient()), - membershipclient.WithSecurity(fmt.Sprintf("Bearer %s", accessToken.Token)), - ), nil -} - -func NewMembershipClientForOrganization( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID string, -) (*membershipclient.SDK, error) { - - organizationToken, err := EnsureOrganizationAccess( - cmd, - relyingParty, - dialog, - profileName, - profile, - organizationID, - ) - if err != nil { - return nil, err - } - - return membershipclient.New( - membershipclient.WithServerURL(profile.GetMembershipURI()), - membershipclient.WithClient(relyingParty.HttpClient()), - membershipclient.WithSecurity(fmt.Sprintf("Bearer %s", organizationToken.Token)), - ), nil -} - -func NewMembershipClientForOrganizationFromFlags( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (string, *membershipclient.SDK, error) { - - organizationID, err := ResolveOrganizationID(cmd, profile) - if err != nil { - return "", nil, err - } - - client, err := NewMembershipClientForOrganization(cmd, relyingParty, dialog, profileName, profile, organizationID) - - return organizationID, client, err -} - -func NewStackClient( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID, stackID string, -) (*formance.Formance, error) { - - stackToken, stackAccess, err := EnsureStackAccess( - cmd, - relyingParty, - dialog, - profileName, - profile, - organizationID, - stackID, - ) - if err != nil { - return nil, err - } - - stackHTTPClient := oauth2.NewClient( - context.WithValue(cmd.Context(), oauth2.HTTPClient, relyingParty.HttpClient()), - NewStackTokenSource( - *stackToken, - stackAccess, - relyingParty, - func(newToken AccessToken) error { - return WriteStackToken(cmd, profileName, stackID, newToken) - }, - cmd, - profileName, - organizationID, - stackID, - ), - ) - stackHTTPTransport, err := newRewriteLocalhostServerURLRoundTripper(stackAccess.URI, stackHTTPClient.Transport) - if err != nil { - return nil, err - } - stackHTTPClient.Transport = stackHTTPTransport - - return formance.New( - formance.WithServerURL(stackAccess.URI), - formance.WithClient(stackHTTPClient), - ), nil -} - -func NewStackClientFromFlags( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (*formance.Formance, error) { - - organizationID, stackID, err := ResolveStackID(cmd, profile) - if err != nil { - return nil, err - } - - return NewStackClient(cmd, relyingParty, dialog, profileName, profile, organizationID, stackID) -} - -// StackClients holds both a typed SDK client and an authenticated raw HTTP client -// sharing the same oauth2 token source. Use HTTPClient for operations that need to -// bypass the SDK's typed discriminated unions (e.g. connector install/update). -type StackClients struct { - SDK *formance.Formance - HTTPClient *http.Client - URI string -} - -func NewStackClientsFromFlags( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (*StackClients, error) { - organizationID, stackID, err := ResolveStackID(cmd, profile) - if err != nil { - return nil, err - } - - stackToken, stackAccess, err := EnsureStackAccess( - cmd, - relyingParty, - dialog, - profileName, - profile, - organizationID, - stackID, - ) - if err != nil { - return nil, err - } - - tokenSource := NewStackTokenSource( - *stackToken, - stackAccess, - relyingParty, - func(newToken AccessToken) error { - return WriteStackToken(cmd, profileName, stackID, newToken) - }, - cmd, - profileName, - organizationID, - stackID, - ) - - baseCtx := context.WithValue(cmd.Context(), oauth2.HTTPClient, relyingParty.HttpClient()) - - sdkHTTPClient := oauth2.NewClient(baseCtx, tokenSource) - sdkHTTPTransport, err := newRewriteLocalhostServerURLRoundTripper(stackAccess.URI, sdkHTTPClient.Transport) - if err != nil { - return nil, err - } - sdkHTTPClient.Transport = sdkHTTPTransport - - sdkClient := formance.New( - formance.WithServerURL(stackAccess.URI), - formance.WithClient(sdkHTTPClient), - ) - - rawHTTPClient := oauth2.NewClient(baseCtx, tokenSource) - rawHTTPClient.Transport = newInjectHTTPHeadersRoundTripper( - http.Header{"User-Agent": []string{fmt.Sprintf("fctl/%s", getVersion(cmd))}}, - rawHTTPClient.Transport, - ) - - return &StackClients{ - SDK: sdkClient, - HTTPClient: rawHTTPClient, - URI: stackAccess.URI, - }, nil -} - -func NewAppDeployClient( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, - organizationID string, -) (*deployserverclient.DeployServer, error) { - - appAlias := GetString(cmd, DeployAppAliasFlag) - if appAlias == "" { - appAlias = DefaultDeployAppAlias - } - - appToken, err := EnsureAppAccess( - cmd, - relyingParty, - dialog, - profileName, - profile, - organizationID, - appAlias, - []string{ - "apps:Read", - "apps:Write", - }, - ) - if err != nil { - return nil, err - } - - return deployserverclient.New( - deployserverclient.WithServerURL(collectionutils.Filter(appToken.Claims.Audience, func(audience string) bool { - return audience != AuthClient - })[0]), - deployserverclient.WithClient(oauth2.NewClient( - context.WithValue(cmd.Context(), oauth2.HTTPClient, relyingParty.HttpClient()), - oauth2.StaticTokenSource(appToken.ToOAuth2()), - )), - ), nil -} - -// todo: deploy use membership token, we have to rely on membership applications -func NewAppDeployClientFromFlags( - cmd *cobra.Command, - relyingParty client.RelyingParty, - dialog Dialog, - profileName string, - profile Profile, -) (string, *deployserverclient.DeployServer, error) { - - organizationID, err := ResolveOrganizationID(cmd, profile) - if err != nil { - return "", nil, err - } - - deployClient, err := NewAppDeployClient(cmd, relyingParty, dialog, profileName, profile, organizationID) - if err != nil { - return "", nil, err - } - - return organizationID, deployClient, nil -} - -type stackTokenSource struct { - mu sync.Mutex - - // Membership token - stackToken AccessToken - - // Token obtained from stack auth server - accessToken *oauth2.Token - - stackAccess *StackAccess - relyingParty client.RelyingParty - onRefresh func(newToken AccessToken) error - - // Cache fields - cmd *cobra.Command - profileName string - organizationID string - stackID string -} - -func (t *stackTokenSource) Token() (*oauth2.Token, error) { - t.mu.Lock() - defer t.mu.Unlock() - - if t.accessToken == nil || t.accessToken.Expiry.Before(time.Now()) { - // Try to load from disk cache - if t.cmd != nil { - cached, err := ReadCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID) - if err == nil && cached != nil && cached.Expiry.After(time.Now().Add(5*time.Second)) { - t.accessToken = &oauth2.Token{ - AccessToken: cached.AccessToken, - TokenType: cached.TokenType, - Expiry: cached.Expiry, - } - return t.accessToken, nil - } - } - - if t.stackToken.Expired() { - newStackToken, err := Refresh(context.Background(), t.relyingParty, t.stackToken) - if err != nil { - oidcErr := &oidc.Error{} - if errors.As(err, &oidcErr) && (oidcErr.ErrorType == oidc.InvalidToken || oidcErr.ErrorType == oidc.InvalidRequest) { - return nil, newErrInvalidAuthentication(fmt.Errorf("stack refresh token expired or not found, please run a command to re-authenticate")) - } - return nil, err - } - t.stackToken = *newStackToken - if err := t.onRefresh(*newStackToken); err != nil { - return nil, err - } - } - - token, err := FetchStackToken(context.Background(), t.relyingParty.HttpClient(), t.stackAccess.URI, t.stackToken.Token) - if err != nil { - return nil, err - } - - t.accessToken = token - - // Write to disk cache (best-effort) - if t.cmd != nil { - _ = WriteCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID, CachedStackAPIToken{ - AccessToken: token.AccessToken, - TokenType: token.TokenType, - Expiry: token.Expiry, - }) - } - } - - return t.accessToken, nil -} - -var _ oauth2.TokenSource = &stackTokenSource{} - -func NewStackTokenSource( - stackToken AccessToken, - stackAccess *StackAccess, - relyingParty client.RelyingParty, - onRefresh func(newToken AccessToken) error, - cmd *cobra.Command, - profileName string, - organizationID string, - stackID string, -) oauth2.TokenSource { - return &stackTokenSource{ - stackToken: stackToken, - stackAccess: stackAccess, - relyingParty: relyingParty, - onRefresh: onRefresh, - cmd: cmd, - profileName: profileName, - organizationID: organizationID, - stackID: stackID, - } -} - -func MembershipServerInfo(ctx context.Context, apiClient *membershipclient.SDK) (*components.ServerInfo, error) { - response, err := apiClient.GetServerInfo(ctx) - if err != nil { - return nil, err - } - - if response.ServerInfo == nil { - return nil, fmt.Errorf("unexpected response: no server info") - } - - return response.ServerInfo, nil -} diff --git a/pkg/clients_authentication_test.go b/pkg/clients_authentication_test.go deleted file mode 100644 index 9d98079f..00000000 --- a/pkg/clients_authentication_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package fctl - -import ( - "context" - "errors" - "net/http" - "os" - "path/filepath" - "testing" - "time" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" - - "github.com/formancehq/go-libs/v4/oidc" -) - -type authenticationDialog struct{} - -func (authenticationDialog) Info(string, ...any) {} - -func authenticationProfile(t *testing.T, expired bool) (*cobra.Command, Profile) { - t.Helper() - cmd := &cobra.Command{} - cmd.SetContext(context.Background()) - cmd.Flags().String(ConfigDir, t.TempDir(), "") - expires := time.Now().Add(time.Hour) - if expired { - expires = time.Now().Add(-time.Hour) - } - profile := Profile{ - MembershipURI: "https://oidc.example.test", - RootTokens: &Tokens{ - Access: authenticationToken(t, "original-subject", expires), - ID: IDToken{Token: "synthetic-id-token"}, - }, - DefaultOrganization: "synthetic-organization", - DefaultStack: "synthetic-stack", - } - return cmd, profile -} - -func TestEnsureMembershipAccessReusesValidToken(t *testing.T) { - cmd, profile := authenticationProfile(t, false) - // A nil RP makes any accidental refresh fail; no profile is written yet. - got, err := EnsureMembershipAccess(cmd, nil, authenticationDialog{}, "test", profile) - - require.NoError(t, err) - require.Equal(t, &profile.RootTokens.Access, got) - _, err = os.Stat(GetFilePath(cmd, "profiles/test/profile.json")) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func TestEnsureMembershipAccessRequiresConnectedProfile(t *testing.T) { - cmd, profile := authenticationProfile(t, true) - profile.RootTokens = nil - - got, err := EnsureMembershipAccess(cmd, nil, authenticationDialog{}, "test", profile) - - require.Nil(t, got) - require.EqualError(t, err, "profile test is not connected, please log in") -} - -func TestEnsureMembershipAccessPersistsRefreshedToken(t *testing.T) { - for _, rotated := range []bool{true, false} { - name := "refresh token omitted" - if rotated { - name = "refresh token rotated" - } - t.Run(name, func(t *testing.T) { - cmd, profile := authenticationProfile(t, true) - require.NoError(t, WriteProfile(cmd, "test", profile)) - want := authenticationToken(t, "refreshed-subject", time.Now().Add(time.Hour)) - response := map[string]string{"access_token": want.Token} - if rotated { - want.Refresh = "rotated-refresh-token" - response["refresh_token"] = want.Refresh - } - calls := 0 - rp := authenticationRelyingParty(t, func(r *http.Request) (*http.Response, error) { - calls++ - require.Equal(t, "synthetic-refresh-token", r.Form.Get("refresh_token")) - return authenticationResponse(t, http.StatusOK, response), nil - }) - - got, err := EnsureMembershipAccess(cmd, rp, authenticationDialog{}, "test", profile) - - require.NoError(t, err) - require.Equal(t, &want, got) - require.Equal(t, 1, calls) - persisted, err := LoadProfile(cmd, "test") - require.NoError(t, err) - require.Equal(t, want, persisted.RootTokens.Access) - require.Equal(t, "synthetic-id-token", persisted.RootTokens.ID.Token) - require.Equal(t, profile, *persisted) - }) - } -} - -func TestEnsureMembershipAccessReauthenticatesRejectedRefreshToken(t *testing.T) { - for _, errorType := range []string{"invalid_token", "invalid_request"} { - for _, authenticateFails := range []bool{false, true} { - name := errorType + "/success" - if authenticateFails { - name = errorType + "/failure" - } - t.Run(name, func(t *testing.T) { - cmd, profile := authenticationProfile(t, true) - require.NoError(t, WriteProfile(cmd, "test", profile)) - original, err := os.ReadFile(GetFilePath(cmd, "profiles/test/profile.json")) - require.NoError(t, err) - refreshCalls := 0 - rp := authenticationRelyingParty(t, func(*http.Request) (*http.Response, error) { - refreshCalls++ - return authenticationResponse(t, http.StatusBadRequest, map[string]string{"error": errorType}), nil - }) - want := authenticationToken(t, "reauthenticated-subject", time.Now().Add(time.Hour)) - want.Refresh = "reauthenticated-refresh-token" - authenticateErr := errors.New("synthetic authentication failure") - authenticateCalls := 0 - authenticate := func() (*Tokens, error) { - authenticateCalls++ - if authenticateFails { - return nil, authenticateErr - } - return &Tokens{Access: want}, nil - } - - got, err := ensureMembershipAccess(cmd, rp, authenticationDialog{}, "test", profile, authenticate) - - require.Equal(t, 1, refreshCalls) - require.Equal(t, 1, authenticateCalls) - if authenticateFails { - require.Nil(t, got) - require.ErrorIs(t, err, authenticateErr) - require.ErrorContains(t, err, "failed to authenticate for membership") - persisted, err := os.ReadFile(GetFilePath(cmd, "profiles/test/profile.json")) - require.NoError(t, err) - require.Equal(t, original, persisted) - return - } - require.NoError(t, err) - require.Equal(t, &want, got) - persisted, err := LoadProfile(cmd, "test") - require.NoError(t, err) - require.Equal(t, want, persisted.RootTokens.Access) - require.Equal(t, "synthetic-id-token", persisted.RootTokens.ID.Token) - require.Equal(t, profile, *persisted) - }) - } - } -} - -func TestEnsureMembershipAccessPreservesRefreshErrors(t *testing.T) { - transportErr := errors.New("synthetic connection failure") - for _, tc := range []struct { - name string - oauthType string - cause error - }{ - {name: "invalid client", oauthType: "invalid_client"}, - {name: "server error", oauthType: "server_error"}, - {name: "invalid grant", oauthType: "invalid_grant"}, - {name: "network failure", cause: transportErr}, - {name: "cancellation", cause: context.Canceled}, - {name: "malformed access token", cause: oidc.ErrParse}, - } { - t.Run(tc.name, func(t *testing.T) { - cmd, profile := authenticationProfile(t, true) - require.NoError(t, WriteProfile(cmd, "test", profile)) - original, err := os.ReadFile(GetFilePath(cmd, "profiles/test/profile.json")) - require.NoError(t, err) - if tc.cause == context.Canceled { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - cmd.SetContext(ctx) - } - rp := authenticationRelyingParty(t, func(r *http.Request) (*http.Response, error) { - switch { - case tc.oauthType != "": - return authenticationResponse(t, http.StatusBadRequest, map[string]string{"error": tc.oauthType}), nil - case tc.cause == oidc.ErrParse: - return authenticationResponse(t, http.StatusOK, map[string]string{"access_token": "not-a-jwt"}), nil - case tc.cause == context.Canceled: - return nil, r.Context().Err() - default: - return nil, tc.cause - } - }) - - got, err := EnsureMembershipAccess(cmd, rp, authenticationDialog{}, "test", profile) - - require.Nil(t, got) - if tc.oauthType != "" { - var oauthErr *oidc.Error - require.ErrorAs(t, err, &oauthErr) - require.Equal(t, tc.oauthType, string(oauthErr.ErrorType)) - require.ErrorContains(t, err, "received unexpected oauth2 error") - } else { - require.ErrorIs(t, err, tc.cause) - require.ErrorContains(t, err, "failed to refresh membership token") - } - persisted, err := os.ReadFile(GetFilePath(cmd, "profiles/test/profile.json")) - require.NoError(t, err) - require.Equal(t, original, persisted) - }) - } -} - -func TestEnsureMembershipAccessReportsPersistenceError(t *testing.T) { - cmd, profile := authenticationProfile(t, true) - // A file in place of the profile directory makes persistence fail reliably. - require.NoError(t, os.WriteFile(filepath.Join(LoadConfigDir(cmd), "profiles"), []byte("blocked"), 0600)) - want := authenticationToken(t, "refreshed-subject", time.Now().Add(time.Hour)) - rp := authenticationRelyingParty(t, func(*http.Request) (*http.Response, error) { - return authenticationResponse(t, http.StatusOK, map[string]string{"access_token": want.Token}), nil - }) - - got, err := EnsureMembershipAccess(cmd, rp, authenticationDialog{}, "test", profile) - - require.Nil(t, got) - var pathErr *os.PathError - require.ErrorAs(t, err, &pathErr) -} diff --git a/pkg/command.go b/pkg/command.go deleted file mode 100644 index fa19c830..00000000 --- a/pkg/command.go +++ /dev/null @@ -1,341 +0,0 @@ -package fctl - -import ( - "encoding/json" - "fmt" - - "github.com/pkg/errors" - "github.com/segmentio/ksuid" - "github.com/spf13/cobra" -) - -var ( - ErrOrganizationNotSpecified = errors.New("organization not specified") - ErrMultipleOrganizationsFound = errors.New("found more than one organization and no organization specified") - ErrNoStackSpecified = errors.New("no stack specified: use --stack=") -) - -func GetSelectedOrganizationID(cmd *cobra.Command) string { - return GetString(cmd, OrganizationFlag) -} - -func ResolveOrganizationID(cmd *cobra.Command, profile Profile) (string, error) { - if id := GetSelectedOrganizationID(cmd); id != "" { - return id, nil - } - - if profile.DefaultOrganization != "" { - return profile.DefaultOrganization, nil - } - - if !profile.IsConnected() || len(profile.RootTokens.ID.Claims.Organizations) == 0 { - return "", ErrOrganizationNotSpecified - } - if len(profile.RootTokens.ID.Claims.Organizations) > 1 { - return "", ErrMultipleOrganizationsFound - } - - return profile.RootTokens.ID.Claims.Organizations[0].ID, nil -} - -func GetSelectedStackID(cmd *cobra.Command, profile Profile) (string, error) { - if id := GetString(cmd, StackFlag); id != "" { - return id, nil - } - - return profile.DefaultStack, nil -} - -func ResolveStackID(cmd *cobra.Command, profile Profile) (string, string, error) { - - organizationID, err := ResolveOrganizationID(cmd, profile) - if err != nil { - return "", "", err - } - - stackID, err := GetSelectedStackID(cmd, profile) - if err != nil { - return "", "", err - } - - if stackID != "" { - return organizationID, stackID, nil - } - - if !profile.IsConnected() { - return "", "", ErrOrganizationNotSpecified - } - - var organizationClaim *OrganizationAccess - for _, organization := range profile.RootTokens.ID.Claims.Organizations { - if organization.ID == organizationID { - organizationClaim = &organization - break - } - } - if organizationClaim == nil { - return "", "", ErrOrganizationNotSpecified - } - - if len(organizationClaim.Stacks) == 0 { - return "", "", ErrNoStackSpecified - } - - if len(organizationClaim.Stacks) > 1 { - return "", "", fmt.Errorf("found more than one stack and no stack specified") - } - - return organizationID, organizationClaim.Stacks[0].ID, nil -} - -type CommandOption interface { - apply(cmd *cobra.Command) -} -type CommandOptionFn func(cmd *cobra.Command) - -func (fn CommandOptionFn) apply(cmd *cobra.Command) { - fn(cmd) -} - -func WithPersistentStringFlag(name, defaultValue, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PersistentFlags().String(name, defaultValue, help) - } -} - -func WithStringFlag(name, defaultValue, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().String(name, defaultValue, help) - } -} - -func WithPersistentStringPFlag(name, short, defaultValue, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PersistentFlags().StringP(name, short, defaultValue, help) - } -} - -func WithBoolFlag(name string, defaultValue bool, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().Bool(name, defaultValue, help) - } -} - -func WithAliases(aliases ...string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Aliases = aliases - } -} - -func WithPersistentBoolPFlag(name, short string, defaultValue bool, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PersistentFlags().BoolP(name, short, defaultValue, help) - } -} - -func WithPersistentBoolFlag(name string, defaultValue bool, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PersistentFlags().Bool(name, defaultValue, help) - } -} - -func WithIntFlag(name string, defaultValue int, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().Int(name, defaultValue, help) - } -} - -func WithStringSliceFlag(name string, defaultValue []string, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().StringSlice(name, defaultValue, help) - } -} - -func WithStringArrayFlag(name string, defaultValue []string, help string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().StringArray(name, defaultValue, help) - } -} - -func WithHiddenFlag(name string) CommandOptionFn { - return func(cmd *cobra.Command) { - _ = cmd.Flags().MarkHidden(name) - } -} - -func WithHidden() CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Hidden = true - } -} - -func WithRunE(fn func(cmd *cobra.Command, args []string) error) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.RunE = fn - } -} - -func WithPersistentPreRunE(fn func(cmd *cobra.Command, args []string) error) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PersistentPreRunE = fn - } -} - -func WithPreRunE(fn func(cmd *cobra.Command, args []string) error) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.PreRunE = fn - } -} - -func WithDeprecatedFlag(name, message string) CommandOptionFn { - return func(cmd *cobra.Command) { - _ = cmd.Flags().MarkDeprecated(name, message) - } -} - -func WithDeprecated(message string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Deprecated = message - } -} - -func WithController[T any](c Controller[T]) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.RunE = func(cmd *cobra.Command, args []string) error { - renderable, err := c.Run(cmd, args) - - if err != nil { - return err - } - - err = WithRender(cmd, args, c, renderable) - - if err != nil { - return err - } - - return nil - } - } -} -func WithRender[T any](cmd *cobra.Command, args []string, c Controller[T], r Renderable) error { - flags := GetString(cmd, OutputFlag) - - switch flags { - case "json": - // Inject into export struct - export := ExportedData{ - Data: c.GetStore(), - } - - // Marshal to JSON then print to stdout - out, err := json.MarshalIndent(export, "", " ") - if err != nil { - return err - } - - _, err = cmd.OutOrStdout().Write(out) - return err - default: - return r.Render(cmd, args) - } -} - -func WithChildCommands(cmds ...*cobra.Command) CommandOptionFn { - return func(cmd *cobra.Command) { - for _, child := range cmds { - cmd.AddCommand(child) - } - } -} - -func WithShortDescription(v string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Short = v - } -} - -func WithArgs(p cobra.PositionalArgs) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Args = p - } -} - -func WithValidArgs(validArgs ...string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.ValidArgs = validArgs - } -} - -func WithValidArgsFunction(fn func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.ValidArgsFunction = fn - } -} - -func WithDescription(v string) CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Long = v - } -} - -func WithSilenceError() CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.SilenceErrors = true - } -} - -func WithConfirmFlag() CommandOptionFn { - return WithBoolFlag(confirmFlag, false, "Confirm action") -} - -func NewStackCommand(use string, opts ...CommandOption) *cobra.Command { - cmd := NewMembershipCommand(use, - append(opts, - WithPersistentStringFlag(StackFlag, "", "Specific stack (not required if only one stack is present)"), - )..., - ) - if err := cmd.RegisterFlagCompletionFunc("stack", StackCompletion); err != nil { - panic(err) - } - return cmd -} - -func NewMembershipCommand(use string, opts ...CommandOption) *cobra.Command { - cmd := NewCommand(use, - append(opts, - WithPersistentStringFlag(OrganizationFlag, "", "Selected organization (not required if only one organization is present)"), - )..., - ) - if err := cmd.RegisterFlagCompletionFunc("organization", OrganizationCompletion); err != nil { - panic(err) - } - return cmd -} - -func NewCommand(use string, opts ...CommandOption) *cobra.Command { - cmd := &cobra.Command{ - Use: use, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - if GetBool(cmd, TelemetryFlag) { - cfg, err := LoadConfig(cmd) - if err != nil { - return - } - - if cfg.UniqueID == "" { - uniqueID := ksuid.New().String() - cfg.UniqueID = uniqueID - err = WriteConfig(cmd, *cfg) - if err != nil { - return - } - } - } - }, - } - for _, opt := range opts { - opt.apply(cmd) - } - return cmd -} diff --git a/pkg/command_context_test.go b/pkg/command_context_test.go deleted file mode 100644 index b6548f5d..00000000 --- a/pkg/command_context_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package fctl - -import ( - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" -) - -func TestResolveOrganizationID(t *testing.T) { - for _, tc := range []struct { - name string - profile Profile - flag, env string - want string - wantErr error - }{ - {name: "disconnected", wantErr: ErrOrganizationNotSpecified}, - {name: "no organizations", profile: contextProfile(), wantErr: ErrOrganizationNotSpecified}, - {name: "unique organization", profile: contextProfile(OrganizationAccess{ID: "only"}), want: "only"}, - {name: "ambiguous organizations", profile: contextProfile(OrganizationAccess{ID: "first"}, OrganizationAccess{ID: "second"}), wantErr: ErrMultipleOrganizationsFound}, - {name: "saved default", profile: Profile{DefaultOrganization: "saved"}, want: "saved"}, - {name: "environment overrides saved default", profile: Profile{DefaultOrganization: "saved"}, env: "environment", want: "environment"}, - {name: "flag overrides environment and saved default", profile: Profile{DefaultOrganization: "saved"}, flag: "explicit", env: "environment", want: "explicit"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("ORGANIZATION", tc.env) - cmd := &cobra.Command{} - cmd.Flags().String(OrganizationFlag, "", "") - if tc.flag != "" { - require.NoError(t, cmd.ParseFlags([]string{"--organization", tc.flag})) - } - id, err := ResolveOrganizationID(cmd, tc.profile) - require.ErrorIs(t, err, tc.wantErr) - require.Equal(t, tc.want, id) - }) - } -} - -func TestResolveStackID(t *testing.T) { - one := OrganizationAccess{ID: "one", Stacks: []StackAccess{{ID: "only"}}} - many := OrganizationAccess{ID: "many", Stacks: []StackAccess{{ID: "first"}, {ID: "second"}}} - for _, tc := range []struct { - name string - profile Profile - args []string - env string - wantOrg, wantStack string - wantErr string - }{ - {name: "disconnected", wantErr: ErrOrganizationNotSpecified.Error()}, - {name: "no organizations", profile: contextProfile(), wantErr: ErrOrganizationNotSpecified.Error()}, - {name: "ambiguous organizations even with explicit stack", profile: contextProfile(one, many), args: []string{"--stack=second"}, wantErr: ErrMultipleOrganizationsFound.Error()}, - {name: "no stacks", profile: contextProfile(OrganizationAccess{ID: "empty"}), wantErr: ErrNoStackSpecified.Error()}, - {name: "unique stack", profile: contextProfile(one), wantOrg: "one", wantStack: "only"}, - {name: "ambiguous stacks", profile: contextProfile(many), wantErr: "found more than one stack and no stack specified"}, - {name: "selected organization scopes inference", profile: contextProfile(many, one), args: []string{"--organization=one"}, wantOrg: "one", wantStack: "only"}, - {name: "unknown organization cannot infer stack", profile: contextProfile(one), args: []string{"--organization=unknown"}, wantErr: ErrOrganizationNotSpecified.Error()}, - {name: "saved defaults", profile: Profile{DefaultOrganization: "saved-org", DefaultStack: "saved-stack"}, wantOrg: "saved-org", wantStack: "saved-stack"}, - {name: "environment overrides saved stack", profile: Profile{DefaultOrganization: "saved-org", DefaultStack: "saved-stack"}, env: "environment", wantOrg: "saved-org", wantStack: "environment"}, - {name: "flag overrides environment and saved stack", profile: Profile{DefaultOrganization: "saved-org", DefaultStack: "saved-stack"}, args: []string{"--stack=explicit"}, env: "environment", wantOrg: "saved-org", wantStack: "explicit"}, - {name: "explicit selection resolves ambiguity", profile: contextProfile(one, many), args: []string{"--organization=many", "--stack=second"}, wantOrg: "many", wantStack: "second"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("ORGANIZATION", "") - t.Setenv("STACK", tc.env) - cmd := &cobra.Command{} - cmd.Flags().String(OrganizationFlag, "", "") - cmd.Flags().String(StackFlag, "", "") - require.NoError(t, cmd.ParseFlags(tc.args)) - organization, stack, err := ResolveStackID(cmd, tc.profile) - if tc.wantErr != "" { - require.EqualError(t, err, tc.wantErr) - } else { - require.NoError(t, err) - } - require.Equal(t, tc.wantOrg, organization) - require.Equal(t, tc.wantStack, stack) - }) - } -} - -func contextProfile(organizations ...OrganizationAccess) Profile { - return Profile{RootTokens: &Tokens{ID: IDToken{Claims: IDTokenClaims{Organizations: organizations}}}} -} diff --git a/pkg/command_render_test.go b/pkg/command_render_test.go deleted file mode 100644 index 88aa572e..00000000 --- a/pkg/command_render_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package fctl - -import ( - "bytes" - "encoding/json" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" -) - -type jsonTestController struct { - Amount int64 `json:"amount"` -} - -func (c *jsonTestController) GetStore() *jsonTestController { return c } - -func (c *jsonTestController) Run(*cobra.Command, []string) (Renderable, error) { - return nil, nil -} - -func TestWithRenderJSON(t *testing.T) { - cmd := NewCommand("json", WithStringFlag(OutputFlag, "json", "")) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - controller := &jsonTestController{Amount: 9007199254740993} - require.NoError(t, WithRender(cmd, nil, controller, nil)) - - var document struct { - Data jsonTestController `json:"data"` - } - require.NoError(t, json.Unmarshal(stdout.Bytes(), &document), "stdout: %q", stdout.String()) - require.Equal(t, controller.Amount, document.Data.Amount, "JSON must preserve integers beyond float64 precision") -} diff --git a/pkg/config.go b/pkg/config.go deleted file mode 100644 index 05ff022b..00000000 --- a/pkg/config.go +++ /dev/null @@ -1,63 +0,0 @@ -package fctl - -import ( - "os" - "path/filepath" - - "github.com/spf13/cobra" -) - -const ( - DefaultMembershipURI = "https://app.formance.cloud/api" - DefaultConsoleURL = "https://portal.formance.cloud" -) - -type Config struct { - CurrentProfile string `json:"currentProfile"` - UniqueID string `json:"uniqueID,omitempty"` -} - -func GetCurrentProfileName(cmd *cobra.Command, config Config) string { - if profile := GetString(cmd, ProfileFlag); profile != "" { - return profile - } - currentProfileName := config.CurrentProfile - if currentProfileName == "" { - currentProfileName = "default" - } - return currentProfileName -} - -func LoadConfigDir(cmd *cobra.Command) string { - return GetString(cmd, ConfigDir) -} - -func LoadConfigFilePath(cmd *cobra.Command) string { - return GetFilePath(cmd, "config.yml") -} - -func GetFilePath(cmd *cobra.Command, filename string) string { - return filepath.Join(LoadConfigDir(cmd), filename) -} - -func UpsertConfigDir(cmd *cobra.Command) error { - return os.MkdirAll(LoadConfigDir(cmd), 0700) -} - -func LoadConfig(cmd *cobra.Command) (*Config, error) { - v, err := ReadJSONFile[Config](cmd, "config.yml") - if os.IsNotExist(err) { - return &Config{ - CurrentProfile: "default", - }, nil - } - return v, err -} - -func WriteConfig(cmd *cobra.Command, config Config) error { - if err := UpsertConfigDir(cmd); err != nil { - return err - } - - return WriteJSONFile(LoadConfigFilePath(cmd), config) -} diff --git a/pkg/config_test.go b/pkg/config_test.go deleted file mode 100644 index a327ffdb..00000000 --- a/pkg/config_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package fctl - -import ( - "os" - "path/filepath" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" -) - -func TestGetCurrentProfileName(t *testing.T) { - for _, tc := range []struct { - name string - flag string - env string - saved string - want string - }{ - {name: "flag overrides environment and saved profile", flag: "flag-profile", env: "env-profile", saved: "saved-profile", want: "flag-profile"}, - {name: "environment overrides saved profile", env: "env-profile", saved: "saved-profile", want: "env-profile"}, - {name: "saved profile", saved: "saved-profile", want: "saved-profile"}, - {name: "default profile", want: "default"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("PROFILE", tc.env) - cmd := &cobra.Command{} - cmd.Flags().String(ProfileFlag, "", "") - if tc.flag != "" { - require.NoError(t, cmd.Flags().Set(ProfileFlag, tc.flag)) - } - - require.Equal(t, tc.want, GetCurrentProfileName(cmd, Config{CurrentProfile: tc.saved})) - }) - } -} - -func TestLoadConfigMissing(t *testing.T) { - dir := t.TempDir() - cmd := &cobra.Command{} - cmd.Flags().String(ConfigDir, dir, "") - - config, err := LoadConfig(cmd) - require.NoError(t, err) - require.Equal(t, &Config{CurrentProfile: "default"}, config) - require.NoFileExists(t, filepath.Join(dir, "config.yml")) -} - -func TestLoadConfigInvalid(t *testing.T) { - dir := t.TempDir() - cmd := &cobra.Command{} - cmd.Flags().String(ConfigDir, dir, "") - require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yml"), []byte(`{"currentProfile":`), 0600)) - - config, err := LoadConfig(cmd) - require.Error(t, err) - require.Nil(t, config) -} - -func TestWriteConfigRoundTrip(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested", "config") - cmd := &cobra.Command{} - cmd.Flags().String(ConfigDir, dir, "") - - want := Config{CurrentProfile: "selected-profile", UniqueID: "synthetic-id"} - require.NoError(t, WriteConfig(cmd, want)) - require.FileExists(t, filepath.Join(dir, "config.yml")) - config, err := LoadConfig(cmd) - require.NoError(t, err) - require.Equal(t, &want, config) - - // Replacing a longer configuration must not leave stale file contents. - want = Config{CurrentProfile: "other"} - require.NoError(t, WriteConfig(cmd, want)) - config, err = LoadConfig(cmd) - require.NoError(t, err) - require.Equal(t, &want, config) - contents, err := os.ReadFile(filepath.Clean(filepath.Join(dir, "config.yml"))) - require.NoError(t, err) - require.JSONEq(t, `{"currentProfile":"other"}`, string(contents)) -} - -func TestLoadCurrentProfile(t *testing.T) { - for _, tc := range []struct { - name string - content string - wantURI string - wantErr bool - }{ - {name: "missing profile uses default endpoint", wantURI: DefaultMembershipURI}, - {name: "saved profile", content: `{"membershipURI":"https://membership.invalid"}`, wantURI: "https://membership.invalid"}, - {name: "invalid profile does not fall back", content: `{broken`, wantErr: true}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("PROFILE", "") - dir := t.TempDir() - cmd := &cobra.Command{} - cmd.Flags().String(ConfigDir, dir, "") - cmd.Flags().String(ProfileFlag, "selected", "") - if tc.content != "" { - profileDir := filepath.Join(dir, "profiles", "selected") - require.NoError(t, os.MkdirAll(profileDir, 0700)) - require.NoError(t, os.WriteFile(filepath.Join(profileDir, "profile.json"), []byte(tc.content), 0600)) - } - profile, name, err := LoadCurrentProfile(cmd, Config{CurrentProfile: "other"}) - require.Equal(t, "selected", name) - if tc.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, tc.wantURI, profile.MembershipURI) - require.False(t, profile.IsConnected()) - }) - } -} diff --git a/pkg/controller.go b/pkg/controller.go deleted file mode 100644 index 46143c53..00000000 --- a/pkg/controller.go +++ /dev/null @@ -1,16 +0,0 @@ -package fctl - -import ( - "github.com/spf13/cobra" -) - -type Renderable interface { - Render(cmd *cobra.Command, args []string) error -} -type Controller[T any] interface { - GetStore() T - Run(cmd *cobra.Command, args []string) (Renderable, error) -} -type ExportedData struct { - Data interface{} `json:"data"` -} diff --git a/pkg/cursor.go b/pkg/cursor.go deleted file mode 100644 index f4e0decb..00000000 --- a/pkg/cursor.go +++ /dev/null @@ -1,78 +0,0 @@ -package fctl - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - "github.com/spf13/cobra" -) - -var ( - cursorFlag = "cursor" - pageSizeFlag = "page-size" -) - -func WithPageSizeFlag() CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().Int32(pageSizeFlag, 15, "Number of items per page") - } -} - -func WithCursorFlag() CommandOptionFn { - return func(cmd *cobra.Command) { - cmd.Flags().String(cursorFlag, "", "Cursor for pagination") - } -} - -func GetCursor(cmd *cobra.Command) (string, error) { - cursor, err := cmd.Flags().GetString(cursorFlag) - if err != nil { - return "", fmt.Errorf("failed to get cursor: %w", err) - } - if cursor == "" { - return "", nil - } - return cursor, nil -} - -func GetPageSize(cmd *cobra.Command) (int32, error) { - pageSize, err := cmd.Flags().GetInt32(pageSizeFlag) - if err != nil { - return 0, fmt.Errorf("failed to get page size: %w", err) - } - if pageSize <= 0 { - return 0, fmt.Errorf("page size must be greater than 0") - } - return pageSize, nil -} - -type Cursor struct { - HasMore bool - PageSize int64 - Next *string - Previous *string -} - -func RenderCursor(writer io.Writer, cursor Cursor) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if cursor.Next == nil { - return "" - } - return *cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if cursor.Previous == nil { - return "" - } - return *cursor.Previous - }()}) - - return pterm.DefaultTable. - WithWriter(writer). - WithData(tableData). - Render() -} diff --git a/pkg/dialog.go b/pkg/dialog.go deleted file mode 100644 index 16480d4a..00000000 --- a/pkg/dialog.go +++ /dev/null @@ -1,21 +0,0 @@ -package fctl - -import ( - "fmt" - - "github.com/pterm/pterm" -) - -type Dialog interface { - Info(msg string, args ...any) -} - -type ptermDialog struct{} - -func (p ptermDialog) Info(msg string, args ...any) { - pterm.DefaultLogger.Info(fmt.Sprintf(msg, args...)) -} - -func NewPTermDialog() Dialog { - return &ptermDialog{} -} diff --git a/pkg/errors.go b/pkg/errors.go deleted file mode 100644 index 9641dc3a..00000000 --- a/pkg/errors.go +++ /dev/null @@ -1,52 +0,0 @@ -package fctl - -import "fmt" - -type ErrUnauthorized struct { -} - -func (e ErrUnauthorized) Is(target error) bool { - _, ok := target.(*ErrUnauthorized) - return ok -} - -func (e *ErrUnauthorized) Error() string { - return "unauthorized access" -} - -func newErrUnauthorized() error { - return &ErrUnauthorized{} -} - -type ErrForbidden struct { -} - -func (e ErrForbidden) Is(target error) bool { - _, ok := target.(*ErrForbidden) - return ok -} - -func (e *ErrForbidden) Error() string { - return "forbidden access" -} - -func newErrForbidden() error { - return &ErrForbidden{} -} - -type UnexpectedStatusCodeError struct { - StatusCode int -} - -func (e UnexpectedStatusCodeError) Is(target error) bool { - _, ok := target.(*UnexpectedStatusCodeError) - return ok -} - -func (e *UnexpectedStatusCodeError) Error() string { - return fmt.Sprintf("unexpected status code: %d", e.StatusCode) -} - -func newUnexpectedStatusCodeError(statusCode int) error { - return &UnexpectedStatusCodeError{StatusCode: statusCode} -} diff --git a/pkg/file.go b/pkg/file.go deleted file mode 100644 index 6637ab54..00000000 --- a/pkg/file.go +++ /dev/null @@ -1,32 +0,0 @@ -package fctl - -import ( - "io" - "os" - "path/filepath" - - "github.com/pkg/errors" - "github.com/spf13/cobra" -) - -func ReadFile(cmd *cobra.Command, where string) (string, error) { - var ret string - if where == "-" { - if NeedConfirm(cmd) { - return "", errors.New("You need to use --confirm flag to use stdin") - } - data, err := io.ReadAll(cmd.InOrStdin()) - if err != nil && err != io.EOF { - return "", errors.Wrapf(err, "reading stdin") - } - - ret = string(data) - } else { - data, err := os.ReadFile(filepath.Clean(where)) - if err != nil { - return "", errors.Wrapf(err, "reading file %s", where) - } - ret = string(data) - } - return ret, nil -} diff --git a/pkg/flags.go b/pkg/flags.go deleted file mode 100644 index 0df9d65a..00000000 --- a/pkg/flags.go +++ /dev/null @@ -1,95 +0,0 @@ -package fctl - -import ( - "os" - "strconv" - "strings" - "time" - - "github.com/iancoleman/strcase" - "github.com/spf13/cobra" -) - -const ( - MembershipURIFlag = "membership-uri" - ConfigDir = "config-dir" - ProfileFlag = "profile" - OutputFlag = "output" - DebugFlag = "debug" - InsecureTlsFlag = "insecure-tls" - HTTPCloseOnErrorFlag = "http-close-on-error" - TelemetryFlag = "telemetry" - StackFlag = "stack" - OrganizationFlag = "organization" - FrameworkURIFlag = "framework-uri" - DeployAppAliasFlag = "deploy-app-alias" -) - -const DefaultDeployAppAlias = "deploy" - -func GetBool(cmd *cobra.Command, flagName string) bool { - v, err := cmd.Flags().GetBool(flagName) - if err != nil { - fromEnv := strings.ToLower(os.Getenv(strcase.ToScreamingSnake(flagName))) - return fromEnv == "true" || fromEnv == "1" - } - return v -} - -func GetString(cmd *cobra.Command, flagName string) string { - v, err := cmd.Flags().GetString(flagName) - if err != nil || v == "" { - return os.Getenv(strcase.ToScreamingSnake(flagName)) - } - return v -} - -func GetStringSlice(cmd *cobra.Command, flagName string) []string { - v, err := cmd.Flags().GetStringSlice(flagName) - if err != nil || len(v) == 0 { - envVar := os.Getenv(strcase.ToScreamingSnake(flagName)) - if envVar == "" { - return []string{} - } - return strings.Split(envVar, " ") - } - return v -} - -func GetInt(cmd *cobra.Command, flagName string) int { - v, err := cmd.Flags().GetInt(flagName) - if err != nil { - v := os.Getenv(strcase.ToScreamingSnake(flagName)) - if v != "" { - v, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return 0 - } - return int(v) - } - return 0 - } - return v -} - -func GetDateTime(cmd *cobra.Command, flagName string) (*time.Time, error) { - v, err := cmd.Flags().GetString(flagName) - if err != nil || v == "" { - v = os.Getenv(strcase.ToScreamingSnake(flagName)) - } - - if v == "" { - return nil, nil - } - - t, err := time.Parse(time.RFC3339, v) - if err != nil { - return nil, err - } - - return &t, nil -} - -func Ptr[T any](t T) *T { - return &t -} diff --git a/pkg/flags_test.go b/pkg/flags_test.go deleted file mode 100644 index 0a25f952..00000000 --- a/pkg/flags_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package fctl - -import ( - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/require" -) - -func TestGetStringContextFlags(t *testing.T) { - for _, flag := range []struct { - name string - env string - }{ - {name: ProfileFlag, env: "PROFILE"}, - {name: OrganizationFlag, env: "ORGANIZATION"}, - {name: StackFlag, env: "STACK"}, - {name: ConfigDir, env: "CONFIG_DIR"}, - } { - t.Run(flag.name, func(t *testing.T) { - for _, tc := range []struct { - name string - missing bool - defaultValue string - set bool - value string - env string - want string - }{ - {name: "explicit flag wins", set: true, value: "flag-value", env: "env-value", want: "flag-value"}, - {name: "unset flag uses environment", env: "env-value", want: "env-value"}, - {name: "explicit empty flag uses environment", set: true, env: "env-value", want: "env-value"}, - {name: "unregistered flag uses environment", missing: true, env: "env-value", want: "env-value"}, - {name: "nonempty registered default wins", defaultValue: "default-value", env: "env-value", want: "default-value"}, - {name: "no value", want: ""}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(flag.env, tc.env) - cmd := &cobra.Command{} - if !tc.missing { - cmd.Flags().String(flag.name, tc.defaultValue, "") - } - if tc.set { - require.NoError(t, cmd.Flags().Set(flag.name, tc.value)) - } - - require.Equal(t, tc.want, GetString(cmd, flag.name)) - }) - } - }) - } -} diff --git a/pkg/http.go b/pkg/http.go deleted file mode 100644 index 044550c0..00000000 --- a/pkg/http.go +++ /dev/null @@ -1,207 +0,0 @@ -package fctl - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/TylerBrock/colorjson" - "github.com/spf13/cobra" -) - -func GetHttpClient(cmd *cobra.Command) *http.Client { - return &http.Client{ - Transport: NewHTTPTransport(cmd), - } -} - -func printBody(data []byte) { - if len(data) == 0 { - return - } - raw := make(map[string]any) - if err := json.Unmarshal(data, &raw); err == nil { - f := colorjson.NewFormatter() - f.Indent = 2 - colorized, err := f.Marshal(raw) - if err != nil { - panic(err) - } - fmt.Println(string(colorized)) - } else { - fmt.Println(string(data)) - } -} - -func debugRoundTripper(rt http.RoundTripper) RoundTripperFn { - return func(req *http.Request) (*http.Response, error) { - data, err := httputil.DumpRequest(req, false) - if err != nil { - panic(err) - } - fmt.Println(string(data)) - - if req.Body != nil { - data, err = io.ReadAll(req.Body) - if err != nil { - panic(err) - } - _ = req.Body.Close() - req.Body = io.NopCloser(bytes.NewBuffer(data)) - printBody(data) - } - - rsp, err := rt.RoundTrip(req) - if err != nil { - return nil, err - } - - data, err = httputil.DumpResponse(rsp, false) - if err != nil { - panic(err) - } - fmt.Println(string(data)) - - if rsp.Body != nil { - data, err = io.ReadAll(rsp.Body) - if err != nil { - panic(err) - } - _ = rsp.Body.Close() - rsp.Body = io.NopCloser(bytes.NewBuffer(data)) - printBody(data) - } - - return rsp, nil - } -} - -func NewHTTPTransport(cmd *cobra.Command) http.RoundTripper { - var transport = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: GetBool(cmd, InsecureTlsFlag), - }, - } - var roundTripper http.RoundTripper = transport - - if GetBool(cmd, HTTPCloseOnErrorFlag) { - roundTripper = &closeOnErrorRoundTripper{ - transport: transport, - } - } - - if GetBool(cmd, DebugFlag) { - roundTripper = debugRoundTripper(roundTripper) - } - - return newInjectHTTPHeadersRoundTripper( - http.Header{ - "User-Agent": []string{fmt.Sprintf("fctl/%s", getVersion(cmd))}, - }, - roundTripper, - ) -} - -var _ http.RoundTripper = (*RoundTripperFn)(nil) - -type RoundTripperFn func(req *http.Request) (*http.Response, error) - -func (fn RoundTripperFn) RoundTrip(req *http.Request) (*http.Response, error) { - return fn(req) -} - -var _ http.RoundTripper = (*closeOnErrorRoundTripper)(nil) - -type closeOnErrorRoundTripper struct { - transport *http.Transport -} - -func (rt *closeOnErrorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - rsp, err := rt.transport.RoundTrip(req) - if err != nil || rsp.StatusCode >= 400 { - rt.transport.CloseIdleConnections() - } - return rsp, err -} - -var _ http.RoundTripper = (*injectHTTPHeadersRoundTripper)(nil) - -type injectHTTPHeadersRoundTripper struct { - headers http.Header - next http.RoundTripper -} - -func (rt *injectHTTPHeadersRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - for key, values := range rt.headers { - for _, value := range values { - req.Header.Add(key, value) - } - } - return rt.next.RoundTrip(req) -} - -func newInjectHTTPHeadersRoundTripper(headers http.Header, next http.RoundTripper) http.RoundTripper { - return &injectHTTPHeadersRoundTripper{ - headers: headers, - next: next, - } -} - -var _ http.RoundTripper = (*rewriteLocalhostServerURLRoundTripper)(nil) - -type rewriteLocalhostServerURLRoundTripper struct { - base *url.URL - next http.RoundTripper -} - -func (rt *rewriteLocalhostServerURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if req.URL.Scheme != "http" || req.URL.Host != "localhost:8080" { - return rt.next.RoundTrip(req) - } - - rewritten := req.Clone(req.Context()) - rewritten.URL = cloneURL(req.URL) - rewritten.URL.Scheme = rt.base.Scheme - rewritten.URL.Host = rt.base.Host - rewritten.URL.Path = joinURLPath(rt.base.Path, req.URL.Path) - rewritten.URL.RawPath = "" - rewritten.Host = rt.base.Host - - return rt.next.RoundTrip(rewritten) -} - -func newRewriteLocalhostServerURLRoundTripper(serverURL string, next http.RoundTripper) (http.RoundTripper, error) { - base, err := url.Parse(serverURL) - if err != nil { - return nil, err - } - return &rewriteLocalhostServerURLRoundTripper{ - base: base, - next: next, - }, nil -} - -func cloneURL(from *url.URL) *url.URL { - to := *from - return &to -} - -func joinURLPath(basePath, requestPath string) string { - basePath = strings.TrimRight(basePath, "/") - requestPath = strings.TrimLeft(requestPath, "/") - if basePath == "" { - return "/" + requestPath - } - if requestPath == "" { - return basePath - } - return basePath + "/" + requestPath -} diff --git a/pkg/http_test.go b/pkg/http_test.go deleted file mode 100644 index b39e9a11..00000000 --- a/pkg/http_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package fctl - -import ( - "net/http" - "reflect" - "testing" - - "github.com/spf13/cobra" -) - -func TestNewHTTPTransportUsesProxyFromEnvironment(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().Bool(DebugFlag, false, "") - cmd.Flags().Bool(HTTPCloseOnErrorFlag, false, "") - cmd.Flags().Bool(InsecureTlsFlag, false, "") - - roundTripper := NewHTTPTransport(cmd) - headerRoundTripper, ok := roundTripper.(*injectHTTPHeadersRoundTripper) - if !ok { - t.Fatalf("expected header-injecting round tripper, got %T", roundTripper) - } - - transport, ok := headerRoundTripper.next.(*http.Transport) - if !ok { - t.Fatalf("expected HTTP transport, got %T", headerRoundTripper.next) - } - if transport.Proxy == nil { - t.Fatal("expected HTTP transport to use proxy settings from the environment") - } - if reflect.ValueOf(transport.Proxy).Pointer() != reflect.ValueOf(http.ProxyFromEnvironment).Pointer() { - t.Fatal("expected HTTP transport proxy to be http.ProxyFromEnvironment") - } -} diff --git a/pkg/metadata.go b/pkg/metadata.go deleted file mode 100644 index 8465da4f..00000000 --- a/pkg/metadata.go +++ /dev/null @@ -1,56 +0,0 @@ -package fctl - -import ( - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/pterm/pterm" - - "github.com/formancehq/go-libs/v4/metadata" -) - -func ParseMetadata(array []string) (metadata.Metadata, error) { - metadata := metadata.Metadata{} - for _, v := range array { - parts := strings.SplitN(v, "=", 2) - if len(parts) == 1 { - return nil, fmt.Errorf("malformed metadata: %s", v) - } - metadata[parts[0]] = parts[1] - } - return metadata, nil -} - -func PrintMetadata(out io.Writer, metadata metadata.Metadata) error { - Section.WithWriter(out).Println("Metadata") - if len(metadata) == 0 { - Println("No metadata.") - return nil - } - tableData := pterm.TableData{} - for k, v := range metadata { - tableData = append(tableData, []string{pterm.LightCyan(k), v}) - } - - return pterm.DefaultTable. - WithWriter(out). - WithData(tableData). - Render() -} - -func MetadataAsShortString[V any](metadata map[string]V) string { - metadataAsString := "" - for k, v := range metadata { - asJson, err := json.Marshal(v) - if err != nil { - panic(err) - } - metadataAsString += fmt.Sprintf("%s=%s ", k, string(asJson)) - } - if len(metadataAsString) > 100 { - metadataAsString = metadataAsString[:100] + "..." - } - return metadataAsString -} diff --git a/pkg/organization.go b/pkg/organization.go deleted file mode 100644 index ba49be60..00000000 --- a/pkg/organization.go +++ /dev/null @@ -1,33 +0,0 @@ -package fctl - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/formancehq/go-libs/v4/collectionutils" -) - -func OrganizationCompletion(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - cfg, err := LoadConfig(cmd) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - profile, _, err := LoadCurrentProfile(cmd, *cfg) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - organizationClaims := profile.RootTokens.ID.Claims.Organizations - organizationClaims = collectionutils.Filter(organizationClaims, func(s OrganizationAccess) bool { - return toComplete == "" || strings.HasPrefix(s.ID, toComplete) - }) - - ret := collectionutils.Map(organizationClaims, func(from OrganizationAccess) string { - return fmt.Sprintf("%s\t%s", from.ID, from.DisplayName) - }) - - return ret, cobra.ShellCompDirectiveNoFileComp -} diff --git a/pkg/pluginsdk/README.md b/pkg/pluginsdk/README.md new file mode 100644 index 00000000..cab7cd33 --- /dev/null +++ b/pkg/pluginsdk/README.md @@ -0,0 +1,96 @@ +# fctl plugin SDK + +This independent Go module defines the public fctl v4 plugin contract. It has +no dependency on Cobra, terminal forms, profiles, or the core's authentication. +Go 1.26 or newer is required. + +An embedded or external plugin implements `GetManifest` and `Execute` and +exposes a factory with the signature `func(*http.Client) pluginsdk.Plugin`. +`GetManifest` must work with a nil HTTP client and without service requests. +The manifest includes declarative forms; fctl owns their presentation. + +## External executable + +```go +func main() { + transport.Serve(ledger.New) +} +``` + +The host uses `transport.Open(ctx, absoluteBinaryPath, httpClient, endpoint)` +and defers `Close`. For metadata only, it can supply a nil client and an empty +endpoint. Execute requires an injected client and endpoint. Its request may +omit Endpoint; a supplied Endpoint must match the host's endpoint. + +The gRPC runtime follows the process and broker design of fctl PR #126 while +retaining the current v4 contract. The HTTP callback uses `GRPCBroker` and +local mutual TLS. Only the host HTTP client contacts the service. Its +authentication, token refresh, request debugging, timeout and cookie jar +continue to apply. Every request and redirect must stay within the endpoint's +origin and path boundary. Dot segments and encoded traversal are rejected. +Credential headers supplied by the plugin are rejected; credential response +headers are removed. Authentication credentials must never be placed in +`ExecuteRequest.Context`. + +The child receives no command-line arguments beyond its executable path and +does not inherit the host's environment. Go-plugin supplies only its protocol +and public TLS handshake values; Windows also receives SYSTEMROOT. The runtime +discards plugin stdout and stderr. Ordinary HTTP transport failures expose a +generic message rather than host paths or credentials. + +Service JSON is transferred as bytes, preserving whitespace and integer +precision. Structured `httpclient.Error` fields and partial response data are +preserved. Request bodies are limited to 4 MiB, request metadata and manifests +to 1 MiB, HTTP headers to 64 KiB, and total result data plus structured error +to 32 MiB. gRPC messages are limited to 48 MiB to include base64 framing. The +runtime performs no automatic HTTP or gRPC retries. + +Canceling the Open context or an in-flight method terminates the executable; +it cannot be reused afterward. Close is idempotent and waits for process +cleanup. Only trusted, verified executables should be opened: a separate +process is not an operating-system sandbox. Artifact discovery, version +selection, digest verification, and installation belong to the host. + +## Validation + +From this module directory: + +```sh +go test -race ./... +nix develop --impure --command golangci-lint run --config ../../.golangci.yml ./... +``` + +The transport suite launches the test binary as an actual plugin, with no +helper arguments or inherited environment, and separately exercises gRPC +servers in-process for coverage. It checks HTTP ownership, credential +isolation, endpoint boundaries, redirects, exact JSON, errors and partial +results, limits, concurrent callbacks, process crashes, and cancellation. + +### Optional aliases and file effects + +`CommandSpec.Aliases` gives alternative command names. `FindCommand` accepts +them and `NormalizeRequest` returns a canonical command path. Hosts validate +alias collisions when registering a manifest. + +`CommandSpec.Files` declares input argument/flag and output flag effects. +The host reads a local file, stdin (`-`) or an explicit HTTPS source into Body; +plugins do not open these sources. `ReadFormat` selects JSON, YAML-to-JSON or +raw text encoded as a JSON string. Inputs are bounded to 4 MiB. `WriteFormat` +selects JSON, NDJSON or YAML. `FormatFlag` can override it; `yml` means YAML. +File writes are atomic and private, and execution errors do not create exports. +Direct SDK callers supply already-read Body and consume Data as before. + +### Optional streams and progress + +Implement `StreamingPlugin.ExecuteStream` for a command with +`CommandSpec.Streams`. `ExecutionStreams` provides host-owned raw input/output, +optional input size and a reporter for `ProgressEvent{Phase, Completed, Total, +Unit}`. The final JSON result remains separate. Propagate all read, write and +report errors and never retain invocation resources. + +The native runtime transfers bounded chunks with backpressure, pulls input only +when the plugin reads, and preserves host-owned streaming HTTP. Ordinary +`Execute` calls and protocol version 1 remain available. A peer without the +streaming extension returns an explicit unsupported-method error. +See [streaming contract](../../docs/plugin-streaming.md) for lifecycle, +progress rendering, file atomicity and migration details. diff --git a/pkg/pluginsdk/aliases_test.go b/pkg/pluginsdk/aliases_test.go new file mode 100644 index 00000000..5671083a --- /dev/null +++ b/pkg/pluginsdk/aliases_test.go @@ -0,0 +1,163 @@ +package pluginsdk_test + +import ( + "bytes" + "encoding/json" + "maps" + "reflect" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func aliasContractManifest() pluginsdk.Manifest { + manifest := contractManifest() + manifest.Name = "legacy-ledger" + manifest.Root.Use = "legacy-ledger" + manifest.Root.Aliases = []string{"legacy_ledger"} + group := &manifest.Root.Subcommands[0] + group.Use, group.Aliases, group.Service = "bank-accounts", []string{"bank_accounts"}, "payments" + group.Subcommands[0].Use = "get-metadata ID [SECOND_ID]" + group.Subcommands[0].Aliases = []string{"get_metadata", "show"} + return manifest +} + +func TestFindAndNormalizeAliasesAtEveryLevel(t *testing.T) { + t.Parallel() + manifest := aliasContractManifest() + canonical := []string{"legacy-ledger", "bank-accounts", "get-metadata"} + for _, path := range [][]string{ + canonical, + {"legacy_ledger", "bank_accounts", "get_metadata"}, + {"legacy-ledger", "bank_accounts", "get-metadata"}, + {"legacy_ledger", "bank-accounts", "show"}, + } { + t.Run(strings.Join(path, "/"), func(t *testing.T) { + t.Parallel() + testNormalizeAliasPath(t, manifest, path, canonical) + }) + } +} + +func testNormalizeAliasPath(t *testing.T, manifest pluginsdk.Manifest, path, canonical []string) { + t.Helper() + command, err := pluginsdk.FindCommand(manifest, path) + if err != nil || !reflect.DeepEqual(command, manifest.Root.Subcommands[0].Subcommands[0]) { + t.Fatalf("alias lookup lost metadata: %+v (%v)", command, err) + } + request := contractRequest() + request.CommandPath = slices.Clone(path) + before := slices.Clone(request.CommandPath) + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil || !slices.Equal(normalized.CommandPath, canonical) { + t.Fatalf("canonical route = %v (%v)", normalized.CommandPath, err) + } + if !slices.Equal(request.CommandPath, before) { + t.Fatal("normalization rewrote the caller's route") + } + normalized.CommandPath[0] = "modified" + if !slices.Equal(request.CommandPath, before) { + t.Fatal("canonical route shares caller storage") + } + service, err := pluginsdk.CommandService(manifest, path) + if err != nil || service != "payments" { + t.Fatalf("alias lookup lost the inherited service: %q (%v)", service, err) + } +} + +func TestAliasNormalizationPreservesChangedFlagsAndBody(t *testing.T) { + t.Parallel() + for _, changed := range []map[string]bool{nil, {}, {"enabled": true, "region": false}} { + request := contractRequest() + request.CommandPath = []string{"legacy_ledger", "bank_accounts", "get_metadata"} + request.Flags["enabled"] = "false" + request.ChangedFlags = maps.Clone(changed) + request.Body = json.RawMessage(`{"amount":900719925474099312345678901234567890}`) + request.Context = map[string]string{"stack": "fixture"} + before := cloneAliasRequest(request) + normalized, err := pluginsdk.NormalizeRequest(aliasContractManifest(), request) + if err != nil { + t.Fatal(err) + } + assertAliasNormalizationData(t, before, normalized) + normalized.ChangedFlags = ensureChangedFlags(normalized.ChangedFlags) + normalized.ChangedFlags["region"] = true + if !reflect.DeepEqual(request, before) { + t.Fatal("normalized changed flags share caller state") + } + } +} + +func ensureChangedFlags(flags map[string]bool) map[string]bool { + if flags == nil { + return make(map[string]bool) + } + return flags +} + +func cloneAliasRequest(request pluginsdk.ExecuteRequest) pluginsdk.ExecuteRequest { + request.CommandPath = slices.Clone(request.CommandPath) + request.Args = slices.Clone(request.Args) + request.Flags = maps.Clone(request.Flags) + request.ChangedFlags = maps.Clone(request.ChangedFlags) + request.Body = bytes.Clone(request.Body) + request.Context = maps.Clone(request.Context) + return request +} + +func assertAliasNormalizationData(t *testing.T, original, normalized pluginsdk.ExecuteRequest) { + t.Helper() + if !reflect.DeepEqual(normalized.ChangedFlags, original.ChangedFlags) || normalized.Flags["enabled"] != "false" || normalized.Flags["limit"] != "100" { + t.Fatalf("default filling changed explicit flag markers: %+v", normalized) + } + if string(normalized.Body) != string(original.Body) || !maps.Equal(normalized.Context, original.Context) || normalized.Endpoint != original.Endpoint || !slices.Equal(normalized.Args, original.Args) { + t.Fatal("alias normalization changed execution data") + } + again, err := pluginsdk.NormalizeRequest(aliasContractManifest(), normalized) + if err != nil || !reflect.DeepEqual(again, normalized) { + t.Fatalf("repeated normalization changed the request: %v", err) + } +} + +func TestAliasesKeepSDKGates(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + edit func(*pluginsdk.Manifest, *pluginsdk.ExecuteRequest) + }{ + {"protocol", func(m *pluginsdk.Manifest, _ *pluginsdk.ExecuteRequest) { m.ProtocolVersion++ }}, + {"arguments", func(_ *pluginsdk.Manifest, r *pluginsdk.ExecuteRequest) { r.Args = nil }}, + {"required flag", func(_ *pluginsdk.Manifest, r *pluginsdk.ExecuteRequest) { delete(r.Flags, "required") }}, + {"unknown changed flag", func(_ *pluginsdk.Manifest, r *pluginsdk.ExecuteRequest) { + r.ChangedFlags = map[string]bool{"missing": false} + }}, + {"invalid body", func(_ *pluginsdk.Manifest, r *pluginsdk.ExecuteRequest) { r.Body = json.RawMessage("{") }}, + {"confirmation", func(m *pluginsdk.Manifest, _ *pluginsdk.ExecuteRequest) { + leaf := &m.Root.Subcommands[0].Subcommands[0] + leaf.Confirm = true + leaf.Flags = append(leaf.Flags, pluginsdk.FlagSpec{Name: "confirm", Type: "bool", Default: "false"}) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := aliasContractManifest() + request := contractRequest() + request.CommandPath = []string{"legacy_ledger", "bank_accounts", "show"} + tc.edit(&manifest, &request) + before := cloneAliasRequest(request) + if _, err := pluginsdk.NormalizeRequest(manifest, request); err == nil { + t.Fatal("alias bypassed an SDK gate") + } + if !reflect.DeepEqual(request, before) { + t.Fatal("failed alias normalization mutated the caller") + } + }) + } + for _, path := range [][]string{{"legacy_ledger", "bank_accounts", "missing"}, {"legacy_ledger", "bank_accounts", "show", "extra"}} { + if _, err := pluginsdk.FindCommand(aliasContractManifest(), path); err == nil { + t.Fatal("alias lookup accepted an unknown route") + } + } +} diff --git a/pkg/pluginsdk/files.go b/pkg/pluginsdk/files.go new file mode 100644 index 00000000..e5506adc --- /dev/null +++ b/pkg/pluginsdk/files.go @@ -0,0 +1,21 @@ +package pluginsdk + +// FileSpec describes file IO performed by the host, never by the plugin. +// ReadArgument and ReadFlag are alternative, optional sources. A supplied +// source conflicts with an explicit body. "-" selects stdin or stdout. +// The host leaves arguments and flags intact and supplies decoded input in Body. +type FileSpec struct { + ReadArgument *int `json:"readArgument,omitzero"` + ReadFlag string `json:"readFlag,omitzero"` + WriteFlag string `json:"writeFlag,omitzero"` + // ReadFormat is "string" (JSON-encoded raw text), "json" (the default), + // or "yaml" (converted to JSON). Input is limited to 4 MiB. + ReadFormat string `json:"readFormat,omitzero"` + // WriteFormat is "json" (the default), "ndjson", or "yaml". + // Without a destination, JSON uses the host's normal output renderer. + WriteFormat string `json:"writeFormat,omitzero"` + // FormatFlag optionally overrides WriteFormat using a declared string flag. + // An empty flag value retains WriteFormat. It accepts json, ndjson, yaml, + // or the historical yml alias for yaml. + FormatFlag string `json:"formatFlag,omitzero"` +} diff --git a/pkg/pluginsdk/files_test.go b/pkg/pluginsdk/files_test.go new file mode 100644 index 00000000..0614dea8 --- /dev/null +++ b/pkg/pluginsdk/files_test.go @@ -0,0 +1,41 @@ +package pluginsdk_test + +import ( + "encoding/json" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func TestFileSpecMetadataRoundTrip(t *testing.T) { + t.Parallel() + spec := pluginsdk.CommandSpec{Use: "import", Aliases: []string{"old_import"}, Files: &pluginsdk.FileSpec{ + ReadArgument: new(0), ReadFlag: "input", ReadFormat: "string", + WriteFlag: "file", WriteFormat: "ndjson", FormatFlag: "format", + }} + raw, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + var decoded pluginsdk.CommandSpec + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Files == nil || decoded.Files.ReadArgument == nil || *decoded.Files.ReadArgument != 0 || + decoded.Files.ReadFlag != "input" || decoded.Files.WriteFlag != "file" || + decoded.Files.ReadFormat != "string" || decoded.Files.WriteFormat != "ndjson" || decoded.Files.FormatFlag != "format" || + len(decoded.Aliases) != 1 || decoded.Aliases[0] != "old_import" { + t.Fatalf("metadata changed: %s", raw) + } + var fields map[string]json.RawMessage + raw, err = json.Marshal(pluginsdk.CommandSpec{Use: "list"}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, exists := fields["files"]; exists { + t.Fatal("nil file metadata was not omitted") + } +} diff --git a/pkg/pluginsdk/go.mod b/pkg/pluginsdk/go.mod new file mode 100644 index 00000000..e2f1c875 --- /dev/null +++ b/pkg/pluginsdk/go.mod @@ -0,0 +1,23 @@ +module github.com/formancehq/fctl/pkg/pluginsdk + +go 1.26.0 + +require ( + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.8.0 + google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 +) + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.57.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-20260706201446-f0a921348800 // indirect +) diff --git a/pkg/pluginsdk/go.sum b/pkg/pluginsdk/go.sum new file mode 100644 index 00000000..e4c8e0e9 --- /dev/null +++ b/pkg/pluginsdk/go.sum @@ -0,0 +1,56 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +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/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/pkg/pluginsdk/httpclient/client.go b/pkg/pluginsdk/httpclient/client.go new file mode 100644 index 00000000..c171dbb6 --- /dev/null +++ b/pkg/pluginsdk/httpclient/client.go @@ -0,0 +1,146 @@ +// Package api implements the shared HTTP boundary for service modules. +package httpclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net" + "net/http" + "net/url" + "strings" +) + +// Client targets one service endpoint, including any gateway prefix. +type Client struct { + base string + http *http.Client + metadata map[string]string +} + +// Error keeps a structured service failure, including partial bulk results. +type Error struct { + StatusCode int + Code string + Message string + Body json.RawMessage +} + +func (e *Error) Error() string { + if e.Code != "" { + return fmt.Sprintf("HTTP %d (%s): %s", e.StatusCode, e.Code, e.Message) + } + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, http.StatusText(e.StatusCode)) +} + +func (c *Client) Endpoint() string { return c.base } +func (c *Client) HTTPClient() *http.Client { return c.http } + +// WithContext returns a client copy with non-secret host metadata. Values must +// never contain authentication material. Metadata is not sent as HTTP headers. +func (c *Client) WithContext(values map[string]string) *Client { + copyClient := *c + copyClient.metadata = maps.Clone(values) + return ©Client +} + +// Context returns a defensive copy of the host metadata, or nil when unset. +func (c *Client) Context() map[string]string { return maps.Clone(c.metadata) } + +func New(base string, client *http.Client) (*Client, error) { + if err := ValidateURL(base); err != nil { + return nil, err + } + return &Client{base: strings.TrimRight(base, "/"), http: client}, nil +} + +// ValidateURL rejects ambiguous endpoints and credentials embedded in URLs. +func ValidateURL(value string) error { + u, err := url.Parse(value) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("endpoint must be an absolute HTTP(S) URL without credentials, query or fragment") + } + return nil +} + +// ValidateSecureURL permits plaintext authentication only on loopback. +func ValidateSecureURL(value string) error { + if err := ValidateURL(value); err != nil { + return err + } + u, err := url.Parse(value) + if err != nil { + return err + } + if u.Scheme == "https" { + return nil + } + ip := net.ParseIP(u.Hostname()) + if u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()) { + return nil + } + return fmt.Errorf("authenticated endpoints require HTTPS except on loopback") +} + +// Path encodes each user-provided identifier as exactly one path segment. +func Path(segments ...string) string { + encoded := make([]string, len(segments)) + for i, segment := range segments { + encoded[i] = url.PathEscape(segment) + } + return "/" + strings.Join(encoded, "/") +} + +// Do preserves JSON numbers and performs each operation exactly once. +func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body json.RawMessage, headers http.Header) (result json.RawMessage, err error) { + u := c.base + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build service request: %w", err) + } + req.Header = headers.Clone() + if req.Header == nil { + req.Header = make(http.Header) + } + req.Header.Set("Accept", "application/json") + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("service request failed: %w", err) + } + defer func() { err = errors.Join(err, resp.Body.Close()) }() + data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20+1)) + if err != nil { + return nil, fmt.Errorf("read service response: %w", err) + } + if len(data) > 32<<20 { + return nil, fmt.Errorf("service response exceeds 32 MiB; reduce page size") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var details struct { + Code string `json:"errorCode"` + Message string `json:"errorMessage"` + } + failure := &Error{StatusCode: resp.StatusCode} + if json.Unmarshal(data, &details) == nil { + failure.Code, failure.Message, failure.Body = details.Code, details.Message, data + } + return nil, failure + } + if len(bytes.TrimSpace(data)) == 0 { + return json.RawMessage("null"), nil + } + if !json.Valid(data) { + return nil, fmt.Errorf("service returned invalid JSON") + } + return data, nil +} diff --git a/pkg/pluginsdk/httpclient/client_test.go b/pkg/pluginsdk/httpclient/client_test.go new file mode 100644 index 00000000..5c866ec3 --- /dev/null +++ b/pkg/pluginsdk/httpclient/client_test.go @@ -0,0 +1,210 @@ +package httpclient_test + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func TestRequestPreservesPathQueryAndNumbers(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/gateway/v3/books/accounts/users%2F001" { + t.Errorf("path: %s", r.URL.EscapedPath()) + } + if r.URL.Query().Get("cursor") != "opaque +/= " { + t.Errorf("query: %s", r.URL.RawQuery) + } + if r.Header.Get("Idempotency-Key") != "request-1" { + t.Error("missing idempotency key") + } + _, err := w.Write([]byte(`{"amount":90071992547409930001}`)) + if err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL+"/gateway", server.Client()) + if err != nil { + t.Fatal(err) + } + response, err := client.Do(t.Context(), http.MethodPost, httpclient.Path("v3", "books", "accounts", "users/001"), url.Values{"cursor": {"opaque +/= "}}, json.RawMessage(`{"amount":90071992547409930001}`), http.Header{"Idempotency-Key": {"request-1"}}) + if err != nil { + t.Fatal(err) + } + if string(response) != `{"amount":90071992547409930001}` { + t.Fatalf("numbers changed: %s", response) + } +} + +//nolint:gocognit // Both explicit merge-patch and default JSON paths must preserve caller headers. +func TestExplicitJSONMediaType(t *testing.T) { + t.Parallel() + for _, contentType := range []string{"application/merge-patch+json", "application/json"} { + t.Run(contentType, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != contentType || r.Header.Get("Accept") != "application/json" { + t.Errorf("headers=%v", r.Header) + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + headers := http.Header{} + if contentType != "application/json" { + headers.Set("Content-Type", contentType) + } + if _, err := client.Do(t.Context(), http.MethodPatch, "/resource", nil, json.RawMessage(`{"value":null}`), headers); err != nil { + t.Fatal(err) + } + if contentType == "application/json" && headers.Get("Content-Type") != "" { + t.Fatal("request mutated caller headers") + } + }) + } +} + +func TestErrorsDoNotRetry(t *testing.T) { + t.Parallel() + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(status) + _, err := w.Write([]byte(`{"errorCode":"TEST_FAILURE","errorMessage":"rejected"}`)) + if err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + _, err = client.Do(t.Context(), http.MethodPost, "/v3/test", nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "TEST_FAILURE") { + t.Fatalf("error: %v", err) + } + if calls != 1 { + t.Fatalf("mutation executed %d times", calls) + } + }) + } +} + +func TestValidateURL(t *testing.T) { + t.Parallel() + for _, value := range []string{"localhost:9000", "file:///tmp/data", "https://user:secret@service", "https://service?token=secret", "https://service#fragment", ""} { + if httpclient.ValidateURL(value) == nil { + t.Errorf("accepted %q", value) + } + } + if err := httpclient.ValidateURL("http://localhost:9000/prefix"); err != nil { + t.Fatal(err) + } +} + +func TestAuthenticatedEndpointsRequireTLS(t *testing.T) { + t.Parallel() + for _, endpoint := range []string{"http://ledger.example.com", "http://192.0.2.1/token"} { + if httpclient.ValidateSecureURL(endpoint) == nil { + t.Errorf("accepted insecure endpoint %s", endpoint) + } + } + for _, endpoint := range []string{"https://ledger.example.com", "http://localhost:9000", "http://127.0.0.1:9000", "http://[::1]:9000"} { + if err := httpclient.ValidateSecureURL(endpoint); err != nil { + t.Errorf("rejected %s: %v", endpoint, err) + } + } +} + +func TestEmptyAndInvalidResponses(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + body string + want string + fail bool + }{{"", "null", false}, {"proxy failure", "", true}} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(tc.body)) + if err != nil { + t.Error(err) + } + })) + client, err := httpclient.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + result, err := client.Do(t.Context(), http.MethodGet, "/", nil, nil, nil) + server.Close() + if (err != nil) != tc.fail || string(result) != tc.want { + t.Errorf("result=%s error=%v", result, err) + } + } +} + +func TestWithContextClonesClientAndMetadata(t *testing.T) { + t.Parallel() + transport := &http.Client{} + base, err := httpclient.New("https://ledger.example/prefix", transport) + if err != nil { + t.Fatal(err) + } + if base.Context() != nil { + t.Fatal("new client has metadata by default") + } + values := map[string]string{"organization": "org-1", "stack": "stack-1", "organizationIDs": `["org-1","org-2"]`} + want := maps.Clone(values) + client := base.WithContext(values) + if client == base || client.Endpoint() != base.Endpoint() || client.HTTPClient() != transport || base.Context() != nil { + t.Fatal("WithContext changed the original client or its HTTP transport") + } + values["organization"] = "host-change" + if !maps.Equal(client.Context(), want) { + t.Fatal("client metadata shares the caller map") + } + view := client.Context() + view["stack"] = "plugin-change" + delete(view, "organizationIDs") + if !maps.Equal(client.Context(), want) { + t.Fatal("Context exposed the client's metadata map") + } + replacement := client.WithContext(map[string]string{"organization": "org-2"}) + if replacement.Context()["organization"] != "org-2" || len(replacement.Context()) != 1 || !maps.Equal(client.Context(), want) { + t.Fatal("derived context merged metadata or changed its parent") + } + if cleared := client.WithContext(nil); cleared.Context() != nil || !maps.Equal(client.Context(), want) { + t.Fatal("clearing derived metadata changed its parent") + } +} + +func TestContextDoesNotEnterHTTPRequests(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() != "/v3/" || r.Header.Get("Organization") != "" || r.Header.Get("Stack") != "" { + t.Error("host metadata was added to the HTTP request") + } + if _, err := w.Write([]byte(`{"data":[]}`)); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + client = client.WithContext(map[string]string{"organization": "org-1", "stack": "stack-1"}) + if _, err := client.Do(t.Context(), http.MethodGet, "/v3/", nil, nil, nil); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/pluginsdk/interaction.go b/pkg/pluginsdk/interaction.go new file mode 100644 index 00000000..8d8434c7 --- /dev/null +++ b/pkg/pluginsdk/interaction.go @@ -0,0 +1,52 @@ +package pluginsdk + +// InputSpec describes a terminal field without depending on a UI library. +// Exactly one binding (Flag, Argument, BodyPointer or Context) is required. +// Explicit arguments, flags and JSON values take precedence over the field. +type InputSpec struct { + Title string `json:"title"` + Description string `json:"description,omitzero"` + Kind string `json:"kind"` + Flag string `json:"flag,omitzero"` + Argument *int `json:"argument,omitzero"` + BodyPointer string `json:"bodyPointer,omitzero"` + Context string `json:"context,omitzero"` + ValueType string `json:"valueType,omitzero"` + Required bool `json:"required,omitzero"` + Secret bool `json:"secret,omitzero"` + Default string `json:"default,omitzero"` + // AlternativeArgument permits a positional name instead of a flag. + AlternativeArgument *int `json:"alternativeArgument,omitzero"` + AlternativeFlag string `json:"alternativeFlag,omitzero"` + Options []InputOption `json:"options,omitzero"` + Source *ChoiceSource `json:"source,omitzero"` +} + +type InputOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// ChoiceSource invokes a read operation through the same plugin and client. +// A $name argument/flag refers to an already resolved request flag; $stack and +// $organization can also refer to host context. Labels and values are field +// names from each resource object, or dot paths such as metadata.name. Exact +// field names take precedence over paths. The host owns rendering and pagination. +type ChoiceSource struct { + CommandPath []string `json:"commandPath"` + Args []string `json:"args,omitzero"` + Flags map[string]string `json:"flags,omitzero"` + ValueField string `json:"valueField"` + LabelFields []string `json:"labelFields,omitzero"` + EmptyMessage string `json:"emptyMessage,omitzero"` + // PreferredPrefix orders suggestions when the exact default is unavailable. + // The user must still select a value explicitly. + PreferredPrefix string `json:"preferredPrefix,omitzero"` + // AfterField uses the final row's exact value for keyset pagination. + // The host requests pages of 100 and follows --after until a shorter page. + AfterField string `json:"afterField,omitzero"` + // ExcludeTrueFields removes entries whose declared boolean field is true. + ExcludeTrueFields []string `json:"excludeTrueFields,omitzero"` + // MatchFields requires each field to match one of its exact scalar values. + MatchFields map[string][]string `json:"matchFields,omitzero"` +} diff --git a/pkg/pluginsdk/sdk.go b/pkg/pluginsdk/sdk.go new file mode 100644 index 00000000..c43edca2 --- /dev/null +++ b/pkg/pluginsdk/sdk.go @@ -0,0 +1,251 @@ +// Package pluginsdk defines the public contract for embedded and future external plugins. +// It has no dependency on Cobra, profile storage or the core's authentication. +package pluginsdk + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +const ProtocolVersion = 1 + +type Plugin interface { + GetManifest(context.Context) (Manifest, error) + Execute(context.Context, ExecuteRequest) (ExecuteResponse, error) +} + +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Service string `json:"service"` + ProtocolVersion int `json:"protocolVersion"` + Root CommandSpec `json:"root"` +} + +type CommandSpec struct { + Use string `json:"use"` + Aliases []string `json:"aliases,omitempty"` + Target string `json:"target,omitzero"` + Inputs []InputSpec `json:"inputs,omitzero"` + Files *FileSpec `json:"files,omitzero"` + Service string `json:"service,omitzero"` + Streams *StreamSpec `json:"streams,omitzero"` + Short string `json:"short"` + Long string `json:"long,omitempty"` + Example string `json:"example,omitempty"` + Args ArgsSpec `json:"args"` + Flags []FlagSpec `json:"flags,omitempty"` + Subcommands []CommandSpec `json:"subcommands,omitempty"` + Runnable bool `json:"runnable"` + Confirm bool `json:"confirm,omitempty"` +} + +type ArgsSpec struct { + Min int `json:"min"` + Max int `json:"max"` +} + +type FlagSpec struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default"` + Usage string `json:"usage"` + Required bool `json:"required,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Body bool `json:"body,omitempty"` + RequireTrue bool `json:"requireTrue,omitempty"` +} + +// ExecuteRequest is transport-neutral. Endpoint is resolved by the host; +// Body contains JSON already read from inline input, a file or stdin. +// Authentication is supplied by the plugin instance's injected HTTP client. +type ExecuteRequest struct { + CommandPath []string `json:"commandPath"` + Args []string `json:"args,omitempty"` + Flags map[string]string `json:"flags,omitempty"` + ChangedFlags map[string]bool `json:"changedFlags,omitempty"` + Body json.RawMessage `json:"body,omitempty"` + Endpoint string `json:"endpoint"` + // Context carries non-secret metadata supplied by the host, independently + // of transport and flags. It must never contain authentication material. + Context map[string]string `json:"context,omitempty"` +} + +// A response can accompany an error, for example to preserve bulk partial results. +type ExecuteResponse struct { + Data json.RawMessage `json:"data,omitempty"` +} + +func CommandName(command CommandSpec) string { + words := strings.Fields(command.Use) + if len(words) == 0 { + return "" + } + return words[0] +} + +func FindCommand(manifest Manifest, path []string) (CommandSpec, error) { + command, _, _, err := find(manifest, path) + return command, err +} + +// CommandService resolves optional per-command service overrides. An override +// is inherited by descendants and names a host-provided connection boundary. +func CommandService(manifest Manifest, path []string) (string, error) { + if len(path) == 0 { + return "", fmt.Errorf("unknown plugin command") + } + service := manifest.Service + for i := range path { + command, err := FindCommand(manifest, path[:i+1]) + if err != nil { + return "", err + } + if command.Service != "" { + service = command.Service + } + } + return service, nil +} + +func find(manifest Manifest, path []string) (CommandSpec, []FlagSpec, []string, error) { + if len(path) == 0 || !commandMatches(manifest.Root, path[0]) { + return CommandSpec{}, nil, nil, fmt.Errorf("unknown plugin command") + } + command := manifest.Root + canonical := make([]string, 0, len(path)) + canonical = append(canonical, CommandName(command)) + var inherited []FlagSpec + for _, name := range path[1:] { + for _, flag := range command.Flags { + if flag.Persistent { + inherited = append(inherited, flag) + } + } + child, found := findChild(command, name) + if !found { + return CommandSpec{}, nil, nil, fmt.Errorf("unknown plugin command %q", strings.Join(path, " ")) + } + command = child + canonical = append(canonical, CommandName(command)) + } + return command, append(inherited, command.Flags...), canonical, nil +} + +func commandMatches(command CommandSpec, name string) bool { + canonical := CommandName(command) + return canonical != "" && (canonical == name || slices.Contains(command.Aliases, name)) +} + +func findChild(command CommandSpec, name string) (CommandSpec, bool) { + for _, child := range command.Subcommands { + if commandMatches(child, name) { + return child, true + } + } + return CommandSpec{}, false +} + +// NormalizeRequest also protects direct SDK callers, independently of the CLI adapter. +// CommandPath accepts aliases and is returned with canonical command names. +func NormalizeRequest(manifest Manifest, request ExecuteRequest) (ExecuteRequest, error) { + if manifest.ProtocolVersion != ProtocolVersion { + return ExecuteRequest{}, fmt.Errorf("unsupported plugin protocol version %d", manifest.ProtocolVersion) + } + command, flags, canonical, err := find(manifest, request.CommandPath) + if err != nil { + return ExecuteRequest{}, err + } + request.CommandPath = canonical + if !command.Runnable { + return ExecuteRequest{}, fmt.Errorf("command is not executable") + } + if len(request.Args) < command.Args.Min || len(request.Args) > command.Args.Max { + return ExecuteRequest{}, fmt.Errorf("command expects %d through %d arguments", command.Args.Min, command.Args.Max) + } + request.Flags, err = normalizeFlags(flags, request, command.Streams != nil && command.Streams.Input) + if err != nil { + return ExecuteRequest{}, err + } + if command.Confirm && request.Flags["confirm"] != "true" { + return ExecuteRequest{}, fmt.Errorf("%s requires --confirm", strings.Join(request.CommandPath, " ")) + } + if request.Body != nil && (!json.Valid(request.Body) || len(request.Body) > 4<<20) { + return ExecuteRequest{}, fmt.Errorf("request body must be valid JSON within 4 MiB") + } + request.Context = maps.Clone(request.Context) + request.ChangedFlags = maps.Clone(request.ChangedFlags) + return request, nil +} + +func normalizeFlags(flags []FlagSpec, request ExecuteRequest, streamedInput bool) (map[string]string, error) { + request.Flags = maps.Clone(request.Flags) + if request.Flags == nil { + request.Flags = make(map[string]string) + } + known := make(map[string]FlagSpec, len(flags)) + for _, flag := range flags { + if _, exists := known[flag.Name]; exists { + return nil, fmt.Errorf("duplicate plugin flag %q", flag.Name) + } + known[flag.Name] = flag + if _, exists := request.Flags[flag.Name]; !exists { + request.Flags[flag.Name] = flag.Default + } + // Streamed input lives outside ExecuteRequest.Body. Its plugin validates + // the actual reader; normalization must not require a second inline body. + if flag.Body && (request.Body != nil || streamedInput) { + flag.Required = false + } + if err := validateFlag(flag, request.Flags[flag.Name]); err != nil { + return nil, err + } + } + if err := validateKnown(request.Flags, known); err != nil { + return nil, err + } + if err := validateKnown(request.ChangedFlags, known); err != nil { + return nil, err + } + + return request.Flags, nil +} + +func validateKnown[T any](values map[string]T, known map[string]FlagSpec) error { + for name := range values { + if _, exists := known[name]; !exists { + return fmt.Errorf("unknown plugin flag %q", name) + } + } + return nil +} + +func validateFlag(flag FlagSpec, value string) error { + if flag.RequireTrue && (flag.Type != "bool" || value != "true") { + return fmt.Errorf("--%s must be enabled for this command", flag.Name) + } + if flag.Required && value == "" { + return fmt.Errorf("--%s is required", flag.Name) + } + switch flag.Type { + case "string": + return nil + case "bool": + if value == "true" || value == "false" { + return nil + } + case "uint32": + if _, err := strconv.ParseUint(value, 10, 32); err == nil { + return nil + } + default: + return fmt.Errorf("unsupported flag type %q", flag.Type) + } + return fmt.Errorf("invalid --%s %s value", flag.Name, flag.Type) +} diff --git a/pkg/pluginsdk/sdk_test.go b/pkg/pluginsdk/sdk_test.go new file mode 100644 index 00000000..b8a1c20a --- /dev/null +++ b/pkg/pluginsdk/sdk_test.go @@ -0,0 +1,543 @@ +package pluginsdk_test + +import ( + "bytes" + "encoding/json" + "maps" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func contractManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{ + Name: "example", Version: "1.2.3", Service: "ledger", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{ + Use: "example", Short: "Example plugin", Long: "Contract fixture", Example: "example records get 9007199254740993", + Flags: []pluginsdk.FlagSpec{ + {Name: "region", Type: "string", Default: "global", Persistent: true}, + {Name: "root-only", Type: "string", Default: "root"}, + }, + Subcommands: []pluginsdk.CommandSpec{{ + Use: "records", Short: "Manage records", + Flags: []pluginsdk.FlagSpec{{Name: "tenant", Type: "string", Default: "default-tenant", Persistent: true}, {Name: "parent-only", Type: "string", Default: "parent"}}, + Subcommands: []pluginsdk.CommandSpec{{ + Use: "get ID [SECOND_ID]", Short: "Read records", Runnable: true, Args: pluginsdk.ArgsSpec{Min: 1, Max: 2}, + Flags: []pluginsdk.FlagSpec{ + {Name: "format", Shorthand: "f", Type: "string", Default: "json", Usage: "Output format"}, + {Name: "enabled", Type: "bool", Default: "true"}, + {Name: "limit", Type: "uint32", Default: "100"}, + {Name: "required", Type: "string", Required: true}, + {Name: "body", Type: "string", Body: true}, + }, + }}, + }}, + }, + } +} + +func contractRequest() pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{CommandPath: []string{"example", "records", "get"}, Args: []string{"9007199254740993"}, Endpoint: "https://stack.example/api/ledger", Flags: map[string]string{"required": "present"}} +} + +func TestFindCommand(t *testing.T) { + manifest := contractManifest() + tests := []struct { + name string + path []string + use string + runnable bool + }{ + {"root", []string{"example"}, "example", false}, + {"parent", []string{"example", "records"}, "records", false}, + {"leaf strips usage arguments", []string{"example", "records", "get"}, "get ID [SECOND_ID]", true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command, err := pluginsdk.FindCommand(manifest, test.path) + if err != nil { + t.Fatal(err) + } + if command.Use != test.use || command.Runnable != test.runnable { + t.Fatalf("unexpected command: %+v", command) + } + if !reflect.DeepEqual(command, testCommand(manifest, test.path)) { + t.Fatal("FindCommand dropped command metadata") + } + }) + } +} + +func testCommand(manifest pluginsdk.Manifest, path []string) pluginsdk.CommandSpec { + command := manifest.Root + for range len(path) - 1 { + command = command.Subcommands[0] + } + return command +} + +func TestFindCommandRejectsUnknownRoutes(t *testing.T) { + for _, path := range [][]string{nil, {}, {"other"}, {"example", "missing"}, {"example", "records", "missing"}, {"records", "get"}, {"example", "records", "get", "extra"}, {"example records get"}} { + t.Run(strings.Join(path, "/"), func(t *testing.T) { + if _, err := pluginsdk.FindCommand(contractManifest(), path); err == nil { + t.Fatal("accepted an unknown route") + } + request := contractRequest() + request.CommandPath = path + if _, err := pluginsdk.NormalizeRequest(contractManifest(), request); err == nil { + t.Fatal("normalization accepted an unknown route") + } + }) + } +} + +func TestFindCommandHandlesBlankUsage(t *testing.T) { + manifest := contractManifest() + manifest.Root.Use = " \t" + if _, err := pluginsdk.FindCommand(manifest, []string{"example"}); err == nil { + t.Fatal("accepted blank root usage") + } + manifest = contractManifest() + manifest.Root.Subcommands = append([]pluginsdk.CommandSpec{{Use: " \t"}}, manifest.Root.Subcommands...) + if _, err := pluginsdk.FindCommand(manifest, contractRequest().CommandPath); err != nil { + t.Fatalf("blank sibling hid a valid route: %v", err) + } +} + +func TestNormalizeDefaultsAndInheritedFlags(t *testing.T) { + request := contractRequest() + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"region": "global", "tenant": "default-tenant", "format": "json", "enabled": "true", "limit": "100", "required": "present", "body": ""} + if !maps.Equal(normalized.Flags, want) { + t.Fatalf("defaults/inheritance mismatch: %v", normalized.Flags) + } + if normalized.Endpoint != request.Endpoint || !reflect.DeepEqual(normalized.Args, request.Args) || !reflect.DeepEqual(normalized.CommandPath, request.CommandPath) { + t.Fatal("normalization changed routing or arguments") + } + for _, name := range []string{"root-only", "parent-only"} { + request.Flags[name] = "explicit" + if _, err := pluginsdk.NormalizeRequest(contractManifest(), request); err == nil { + t.Fatalf("inherited non-persistent flag %s", name) + } + delete(request.Flags, name) + } +} + +func TestNormalizeExplicitValuesAndChangedFlags(t *testing.T) { + request := contractRequest() + request.Flags["enabled"] = "false" + request.Flags["region"] = "eu" + request.Flags["format"] = "" + request.Flags["limit"] = "0" + request.ChangedFlags = map[string]bool{"enabled": true, "region": true, "format": true, "limit": true, "tenant": false} + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"enabled", "region", "format", "limit"} { + if normalized.Flags[name] != request.Flags[name] { + t.Fatalf("default replaced explicit %s", name) + } + } + if !maps.Equal(normalized.ChangedFlags, request.ChangedFlags) { + t.Fatal("ChangedFlags did not preserve explicit false/unchanged markers") + } + if _, exists := normalized.ChangedFlags["body"]; exists { + t.Fatal("normalization marked a default as changed") + } +} + +func TestNormalizeDoesNotMutateCaller(t *testing.T) { + request := contractRequest() + request.ChangedFlags = map[string]bool{"required": true} + request.Body = json.RawMessage(`{"id":9007199254740993}`) + before := jsonClone(t, request) + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(request, before) { + t.Fatal("normalization mutated caller state") + } + normalized.Flags["required"] = "changed-after-normalize" + if request.Flags["required"] != "present" { + t.Fatal("normalized flags share the caller map") + } + request.Flags["required"] = "changed-after-call" + if normalized.Flags["required"] != "changed-after-normalize" { + t.Fatal("caller flags share the normalized map") + } +} + +func TestNormalizeContextIsTransportNeutralAndIsolated(t *testing.T) { + t.Parallel() + request := contractRequest() + request.Context = map[string]string{"organization": "org-1", "stack": "stack-1", "organizationIDs": `["org-1","org-2"]`} + before := jsonClone(t, request) + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(request, before) || !maps.Equal(normalized.Context, before.Context) { + t.Fatal("normalization changed caller state or host metadata") + } + if restored := jsonClone(t, normalized); !maps.Equal(restored.Context, normalized.Context) { + t.Fatal("protocol serialization lost context metadata") + } + normalized.Context["organization"] = "plugin-change" + if request.Context["organization"] != "org-1" { + t.Fatal("plugin metadata mutation reached the caller") + } + request.Context["stack"] = "host-change" + if normalized.Context["stack"] != "stack-1" { + t.Fatal("caller metadata mutation reached the normalized request") + } +} + +func TestNormalizeContextIsOptional(t *testing.T) { + t.Parallel() + request := contractRequest() + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil || normalized.Context != nil || request.Context != nil { + t.Fatalf("nil context changed: normalized=%v request=%v err=%v", normalized.Context, request.Context, err) + } + request.Context = map[string]string{} + normalized, err = pluginsdk.NormalizeRequest(contractManifest(), request) + if err != nil || normalized.Context == nil { + t.Fatalf("explicit empty context changed: %v, %v", normalized.Context, err) + } + normalized.Context["region"] = "eu" + if len(request.Context) != 0 { + t.Fatal("empty context maps share storage") + } +} + +func TestNormalizeFailureDoesNotMutateCaller(t *testing.T) { + request := contractRequest() + request.Flags["enabled"] = "not-a-bool" + request.ChangedFlags = map[string]bool{"enabled": true} + before := jsonClone(t, request) + if _, err := pluginsdk.NormalizeRequest(contractManifest(), request); err == nil { + t.Fatal("accepted invalid bool") + } + if !reflect.DeepEqual(request, before) { + t.Fatal("failed normalization mutated caller flags") + } +} + +func TestNormalizeArgumentCounts(t *testing.T) { + for _, count := range []int{0, 1, 2, 3} { + t.Run(strconv.Itoa(count), func(t *testing.T) { + request := contractRequest() + request.Args = make([]string, count) + _, err := pluginsdk.NormalizeRequest(contractManifest(), request) + valid := count == 1 || count == 2 + if (err == nil) != valid { + t.Fatalf("argument count %d: %v", count, err) + } + }) + } +} + +func TestNormalizeRejectsNonRunnableCommands(t *testing.T) { + for _, path := range [][]string{{"example"}, {"example", "records"}} { + if _, err := pluginsdk.NormalizeRequest(contractManifest(), pluginsdk.ExecuteRequest{CommandPath: path}); err == nil { + t.Fatal("accepted a non-runnable command") + } + } +} + +func TestNormalizeRequiredFlags(t *testing.T) { + for _, flags := range []map[string]string{nil, {}, {"required": ""}} { + request := contractRequest() + request.Flags = flags + if _, err := pluginsdk.NormalizeRequest(contractManifest(), request); err == nil { + t.Fatal("accepted missing required string flag") + } + } + manifest := contractManifest() + manifest.Root.Subcommands[0].Subcommands[0].Flags[3].Default = "default-required" + request := contractRequest() + request.Flags = nil + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + t.Fatal(err) + } + if normalized.Flags["required"] != "default-required" { + t.Fatal("required flag lost its non-empty default") + } + if request.Flags != nil { + t.Fatal("normalization initialized the caller's nil map") + } +} + +func TestNormalizeFlagValidation(t *testing.T) { + tests := []struct { + name, flag, value string + valid bool + }{ + {"bool true", "enabled", "true", true}, {"bool false", "enabled", "false", true}, + {"bool numeric", "enabled", "1", false}, {"bool uppercase", "enabled", "TRUE", false}, {"bool empty", "enabled", "", false}, + {"uint32 zero", "limit", "0", true}, {"uint32 max", "limit", "4294967295", true}, + {"uint32 overflow", "limit", "4294967296", false}, {"uint32 negative", "limit", "-1", false}, {"uint32 fraction", "limit", "1.5", false}, {"uint32 empty", "limit", "", false}, + {"uint32 huge integer", "limit", "18446744073709551616", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := contractRequest() + request.Flags[test.flag] = test.value + _, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if (err == nil) != test.valid { + t.Fatalf("unexpected validation for %s=%q: %v", test.flag, test.value, err) + } + }) + } +} + +func TestNormalizeUnknownFlags(t *testing.T) { + tests := []struct { + name string + flags map[string]string + changed map[string]bool + }{ + {"unknown value", map[string]string{"unknown": "value"}, nil}, + {"unknown empty", map[string]string{"unknown": ""}, nil}, + {"unknown changed true", nil, map[string]bool{"unknown": true}}, + {"unknown changed false", nil, map[string]bool{"unknown": false}}, + {"non-persistent changed flag", nil, map[string]bool{"root-only": true}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := contractRequest() + maps.Copy(request.Flags, test.flags) + request.ChangedFlags = test.changed + if _, err := pluginsdk.NormalizeRequest(contractManifest(), request); err == nil { + t.Fatal("accepted unknown flag metadata") + } + }) + } +} + +func TestNormalizeConfirmation(t *testing.T) { + manifest := contractManifest() + leaf := &manifest.Root.Subcommands[0].Subcommands[0] + leaf.Confirm = true + leaf.Flags = append(leaf.Flags, pluginsdk.FlagSpec{Name: "confirm", Type: "bool", Default: "false"}) + for _, value := range []string{"", "false", "true", "TRUE", "1"} { + t.Run(value, func(t *testing.T) { + request := contractRequest() + if value != "" { + request.Flags["confirm"] = value + } + _, err := pluginsdk.NormalizeRequest(manifest, request) + if (err == nil) != (value == "true") { + t.Fatalf("incorrect confirmation validation for %q: %v", value, err) + } + }) + } +} + +func TestNormalizeBody(t *testing.T) { + tests := []struct { + name string + body json.RawMessage + valid bool + }{ + {"absent", nil, true}, {"empty non-nil", json.RawMessage{}, false}, + {"object", json.RawMessage(`{"id":9007199254740993}`), true}, + {"array", json.RawMessage(`[18446744073709551615,-9007199254740993]`), true}, + {"null", json.RawMessage(`null`), true}, {"scalar", json.RawMessage(`9007199254740993`), true}, + {"invalid", json.RawMessage(`{"id":`), false}, {"multiple values", json.RawMessage(`{} {}`), false}, + {"whitespace only", json.RawMessage(" \n"), false}, + {"exact limit", quotedBody(4 << 20), true}, {"oversized valid JSON", quotedBody((4 << 20) + 1), false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := contractRequest() + request.Body = test.body + normalized, err := pluginsdk.NormalizeRequest(contractManifest(), request) + if (err == nil) != test.valid { + t.Fatalf("incorrect body validation: %v", err) + } + if err == nil && !bytes.Equal(normalized.Body, test.body) { + t.Fatal("normalization changed JSON bytes or number precision") + } + }) + } +} + +func quotedBody(size int) json.RawMessage { + return json.RawMessage(`"` + strings.Repeat("x", size-2) + `"`) +} + +func TestNormalizeRejectsDuplicateFlags(t *testing.T) { + for _, kind := range []string{"inherited defaults", "inherited types", "inherited required", "own duplicates", "ancestor duplicates"} { + t.Run(kind, func(t *testing.T) { + manifest := contractManifest() + parent := pluginsdk.FlagSpec{Name: "collision", Type: "string", Default: "parent", Persistent: true} + child := pluginsdk.FlagSpec{Name: "collision", Type: "string", Default: "child"} + request := contractRequest() + switch kind { + case "inherited types": + parent.Type = "uint32" + parent.Default = "10" + request.Flags["collision"] = "42" + case "inherited required": + parent.Required = true + request.Flags["collision"] = "present" + } + manifest.Root.Flags = append(manifest.Root.Flags, parent) + leaf := &manifest.Root.Subcommands[0].Subcommands[0] + switch kind { + case "own duplicates": + leaf.Flags = append(leaf.Flags, leaf.Flags[0]) + case "ancestor duplicates": + manifest.Root.Subcommands[0].Flags = append(manifest.Root.Subcommands[0].Flags, parent) + default: + leaf.Flags = append(leaf.Flags, child) + } + before := jsonClone(t, request) + if _, err := pluginsdk.NormalizeRequest(manifest, request); err == nil { + t.Fatal("accepted duplicate flags in the effective command scope") + } + if !reflect.DeepEqual(request, before) { + t.Fatal("duplicate rejection mutated caller flags") + } + }) + } +} + +func TestNormalizeNonPersistentNameCanBeReused(t *testing.T) { + manifest := contractManifest() + manifest.Root.Flags = append(manifest.Root.Flags, pluginsdk.FlagSpec{Name: "local", Type: "uint32", Default: "10"}) + leaf := &manifest.Root.Subcommands[0].Subcommands[0] + leaf.Flags = append(leaf.Flags, pluginsdk.FlagSpec{Name: "local", Type: "string", Default: "child"}) + normalized, err := pluginsdk.NormalizeRequest(manifest, contractRequest()) + if err != nil { + t.Fatalf("ancestor non-persistent flag leaked into child scope: %v", err) + } + if normalized.Flags["local"] != "child" { + t.Fatal("ancestor non-persistent default replaced the child's flag") + } +} + +func TestJSONRoundTripManifest(t *testing.T) { + manifest := contractManifest() + if got := jsonClone(t, manifest); !reflect.DeepEqual(got, manifest) { + t.Fatal("manifest metadata changed during JSON round trip") + } +} + +func TestJSONRoundTripRequest(t *testing.T) { + request := contractRequest() + request.Body = json.RawMessage(`{"id":9007199254740993,"amount":18446744073709551615,"negative":-9007199254740993}`) + request.Flags["enabled"] = "false" + request.ChangedFlags = map[string]bool{"enabled": true, "limit": false} + if got := jsonClone(t, request); !reflect.DeepEqual(got, request) { + t.Fatal("request lost number precision, explicit false or changed flags") + } +} + +func TestJSONRoundTripResponse(t *testing.T) { + response := pluginsdk.ExecuteResponse{Data: json.RawMessage(`{"id":9007199254740993,"amount":18446744073709551615,"nested":[-9007199254740993]}`)} + if got := jsonClone(t, response); !bytes.Equal(got.Data, response.Data) { + t.Fatal("response lost JSON integer precision") + } + empty := pluginsdk.ExecuteResponse{} + if got := jsonClone(t, empty); got.Data != nil { + t.Fatal("empty response gained data") + } +} + +func jsonClone[T any](t *testing.T, value T) T { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var restored T + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + return restored +} + +func TestNormalizeRejectsUnsupportedProtocolVersion(t *testing.T) { + for _, version := range []int{-1, 0, pluginsdk.ProtocolVersion + 1} { + t.Run(strconv.Itoa(version), func(t *testing.T) { + manifest := contractManifest() + manifest.ProtocolVersion = version + request := contractRequest() + before := jsonClone(t, request) + if _, err := pluginsdk.NormalizeRequest(manifest, request); err == nil { + t.Fatal("accepted an unsupported manifest protocol version") + } + if !reflect.DeepEqual(request, before) { + t.Fatal("protocol rejection mutated the request") + } + }) + } +} + +func TestNormalizeRejectsUnsupportedFlagType(t *testing.T) { + manifest := contractManifest() + leaf := &manifest.Root.Subcommands[0].Subcommands[0] + leaf.Flags = append(leaf.Flags, pluginsdk.FlagSpec{Name: "unsupported", Type: "uint64", Default: "1"}) + if _, err := pluginsdk.NormalizeRequest(manifest, contractRequest()); err == nil { + t.Fatal("accepted unsupported flag type") + } +} + +func TestNormalizeRequiredBody(t *testing.T) { + manifest := contractManifest() + manifest.Root.Subcommands[0].Subcommands[0].Flags[4].Required = true + before := jsonClone(t, manifest) + for _, test := range []struct { + name string + body json.RawMessage + valid bool + }{ + {"missing", nil, false}, + {"provided", json.RawMessage(`{"id":9007199254740993}`), true}, + {"invalid", json.RawMessage(`{"id":`), false}, + } { + t.Run(test.name, func(t *testing.T) { + request := contractRequest() + request.Body = test.body + _, err := pluginsdk.NormalizeRequest(manifest, request) + if (err == nil) != test.valid { + t.Fatalf("incorrect required body validation: %v", err) + } + if !reflect.DeepEqual(manifest, before) { + t.Fatal("body normalization mutated the manifest's required flag") + } + }) + } +} + +func TestNormalizeStreamedInputDoesNotRequireInlineBody(t *testing.T) { + t.Parallel() + manifest := contractManifest() + leaf := &manifest.Root.Subcommands[0].Subcommands[0] + leaf.Flags[4].Required = true + leaf.Streams = &pluginsdk.StreamSpec{Input: true} + before := jsonClone(t, manifest) + request := contractRequest() + request.Body = nil + if _, err := pluginsdk.NormalizeRequest(manifest, request); err != nil { + t.Fatalf("stream input also required an inline body: %v", err) + } + if !reflect.DeepEqual(manifest, before) { + t.Fatal("stream normalization mutated the manifest") + } + leaf.Streams.Input = false + if _, err := pluginsdk.NormalizeRequest(manifest, request); err == nil { + t.Fatal("an output-only stream bypassed the required body") + } +} diff --git a/pkg/pluginsdk/serverinfo.go b/pkg/pluginsdk/serverinfo.go new file mode 100644 index 00000000..e6badb56 --- /dev/null +++ b/pkg/pluginsdk/serverinfo.go @@ -0,0 +1,33 @@ +package pluginsdk + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ServiceVersion accepts the historical enveloped /_info response and the +// current direct response. Conflicting versions are rejected before execution. +func ServiceVersion(data json.RawMessage) (string, error) { + var info struct { + Version string `json:"version"` + Data struct { + Version string `json:"version"` + } `json:"data"` + } + if err := json.Unmarshal(data, &info); err != nil { + return "", fmt.Errorf("decode service version: %w", err) + } + version := strings.TrimPrefix(info.Version, "v") + nested := strings.TrimPrefix(info.Data.Version, "v") + if version != "" && nested != "" && version != nested { + return "", fmt.Errorf("service reports conflicting versions") + } + if version == "" { + version = nested + } + if version == "" || strings.TrimSpace(version) != version { + return "", fmt.Errorf("service /_info did not report a valid version") + } + return version, nil +} diff --git a/pkg/pluginsdk/serverinfo_test.go b/pkg/pluginsdk/serverinfo_test.go new file mode 100644 index 00000000..bee0f0ec --- /dev/null +++ b/pkg/pluginsdk/serverinfo_test.go @@ -0,0 +1,25 @@ +package pluginsdk + +import "testing" + +func TestServiceVersion(t *testing.T) { + for _, test := range []struct { + body, want string + bad bool + }{ + {`{"version":"v3.0.0-beta.10"}`, "3.0.0-beta.10", false}, + {`{"data":{"version":"v2.4.15"}}`, "2.4.15", false}, + {`{"data":{"version":"2.4.15"},"version":"v2.4.15"}`, "2.4.15", false}, + {`{"data":{"version":"2.4.15"},"version":"3.0.0"}`, "", true}, + {`{"data":{}}`, "", true}, + {`{"version":" 3.0.0"}`, "", true}, + {`{"version":"3.0.0"} {}`, "", true}, + } { + t.Run(test.body, func(t *testing.T) { + got, err := ServiceVersion([]byte(test.body)) + if (err != nil) != test.bad || got != test.want { + t.Fatalf("got %q, %v; want %q bad=%v", got, err, test.want, test.bad) + } + }) + } +} diff --git a/pkg/pluginsdk/service_test.go b/pkg/pluginsdk/service_test.go new file mode 100644 index 00000000..a9a5777e --- /dev/null +++ b/pkg/pluginsdk/service_test.go @@ -0,0 +1,119 @@ +package pluginsdk_test + +import ( + "bytes" + "maps" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func serviceManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "multi", Version: "test", Service: "fallback", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{ + Use: "multi", Service: "root-service", Runnable: true, + Subcommands: []pluginsdk.CommandSpec{ + {Use: "default", Runnable: true}, + {Use: "branch", Service: "branch-service", Subcommands: []pluginsdk.CommandSpec{ + {Use: "inherited", Runnable: true}, + {Use: "leaf", Service: "leaf-service", Runnable: true}, + }}, + }, + }} +} + +func TestCommandServiceInheritance(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ path, want string }{ + {"multi", "root-service"}, {"multi default", "root-service"}, + {"multi branch", "branch-service"}, {"multi branch inherited", "branch-service"}, + {"multi branch leaf", "leaf-service"}, + } { + t.Run(tc.path, func(t *testing.T) { + t.Parallel() + got, err := pluginsdk.CommandService(serviceManifest(), strings.Fields(tc.path)) + if err != nil || got != tc.want { + t.Fatalf("service = %q, %v; want %q", got, err, tc.want) + } + }) + } + manifest := serviceManifest() + manifest.Root.Service = "" + got, err := pluginsdk.CommandService(manifest, []string{"multi", "default"}) + if err != nil || got != manifest.Service { + t.Fatalf("manifest fallback = %q, %v", got, err) + } +} + +func TestCommandServiceRejectsUnknownPaths(t *testing.T) { + t.Parallel() + for _, path := range [][]string{nil, {}, {"wrong"}, {"multi", "missing"}, {"multi", "branch", "missing"}, {"multi", "branch", "leaf", "extra"}} { + t.Run(strings.Join(path, "/"), func(t *testing.T) { + t.Parallel() + got, err := pluginsdk.CommandService(serviceManifest(), path) + if err == nil || got != "" { + t.Fatalf("invalid path %v returned %q, %v", path, got, err) + } + }) + } +} + +func TestRequireTrueValidation(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, kind, value, fallback string + ok bool + }{ + {"enabled", "bool", "true", "false", true}, + {"false", "bool", "false", "false", false}, + {"omitted", "bool", "", "false", false}, + {"default-enabled", "bool", "", "true", true}, + {"malformed", "bool", "yes", "false", false}, + {"string-true", "string", "true", "", false}, + {"integer", "uint32", "1", "0", false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + manifest := serviceManifest() + manifest.Root.Flags = []pluginsdk.FlagSpec{{Name: "experimental", Type: tc.kind, Default: tc.fallback, Persistent: true, RequireTrue: true}} + request := pluginsdk.ExecuteRequest{CommandPath: []string{"multi", "branch", "leaf"}, Flags: map[string]string{}} + if tc.value != "" { + request.Flags["experimental"] = tc.value + } + got, err := pluginsdk.NormalizeRequest(manifest, request) + if (err == nil) != tc.ok { + t.Fatalf("NormalizeRequest error = %v; want success %v", err, tc.ok) + } + if tc.ok && got.Flags["experimental"] != "true" { + t.Fatal("enabled gate was lost") + } + if !tc.ok && !strings.Contains(err.Error(), "experimental") { + t.Fatalf("unactionable error: %v", err) + } + }) + } +} + +func TestNormalizationPreservesCallerMapsAndBody(t *testing.T) { + t.Parallel() + manifest := serviceManifest() + manifest.Root.Flags = []pluginsdk.FlagSpec{ + {Name: "experimental", Type: "bool", Default: "false", RequireTrue: true}, + {Name: "data", Type: "string", Body: true, Required: true}, + {Name: "limit", Type: "uint32", Default: "10"}, + } + request := pluginsdk.ExecuteRequest{CommandPath: []string{"multi"}, Flags: map[string]string{"experimental": "true"}, ChangedFlags: map[string]bool{"experimental": true}, Body: []byte(`{"amount":90071992547409930001}`), Context: map[string]string{"stack": "fixture"}} + flags, metadata, body := maps.Clone(request.Flags), maps.Clone(request.Context), bytes.Clone(request.Body) + normalized, err := pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + t.Fatal(err) + } + if normalized.Flags["limit"] != "10" || !bytes.Equal(normalized.Body, body) { + t.Fatal("defaults or precise body lost") + } + normalized.Flags["experimental"] = "false" + normalized.Context["stack"] = "mutated" + if !maps.Equal(request.Flags, flags) || !maps.Equal(request.Context, metadata) || !bytes.Equal(request.Body, body) { + t.Fatal("normalization or handler mutation changed the caller request") + } +} diff --git a/pkg/pluginsdk/stream.go b/pkg/pluginsdk/stream.go new file mode 100644 index 00000000..d2010e11 --- /dev/null +++ b/pkg/pluginsdk/stream.go @@ -0,0 +1,68 @@ +package pluginsdk + +import ( + "context" + "fmt" + "io" + "unicode" + "unicode/utf8" +) + +// StreamingPlugin is an optional extension. Ordinary commands keep using Execute. +// Commands with Streams must use ExecuteStream; unsupported hosts fail explicitly. +type StreamingPlugin interface { + Plugin + ExecuteStream(context.Context, ExecuteRequest, ExecutionStreams) (ExecuteResponse, error) +} + +// StreamSpec declares raw byte streams. FileSpec still selects host-owned sources, +// destinations and formats. Stream output bypasses the JSON/table renderer. +type StreamSpec struct { + Input bool `json:"input,omitzero"` + Output bool `json:"output,omitzero"` +} + +// ExecutionStreams live only for this invocation. The host owns and closes the +// input/output. Plugins must propagate read, write and Report errors and must not +// retain streams or reporters after returning. Total input size is optional. +// A blocking stream must also implement io.Closer and unblock IO on Close. +// The native transport can close such host streams on cancellation. +type ExecutionStreams struct { + Input io.Reader + InputSize *int64 + Output io.Writer + Report func(ProgressEvent) error +} + +// ProgressEvent is an absolute business counter, never terminal text. Total is +// optional; absence means indeterminate. Completed counts confirmed work for the +// named phase/unit. A different phase may describe bytes read before acceptance. +type ProgressEvent struct { + Phase string `json:"phase"` + Completed int64 `json:"completed"` + Total *int64 `json:"total,omitzero"` + Unit string `json:"unit"` +} + +// Validate rejects malformed events before they cross a transport or terminal. +func (p ProgressEvent) Validate() error { + if !progressLabel(p.Phase) || !progressLabel(p.Unit) || p.Completed < 0 || + (p.Total != nil && (*p.Total < 0 || p.Completed > *p.Total)) { + return fmt.Errorf("invalid plugin progress event") + } + return nil +} + +func progressLabel(label string) bool { + return len(label) > 0 && len(label) <= 128 && utf8.ValidString(label) && + !containsControl(label) +} + +func containsControl(value string) bool { + for _, char := range value { + if unicode.IsControl(char) || unicode.Is(unicode.Cf, char) { + return true + } + } + return false +} diff --git a/pkg/pluginsdk/stream_test.go b/pkg/pluginsdk/stream_test.go new file mode 100644 index 00000000..a2d835da --- /dev/null +++ b/pkg/pluginsdk/stream_test.go @@ -0,0 +1,26 @@ +package pluginsdk + +import "testing" + +func TestProgressEventValidation(t *testing.T) { + for _, event := range []ProgressEvent{ + {Phase: "import", Unit: "logs", Completed: 123}, + {Phase: "read", Unit: "bytes", Total: new(int64(0))}, + {Phase: "read", Unit: "bytes", Completed: 8, Total: new(int64(10))}, + } { + if err := event.Validate(); err != nil { + t.Fatalf("valid event %+v: %v", event, err) + } + } + for _, event := range []ProgressEvent{ + {Phase: "", Unit: "logs"}, {Phase: "import", Unit: ""}, + {Phase: "\x1b[2J", Unit: "logs"}, {Phase: "import", Unit: "\u202elogs"}, + {Phase: "import", Unit: "logs", Completed: -1}, + {Phase: "import", Unit: "logs", Total: new(int64(-1))}, + {Phase: "import", Unit: "logs", Completed: 2, Total: new(int64(1))}, + } { + if err := event.Validate(); err == nil { + t.Fatalf("invalid event accepted: %+v", event) + } + } +} diff --git a/pkg/pluginsdk/transport/client.go b/pkg/pluginsdk/transport/client.go new file mode 100644 index 00000000..092d72f9 --- /dev/null +++ b/pkg/pluginsdk/transport/client.go @@ -0,0 +1,257 @@ +// Package transport serves and loads external fctl plugins over local gRPC. +// The host retains its HTTP client and credentials; plugins receive a scoped +// HTTP callback instead. Executables must be trusted: this is not a sandbox. +package transport + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +// ErrClosed is returned when a terminated runtime is used again. +var ErrClosed = errors.New("plugin runtime is closed") + +// Client is a running external plugin. It implements the same contract as an +// embedded plugin. Close releases the process and all its HTTP callbacks. +type Client struct { + remote *remotePlugin + process *goplugin.Client + http *http.Client + endpoint *url.URL + lifetime context.Context + cancel context.CancelFunc + done chan struct{} + once sync.Once + mu sync.Mutex + stop func() bool +} + +var _ pluginsdk.Plugin = (*Client)(nil) + +// Open starts binary without arguments or inherited environment variables. +// Cancellation of ctx, or of an RPC, terminates the process. A nil client and +// empty endpoint are allowed for metadata inspection; Execute needs both. +// The host must verify the binary's provenance/digest before calling Open. +func Open(ctx context.Context, binary string, client *http.Client, endpoint string) (*Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !filepath.IsAbs(binary) { + return nil, fmt.Errorf("plugin executable must be an absolute path") + } + target, err := parseEndpoint(endpoint) + if err != nil { + return nil, err + } + lifetime, cancel := context.WithCancel(ctx) + command := exec.CommandContext(lifetime, binary) + command.Env = []string{} + command.WaitDelay = time.Second + if runtime.GOOS == "windows" { + // Required by the Windows runtime; no user configuration is inherited. + command.Env = append(command.Env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT")) + } + process := goplugin.NewClient(&goplugin.ClientConfig{ + HandshakeConfig: handshake, + Plugins: goplugin.PluginSet{pluginName: &grpcPlugin{}}, + Cmd: command, + SkipHostEnv: true, + AllowedProtocols: []goplugin.Protocol{ + goplugin.ProtocolGRPC, + }, + StartTimeout: 10 * time.Second, + AutoMTLS: true, + Logger: hclog.NewNullLogger(), + SyncStdout: io.Discard, + SyncStderr: io.Discard, + Stderr: io.Discard, + GRPCDialOptions: []grpc.DialOption{grpc.WithDisableRetry()}, + }) + protocol, err := process.Client() + if err != nil { + cancel() + process.Kill() + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("start external plugin: %w", err) + } + dispensed, err := protocol.Dispense(pluginName) + if err != nil { + cancel() + process.Kill() + return nil, fmt.Errorf("load external plugin: %w", err) + } + remote, ok := dispensed.(*remotePlugin) + if !ok { + cancel() + process.Kill() + return nil, fmt.Errorf("invalid external plugin implementation") + } + result := &Client{remote: remote, process: process, http: client, endpoint: target, + lifetime: lifetime, cancel: cancel, done: make(chan struct{})} + result.mu.Lock() + result.stop = context.AfterFunc(ctx, result.close) + result.mu.Unlock() + if ctx.Err() != nil { + result.close() + return nil, ctx.Err() + } + return result, nil +} + +func parseEndpoint(endpoint string) (*url.URL, error) { + if endpoint == "" { + return nil, nil + } + if err := httpclient.ValidateURL(endpoint); err != nil { + return nil, err + } + target, err := url.Parse(strings.TrimRight(endpoint, "/")) + if err != nil { + return nil, err + } + if err := validatePath(target); err != nil { + return nil, err + } + return target, nil +} + +// Close is safe to call concurrently and more than once. It waits until the +// subprocess is reaped. A closed or canceled Client cannot be restarted. +func (c *Client) Close() error { + c.close() + return nil +} + +func (c *Client) close() { + c.once.Do(func() { + c.mu.Lock() + if c.stop != nil { + c.stop() + } + c.mu.Unlock() + c.cancel() + c.process.Kill() + close(c.done) + }) +} + +func (c *Client) check(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if c.lifetime.Err() != nil { + return ErrClosed + } + return nil +} + +// GetManifest uses factory(nil) in the plugin. It never enables HTTP callbacks. +func (c *Client) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if err := c.check(ctx); err != nil { + return pluginsdk.Manifest{}, err + } + stop := context.AfterFunc(ctx, c.close) + defer stop() + var manifest pluginsdk.Manifest + if err := invoke(ctx, c.remote.conn, pluginService+"/GetManifest", struct{}{}, &manifest); err != nil { + return pluginsdk.Manifest{}, c.rpcError(ctx, err) + } + if manifest.ProtocolVersion != pluginsdk.ProtocolVersion { + return pluginsdk.Manifest{}, fmt.Errorf("unsupported plugin protocol version %d", manifest.ProtocolVersion) + } + return manifest, nil +} + +// Execute preserves SDK requests and partial results. The HTTP callback is +// unique to this invocation, and is removed even when execution fails. +func (c *Client) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if err := c.check(ctx); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if c.http == nil || c.endpoint == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("plugin execution requires a host HTTP client and endpoint") + } + if request.Endpoint == "" { + request.Endpoint = c.endpoint.String() + } + if strings.TrimRight(request.Endpoint, "/") != c.endpoint.String() { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("plugin endpoint differs from host endpoint") + } + if err := validateRequest(request); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + stop := context.AfterFunc(ctx, c.close) + defer stop() + callback := c.serveHTTP() + defer callback.stop() + var response resultWire + err := invoke(ctx, c.remote.conn, pluginService+"/Execute", executeWire{BrokerID: callback.id, Request: toWire(request)}, &response) + if err != nil { + return pluginsdk.ExecuteResponse{}, c.rpcError(ctx, err) + } + if err := validateResult(response); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return pluginsdk.ExecuteResponse{Data: response.Data}, response.Error.err() +} + +func (c *Client) rpcError(ctx context.Context, err error) error { + if ctx.Err() != nil { + c.close() + return ctx.Err() + } + if c.lifetime.Err() != nil { + return ErrClosed + } + // The peer can report cancellation before the local context timer fires. + // Both transport cancellation statuses must terminate and reap the plugin. + code := status.Code(err) + if code == codes.DeadlineExceeded || code == codes.Canceled { + c.close() + if code == codes.DeadlineExceeded { + return context.DeadlineExceeded + } + return context.Canceled + } + return err +} + +func validateRequest(request pluginsdk.ExecuteRequest) error { + if len(request.Body) > maxBodyBytes { + return fmt.Errorf("plugin request body exceeds 4 MiB") + } + metadata := toWire(request) + metadata.Body = nil + encoded, err := pack(metadata) + if err != nil || len(encoded.Value) > maxMetaBytes { + return fmt.Errorf("plugin request metadata exceeds 1 MiB") + } + for key := range request.Context { + if credentialKey(key) { + return fmt.Errorf("plugin context must not contain credentials") + } + } + return nil +} diff --git a/pkg/pluginsdk/transport/http.go b/pkg/pluginsdk/transport/http.go new file mode 100644 index 00000000..206a6032 --- /dev/null +++ b/pkg/pluginsdk/transport/http.go @@ -0,0 +1,263 @@ +package transport + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type httpRequestWire struct { + Method string `json:"method"` + URL string `json:"url"` + Header http.Header `json:"header,omitzero"` + Body []byte `json:"body,omitzero"` +} + +type httpResponseWire struct { + StatusCode int `json:"statusCode"` + Header http.Header `json:"header,omitzero"` + Body []byte `json:"body,omitzero"` + Error *errorWire `json:"error,omitzero"` +} + +type callbackTransport struct { + conn *grpc.ClientConn + execution context.Context + streaming bool +} + +func (c *callbackTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if c.streaming { + return c.roundTripStream(request) + } + ctx, cancel := context.WithCancel(request.Context()) + //nolint:contextcheck // Merge the request context with its parent Execute RPC. + stop := context.AfterFunc(c.execution, cancel) + defer cancel() + defer stop() + var body []byte + if request.Body != nil { + var err error + body, err = readLimited(request.Body, maxBodyBytes) + err = errors.Join(err, request.Body.Close()) + if err != nil { + return nil, err + } + } + var response httpResponseWire + if err := invoke(ctx, c.conn, httpService+"/Do", httpRequestWire{request.Method, request.URL.String(), request.Header, body}, &response); err != nil { + return nil, err + } + if response.Error != nil { + return nil, response.Error.err() + } + return &http.Response{StatusCode: response.StatusCode, Status: fmt.Sprintf("%d %s", response.StatusCode, http.StatusText(response.StatusCode)), + Header: response.Header, Body: io.NopCloser(bytes.NewReader(response.Body)), + ContentLength: int64(len(response.Body)), Request: request, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1}, nil +} + +type callbackServer struct { + id uint32 + mu sync.Mutex + server *grpc.Server + stopped bool +} + +func (c *Client) serveHTTP() *callbackServer { + callback := &callbackServer{id: c.remote.broker.NextId()} + stop := context.AfterFunc(c.lifetime, callback.stop) + go func() { + defer stop() + c.remote.broker.AcceptAndServe(callback.id, func(options []grpc.ServerOption) *grpc.Server { + server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(maxWireBytes), grpc.MaxSendMsgSize(maxWireBytes))...) + server.RegisterService(&httpDescriptor, &hostHTTP{client: c.http, endpoint: c.endpoint}) + callback.mu.Lock() + callback.server = server + if callback.stopped { + server.Stop() + } + callback.mu.Unlock() + return server + }) + }() + return callback +} + +func (c *callbackServer) stop() { + c.mu.Lock() + defer c.mu.Unlock() + c.stopped = true + if c.server != nil { + c.server.Stop() + } +} + +type hostHTTP struct { + client *http.Client + endpoint *url.URL +} + +func (h *hostHTTP) Do(ctx context.Context, message *wrapperspb.BytesValue) (result *wrapperspb.BytesValue, err error) { + var wire httpRequestWire + if err := unpack(message, &wire); err != nil { + return nil, err + } + request, err := h.buildRequest(ctx, wire) + if err != nil { + return nil, err + } + // Preserve the injected transport, timeout and cookie jar. CheckRedirect is + // wrapped so that credentials cannot follow a redirect out of this boundary. + client := *h.client + client.CheckRedirect = h.checkRedirect + response, err := client.Do(request) + if err != nil { + // Transport errors can include URLs, credentials or host file paths. + // Keep cancellation semantics, but never disclose those details. + failure := &errorWire{Kind: "host", Message: "host HTTP request failed"} + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + failure = encodeError(err) + } + return pack(httpResponseWire{Error: failure}) + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil { + err = errors.Join(err, status.Error(codes.Unavailable, "host HTTP response close failed")) + } + }() + payload, err := readResponse(response) + if err != nil { + return nil, err + } + return pack(payload) +} + +func (h *hostHTTP) buildRequest(ctx context.Context, wire httpRequestWire) (*http.Request, error) { + if len(wire.Body) > maxBodyBytes || headerSize(wire.Header) > maxHeadBytes { + return nil, status.Error(codes.ResourceExhausted, "plugin HTTP request exceeds transport limit") + } + target, err := url.Parse(wire.URL) + if err != nil || validateEndpoint(h.endpoint, target) != nil { + return nil, status.Error(codes.PermissionDenied, "plugin HTTP request is outside its endpoint") + } + for name := range wire.Header { + if credentialKey(name) || strings.EqualFold(name, "Host") { + return nil, status.Error(codes.PermissionDenied, "plugin HTTP headers must not contain credentials or override Host") + } + } + request, err := http.NewRequestWithContext(ctx, wire.Method, target.String(), bytes.NewReader(wire.Body)) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid plugin HTTP request") + } + request.Header = wire.Header.Clone() + return request, nil +} + +func (h *hostHTTP) checkRedirect(next *http.Request, previous []*http.Request) error { + if err := validateEndpoint(h.endpoint, next.URL); err != nil { + return err + } + if h.client.CheckRedirect != nil { + return h.client.CheckRedirect(next, previous) + } + if len(previous) >= 10 { + return fmt.Errorf("too many service redirects") + } + return nil +} + +func readResponse(response *http.Response) (payload httpResponseWire, err error) { + body, err := readLimited(response.Body, maxDataBytes) + if err != nil { + return payload, status.Error(codes.ResourceExhausted, "host HTTP response exceeds transport limit or cannot be read") + } + headers := response.Header.Clone() + for name := range headers { + if credentialKey(name) { + delete(headers, name) + } + } + if headerSize(headers) > maxHeadBytes { + return payload, status.Error(codes.ResourceExhausted, "host HTTP response headers exceed transport limit") + } + return httpResponseWire{StatusCode: response.StatusCode, Header: headers, Body: body}, nil +} + +func readLimited(reader io.Reader, limit int) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(reader, int64(limit)+1)) + if err != nil { + return nil, err + } + if len(body) > limit { + return nil, fmt.Errorf("HTTP body exceeds transport limit") + } + return body, nil +} + +func headerSize(headers http.Header) int { + size := 0 + for name, values := range headers { + size += len(name) + for _, value := range values { + size += len(value) + } + } + return size +} + +func credentialKey(name string) bool { + name = strings.ToLower(name) + name = strings.NewReplacer("-", "", "_", "", " ", "").Replace(name) + switch name { + case "authorization", "proxyauthorization", "cookie", "setcookie", "token", "accesstoken", "refreshtoken", "idtoken", "clientsecret", "password", "apikey", "xapikey", "xauthtoken": + return true + default: + return false + } +} + +func validateEndpoint(base, target *url.URL) error { + if target == nil || !strings.EqualFold(target.Scheme, base.Scheme) || !strings.EqualFold(target.Host, base.Host) || target.User != nil || target.Fragment != "" || target.Opaque != "" { + return fmt.Errorf("plugin HTTP request is outside its endpoint") + } + if err := validatePath(target); err != nil { + return err + } + basePath := strings.TrimRight(base.Path, "/") + if target.Path != basePath && !strings.HasPrefix(target.Path, basePath+"/") { + return fmt.Errorf("plugin HTTP request is outside its endpoint path") + } + return nil +} + +func validatePath(target *url.URL) error { + // Reject traversal even through encoded separators or double decoding in + // a gateway. Encoded slashes in ordinary service IDs remain supported. + value := target.EscapedPath() + for { + if strings.Contains(value, "\\") { + return fmt.Errorf("plugin endpoint paths must not contain backslashes") + } + for segment := range strings.SplitSeq(value, "/") { + if segment == "." || segment == ".." { + return fmt.Errorf("plugin endpoint paths must not contain dot segments") + } + } + decoded, err := url.PathUnescape(value) + if err != nil || decoded == value { + return nil + } + value = decoded + } +} diff --git a/pkg/pluginsdk/transport/http_stream.go b/pkg/pluginsdk/transport/http_stream.go new file mode 100644 index 00000000..d52cdcea --- /dev/null +++ b/pkg/pluginsdk/transport/http_stream.go @@ -0,0 +1,477 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const ( + httpStreamChunkBytes = 64 << 10 + httpStreamWireBytes = maxMetaBytes + 64 // Metadata plus protobuf framing. + httpStreamRequest = byte(1) + httpStreamResponse = byte(2) + httpStreamData = byte(3) + httpStreamEOF = byte(4) +) + +// Exchange is additive to the v1 unary Do RPC. Each BytesValue starts with a +// frame kind. The first frame in each direction contains JSON metadata; data +// frames contain up to 64 KiB of raw bytes, followed by an explicit EOF frame. +// gRPC completion must also succeed: EOF alone cannot hide a final read/close +// failure. Neither request nor response bodies pass through JSON or pack. +type httpStreamRequestWire struct { + Method string `json:"method"` + URL string `json:"url"` + Header http.Header `json:"header,omitzero"` + ContentLength int64 `json:"contentLength"` + HasBody bool `json:"hasBody"` +} + +type httpStreamResponseWire struct { + StatusCode int `json:"statusCode"` + Header http.Header `json:"header,omitzero"` + ContentLength int64 `json:"contentLength"` + Error *errorWire `json:"error,omitzero"` +} + +type httpStreamRPC interface { + Exchange(grpc.ServerStream) error +} + +// Append this descriptor to httpDescriptor.Streams when registering the host +// callback. Keeping a separate descriptor leaves the unary service unchanged. +func httpStreamDescriptor() grpc.StreamDesc { + return grpc.StreamDesc{StreamName: "Exchange", Handler: httpExchangeHandler, ServerStreams: true, ClientStreams: true} +} + +func httpExchangeHandler(server any, stream grpc.ServerStream) error { + implementation, ok := server.(httpStreamRPC) + if !ok { + return status.Error(codes.Internal, "invalid streaming HTTP RPC implementation") + } + return implementation.Exchange(stream) +} + +func httpStreamMetadata(kind byte, value any) (*wrapperspb.BytesValue, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "invalid HTTP stream metadata") + } + if len(data) > maxMetaBytes { + return nil, status.Error(codes.ResourceExhausted, "HTTP stream metadata exceeds transport limit") + } + return wrapperspb.Bytes(append([]byte{kind}, data...)), nil +} + +func decodeHTTPStreamMetadata(message *wrapperspb.BytesValue, kind byte, value any) error { + if message == nil || len(message.Value) > maxMetaBytes+1 { + return status.Error(codes.ResourceExhausted, "HTTP stream metadata exceeds transport limit") + } + if len(message.Value) < 2 || message.Value[0] != kind || json.Unmarshal(message.Value[1:], value) != nil { + return status.Error(codes.InvalidArgument, "invalid HTTP stream metadata") + } + return nil +} + +type httpFrameReceiver interface { + RecvMsg(any) error +} + +func receiveHTTPStreamFrame(receiver httpFrameReceiver) ([]byte, error) { + var message wrapperspb.BytesValue + if err := receiver.RecvMsg(&message); err != nil { + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + if len(message.Value) == 0 { + return nil, status.Error(codes.InvalidArgument, "empty HTTP stream frame") + } + switch message.Value[0] { + case httpStreamData: + if len(message.Value) > httpStreamChunkBytes+1 { + return nil, status.Error(codes.ResourceExhausted, "HTTP stream chunk exceeds 64 KiB") + } + if len(message.Value) == 1 { + return nil, status.Error(codes.InvalidArgument, "empty HTTP stream chunk") + } + case httpStreamEOF: + if len(message.Value) != 1 { + return nil, status.Error(codes.InvalidArgument, "invalid HTTP stream EOF") + } + default: + return nil, status.Error(codes.InvalidArgument, "unexpected HTTP stream frame") + } + return message.Value, nil +} + +// roundTripStream keeps its context alive until the response body is consumed +// or closed. A request body must obey net/http's concurrent Read/Close contract, +// so closing it interrupts a blocked Read on cancellation or an early response. +func (c *callbackTransport) roundTripStream(request *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancelCause(request.Context()) + //nolint:contextcheck // Merge the HTTP request with its parent Execute RPC. + stopExecution := context.AfterFunc(c.execution, func() { cancel(context.Cause(c.execution)) }) + closeInput := sync.OnceFunc(func() { + if request.Body != nil { + if err := request.Body.Close(); err != nil { + cancel(err) + } + } + }) + stopInput := context.AfterFunc(ctx, closeInput) + finish := sync.OnceFunc(func() { + cancel(context.Canceled) + stopExecution() + stopInput() + closeInput() + }) + response, err := c.openHTTPStream(ctx, request, cancel, closeInput, finish) + if err != nil { + finish() + } + return response, err +} + +func (c *callbackTransport) openHTTPStream(ctx context.Context, request *http.Request, cancel context.CancelCauseFunc, closeInput, finish func()) (*http.Response, error) { + metadata := httpStreamRequestWire{Method: request.Method, URL: request.URL.String(), Header: request.Header, + ContentLength: request.ContentLength, HasBody: request.Body != nil && request.Body != http.NoBody} + if metadata.ContentLength < -1 || (!metadata.HasBody && metadata.ContentLength > 0) { + return nil, status.Error(codes.InvalidArgument, "invalid HTTP stream request metadata") + } + if headerSize(metadata.Header) > maxHeadBytes { + return nil, status.Error(codes.ResourceExhausted, "HTTP stream request headers exceed transport limit") + } + message, err := httpStreamMetadata(httpStreamRequest, metadata) + if err != nil { + return nil, err + } + descriptor := httpStreamDescriptor() + // Connections are opened with WithDisableRetry. There is one Exchange call, + // no fallback to unary, no body replay and no application retry loop. + stream, err := c.conn.NewStream(ctx, &descriptor, "/"+httpService+"/Exchange", + grpc.MaxCallRecvMsgSize(httpStreamWireBytes), grpc.MaxCallSendMsgSize(httpStreamWireBytes)) + if err != nil { + return nil, httpStreamError(ctx, err) + } + if err := stream.SendMsg(message); err != nil && !errors.Is(err, io.EOF) { + return nil, httpStreamError(ctx, err) + } + go func() { + defer closeInput() + sendHTTPStreamBody(stream, request.Body, cancel) + }() + var header wrapperspb.BytesValue + if err := stream.RecvMsg(&header); err != nil { + return nil, httpStreamError(ctx, err) + } + var wire httpStreamResponseWire + if err := decodeHTTPStreamMetadata(&header, httpStreamResponse, &wire); err != nil { + return nil, err + } + if wire.Error != nil { + return nil, wire.Error.err() + } + if wire.StatusCode < 100 || wire.StatusCode > 999 || wire.ContentLength < -1 || headerSize(wire.Header) > maxHeadBytes { + return nil, status.Error(codes.InvalidArgument, "invalid HTTP stream response metadata") + } + return &http.Response{StatusCode: wire.StatusCode, Status: fmt.Sprintf("%d %s", wire.StatusCode, http.StatusText(wire.StatusCode)), + Header: wire.Header, ContentLength: wire.ContentLength, Request: request, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Body: &httpStreamBody{stream: stream, ctx: ctx, finish: finish}}, nil +} + +type httpStreamSender interface { + SendMsg(any) error +} + +type httpStreamCopyResult struct { + readErr error + sendErr error +} + +func copyHTTPStreamChunks(sender httpStreamSender, body io.Reader) httpStreamCopyResult { + if body == nil { + return httpStreamCopyResult{readErr: io.EOF} + } + buffer := make([]byte, httpStreamChunkBytes+1) + buffer[0] = httpStreamData + for { + n, err := body.Read(buffer[1:]) + if n > 0 { + // gRPC may retain a message for tracing after SendMsg returns. + if sendErr := sender.SendMsg(wrapperspb.Bytes(bytes.Clone(buffer[:n+1]))); sendErr != nil { + return httpStreamCopyResult{readErr: err, sendErr: sendErr} + } + } + if err != nil { + return httpStreamCopyResult{readErr: err} + } + } +} + +// RecvMsg owns send failures and the final status. Only a local source Read +// failure cancels that receive; EOF and early HTTP responses are normal. +func sendHTTPStreamBody(stream grpc.ClientStream, body io.Reader, cancel context.CancelCauseFunc) { + result := copyHTTPStreamChunks(stream, body) + if result.readErr != nil && !errors.Is(result.readErr, io.EOF) { + cancel(result.readErr) + return + } + if result.sendErr != nil { + return + } + if err := stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamEOF})); err != nil { + return + } + // RecvMsg owns the final status even when CloseSend fails. + if err := stream.CloseSend(); err != nil { + return + } +} + +type httpStreamBody struct { + stream grpc.ClientStream + ctx context.Context + finish func() + pending []byte + terminal error // Only Read mutates these fields; Close only cancels the RPC. +} + +func (b *httpStreamBody) Read(buffer []byte) (int, error) { + if len(buffer) == 0 { + return 0, nil + } + if b.terminal != nil { + return 0, b.terminal + } + if b.ctx.Err() != nil { + return 0, context.Cause(b.ctx) + } + if len(b.pending) == 0 { + frame, err := receiveHTTPStreamFrame(b.stream) + if err == nil && frame[0] == httpStreamEOF { + err = b.receiveCompletion() + } + if err != nil { + b.terminal = httpStreamError(b.ctx, err) + b.finish() + return 0, b.terminal + } + b.pending = frame[1:] + } + n := copy(buffer, b.pending) + b.pending = b.pending[n:] + return n, nil +} + +func (b *httpStreamBody) receiveCompletion() error { + var extra wrapperspb.BytesValue + if err := b.stream.RecvMsg(&extra); err != nil { + return err + } + return status.Error(codes.InvalidArgument, "HTTP stream frame after EOF") +} + +func (b *httpStreamBody) Close() error { + b.finish() + return nil +} + +func httpStreamError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return context.Cause(ctx) + } + if status.Code(err) == codes.Canceled { + return context.Canceled + } + if status.Code(err) == codes.DeadlineExceeded { + return context.DeadlineExceeded + } + return err +} + +// Exchange validates only metadata with buildRequest. An unbuffered pipe feeds +// the injected host transport while the handler sends the response concurrently. +// Closing the pipe unblocks Write; returning cancels gRPC's remaining RecvMsg. +func (h *hostHTTP) Exchange(stream grpc.ServerStream) error { + ctx, cancel := context.WithCancelCause(stream.Context()) + defer cancel(context.Canceled) + request, wire, err := h.receiveHTTPStreamRequest(ctx, stream) + if err != nil { + return err + } + closeInput := pipeHTTPStreamInput(ctx, stream, request, wire, cancel) + defer closeInput() + client := *h.client + client.CheckRedirect = h.checkRedirect + response, err := client.Do(request) //nolint:bodyclose // sendHTTPStreamResponse owns and closes the body on every path. + if err != nil { + return sendHTTPStreamFailure(ctx, stream, err) + } + return sendHTTPStreamResponse(ctx, stream, response) +} + +func (h *hostHTTP) receiveHTTPStreamRequest(ctx context.Context, stream grpc.ServerStream) (*http.Request, httpStreamRequestWire, error) { + var wire httpStreamRequestWire + var message wrapperspb.BytesValue + if err := stream.RecvMsg(&message); err != nil { + return nil, wire, err + } + if err := decodeHTTPStreamMetadata(&message, httpStreamRequest, &wire); err != nil { + return nil, wire, err + } + if wire.ContentLength < -1 || (!wire.HasBody && wire.ContentLength > 0) { + return nil, wire, status.Error(codes.InvalidArgument, "invalid HTTP stream content length") + } + request, err := h.buildRequest(ctx, httpRequestWire{Method: wire.Method, URL: wire.URL, Header: wire.Header}) + if err != nil { + return nil, wire, err + } + request.ContentLength = wire.ContentLength + request.GetBody = nil // Streaming input is never replayable, including redirects. + if !wire.HasBody { + if err := receiveHTTPStreamInput(stream, io.Discard, wire); err != nil { + return nil, wire, err + } + } + return request, wire, nil +} + +func pipeHTTPStreamInput(ctx context.Context, stream grpc.ServerStream, request *http.Request, wire httpStreamRequestWire, cancel context.CancelCauseFunc) func() { + if !wire.HasBody { + return func() {} + } + reader, writer := io.Pipe() + closePipe := sync.OnceFunc(func() { + if err := reader.CloseWithError(context.Canceled); err != nil { + cancel(err) + } + if err := writer.CloseWithError(context.Canceled); err != nil { + cancel(err) + } + }) + stopPipe := context.AfterFunc(ctx, closePipe) + if wire.HasBody { + request.Body = reader + } + go func() { + err := receiveHTTPStreamInput(stream, writer, wire) + if err != nil && !errors.Is(err, io.ErrClosedPipe) { + cancel(err) + } + if closeErr := writer.CloseWithError(err); closeErr != nil { + cancel(closeErr) + } + }() + return func() { + stopPipe() + closePipe() + } +} + +func sendHTTPStreamFailure(ctx context.Context, stream grpc.ServerStream, failure error) error { + if ctx.Err() != nil { + return context.Cause(ctx) + } + wire := &errorWire{Kind: "host", Message: "host HTTP request failed"} + if errors.Is(failure, context.Canceled) || errors.Is(failure, context.DeadlineExceeded) { + wire = encodeError(failure) + } + header, err := httpStreamMetadata(httpStreamResponse, httpStreamResponseWire{Error: wire}) + if err != nil { + return err + } + return stream.SendMsg(header) +} + +func receiveHTTPStreamInput(stream grpc.ServerStream, writer io.Writer, wire httpStreamRequestWire) error { + var received int64 + for { + frame, err := receiveHTTPStreamFrame(stream) + if err != nil { + return err + } + if frame[0] == httpStreamEOF { + if wire.HasBody && wire.ContentLength > 0 && received != wire.ContentLength { + return status.Error(codes.InvalidArgument, "HTTP stream input length mismatch") + } + return completeHTTPStreamInput(stream) + } + if !wire.HasBody || (wire.ContentLength > 0 && int64(len(frame)-1) > wire.ContentLength-received) { + return status.Error(codes.InvalidArgument, "unexpected HTTP stream input data") + } + if _, err := writer.Write(frame[1:]); err != nil { + return err + } + received += int64(len(frame) - 1) + } +} + +func completeHTTPStreamInput(stream grpc.ServerStream) error { + var extra wrapperspb.BytesValue + err := stream.RecvMsg(&extra) + if err == nil { + return status.Error(codes.InvalidArgument, "HTTP stream input frame after EOF") + } + if !errors.Is(err, io.EOF) { + return err + } + return nil +} + +func sendHTTPStreamResponse(ctx context.Context, stream grpc.ServerStream, response *http.Response) (err error) { + closeBody := sync.OnceValue(response.Body.Close) + stopBody := context.AfterFunc(ctx, func() { + // The deferred close reports failures; cancellation must interrupt Read. + if closeErr := closeBody(); closeErr != nil { + return + } + }) + defer stopBody() + defer func() { + if closeErr := closeBody(); closeErr != nil { + err = errors.Join(err, status.Error(codes.Unavailable, "host HTTP response close failed")) + } + }() + headers := response.Header.Clone() + for name := range headers { + if credentialKey(name) { + delete(headers, name) + } + } + if headerSize(headers) > maxHeadBytes { + return status.Error(codes.ResourceExhausted, "host HTTP response headers exceed transport limit") + } + header, err := httpStreamMetadata(httpStreamResponse, httpStreamResponseWire{ + StatusCode: response.StatusCode, Header: headers, ContentLength: response.ContentLength, + }) + if err != nil { + return err + } + if err := stream.SendMsg(header); err != nil { + return err + } + result := copyHTTPStreamChunks(stream, response.Body) + if result.sendErr != nil { + return result.sendErr + } + if !errors.Is(result.readErr, io.EOF) { + if ctx.Err() != nil { + return context.Cause(ctx) + } + return status.Error(codes.Unavailable, "host HTTP response read failed") + } + return stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamEOF})) +} diff --git a/pkg/pluginsdk/transport/http_stream_test.go b/pkg/pluginsdk/transport/http_stream_test.go new file mode 100644 index 00000000..6dc20785 --- /dev/null +++ b/pkg/pluginsdk/transport/http_stream_test.go @@ -0,0 +1,742 @@ +package transport + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +const httpStreamTestURL = "https://stream.example/api/ledger" + +func newHTTPStreamCallback(execution context.Context, t *testing.T, host httpRPC) *callbackTransport { + t.Helper() + listener := bufconn.Listen(128 << 10) + server := grpc.NewServer(grpc.MaxRecvMsgSize(httpStreamWireBytes), grpc.MaxSendMsgSize(httpStreamWireBytes)) + descriptor := httpDescriptor + descriptor.Streams = []grpc.StreamDesc{httpStreamDescriptor()} + server.RegisterService(&descriptor, host) + served := make(chan error, 1) + go func() { served <- server.Serve(listener) }() + conn, err := grpc.NewClient("passthrough:///http-stream", grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDisableRetry(), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + })) + if err != nil { + server.Stop() + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + server.Stop() + if err := listener.Close(); err != nil { + t.Error(err) + } + if err := <-served; err != nil && !errors.Is(err, grpc.ErrServerStopped) { + t.Error(err) + } + }) + return &callbackTransport{conn: conn, execution: execution} +} + +func httpStreamTestHost(t *testing.T, client *http.Client, endpoint string) *hostHTTP { + t.Helper() + return &hostHTTP{client: client, endpoint: mustURL(t, endpoint)} +} + +func httpStreamTestRequest(ctx context.Context, t *testing.T, method, target string, body io.Reader) *http.Request { + t.Helper() + request, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + t.Fatal(err) + } + return request +} + +func waitHTTPStreamSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("HTTP stream operation did not finish") + } +} + +func httpStreamTestFailure(t *testing.T, callback *callbackTransport, request *http.Request) error { + t.Helper() + response, err := callback.roundTripStream(request) + if err != nil { + return err + } + defer func() { + if closeErr := response.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + }() + _, err = io.Copy(io.Discard, response.Body) + return err +} + +// The source is generated one Read at a time. After the prefix, a gate prevents +// the producer from finishing before the consumer has seen incremental data. +type httpStreamLazyBody struct { + prefix []byte + remaining int64 + gate <-chan struct{} + closed chan struct{} + once sync.Once + readBytes atomic.Int64 + maxRead atomic.Int64 +} + +func newHTTPStreamLazyBody(prefix string, remaining int64, gate <-chan struct{}) *httpStreamLazyBody { + return &httpStreamLazyBody{prefix: []byte(prefix), remaining: remaining, gate: gate, closed: make(chan struct{})} +} + +func (b *httpStreamLazyBody) Read(buffer []byte) (int, error) { + b.maxRead.Store(max(b.maxRead.Load(), int64(len(buffer)))) + if len(b.prefix) > 0 { + n := copy(buffer, b.prefix) + b.prefix = b.prefix[n:] + b.readBytes.Add(int64(n)) + return n, nil + } + if b.remaining == 0 { + return 0, io.EOF + } + select { + case <-b.gate: + case <-b.closed: + return 0, io.ErrClosedPipe + } + n := int(min(int64(len(buffer)), b.remaining)) + for i := range n { + buffer[i] = 'x' + } + b.remaining -= int64(n) + b.readBytes.Add(int64(n)) + return n, nil +} + +func (b *httpStreamLazyBody) Close() error { + b.once.Do(func() { close(b.closed) }) + return nil +} + +func TestHTTPStreamLargeLazyResponse(t *testing.T) { + t.Parallel() + const prefix = "{\"id\":90071992547409930001}\n" + const size = int64(40 << 20) + gate := make(chan struct{}) + body := newHTTPStreamLazyBody(prefix, size, gate) + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: body, ContentLength: size + int64(len(prefix)), Header: http.Header{ + "Content-Type": {"application/x-ndjson"}, "Content-Length": {strconv.FormatInt(size+int64(len(prefix)), 10)}, + "Authorization": {"secret"}, "Set-Cookie": {"secret"}, "X-Request-Id": {"visible"}, + }}, nil + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + response, err := callback.roundTripStream(httpStreamTestRequest(t.Context(), t, "GET", httpStreamTestURL+"/dump", nil)) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := response.Body.Close(); err != nil { + t.Error(err) + } + }() + if response.ContentLength != size+int64(len(prefix)) || response.Header.Get("Content-Length") != strconv.FormatInt(response.ContentLength, 10) || + response.Header.Get("Authorization") != "" || response.Header.Get("Set-Cookie") != "" || response.Header.Get("X-Request-Id") != "visible" { + t.Fatalf("response metadata changed: %+v", response) + } + first := make([]byte, len(prefix)) + if _, err := io.ReadFull(response.Body, first); err != nil || string(first) != prefix { + t.Fatalf("incremental prefix = %q, %v", first, err) + } + if body.readBytes.Load() != int64(len(prefix)) { + t.Fatal("response was buffered before incremental delivery") + } + close(gate) + written, err := io.Copy(io.Discard, response.Body) + if err != nil || written != size || body.maxRead.Load() > httpStreamChunkBytes { + t.Fatalf("large stream = %d, %v, read size = %d", written, err, body.maxRead.Load()) + } + waitHTTPStreamSignal(t, body.closed) +} + +func TestHTTPStreamRequestAndResponseAreConcurrent(t *testing.T) { + t.Parallel() + for _, known := range []bool{true, false} { + t.Run(strconv.FormatBool(known), func(t *testing.T) { + t.Parallel() + testHTTPStreamDuplex(t, known) + }) + } +} + +func testHTTPStreamDuplex(t *testing.T, known bool) { + t.Helper() + const prefix = "request-prefix\n" + const size = int64(40 << 20) + gate := make(chan struct{}) + input := newHTTPStreamLazyBody(prefix, size, gate) + finished := make(chan struct{}) + wantLength := int64(-1) + if known { + wantLength = size + int64(len(prefix)) + } + server := httptest.NewServer(httpStreamDuplexHandler(t, prefix, size, wantLength, finished)) + t.Cleanup(server.Close) + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, server.Client(), server.URL)) + request := httpStreamTestRequest(t.Context(), t, "POST", server.URL+"/bulk", input) + request.ContentLength = wantLength + request.Header.Set("Content-Type", "application/x-ndjson") + response, err := callback.roundTripStream(request) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := response.Body.Close(); err != nil { + t.Error(err) + } + }() + first := make([]byte, len("accepted\n")) + if _, err := io.ReadFull(response.Body, first); err != nil || string(first) != "accepted\n" { + t.Fatalf("response before upload completion = %q, %v", first, err) + } + if input.readBytes.Load() != int64(len(prefix)) || response.ContentLength != -1 { + t.Fatal("upload finished before incremental response or unknown length was lost") + } + close(gate) + last, err := io.ReadAll(response.Body) + if err != nil || string(last) != "done\n" || input.maxRead.Load() > httpStreamChunkBytes { + t.Fatalf("duplex completion = %q, %v, read size = %d", last, err, input.maxRead.Load()) + } + waitHTTPStreamSignal(t, finished) + waitHTTPStreamSignal(t, input.closed) +} + +func httpStreamDuplexHandler(t *testing.T, prefix string, size, wantLength int64, finished chan<- struct{}) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + defer close(finished) + if r.ContentLength != wantLength || r.Header.Get("Content-Type") != "application/x-ndjson" { + t.Errorf("request metadata = %d, %v", r.ContentLength, r.Header) + } + controller := http.NewResponseController(w) + if err := controller.EnableFullDuplex(); err != nil { + t.Error(err) + return + } + first := make([]byte, len(prefix)) + if _, err := io.ReadFull(r.Body, first); err != nil || string(first) != prefix { + t.Errorf("request prefix = %q, %v", first, err) + return + } + if err := writeHTTPStreamAccepted(w); err != nil { + t.Error(err) + return + } + if n, err := io.Copy(io.Discard, r.Body); err != nil || n != size { + t.Errorf("request remainder = %d, %v", n, err) + } + if _, err := io.WriteString(w, "done\n"); err != nil { + t.Error(err) + } + } +} + +func writeHTTPStreamAccepted(w http.ResponseWriter) error { + if _, err := io.WriteString(w, "accepted\n"); err != nil { + return err + } + return http.NewResponseController(w).Flush() +} + +type httpStreamBlockingBody struct { + started chan struct{} + closed chan struct{} + returned chan struct{} + start sync.Once + close sync.Once + read sync.Once +} + +func newHTTPStreamBlockingBody() *httpStreamBlockingBody { + return &httpStreamBlockingBody{started: make(chan struct{}), closed: make(chan struct{}), returned: make(chan struct{})} +} + +func (b *httpStreamBlockingBody) Read([]byte) (int, error) { + b.start.Do(func() { close(b.started) }) + <-b.closed + b.read.Do(func() { close(b.returned) }) + return 0, io.ErrClosedPipe +} + +func (b *httpStreamBlockingBody) Close() error { + b.close.Do(func() { close(b.closed) }) + return nil +} + +func TestHTTPStreamCancellationAndCloseUnblockBodies(t *testing.T) { + t.Parallel() + for _, cause := range []string{"request", "execution", "close"} { + t.Run(cause, func(t *testing.T) { + t.Parallel() + testHTTPStreamCancellationAndClose(t, cause) + }) + } +} + +func testHTTPStreamCancellationAndClose(t *testing.T, cause string) { + t.Helper() + requestCtx, cancelRequest := context.WithCancel(t.Context()) + defer cancelRequest() + executionCtx, cancelExecution := context.WithCancel(t.Context()) + defer cancelExecution() + input, output := newHTTPStreamBlockingBody(), newHTTPStreamBlockingBody() + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: output, ContentLength: -1}, nil + })} + callback := newHTTPStreamCallback(executionCtx, t, httpStreamTestHost(t, client, httpStreamTestURL)) + response, err := callback.roundTripStream(httpStreamTestRequest(requestCtx, t, "POST", httpStreamTestURL, input)) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := response.Body.Close(); err != nil { + t.Error(err) + } + }() + read := make(chan error, 1) + go func() { _, err := io.Copy(io.Discard, response.Body); read <- err }() + waitHTTPStreamSignal(t, input.started) + waitHTTPStreamSignal(t, output.started) + if cause == "request" { + cancelRequest() + } else if cause == "execution" { + cancelExecution() + } else if err := response.Body.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-read: + if !errors.Is(err, context.Canceled) { + t.Fatalf("body cancellation = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("response read did not stop") + } + waitHTTPStreamSignal(t, input.returned) + waitHTTPStreamSignal(t, output.returned) +} + +func TestHTTPStreamEarlyResponseStopsUpload(t *testing.T) { + t.Parallel() + input := newHTTPStreamBlockingBody() + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusRequestEntityTooLarge, Body: io.NopCloser(strings.NewReader("rejected")), ContentLength: 8}, nil + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + response, err := callback.roundTripStream(httpStreamTestRequest(t.Context(), t, "POST", httpStreamTestURL, input)) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := response.Body.Close(); err != nil { + t.Error(err) + } + }() + data, err := io.ReadAll(response.Body) + if err != nil || string(data) != "rejected" || response.StatusCode != 413 { + t.Fatalf("early response = %q, %v", data, err) + } + waitHTTPStreamSignal(t, input.returned) +} + +type httpStreamFailureBody struct { + readErr error + closeErr error + closed atomic.Bool +} + +type simultaneousHTTPStreamFailure struct{ grpc.ClientStream } + +func (simultaneousHTTPStreamFailure) SendMsg(any) error { return io.EOF } + +type partialHTTPStreamFailure struct{} + +func (partialHTTPStreamFailure) Read(data []byte) (int, error) { + data[0] = 'x' + return 1, io.ErrUnexpectedEOF +} + +func TestHTTPStreamPreservesReadErrorWhenSendAlsoFails(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(nil) + sendHTTPStreamBody(simultaneousHTTPStreamFailure{}, partialHTTPStreamFailure{}, cancel) + if !errors.Is(context.Cause(ctx), io.ErrUnexpectedEOF) { + t.Fatalf("source failure was replaced by send EOF: %v", context.Cause(ctx)) + } +} + +func (b *httpStreamFailureBody) Read([]byte) (int, error) { return 0, b.readErr } +func (b *httpStreamFailureBody) Close() error { + b.closed.Store(true) + return b.closeErr +} + +func TestHTTPStreamFailuresAreRedactedAndNotRetried(t *testing.T) { + t.Parallel() + for _, failure := range []string{"transport", "read", "close"} { + t.Run(failure, func(t *testing.T) { + t.Parallel() + testHTTPStreamFailure(t, failure) + }) + } +} + +func testHTTPStreamFailure(t *testing.T, failure string) { + t.Helper() + var calls atomic.Int32 + secret := errors.New("Bearer secret /private/host/credentials") + body := &httpStreamFailureBody{readErr: io.EOF} + switch failure { + case "read": + body.readErr = secret + case "close": + body.closeErr = secret + } + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls.Add(1) + if failure == "transport" { + return nil, secret + } + return &http.Response{StatusCode: 200, Body: body, ContentLength: -1}, nil + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + err := httpStreamTestFailure(t, callback, httpStreamTestRequest(t.Context(), t, "GET", httpStreamTestURL, nil)) + if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "/private") || calls.Load() != 1 { + t.Fatalf("failure = %v, attempts = %d", err, calls.Load()) + } + if failure != "transport" && !body.closed.Load() { + t.Fatal("failed response body was not closed") + } +} + +func TestHTTPStreamSourceReadErrorIsPreserved(t *testing.T) { + t.Parallel() + original := errors.New("source failed") + input := &httpStreamFailureBody{readErr: original} + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + _, err := io.Copy(io.Discard, request.Body) + return nil, err + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + err := httpStreamTestFailure(t, callback, httpStreamTestRequest(t.Context(), t, "POST", httpStreamTestURL, input)) + if !errors.Is(err, original) || !input.closed.Load() { + t.Fatalf("source error = %v, closed = %v", err, input.closed.Load()) + } +} + +func TestHTTPStreamEndpointHeadersAndRedirects(t *testing.T) { + t.Parallel() + var escaped atomic.Int32 + outside := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { escaped.Add(1) })) + t.Cleanup(outside.Close) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/ledger/inside": + http.Redirect(w, r, "/api/ledger/ok", http.StatusFound) + case "/api/ledger/origin": + http.Redirect(w, r, outside.URL, http.StatusFound) + case "/api/ledger/sibling": + http.Redirect(w, r, "/api/auth/secret", http.StatusFound) + case "/api/ledger/ok": + if _, err := io.WriteString(w, "ok"); err != nil { + t.Error(err) + } + default: + escaped.Add(1) + } + })) + t.Cleanup(server.Close) + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, server.Client(), server.URL+"/api/ledger")) + for _, path := range []string{"/api/auth/secret", "/api/ledger-other", "/api/ledger/../auth", "/api/ledger/%252e%252e/auth", "/api/ledger/%5c..%5cauth", "/api/ledger/origin", "/api/ledger/sibling"} { + request := httpStreamTestRequest(t.Context(), t, "GET", server.URL+path, nil) + if err := httpStreamTestFailure(t, callback, request); err == nil { + t.Fatalf("endpoint escape accepted: %s", path) + } + } + for _, name := range []string{"Authorization", "Cookie", "Proxy-Authorization", "X-Api-Key", "Host"} { + request := httpStreamTestRequest(t.Context(), t, "GET", server.URL+"/api/ledger/ok", nil) + request.Header.Set(name, "secret") + if err := httpStreamTestFailure(t, callback, request); err == nil { + t.Fatalf("credential header accepted: %s", name) + } + } + response, err := callback.roundTripStream(httpStreamTestRequest(t.Context(), t, "GET", server.URL+"/api/ledger/inside", nil)) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + if closeErr := response.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + if err != nil || string(data) != "ok" || escaped.Load() != 0 { + t.Fatalf("redirect = %q, %v, escapes = %d", data, err, escaped.Load()) + } +} + +type httpStreamTestPeer struct { + *hostHTTP + exchange func(grpc.ServerStream) error +} + +func (p *httpStreamTestPeer) Exchange(stream grpc.ServerStream) error { return p.exchange(stream) } + +func TestHTTPStreamRejectsTruncatedAndInvalidResponseFrames(t *testing.T) { + t.Parallel() + for _, failure := range []string{"missing EOF", "oversized chunk", "empty chunk", "after EOF", "missing header"} { + t.Run(failure, func(t *testing.T) { + t.Parallel() + peer := &httpStreamTestPeer{hostHTTP: &hostHTTP{}, exchange: httpStreamInvalidResponse(failure)} + callback := newHTTPStreamCallback(t.Context(), t, peer) + if err := httpStreamTestFailure(t, callback, httpStreamTestRequest(t.Context(), t, "GET", httpStreamTestURL, nil)); err == nil { + t.Fatal("invalid response stream accepted") + } + }) + } +} + +func httpStreamInvalidResponse(failure string) func(grpc.ServerStream) error { + return func(stream grpc.ServerStream) error { + var initial wrapperspb.BytesValue + if err := stream.RecvMsg(&initial); err != nil { + return err + } + if failure == "missing header" { + return stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamData, 'x'})) + } + header, err := httpStreamMetadata(httpStreamResponse, httpStreamResponseWire{StatusCode: 200, ContentLength: -1}) + if err != nil { + return err + } + if err := stream.SendMsg(header); err != nil { + return err + } + return sendInvalidHTTPStreamFrame(stream, failure) + } +} + +func sendInvalidHTTPStreamFrame(stream grpc.ServerStream, failure string) error { + switch failure { + case "missing EOF": + return nil + case "oversized chunk": + return stream.SendMsg(wrapperspb.Bytes(append([]byte{httpStreamData}, make([]byte, httpStreamChunkBytes+1)...))) + case "empty chunk": + return stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamData})) + case "after EOF": + if err := stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamEOF})); err != nil { + return err + } + return stream.SendMsg(wrapperspb.Bytes([]byte{httpStreamData, 'x'})) + default: + return errors.New("invalid test case") + } +} + +// SendMsg fails while the input worker is blocked on its pipe Write. synctest +// fails the test if that worker remains blocked after Exchange returns. +type httpStreamSendFailure struct { + grpc.ServerStream + ctx context.Context + initial *wrapperspb.BytesValue + started chan struct{} +} + +func (s *httpStreamSendFailure) Context() context.Context { return s.ctx } +func (s *httpStreamSendFailure) RecvMsg(value any) error { + message, ok := value.(*wrapperspb.BytesValue) + if !ok { + return errors.New("invalid test message") + } + if s.initial != nil { + message.Value = s.initial.Value + s.initial = nil + return nil + } + message.Value = []byte{httpStreamData, 'x'} + close(s.started) + return nil +} + +func (s *httpStreamSendFailure) SendMsg(any) error { + <-s.started + synctest.Wait() + return status.Error(codes.Unavailable, "peer stopped receiving") +} + +func TestHTTPStreamFailedSendUnblocksInputWriter(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + body := &httpStreamFailureBody{readErr: io.EOF} + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: body, ContentLength: -1}, nil + })} + initial, err := httpStreamMetadata(httpStreamRequest, httpStreamRequestWire{Method: "POST", URL: httpStreamTestURL, HasBody: true, ContentLength: -1}) + if err != nil { + t.Fatal(err) + } + stream := &httpStreamSendFailure{ctx: t.Context(), initial: initial, started: make(chan struct{})} + err = httpStreamTestHost(t, client, httpStreamTestURL).Exchange(stream) + if status.Code(err) != codes.Unavailable || !body.closed.Load() { + t.Fatalf("failed writer = %v, body closed = %v", err, body.closed.Load()) + } + }) +} + +func TestHTTPStreamMetadataLimits(t *testing.T) { + t.Parallel() + if _, err := httpStreamMetadata(httpStreamRequest, strings.Repeat("x", maxMetaBytes)); status.Code(err) != codes.ResourceExhausted { + t.Fatalf("oversized metadata = %v", err) + } + body := &httpStreamFailureBody{readErr: io.EOF} + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: body, Header: http.Header{"X-Large": {strings.Repeat("x", maxHeadBytes)}}, ContentLength: -1}, nil + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + err := httpStreamTestFailure(t, callback, httpStreamTestRequest(t.Context(), t, "GET", httpStreamTestURL, nil)) + if status.Code(err) != codes.ResourceExhausted || !body.closed.Load() { + t.Fatalf("oversized response headers = %v, closed = %v", err, body.closed.Load()) + } + request := httpStreamTestRequest(t.Context(), t, "GET", httpStreamTestURL, nil) + request.Header.Set("X-Large", strings.Repeat("x", maxHeadBytes)) + if err := httpStreamTestFailure(t, callback, request); err == nil { + t.Fatal("oversized request headers accepted") + } + if err := httpExchangeHandler(struct{}{}, nil); status.Code(err) != codes.Internal { + t.Fatalf("invalid service implementation = %v", err) + } +} + +func TestHTTPStreamRejectsInvalidRequestFrames(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + body bool + length int64 + frames [][]byte + code codes.Code + }{ + {name: "oversized chunk", body: true, length: -1, frames: [][]byte{append([]byte{httpStreamData}, make([]byte, httpStreamChunkBytes+1)...)}, code: codes.ResourceExhausted}, + {name: "empty chunk", body: true, length: -1, frames: [][]byte{{httpStreamData}}, code: codes.InvalidArgument}, + {name: "missing EOF", body: true, length: -1, frames: [][]byte{{httpStreamData, 'x'}}, code: codes.Unknown}, + {name: "short input", body: true, length: 2, frames: [][]byte{{httpStreamData, 'x'}, {httpStreamEOF}}, code: codes.InvalidArgument}, + {name: "excess input", body: true, length: 1, frames: [][]byte{{httpStreamData, 'x', 'y'}}, code: codes.InvalidArgument}, + {name: "absent body", frames: [][]byte{{httpStreamData, 'x'}}, code: codes.InvalidArgument}, + {name: "after EOF", frames: [][]byte{{httpStreamEOF}, {httpStreamData, 'x'}}, code: codes.InvalidArgument}, + {name: "invalid length", length: -2, code: codes.InvalidArgument}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + client := &http.Client{Transport: roundTripFunc(drainHTTPStreamRequest)} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + initial, err := httpStreamMetadata(httpStreamRequest, httpStreamRequestWire{ + Method: "POST", URL: httpStreamTestURL, HasBody: test.body, ContentLength: test.length, + }) + if err != nil { + t.Fatal(err) + } + if err := invalidHTTPStreamRequest(t, callback, initial, test.frames); status.Code(err) != test.code { + t.Fatalf("invalid request = %v, want %s", err, test.code) + } + }) + } +} + +func drainHTTPStreamRequest(request *http.Request) (*http.Response, error) { + if request.Body != nil { + if _, err := io.Copy(io.Discard, request.Body); err != nil { + return nil, err + } + } + return &http.Response{StatusCode: 200, Body: http.NoBody, ContentLength: 0}, nil +} + +func invalidHTTPStreamRequest(t *testing.T, callback *callbackTransport, initial *wrapperspb.BytesValue, frames [][]byte) error { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + descriptor := httpStreamDescriptor() + stream, err := callback.conn.NewStream(ctx, &descriptor, "/"+httpService+"/Exchange") + if err != nil { + t.Fatal(err) + } + if err := stream.SendMsg(initial); err != nil { + t.Fatal(err) + } + for _, frame := range frames { + if err := stream.SendMsg(wrapperspb.Bytes(frame)); err != nil { + break // RecvMsg contains the authoritative gRPC failure. + } + } + if err := stream.CloseSend(); err != nil { + t.Error(err) + } + var reply wrapperspb.BytesValue + err = stream.RecvMsg(&reply) + if err == nil { + t.Fatalf("invalid request returned response metadata: %q", reply.Value) + } + return err +} + +func TestHTTPStreamHostPreservesRedirectPolicyAndCannotReplayInput(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls.Add(1) + if request.GetBody != nil || request.ContentLength != 3 { + return nil, fmt.Errorf("stream body can replay or length changed") + } + if _, err := io.Copy(io.Discard, request.Body); err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusTemporaryRedirect, Header: http.Header{"Location": {httpStreamTestURL + "/next"}}, + Body: io.NopCloser(bytes.NewReader(nil)), ContentLength: 0}, nil + })} + callback := newHTTPStreamCallback(t.Context(), t, httpStreamTestHost(t, client, httpStreamTestURL)) + response, err := callback.roundTripStream(httpStreamTestRequest(t.Context(), t, "POST", httpStreamTestURL, strings.NewReader("abc"))) + if err != nil { + t.Fatal(err) + } + if err := response.Body.Close(); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusTemporaryRedirect || calls.Load() != 1 { + t.Fatalf("non-replayable redirect = %d, calls = %d", response.StatusCode, calls.Load()) + } +} diff --git a/pkg/pluginsdk/transport/inprocess_test.go b/pkg/pluginsdk/transport/inprocess_test.go new file mode 100644 index 00000000..7b169df5 --- /dev/null +++ b/pkg/pluginsdk/transport/inprocess_test.go @@ -0,0 +1,143 @@ +package transport + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + goplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +// This complements subprocess tests: the server-side paths are measured by +// coverage in the host test process, while real executables prove isolation. +func inProcessPlugin(t *testing.T, factory func(*http.Client) pluginsdk.Plugin, httpClient *http.Client, endpoint string) *Client { + t.Helper() + protocol, _ := goplugin.TestPluginGRPCConn(t, false, goplugin.PluginSet{pluginName: &grpcPlugin{factory: factory}}) + t.Cleanup(func() { + if err := protocol.Close(); err != nil { + t.Error(err) + } + }) + dispensed, err := protocol.Dispense(pluginName) + if err != nil { + t.Fatal(err) + } + remote, ok := dispensed.(*remotePlugin) + if !ok { + t.Fatal("invalid fixture plugin") + } + target, err := parseEndpoint(endpoint) + if err != nil { + t.Fatal(err) + } + return &Client{remote: remote, http: httpClient, endpoint: target, lifetime: t.Context()} +} + +func TestGRPCServerRoundTripAndHostHTTP(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeResponse(t, w, ` {"id":90071992547409930001} `) + })) + defer server.Close() + client := inProcessPlugin(t, func(client *http.Client) pluginsdk.Plugin { return &helperPlugin{http: client} }, server.Client(), server.URL) + manifest, err := client.GetManifest(t.Context()) + if err != nil || !reflect.DeepEqual(manifest, helperManifest()) { + t.Fatalf("manifest = %+v, %v", manifest, err) + } + request := helperRequest("http") + response, err := client.Execute(t.Context(), request) + if err != nil || string(response.Data) != ` {"id":90071992547409930001} ` { + t.Fatalf("HTTP = %s, %v", response.Data, err) + } + response, err = client.Execute(t.Context(), helperRequest("error")) + if failure, ok := errors.AsType[*httpclient.Error](err); !ok || failure.StatusCode != 409 || len(response.Data) == 0 { + t.Fatalf("partial error = %s, %v", response.Data, err) + } +} + +type metadataPlugin struct { + manifest pluginsdk.Manifest + err error +} + +func (p *metadataPlugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { + return p.manifest, p.err +} + +func (*metadataPlugin) Execute(context.Context, pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + return pluginsdk.ExecuteResponse{}, errors.New("not executable") +} + +func TestGRPCRejectsIncompatibleAndBrokenMetadata(t *testing.T) { + t.Parallel() + manifest := helperManifest() + manifest.ProtocolVersion++ + client := inProcessPlugin(t, func(*http.Client) pluginsdk.Plugin { return &metadataPlugin{manifest: manifest} }, nil, "") + if _, err := client.GetManifest(t.Context()); err == nil { + t.Fatal("incompatible manifest accepted") + } + broken := inProcessPlugin(t, func(*http.Client) pluginsdk.Plugin { return &metadataPlugin{err: errors.New("broken metadata")} }, nil, "") + if _, err := broken.GetManifest(t.Context()); err == nil { + t.Fatal("metadata failure was lost") + } +} + +func TestRPCRejectsInvalidPayloadAndPreservesErrorKinds(t *testing.T) { + t.Parallel() + server := &pluginServer{} + if _, err := server.Execute(t.Context(), wrapperspb.Bytes([]byte(`{}`))); status.Code(err) != codes.InvalidArgument { + t.Fatalf("missing broker = %v", err) + } + if _, err := server.Execute(t.Context(), wrapperspb.Bytes([]byte(`invalid`))); status.Code(err) != codes.InvalidArgument { + t.Fatalf("bad JSON = %v", err) + } + for _, original := range []error{context.Canceled, context.DeadlineExceeded} { + if !errors.Is(encodeError(original).err(), original) { + t.Fatalf("context error changed: %v", original) + } + } + if _, err := pack(make(chan int)); err == nil { + t.Fatal("invalid outgoing payload accepted") + } + if err := unpack(nil, new(resultWire)); err == nil { + t.Fatal("missing incoming payload accepted") + } + if err := validateResult(resultWire{Data: []byte(`invalid`)}); err == nil { + t.Fatal("invalid response JSON accepted") + } +} + +func TestGRPCDescriptorsSupportInterceptors(t *testing.T) { + t.Parallel() + decode := func(value any) error { + message, ok := value.(*wrapperspb.BytesValue) + if !ok { + return errors.New("invalid decoded request") + } + message.Value = []byte(`{}`) + return nil + } + interceptor := func(ctx context.Context, message any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if info.FullMethod != "/"+pluginService+"/GetManifest" { + return nil, errors.New("invalid method metadata") + } + return handler(ctx, message) + } + server := &pluginServer{factory: func(*http.Client) pluginsdk.Plugin { return &helperPlugin{} }} + if _, err := manifestHandler(server, t.Context(), decode, interceptor); err != nil { + t.Fatal(err) + } + if _, err := manifestHandler(server, t.Context(), func(any) error { return errors.New("decode failed") }, nil); err == nil { + t.Fatal("request decode failure lost") + } +} diff --git a/pkg/pluginsdk/transport/rpc_error_test.go b/pkg/pluginsdk/transport/rpc_error_test.go new file mode 100644 index 00000000..6580d817 --- /dev/null +++ b/pkg/pluginsdk/transport/rpc_error_test.go @@ -0,0 +1,72 @@ +package transport + +import ( + "context" + "errors" + "fmt" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestRPCErrorStopsPluginBeforeLocalCancellation(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + code codes.Code + want error + }{ + {name: "deadline", code: codes.DeadlineExceeded, want: context.DeadlineExceeded}, + {name: "canceled", code: codes.Canceled, want: context.Canceled}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assertRPCCancellationReapsPlugin(t, test.code, test.want) + }) + } +} + +func assertRPCCancellationReapsPlugin(t *testing.T, code codes.Code, want error) { + t.Helper() + client := openHelper(t, nil, "") + ctx := t.Context() + if ctx.Err() != nil || client.process.Exited() { + t.Fatal("RPC must start with a live context and plugin") + } + // Match invoke's wrapping without relying on either deadline timer. + rpcErr := fmt.Errorf("plugin RPC: %w", status.Error(code, "peer stopped the RPC")) + err := client.rpcError(ctx, rpcErr) + if !errors.Is(err, want) { + t.Fatalf("RPC cancellation = %v, want %v", err, want) + } + if ctx.Err() != nil { + t.Fatalf("RPC cancellation changed the caller context: %v", ctx.Err()) + } + if !client.process.Exited() { + t.Fatal("RPC cancellation returned before reaping the plugin") + } + if _, err := client.GetManifest(ctx); !errors.Is(err, ErrClosed) { + t.Fatalf("closed runtime restarted: %v", err) + } +} + +func TestRPCErrorPreservesOtherFailures(t *testing.T) { + t.Parallel() + for _, code := range []codes.Code{codes.Unavailable, codes.FailedPrecondition} { + t.Run(code.String(), func(t *testing.T) { + t.Parallel() + client := openHelper(t, nil, "") + rpcErr := fmt.Errorf("plugin RPC: %w", status.Error(code, "peer rejected the RPC")) + if err := client.rpcError(t.Context(), rpcErr); !errors.Is(err, rpcErr) { + t.Fatalf("RPC failure changed: %v", err) + } + if client.process.Exited() { + t.Fatal("ordinary RPC failure terminated the plugin") + } + if _, err := client.GetManifest(t.Context()); err != nil { + t.Fatalf("ordinary RPC failure closed the runtime: %v", err) + } + }) + } +} diff --git a/pkg/pluginsdk/transport/server.go b/pkg/pluginsdk/transport/server.go new file mode 100644 index 00000000..b1074046 --- /dev/null +++ b/pkg/pluginsdk/transport/server.go @@ -0,0 +1,100 @@ +package transport + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const pluginName = "fctl-plugin" + +// The transport handshake is intentionally separate from the SDK's manifest +// protocol. Its cookie identifies compatible executables, not trust in them. +var handshake = goplugin.HandshakeConfig{ + ProtocolVersion: 1, MagicCookieKey: "FCTL_PLUGIN_V4", MagicCookieValue: "formance-fctl-v4", +} + +type grpcPlugin struct { + goplugin.NetRPCUnsupportedPlugin + factory func(*http.Client) pluginsdk.Plugin +} + +func (p *grpcPlugin) GRPCServer(broker *goplugin.GRPCBroker, server *grpc.Server) error { + server.RegisterService(&pluginDescriptor, &pluginServer{factory: p.factory, broker: broker}) + return nil +} + +func (*grpcPlugin) GRPCClient(_ context.Context, broker *goplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) { + return &remotePlugin{broker: broker, conn: conn}, nil +} + +// Serve runs a plugin executable. Call it from main with the same factory used +// by an embedded plugin. Metadata is requested from factory(nil), without HTTP. +// Execution receives a client which calls back into the host for every request. +func Serve(factory func(*http.Client) pluginsdk.Plugin) { + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: handshake, + Plugins: goplugin.PluginSet{pluginName: &grpcPlugin{factory: factory}}, + Logger: hclog.NewNullLogger(), + GRPCServer: func(options []grpc.ServerOption) *grpc.Server { + return grpc.NewServer(append(options, grpc.MaxRecvMsgSize(maxWireBytes), grpc.MaxSendMsgSize(maxWireBytes))...) + }, + }) +} + +type remotePlugin struct { + broker *goplugin.GRPCBroker + conn *grpc.ClientConn +} + +type pluginServer struct { + factory func(*http.Client) pluginsdk.Plugin + broker *goplugin.GRPCBroker +} + +func (s *pluginServer) GetManifest(ctx context.Context, _ *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + manifest, err := s.factory(nil).GetManifest(ctx) + if err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + message, err := pack(manifest) + if err == nil && len(message.Value) > maxMetaBytes { + return nil, status.Error(codes.ResourceExhausted, "plugin manifest exceeds 1 MiB") + } + return message, err +} + +func (s *pluginServer) Execute(ctx context.Context, message *wrapperspb.BytesValue) (response *wrapperspb.BytesValue, err error) { + var request executeWire + if err := unpack(message, &request); err != nil { + return nil, err + } + if request.BrokerID == 0 { + return nil, status.Error(codes.InvalidArgument, "missing host HTTP callback") + } + conn, err := s.broker.DialWithOptions(request.BrokerID, grpc.WithDisableRetry()) + if err != nil { + return nil, status.Error(codes.Unavailable, "connect host HTTP callback") + } + defer func() { err = errors.Join(err, conn.Close()) }() + client := &http.Client{Transport: &callbackTransport{conn: conn, execution: ctx}, CheckRedirect: func(*http.Request, []*http.Request) error { + // The host client owns redirects, and must check each hop itself. + return http.ErrUseLastResponse + }} + result, executeErr := s.factory(client).Execute(ctx, request.Request.request()) + resultMessage := resultWire{Data: result.Data, Error: encodeError(executeErr)} + if err := validateResult(resultMessage); err != nil { + return nil, status.Error(codes.ResourceExhausted, fmt.Sprintf("invalid plugin result: %s", err)) + } + return pack(resultMessage) +} diff --git a/pkg/pluginsdk/transport/stream.go b/pkg/pluginsdk/transport/stream.go new file mode 100644 index 00000000..cd4f8398 --- /dev/null +++ b/pkg/pluginsdk/transport/stream.go @@ -0,0 +1,406 @@ +package transport + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const streamChunkBytes = 64 << 10 + +type streamStart struct { + Execution executeWire `json:"execution"` + Input bool `json:"input,omitzero"` + InputSize *int64 `json:"inputSize,omitzero"` + Output bool `json:"output,omitzero"` +} + +// Input is pulled by the plugin: one bounded chunk per request. There is no +// background uploader, unbounded queue, or read-ahead before business checks. +type streamFrame struct { + Start *streamStart `json:"start,omitzero"` + Ready bool `json:"ready,omitzero"` + Pull bool `json:"pull,omitzero"` + Data []byte `json:"data,omitzero"` + End bool `json:"end,omitzero"` + Failure *errorWire `json:"failure,omitzero"` + Progress *pluginsdk.ProgressEvent `json:"progress,omitzero"` + Result *resultWire `json:"result,omitzero"` +} + +type frameSender interface{ SendMsg(any) error } +type frameReceiver interface{ RecvMsg(any) error } + +func sendFrame(stream frameSender, frame streamFrame) error { + message, err := pack(frame) + if err != nil { + return err + } + return stream.SendMsg(message) +} + +func receiveFrame(stream frameReceiver) (streamFrame, error) { + var message wrapperspb.BytesValue + if err := stream.RecvMsg(&message); err != nil { + return streamFrame{}, err + } + var frame streamFrame + if err := unpack(&message, &frame); err != nil { + return streamFrame{}, err + } + if len(frame.Data) > streamChunkBytes { + return streamFrame{}, status.Error(codes.ResourceExhausted, "plugin stream chunk exceeds 64 KiB") + } + kinds := 0 + for _, present := range []bool{frame.Start != nil, frame.Ready, frame.Pull, frame.Data != nil, + frame.End, frame.Failure != nil, frame.Progress != nil, frame.Result != nil} { + if present { + kinds++ + } + } + if kinds != 1 { + return streamFrame{}, status.Error(codes.InvalidArgument, "invalid plugin stream frame") + } + return frame, nil +} + +var executeStreamDescriptor = grpc.StreamDesc{ + StreamName: "ExecuteStream", ServerStreams: true, ClientStreams: true, + Handler: func(server any, stream grpc.ServerStream) error { + implementation, ok := server.(*pluginServer) + if !ok { + return status.Error(codes.Internal, "invalid plugin stream implementation") + } + return implementation.ExecuteStream(stream) + }, +} + +// ExecuteStream keeps the final bounded JSON result separate from raw output +// chunks and progress. It does not retry or replay reads, writes or operations. +func (c *Client) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (response pluginsdk.ExecuteResponse, err error) { + request, err = c.streamRequest(ctx, request, streams) + if err != nil { + return response, err + } + stop := context.AfterFunc(ctx, c.close) + defer stop() + callCtx, cancel := context.WithCancel(ctx) + defer cancel() + // Interruptible streams are closed only on cancellation. A successful + // call leaves both streams to their owner. + defer interruptStreamOnCancel(callCtx, streams.Input)() + defer interruptStreamOnCancel(callCtx, streams.Output)() + callback := c.serveHTTP() + defer callback.stop() + stream, err := c.remote.conn.NewStream(callCtx, &executeStreamDescriptor, "/"+pluginService+"/ExecuteStream", + grpc.MaxCallRecvMsgSize(maxWireBytes), grpc.MaxCallSendMsgSize(maxWireBytes)) + if err != nil { + return response, c.rpcError(ctx, err) + } + defer func() { err = errors.Join(err, stream.CloseSend()) }() + start := &streamStart{Execution: executeWire{BrokerID: callback.id, Request: toWire(request)}, + Input: streams.Input != nil, InputSize: streams.InputSize, Output: streams.Output != nil} + if err := sendFrame(stream, streamFrame{Start: start}); err != nil { + return response, c.rpcError(ctx, err) + } + ready, err := receiveFrame(stream) + if err != nil { + return response, c.rpcError(ctx, err) + } + if !ready.Ready { + return response, fmt.Errorf("plugin did not acknowledge stream execution") + } + result, err := consumeExecution(stream, streams) + if err != nil { + return pluginsdk.ExecuteResponse{Data: result.Data}, errors.Join(result.Error.err(), c.rpcError(ctx, err)) + } + return pluginsdk.ExecuteResponse{Data: result.Data}, result.Error.err() +} + +func interruptStreamOnCancel(ctx context.Context, stream any) func() { + closer, ok := stream.(io.Closer) + if !ok { + return func() {} + } + done := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + //nolint:errcheck // Cancellation propagates via the RPC; Close interrupts blocked IO. + _ = closer.Close() + close(done) + }) + return func() { + if !stop() { + <-done + } + } +} + +func (c *Client) streamRequest(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteRequest, error) { + if err := c.check(ctx); err != nil { + return request, err + } + if c.http == nil || c.endpoint == nil { + return request, fmt.Errorf("plugin execution requires a host HTTP client and endpoint") + } + if request.Endpoint == "" { + request.Endpoint = c.endpoint.String() + } + if strings.TrimRight(request.Endpoint, "/") != c.endpoint.String() { + return request, fmt.Errorf("plugin endpoint differs from host endpoint") + } + if err := validateRequest(request); err != nil { + return request, err + } + if streams.Input != nil && request.Body != nil { + return request, fmt.Errorf("plugin stream input cannot be combined with Body") + } + if streams.InputSize != nil && (streams.Input == nil || *streams.InputSize < 0) { + return request, fmt.Errorf("invalid plugin input size") + } + return request, nil +} + +func consumeExecution(stream grpc.ClientStream, streams pluginsdk.ExecutionStreams) (resultWire, error) { + input := &inputSender{reader: streams.Input} + for { + frame, err := receiveFrame(stream) + if err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return resultWire{}, err + } + if err := consumeFrame(stream, frame, streams, input); err != nil { + return resultWire{}, err + } + if frame.Result != nil { + return completeExecutionResult(stream, *frame.Result, input.err) + } + } +} + +// A result frame alone cannot authorize publication: the RPC's terminal status +// must also succeed. A peer can fail while closing resources after sending it. +func completeExecutionResult(stream frameReceiver, result resultWire, inputErr error) (resultWire, error) { + _, terminalErr := receiveFrame(stream) + if errors.Is(terminalErr, io.EOF) { + terminalErr = nil + } else if terminalErr == nil { + terminalErr = fmt.Errorf("plugin sent a frame after its final result") + } + if errors.Is(inputErr, io.EOF) { + inputErr = nil + } + return result, errors.Join(terminalErr, inputErr) +} + +func consumeFrame(stream grpc.ClientStream, frame streamFrame, streams pluginsdk.ExecutionStreams, input *inputSender) error { + switch { + case frame.Pull: + return input.send(stream) + case frame.Data != nil: + if streams.Output == nil { + return fmt.Errorf("plugin wrote an undeclared output stream") + } + written, err := streams.Output.Write(frame.Data) + if err == nil && written != len(frame.Data) { + return io.ErrShortWrite + } + return err + case frame.Progress != nil: + if err := frame.Progress.Validate(); err != nil { + return err + } + if streams.Report != nil { + return streams.Report(*frame.Progress) + } + return nil + case frame.Result != nil: + return validateResult(*frame.Result) + default: + return fmt.Errorf("unexpected plugin execution frame") + } +} + +type inputSender struct { + reader io.Reader + err error + buffer [streamChunkBytes]byte +} + +func (i *inputSender) send(stream frameSender) error { + if i.reader == nil { + return fmt.Errorf("plugin requested an undeclared input stream") + } + if i.err != nil { + if errors.Is(i.err, io.EOF) { + return sendFrame(stream, streamFrame{End: true}) + } + failure := &errorWire{Kind: "input", Message: "host input read failed"} + if errors.Is(i.err, context.Canceled) || errors.Is(i.err, context.DeadlineExceeded) { + failure = encodeError(i.err) + } + return sendFrame(stream, streamFrame{Failure: failure}) + } + for range 100 { + n, err := i.reader.Read(i.buffer[:]) + i.err = err + if n > 0 { + return sendFrame(stream, streamFrame{Data: i.buffer[:n]}) + } + if err != nil { + return i.send(stream) + } + } + i.err = io.ErrNoProgress + return i.send(stream) +} + +func (s *pluginServer) ExecuteStream(stream grpc.ServerStream) (err error) { + start, request, err := s.streamRequest(stream) + if err != nil { + return err + } + conn, err := s.broker.DialWithOptions(start.Execution.BrokerID, grpc.WithDisableRetry()) + if err != nil { + return status.Error(codes.Unavailable, "connect host HTTP callback") + } + defer func() { err = errors.Join(err, conn.Close()) }() + client := &http.Client{Transport: &callbackTransport{conn: conn, execution: stream.Context(), streaming: true}, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + plugin, ok := s.factory(client).(pluginsdk.StreamingPlugin) + if !ok { + return status.Error(codes.Unimplemented, "plugin does not implement streaming execution") + } + sender := &executionSender{stream: stream} + if err := sender.send(streamFrame{Ready: true}); err != nil { + return err + } + streams := pluginsdk.ExecutionStreams{InputSize: start.InputSize, Report: sender.report} + if start.Input { + streams.Input = &executionReader{stream: stream, sender: sender} + } + if start.Output { + streams.Output = sender + } + result, executeErr := plugin.ExecuteStream(stream.Context(), request, streams) + final := resultWire{Data: result.Data, Error: encodeError(executeErr)} + if err := validateResult(final); err != nil { + return status.Error(codes.ResourceExhausted, "invalid final plugin stream result") + } + return sender.send(streamFrame{Result: &final}) +} + +func (s *pluginServer) streamRequest(stream grpc.ServerStream) (*streamStart, pluginsdk.ExecuteRequest, error) { + frame, err := receiveFrame(stream) + if err != nil { + return nil, pluginsdk.ExecuteRequest{}, err + } + if frame.Start == nil || frame.Start.Execution.BrokerID == 0 { + return nil, pluginsdk.ExecuteRequest{}, status.Error(codes.InvalidArgument, "missing plugin stream start or HTTP callback") + } + start := frame.Start + request := start.Execution.Request.request() + if err := validateRequest(request); err != nil { + return nil, pluginsdk.ExecuteRequest{}, status.Error(codes.InvalidArgument, err.Error()) + } + if (start.Input && request.Body != nil) || (start.InputSize != nil && (!start.Input || *start.InputSize < 0)) { + return nil, pluginsdk.ExecuteRequest{}, status.Error(codes.InvalidArgument, "invalid plugin stream input") + } + manifest, err := s.factory(nil).GetManifest(stream.Context()) + if err != nil { + return nil, pluginsdk.ExecuteRequest{}, err + } + request, err = pluginsdk.NormalizeRequest(manifest, request) + if err != nil { + return nil, pluginsdk.ExecuteRequest{}, err + } + spec, err := pluginsdk.FindCommand(manifest, request.CommandPath) + if err != nil || spec.Streams == nil || (start.Input && !spec.Streams.Input) || (start.Output && !spec.Streams.Output) { + return nil, pluginsdk.ExecuteRequest{}, status.Error(codes.InvalidArgument, "plugin command does not declare the requested streams") + } + return start, request, nil +} + +type executionSender struct { + stream grpc.ServerStream + mu sync.Mutex +} + +func (s *executionSender) send(frame streamFrame) error { + s.mu.Lock() + defer s.mu.Unlock() + return sendFrame(s.stream, frame) +} + +func (s *executionSender) Write(data []byte) (int, error) { + written := 0 + for len(data) > 0 { + n := min(len(data), streamChunkBytes) + if err := s.send(streamFrame{Data: data[:n]}); err != nil { + return written, err + } + written += n + data = data[n:] + } + return written, nil +} + +func (s *executionSender) report(progress pluginsdk.ProgressEvent) error { + if err := progress.Validate(); err != nil { + return err + } + return s.send(streamFrame{Progress: &progress}) +} + +type executionReader struct { + stream grpc.ServerStream + sender *executionSender + buffer []byte + end bool +} + +func (r *executionReader) Read(target []byte) (int, error) { + if len(target) == 0 { + return 0, nil + } + if len(r.buffer) == 0 && !r.end { + if err := r.sender.send(streamFrame{Pull: true}); err != nil { + return 0, err + } + frame, err := receiveFrame(r.stream) + if err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return 0, err + } + switch { + case frame.End: + r.end = true + case frame.Failure != nil: + return 0, frame.Failure.err() + case len(frame.Data) != 0: + r.buffer = frame.Data + default: + return 0, fmt.Errorf("unexpected plugin input frame") + } + } + if r.end { + return 0, io.EOF + } + n := copy(target, r.buffer) + r.buffer = r.buffer[n:] + return n, nil +} diff --git a/pkg/pluginsdk/transport/stream_test.go b/pkg/pluginsdk/transport/stream_test.go new file mode 100644 index 00000000..1a0db66f --- /dev/null +++ b/pkg/pluginsdk/transport/stream_test.go @@ -0,0 +1,283 @@ +package transport + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func (h *helperPlugin) ExecuteStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (result pluginsdk.ExecuteResponse, err error) { + if request.Flags["action"] == "reject" { + return pluginsdk.ExecuteResponse{Data: []byte(`{"imported":0}`)}, fmt.Errorf("rejected before input") + } + if request.Flags["action"] == "invalid-progress" { + return pluginsdk.ExecuteResponse{}, streams.Report(pluginsdk.ProgressEvent{Phase: "\x1b[2J", Unit: "bytes"}) + } + if request.Flags["action"] == "cancel" { + <-ctx.Done() + return pluginsdk.ExecuteResponse{}, ctx.Err() + } + if request.Flags["action"] == "http-stream" { + return h.executeHTTPStream(ctx, request, streams) + } + return copyHelperStream(streams) +} + +func (h *helperPlugin) executeHTTPStream(ctx context.Context, request pluginsdk.ExecuteRequest, streams pluginsdk.ExecutionStreams) (result pluginsdk.ExecuteResponse, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, request.Endpoint+"/export", streams.Input) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + response, err := h.http.Do(req) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + defer func() { err = errors.Join(err, response.Body.Close()) }() + n, err := io.Copy(streams.Output, response.Body) + return pluginsdk.ExecuteResponse{Data: fmt.Appendf(nil, `{"bytes":%d}`, n)}, err +} + +func copyHelperStream(streams pluginsdk.ExecutionStreams) (pluginsdk.ExecuteResponse, error) { + var n int64 + buffer := make([]byte, 32<<10) + for { + read, err := streams.Input.Read(buffer) + if read > 0 { + written, writeErr := streams.Output.Write(buffer[:read]) + n += int64(written) + if writeErr != nil { + return pluginsdk.ExecuteResponse{}, writeErr + } + if err := streams.Report(pluginsdk.ProgressEvent{Phase: "copy", Unit: "bytes", Completed: n, Total: streams.InputSize}); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + } + if errors.Is(err, io.EOF) { + return pluginsdk.ExecuteResponse{Data: fmt.Appendf(nil, `{"bytes":%d}`, n)}, nil + } + if err != nil { + return pluginsdk.ExecuteResponse{Data: fmt.Appendf(nil, `{"bytes":%d}`, n)}, err + } + } +} + +// This reader generates large inputs without allocating the complete payload. +type countedReader struct { + remaining int64 + read int64 +} + +func (r *countedReader) Read(target []byte) (int, error) { + if r.remaining == 0 { + return 0, io.EOF + } + n := int(min(int64(len(target)), r.remaining)) + for i := range n { + target[i] = "abcdefghijklmnopqrstuvwxyz"[(r.read+int64(i))%26] + } + r.remaining -= int64(n) + r.read += int64(n) + return n, nil +} + +type checkedWriter struct{ written int64 } + +func (w *checkedWriter) Write(data []byte) (int, error) { + for i, value := range data { + if value != "abcdefghijklmnopqrstuvwxyz"[(w.written+int64(i))%26] { + return 0, fmt.Errorf("stream content changed at %d", w.written+int64(i)) + } + } + w.written += int64(len(data)) + return len(data), nil +} + +func TestExecutableStreamsLargeDataAndProgress(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + const size = int64(80 << 20) + reader := &countedReader{remaining: size} + writer := &checkedWriter{} + var last int64 + updates := 0 + result, err := client.ExecuteStream(t.Context(), helperRequest("copy"), pluginsdk.ExecutionStreams{ + Input: reader, InputSize: new(size), Output: writer, + Report: func(event pluginsdk.ProgressEvent) error { + if event.Completed <= last || event.Total == nil || *event.Total != size { + return fmt.Errorf("invalid progress counter %+v", event) + } + if reader.read-writer.written > streamChunkBytes { + return fmt.Errorf("input was buffered before output") + } + last = event.Completed + updates++ + return nil + }, + }) + if err != nil || writer.written != size || last != size || updates < 2 || string(result.Data) != `{"bytes":83886080}` { + t.Fatalf("stream bytes=%d progress=%d updates=%d result=%s err=%v", writer.written, last, updates, result.Data, err) + } +} + +func TestStreamingDoesNotReadBeforeBusinessChecks(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + reader := &countedReader{remaining: 10 << 20} + result, err := client.ExecuteStream(t.Context(), helperRequest("reject"), pluginsdk.ExecutionStreams{Input: reader}) + if err == nil || reader.read != 0 || string(result.Data) != `{"imported":0}` { + t.Fatalf("rejected execution read=%d result=%s err=%v", reader.read, result.Data, err) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestStreamWriterAndReporterErrorsStopExecution(t *testing.T) { + for _, failure := range []string{"writer", "reporter", "invalid-progress"} { + t.Run(failure, func(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + streams := pluginsdk.ExecutionStreams{Input: strings.NewReader("payload"), Output: io.Discard} + action := "copy" + switch failure { + case "writer": + streams.Output = failingWriter{} + case "reporter": + streams.Report = func(pluginsdk.ProgressEvent) error { return io.ErrClosedPipe } + default: + action = failure + } + if _, err := client.ExecuteStream(t.Context(), helperRequest(action), streams); err == nil { + t.Fatal("stream failure lost") + } + }) + } +} + +func TestStreamCancellationReapsExecutable(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + _, err := client.ExecuteStream(ctx, helperRequest("cancel"), pluginsdk.ExecutionStreams{}) + if !errors.Is(err, context.DeadlineExceeded) || !client.process.Exited() { + t.Fatalf("cancellation err=%v exited=%v", err, client.process.Exited()) + } +} + +type blockingStreamOutput struct { + *io.PipeWriter + started chan struct{} +} + +func (w *blockingStreamOutput) Write(data []byte) (int, error) { + close(w.started) + return w.PipeWriter.Write(data) +} + +func TestStreamCancellationInterruptsBlockedOutput(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + reader, writer := io.Pipe() + t.Cleanup(func() { + if err := errors.Join(reader.Close(), writer.Close()); err != nil { + t.Error(err) + } + }) + output := &blockingStreamOutput{PipeWriter: writer, started: make(chan struct{})} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := client.ExecuteStream(ctx, helperRequest("copy"), pluginsdk.ExecutionStreams{Input: strings.NewReader("payload"), Output: output}) + done <- err + }() + select { + case <-output.started: + case <-time.After(5 * time.Second): + t.Fatal("output was not reached") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) || !client.process.Exited() { + t.Fatalf("blocked output cancellation err=%v exited=%v", err, client.process.Exited()) + } + case <-time.After(5 * time.Second): + t.Fatal("output Write did not unblock on cancellation") + } +} + +type failedStreamReader struct{ sent bool } + +func (r *failedStreamReader) Read(data []byte) (int, error) { + if r.sent { + return 0, io.ErrUnexpectedEOF + } + r.sent = true + return copy(data, "partial"), io.ErrUnexpectedEOF +} + +func TestStreamReadFailurePreservesPartialResult(t *testing.T) { + client := openHelper(t, http.DefaultClient, "https://unused.example") + result, err := client.ExecuteStream(t.Context(), helperRequest("copy"), pluginsdk.ExecutionStreams{ + Input: &failedStreamReader{}, Output: io.Discard, + }) + if !errors.Is(err, io.ErrUnexpectedEOF) || string(result.Data) != `{"bytes":7}` { + t.Fatalf("source failure result=%s err=%v", result.Data, err) + } +} + +type terminalFrameReceiver struct { + frame *wrapperspb.BytesValue + err error +} + +func (r terminalFrameReceiver) RecvMsg(value any) error { + if r.err != nil { + return r.err + } + message, ok := value.(*wrapperspb.BytesValue) + if !ok { + return fmt.Errorf("invalid test frame receiver") + } + message.Value = r.frame.Value + return nil +} + +func TestResultFrameRequiresSuccessfulTerminalStatus(t *testing.T) { + result := resultWire{Data: []byte(`{"exported":100}`)} + trailing, err := pack(streamFrame{Progress: &pluginsdk.ProgressEvent{Phase: "late", Unit: "logs"}}) + if err != nil { + t.Fatal(err) + } + for _, receiver := range []terminalFrameReceiver{ + {err: status.Error(codes.DataLoss, "failed after result")}, + {frame: trailing}, + } { + partial, err := completeExecutionResult(receiver, result, nil) + if err == nil || string(partial.Data) != string(result.Data) { + t.Fatalf("terminal failure accepted: data=%s err=%v", partial.Data, err) + } + } + if _, err := completeExecutionResult(terminalFrameReceiver{err: io.EOF}, result, nil); err != nil { + t.Fatalf("successful terminal status rejected: %v", err) + } +} + +func TestUnaryOnlyPluginRejectsStreamingWithoutReading(t *testing.T) { + manifest := helperManifest() + client := inProcessPlugin(t, func(*http.Client) pluginsdk.Plugin { return &metadataPlugin{manifest: manifest} }, http.DefaultClient, "https://unused.example") + input := &countedReader{remaining: 1 << 20} + _, err := client.ExecuteStream(t.Context(), helperRequest("copy"), pluginsdk.ExecutionStreams{Input: input}) + if status.Code(err) != codes.Unimplemented || input.read != 0 { + t.Fatalf("unsupported stream read=%d err=%v", input.read, err) + } +} diff --git a/pkg/pluginsdk/transport/transport_test.go b/pkg/pluginsdk/transport/transport_test.go new file mode 100644 index 00000000..2f4161cc --- /dev/null +++ b/pkg/pluginsdk/transport/transport_test.go @@ -0,0 +1,596 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +// The test binary is a real go-plugin executable when launched by Open. It +// deliberately has no test-selection argument or inherited helper variable. +func TestMain(m *testing.M) { + if os.Getenv(handshake.MagicCookieKey) == handshake.MagicCookieValue { + Serve(func(client *http.Client) pluginsdk.Plugin { return &helperPlugin{http: client} }) + os.Exit(0) + } + os.Exit(m.Run()) +} + +type helperPlugin struct{ http *http.Client } + +func helperManifest() pluginsdk.Manifest { + return pluginsdk.Manifest{Name: "test", Version: "3.0.0", Service: "ledger", ProtocolVersion: pluginsdk.ProtocolVersion, + Root: pluginsdk.CommandSpec{Use: "test", Runnable: true, Args: pluginsdk.ArgsSpec{Max: 1}, + Streams: &pluginsdk.StreamSpec{Input: true, Output: true}, + Flags: []pluginsdk.FlagSpec{ + {Name: "action", Type: "string", Default: "echo"}, {Name: "path", Type: "string", Default: "/v3/books"}, + {Name: "method", Type: "string", Default: "GET"}, {Name: "url", Type: "string"}, + {Name: "header", Type: "string"}, {Name: "partial", Type: "bool", Default: "false"}, + {Name: "size", Type: "uint32", Default: "0"}, {Name: "enabled", Type: "bool", Default: "true"}, + }, Inputs: []pluginsdk.InputSpec{{Title: "Book", Kind: "select", Flag: "path", Required: true, + Source: &pluginsdk.ChoiceSource{CommandPath: []string{"test"}, Flags: map[string]string{"action": "http"}, + ValueField: "name", MatchFields: map[string][]string{"state": {"ready"}}, ExcludeTrueFields: []string{"disabled"}}}}, + }} +} + +func (h *helperPlugin) GetManifest(context.Context) (pluginsdk.Manifest, error) { + if h.http != nil { + return pluginsdk.Manifest{}, fmt.Errorf("manifest inspection received an HTTP client") + } + return helperManifest(), nil +} + +func (h *helperPlugin) Execute(ctx context.Context, request pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + request, err := pluginsdk.NormalizeRequest(helperManifest(), request) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + switch request.Flags["action"] { + case "environment": + data, err := json.Marshal(struct { + Environment []string `json:"environment"` + Arguments []string `json:"arguments"` + }{os.Environ(), os.Args}) + return pluginsdk.ExecuteResponse{Data: data}, err + case "request": + data, err := json.Marshal(toWire(request)) + return pluginsdk.ExecuteResponse{Data: data}, err + case "error": + data := []byte(` {"id":90071992547409930001,"results":[{"status":"ok"}]} `) + return pluginsdk.ExecuteResponse{Data: data}, fmt.Errorf("wrapped: %w", &httpclient.Error{StatusCode: 409, + Code: "PARTIAL", Message: "one operation failed", Body: []byte(` {"failed":18446744073709551615000} `)}) + case "large": + size, err := strconv.Atoi(request.Flags["size"]) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + return pluginsdk.ExecuteResponse{Data: []byte(`"` + strings.Repeat("x", size) + `"`)}, nil + case "ignore-context": + select {} + case "cancel": + <-ctx.Done() + return pluginsdk.ExecuteResponse{}, ctx.Err() + case "crash": + os.Exit(23) + case "raw-http": + return h.rawHTTP(ctx, request) + case "http": + client, err := httpclient.New(request.Endpoint, h.http) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + headers := http.Header{} + if name := request.Flags["header"]; name != "" { + headers.Set(name, "plugin-controlled") + } + data, err := client.Do(ctx, request.Flags["method"], request.Flags["path"], nil, request.Body, headers) + if failure, ok := errors.AsType[*httpclient.Error](err); ok && request.Flags["partial"] == "true" { + data = failure.Body + } + return pluginsdk.ExecuteResponse{Data: data}, err + } + return pluginsdk.ExecuteResponse{Data: request.Body}, nil +} + +func (h *helperPlugin) rawHTTP(ctx context.Context, request pluginsdk.ExecuteRequest) (result pluginsdk.ExecuteResponse, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", request.Flags["url"], nil) + if err != nil { + return result, err + } + response, err := h.http.Do(req) + if err != nil { + return result, err + } + defer func() { err = errors.Join(err, response.Body.Close()) }() + data, err := json.Marshal(response.Header) + return pluginsdk.ExecuteResponse{Data: data}, err +} + +func helperBinary(t *testing.T) string { + t.Helper() + path, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return path +} + +func openHelper(t *testing.T, client *http.Client, endpoint string) *Client { + t.Helper() + runtime, err := Open(t.Context(), helperBinary(t), client, endpoint) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := runtime.Close(); err != nil { + t.Error(err) + } + if !runtime.process.Exited() { + t.Error("Close did not reap plugin process") + } + }) + return runtime +} + +func helperRequest(action string) pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{CommandPath: []string{"test"}, Flags: map[string]string{"action": action}} +} + +func TestManifestWorksWithoutHTTPAndPreservesForms(t *testing.T) { + t.Parallel() + client := openHelper(t, nil, "") + manifest, err := client.GetManifest(t.Context()) + if err != nil || !reflect.DeepEqual(manifest, helperManifest()) { + t.Fatalf("manifest round trip = %+v, %v", manifest, err) + } + if _, err := client.Execute(t.Context(), helperRequest("http")); err == nil { + t.Fatal("execution accepted missing host HTTP client") + } +} + +func TestRoundTripPreservesExactJSONAndRequestMetadata(t *testing.T) { + t.Parallel() + client := openHelper(t, http.DefaultClient, "https://unused.example/api/ledger") + body := []byte(" {\n\"amount\":90071992547409930001, \"negative\":-18446744073709551615000} \n") + request := helperRequest("echo") + request.Body = body + response, err := client.Execute(t.Context(), request) + if err != nil || !bytes.Equal(response.Data, body) { + t.Fatalf("exact JSON changed: %s, %v", response.Data, err) + } + request.Flags["action"] = "request" + request.Args = []string{"large-ID-18446744073709551615000"} + request.Flags["enabled"] = "false" + request.ChangedFlags = map[string]bool{"enabled": true, "partial": false} + request.Context = map[string]string{"organization": "org", "stack": "stack"} + response, err = client.Execute(t.Context(), request) + if err != nil { + t.Fatal(err) + } + var received requestWire + if err := json.Unmarshal(response.Data, &received); err != nil { + t.Fatal(err) + } + if !bytes.Equal(received.Body, body) || !reflect.DeepEqual(received.Args, request.Args) || + !reflect.DeepEqual(received.Context, request.Context) || !reflect.DeepEqual(received.ChangedFlags, request.ChangedFlags) || + received.Flags["enabled"] != "false" || received.Endpoint != "https://unused.example/api/ledger" { + t.Fatalf("request metadata lost: %+v", received) + } +} + +func TestStructuredErrorAndPartialData(t *testing.T) { + t.Parallel() + client := openHelper(t, http.DefaultClient, "https://unused.example") + response, err := client.Execute(t.Context(), helperRequest("error")) + failure, ok := errors.AsType[*httpclient.Error](err) + if !ok || failure.StatusCode != 409 || failure.Code != "PARTIAL" || failure.Message != "one operation failed" || + string(failure.Body) != ` {"failed":18446744073709551615000} ` || string(response.Data) != ` {"id":90071992547409930001,"results":[{"status":"ok"}]} ` { + t.Fatalf("structured error or partial data changed: %s, %+v", response.Data, err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (r roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return r(request) } + +func TestHTTPRemainsHostOwnedAndDoesNotExposeCredentials(t *testing.T) { + t.Setenv("FCTL_TEST_SECRET", "never-inherit-this-secret") + t.Setenv("AWS_SECRET_ACCESS_KEY", "never-inherit-this-secret") + t.Setenv("FCTL_ACCESS_TOKEN", "never-inherit-this-secret") + var attempts atomic.Int32 + const body = " {\n\"amount\":90071992547409930001} \n" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer host-only-token" { + t.Error("host transport did not supply authentication") + } + if r.URL.EscapedPath() != "/api/ledger/v3/books/users%2F001" || r.Method != "POST" { + t.Errorf("routing changed: %s %s", r.Method, r.URL.EscapedPath()) + } + data, err := io.ReadAll(r.Body) + if err != nil || string(data) != body { + t.Errorf("HTTP body changed: %q, %v", data, err) + } + w.Header().Set("Set-Cookie", "session=host-only-token") + w.Header().Set("Authorization", "Bearer host-only-token") + writeResponse(t, w, body) + })) + defer server.Close() + host := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + attempts.Add(1) // The host's debug/auth/refresh transport remains on this path. + request = request.Clone(request.Context()) + request.Header.Set("Authorization", "Bearer host-only-token") + return http.DefaultTransport.RoundTrip(request) + })} + client := openHelper(t, host, server.URL+"/api/ledger") + request := helperRequest("environment") + response, err := client.Execute(t.Context(), request) + if err != nil || bytes.Contains(response.Data, []byte("never-inherit-this-secret")) { + t.Fatalf("secret inherited: %v", err) + } + assertPrivateEnvironment(t, response.Data) + request = helperRequest("http") + request.Flags["path"] = "/v3/books/users%2F001" + request.Flags["method"] = "POST" + request.Body = []byte(body) + response, err = client.Execute(t.Context(), request) + if err != nil || string(response.Data) != body || attempts.Load() != 1 { + t.Fatalf("host callback = %q, %v, attempts=%d", response.Data, err, attempts.Load()) + } +} + +func TestHTTPFailurePreservesStructuredPartialResponseWithoutRetry(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + const body = ` {"errorCode":"PARTIAL","errorMessage":"one failed","results":[{"id":90071992547409930001}]} ` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusConflict) + writeResponse(t, w, body) + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL+"/api/ledger") + request := helperRequest("http") + request.Flags["partial"] = "true" + request.Flags["method"] = "POST" + response, err := client.Execute(t.Context(), request) + failure, ok := errors.AsType[*httpclient.Error](err) + if !ok || failure.Code != "PARTIAL" || failure.StatusCode != 409 || string(failure.Body) != body || string(response.Data) != body || calls.Load() != 1 { + t.Fatalf("HTTP failure changed or retried: data=%s, err=%v, calls=%d", response.Data, err, calls.Load()) + } +} + +func TestHTTPResponsesHideCredentialHeaders(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Authorization", "Bearer hidden") + w.Header().Set("Set-Cookie", "hidden") + w.Header().Set("X-Request-Id", "visible") + writeResponse(t, w, `{}`) + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL+"/api/ledger") + request := helperRequest("raw-http") + request.Flags["url"] = server.URL + "/api/ledger/v3/books" + response, err := client.Execute(t.Context(), request) + if err != nil || bytes.Contains(response.Data, []byte("hidden")) || !bytes.Contains(response.Data, []byte("visible")) { + t.Fatalf("credential headers escaped: %s, %v", response.Data, err) + } +} + +func TestHTTPRejectsEndpointEscapesAndCredentialHeaders(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + writeResponse(t, w, `{}`) + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL+"/api/ledger") + for _, suffix := range []string{ + "/api/auth/v3", "/api/ledger-other/v3", "/api/ledger/../auth", "/api/ledger/%2e%2e/auth", + "/api/ledger/%252e%252e/auth", "/api/ledger/v3%2f..%2f..%2fauth", "/api/ledger/%5c..%5cauth", + } { + t.Run(suffix, func(t *testing.T) { + request := helperRequest("raw-http") + request.Flags["url"] = server.URL + suffix + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatal("HTTP callback allowed endpoint escape") + } + }) + } + for _, target := range []string{"http://127.0.0.1:1/api/ledger/v3", strings.Replace(server.URL, "http:", "https:", 1) + "/api/ledger/v3", "http://user:password@" + strings.TrimPrefix(server.URL, "http://") + "/api/ledger/v3"} { + request := helperRequest("raw-http") + request.Flags["url"] = target + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatal("HTTP callback allowed changed origin or credentials") + } + } + for _, name := range []string{"Authorization", "Cookie", "Proxy-Authorization", "X-Api-Key", "Host"} { + request := helperRequest("http") + request.Flags["header"] = name + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatalf("HTTP callback accepted %s", name) + } + } + if calls.Load() != 0 { + t.Fatalf("rejected request reached the endpoint %d times", calls.Load()) + } +} + +func TestHTTPChecksRedirectBoundaries(t *testing.T) { + t.Parallel() + var escaped atomic.Int32 + outside := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + escaped.Add(1) + writeResponse(t, w, `{}`) + })) + defer outside.Close() + var server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/ledger/inside": + http.Redirect(w, r, "/api/ledger/ok", http.StatusFound) + case "/api/ledger/origin": + http.Redirect(w, r, outside.URL, http.StatusFound) + case "/api/ledger/sibling": + http.Redirect(w, r, "/api/auth/secret", http.StatusFound) + case "/api/ledger/ok": + writeResponse(t, w, `{"ok":true}`) + default: + escaped.Add(1) + writeResponse(t, w, `{}`) + } + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL+"/api/ledger") + for _, path := range []string{"/origin", "/sibling"} { + request := helperRequest("http") + request.Flags["path"] = path + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatal("host followed redirect outside endpoint") + } + } + request := helperRequest("http") + request.Flags["path"] = "/inside" + response, err := client.Execute(t.Context(), request) + if err != nil || string(response.Data) != `{"ok":true}` || escaped.Load() != 0 { + t.Fatalf("redirect behavior = %s, %v; escapes=%d", response.Data, err, escaped.Load()) + } +} + +func TestTransportErrorsDoNotExposeHostSecrets(t *testing.T) { + t.Parallel() + host := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("private transport: Bearer host-secret at /private/auth/credentials") + })} + client := openHelper(t, host, "https://unused.example") + _, err := client.Execute(t.Context(), helperRequest("http")) + if err == nil || strings.Contains(err.Error(), "host-secret") || strings.Contains(err.Error(), "/private/auth") { + t.Fatalf("host transport disclosed credentials: %v", err) + } +} + +func TestCancelRPCStopsUncooperativePlugin(t *testing.T) { + t.Parallel() + client := openHelper(t, http.DefaultClient, "https://unused.example") + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + _, err := client.Execute(ctx, helperRequest("ignore-context")) + if !errors.Is(err, context.DeadlineExceeded) || !client.process.Exited() { + t.Fatalf("deadline did not stop plugin: %v, exited=%v", err, client.process.Exited()) + } + if _, err := client.GetManifest(t.Context()); !errors.Is(err, ErrClosed) { + t.Fatalf("closed runtime restarted: %v", err) + } +} + +func TestCancellationPropagatesToHostHTTP(t *testing.T) { + t.Parallel() + started, stopped := make(chan struct{}), make(chan struct{}) + host := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + close(started) + <-request.Context().Done() + close(stopped) + return nil, request.Context().Err() + })} + client := openHelper(t, host, "https://unused.example") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + finished := make(chan error, 1) + go func() { _, err := client.Execute(ctx, helperRequest("http")); finished <- err }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("HTTP callback did not start") + } + cancel() + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled execution did not finish") + } + select { + case <-stopped: + case <-time.After(5 * time.Second): + t.Fatal("host HTTP callback did not stop") + } +} + +func TestOpenLifetimeCancellationAndConcurrentClose(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + client, err := Open(ctx, helperBinary(t), nil, "") + if err != nil { + cancel() + t.Fatal(err) + } + cancel() + var calls sync.WaitGroup + for range 8 { + calls.Go(func() { + if err := client.Close(); err != nil { + t.Error(err) + } + }) + } + calls.Wait() + if !client.process.Exited() { + t.Fatal("canceled Open context retained the process") + } +} + +func TestConcurrentExecutionsUseIndependentHTTPCallbacks(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeResponse(t, w, fmt.Sprintf(`{"path":%q}`, r.URL.Path)) + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL+"/api/ledger") + var calls sync.WaitGroup + for i := range 5 { + calls.Go(func() { + request := helperRequest("http") + request.Flags["path"] = "/" + strconv.Itoa(i) + response, err := client.Execute(t.Context(), request) + if err != nil || !strings.Contains(string(response.Data), "/api/ledger/"+strconv.Itoa(i)) { + t.Errorf("callback crossed invocations: %s, %v", response.Data, err) + } + }) + } + calls.Wait() +} + +func TestPluginCrashReturnsErrorAndCanBeClosed(t *testing.T) { + t.Parallel() + client := openHelper(t, http.DefaultClient, "https://unused.example") + if _, err := client.Execute(t.Context(), helperRequest("crash")); err == nil { + t.Fatal("plugin crash did not report error") + } +} + +func TestInputValidationAndTransportLimits(t *testing.T) { + t.Parallel() + for _, endpoint := range []string{"ftp://host", "https://user:secret@host", "https://host/path?token=secret", "https://host/../path"} { + if _, err := Open(t.Context(), helperBinary(t), nil, endpoint); err == nil { + t.Fatalf("accepted invalid endpoint %q", endpoint) + } + } + if _, err := Open(t.Context(), "relative-plugin", nil, ""); err == nil { + t.Fatal("accepted relative binary path") + } + if _, err := Open(t.Context(), "/missing/fctl-plugin", nil, ""); err == nil { + t.Fatal("missing binary did not fail") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := Open(ctx, helperBinary(t), nil, ""); !errors.Is(err, context.Canceled) { + t.Fatalf("pre-canceled Open = %v", err) + } + client := openHelper(t, http.DefaultClient, "https://unused.example") + for _, mutate := range []func(*pluginsdk.ExecuteRequest){ + func(r *pluginsdk.ExecuteRequest) { r.Endpoint = "https://other.example" }, + func(r *pluginsdk.ExecuteRequest) { r.Body = bytes.Repeat([]byte("x"), maxBodyBytes+1) }, + func(r *pluginsdk.ExecuteRequest) { r.Context = map[string]string{"client_secret": "secret"} }, + func(r *pluginsdk.ExecuteRequest) { + r.Context = map[string]string{"note": strings.Repeat("x", maxMetaBytes)} + }, + } { + request := helperRequest("echo") + mutate(&request) + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatal("invalid execution passed validation") + } + } + request := helperRequest("large") + request.Flags["size"] = strconv.Itoa(maxDataBytes) + if _, err := client.Execute(t.Context(), request); err == nil { + t.Fatal("accepted plugin result exceeding 32 MiB") + } +} + +func TestHTTPBodyLimits(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if _, err := io.Copy(w, io.LimitReader(&repeatingReader{}, maxDataBytes+1)); err != nil && !strings.Contains(err.Error(), "broken pipe") && !strings.Contains(err.Error(), "connection reset") { + t.Error(err) + } + })) + defer server.Close() + client := openHelper(t, server.Client(), server.URL) + if _, err := client.Execute(t.Context(), helperRequest("http")); err == nil { + t.Fatal("accepted HTTP response exceeding 32 MiB") + } +} + +type repeatingReader struct{} + +func (*repeatingReader) Read(buffer []byte) (int, error) { + for i := range buffer { + buffer[i] = 'x' + } + return len(buffer), nil +} + +func TestEndpointAllowsOrdinaryEncodedIdentifiers(t *testing.T) { + t.Parallel() + base := mustURL(t, "https://stack.example/api/ledger") + for _, path := range []string{"/api/ledger", "/api/ledger/v3/books/a%2Fb", "/api/ledger/v3/books/100%25", "/api/ledger/v3/books/x%252Fy"} { + target := mustURL(t, "https://stack.example"+path) + if err := validateEndpoint(base, target); err != nil { + t.Fatalf("encoded identifier %s rejected: %v", path, err) + } + } +} + +func assertPrivateEnvironment(t *testing.T, data []byte) { + t.Helper() + var environment struct { + Environment []string `json:"environment"` + Arguments []string `json:"arguments"` + } + if err := json.Unmarshal(data, &environment); err != nil || len(environment.Arguments) != 1 { + t.Fatalf("plugin received process arguments: %+v, %v", environment, err) + } + for _, value := range environment.Environment { + if strings.HasPrefix(value, "AWS_") || strings.HasPrefix(value, "FCTL_ACCESS_TOKEN=") || strings.HasPrefix(value, "FCTL_TEST_SECRET=") { + t.Fatal("plugin inherited host credentials") + } + } +} + +func mustURL(t *testing.T, value string) *url.URL { + t.Helper() + u, err := url.Parse(value) + if err != nil { + t.Fatal(err) + } + return u +} + +func writeResponse(t *testing.T, w http.ResponseWriter, body string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + //nolint:gosec // JSON test fixtures include deliberately oversized and malformed bodies. + if _, err := io.WriteString(w, body); err != nil { + t.Error(err) + } +} diff --git a/pkg/pluginsdk/transport/wire.go b/pkg/pluginsdk/transport/wire.go new file mode 100644 index 00000000..c3add59e --- /dev/null +++ b/pkg/pluginsdk/transport/wire.go @@ -0,0 +1,225 @@ +package transport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const ( + pluginService = "formance.fctl.plugins.v1.Plugin" + httpService = "formance.fctl.plugins.v1.HTTP" + maxWireBytes = 48 << 20 // Includes base64 framing of a 32 MiB response. + maxBodyBytes = 4 << 20 + maxDataBytes = 32 << 20 + maxMetaBytes = 1 << 20 + maxHeadBytes = 64 << 10 +) + +// Byte slices, rather than RawMessage or protobuf Struct, keep service JSON +// byte-for-byte intact, including integers larger than IEEE-754 can represent. +type requestWire struct { + CommandPath []string `json:"commandPath"` + Args []string `json:"args,omitzero"` + Flags map[string]string `json:"flags,omitzero"` + ChangedFlags map[string]bool `json:"changedFlags,omitzero"` + Body []byte `json:"body,omitzero"` + Endpoint string `json:"endpoint"` + Context map[string]string `json:"context,omitzero"` +} + +func toWire(r pluginsdk.ExecuteRequest) requestWire { + return requestWire{r.CommandPath, r.Args, r.Flags, r.ChangedFlags, r.Body, r.Endpoint, r.Context} +} + +func (r requestWire) request() pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{CommandPath: r.CommandPath, Args: r.Args, Flags: r.Flags, + ChangedFlags: r.ChangedFlags, Body: r.Body, Endpoint: r.Endpoint, Context: r.Context} +} + +type executeWire struct { + BrokerID uint32 `json:"brokerID"` + Request requestWire `json:"request"` +} + +type resultWire struct { + Data []byte `json:"data,omitzero"` + Error *errorWire `json:"error,omitzero"` +} + +type errorWire struct { + Kind string `json:"kind"` + Message string `json:"message"` + StatusCode int `json:"statusCode,omitzero"` + Code string `json:"code,omitzero"` + Body []byte `json:"body,omitzero"` +} + +func encodeError(err error) *errorWire { + if err == nil { + return nil + } + if failure, ok := errors.AsType[*httpclient.Error](err); ok { + return &errorWire{Kind: "http", Message: failure.Message, StatusCode: failure.StatusCode, Code: failure.Code, Body: failure.Body} + } + if errors.Is(err, context.Canceled) { + return &errorWire{Kind: "canceled", Message: context.Canceled.Error()} + } + if errors.Is(err, context.DeadlineExceeded) { + return &errorWire{Kind: "deadline", Message: context.DeadlineExceeded.Error()} + } + return &errorWire{Kind: "plugin", Message: err.Error()} +} + +func (e *errorWire) err() error { + if e == nil { + return nil + } + switch e.Kind { + case "http": + return &httpclient.Error{StatusCode: e.StatusCode, Code: e.Code, Message: e.Message, Body: e.Body} + case "canceled": + return context.Canceled + case "deadline": + return context.DeadlineExceeded + default: + return errors.New(e.Message) + } +} + +func pack(value any) (*wrapperspb.BytesValue, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode plugin message: %w", err) + } + if len(data) > maxWireBytes { + return nil, status.Error(codes.ResourceExhausted, "plugin message exceeds transport limit") + } + return wrapperspb.Bytes(data), nil +} + +func unpack(message *wrapperspb.BytesValue, value any) error { + if message == nil || len(message.Value) > maxWireBytes { + return status.Error(codes.ResourceExhausted, "plugin message exceeds transport limit") + } + if err := json.Unmarshal(message.Value, value); err != nil { + return status.Error(codes.InvalidArgument, "invalid plugin transport message") + } + return nil +} + +func validateResult(result resultWire) error { + if len(result.Data) > maxDataBytes || (len(result.Data) != 0 && !json.Valid(result.Data)) { + return fmt.Errorf("plugin response must be valid JSON within 32 MiB") + } + size := len(result.Data) + if result.Error != nil { + size += len(result.Error.Body) + len(result.Error.Message) + len(result.Error.Code) + } + if size > maxDataBytes { + return fmt.Errorf("plugin response and error exceed 32 MiB") + } + return nil +} + +type pluginRPC interface { + GetManifest(context.Context, *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) + Execute(context.Context, *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) +} + +type httpRPC interface { + Do(context.Context, *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) +} + +// These descriptors use the standard protobuf BytesValue message. The payload +// schemas above are the versioned SDK contract; no custom gRPC codec is needed. +var pluginDescriptor = grpc.ServiceDesc{ + ServiceName: pluginService, + HandlerType: (*pluginRPC)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "GetManifest", Handler: manifestHandler}, + {MethodName: "Execute", Handler: executeHandler}, + }, + Streams: []grpc.StreamDesc{executeStreamDescriptor}, +} + +var httpDescriptor = grpc.ServiceDesc{ + ServiceName: httpService, + HandlerType: (*httpRPC)(nil), + Methods: []grpc.MethodDesc{{MethodName: "Do", Handler: httpHandler}}, + Streams: []grpc.StreamDesc{httpStreamDescriptor()}, +} + +//nolint:revive // gRPC's MethodHandler requires server before context. +func manifestHandler(server any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + return handle(ctx, server, decode, interceptor, pluginService+"/GetManifest", func(ctx context.Context, message *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + implementation, ok := server.(pluginRPC) + if !ok { + return nil, status.Error(codes.Internal, "invalid plugin RPC implementation") + } + return implementation.GetManifest(ctx, message) + }) +} + +//nolint:revive // gRPC's MethodHandler requires server before context. +func executeHandler(server any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + return handle(ctx, server, decode, interceptor, pluginService+"/Execute", func(ctx context.Context, message *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + implementation, ok := server.(pluginRPC) + if !ok { + return nil, status.Error(codes.Internal, "invalid plugin RPC implementation") + } + return implementation.Execute(ctx, message) + }) +} + +//nolint:revive // gRPC's MethodHandler requires server before context. +func httpHandler(server any, ctx context.Context, decode func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + return handle(ctx, server, decode, interceptor, httpService+"/Do", func(ctx context.Context, message *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + implementation, ok := server.(httpRPC) + if !ok { + return nil, status.Error(codes.Internal, "invalid HTTP RPC implementation") + } + return implementation.Do(ctx, message) + }) +} + +func handle(ctx context.Context, server any, decode func(any) error, interceptor grpc.UnaryServerInterceptor, method string, call func(context.Context, *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error)) (any, error) { + message := new(wrapperspb.BytesValue) + if err := decode(message); err != nil { + return nil, err + } + if interceptor == nil { + return call(ctx, message) + } + return interceptor(ctx, message, &grpc.UnaryServerInfo{Server: server, FullMethod: "/" + method}, func(ctx context.Context, request any) (any, error) { + message, ok := request.(*wrapperspb.BytesValue) + if !ok { + return nil, status.Error(codes.Internal, "invalid plugin RPC request") + } + return call(ctx, message) + }) +} + +func invoke(ctx context.Context, conn *grpc.ClientConn, method string, request, result any) error { + message, err := pack(request) + if err != nil { + return err + } + var response wrapperspb.BytesValue + if err := conn.Invoke(ctx, "/"+method, message, &response, grpc.MaxCallRecvMsgSize(maxWireBytes), grpc.MaxCallSendMsgSize(maxWireBytes)); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("plugin RPC: %w", err) + } + return unpack(&response, result) +} diff --git a/pkg/print.go b/pkg/print.go deleted file mode 100644 index 410aed7d..00000000 --- a/pkg/print.go +++ /dev/null @@ -1,13 +0,0 @@ -package fctl - -import ( - "github.com/pterm/pterm" -) - -func Println(args ...any) { - pterm.Println(args...) -} - -func Printfln(fmt string, args ...any) { - pterm.Printfln(fmt, args...) -} diff --git a/pkg/printer/cursor.go b/pkg/printer/cursor.go deleted file mode 100644 index c33b092d..00000000 --- a/pkg/printer/cursor.go +++ /dev/null @@ -1,71 +0,0 @@ -package printer - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" -) - -type CursorData struct { - HasMore bool - Next *string - PageSize int64 - Previous *string -} - -func Cursor(writer io.Writer, cursor *CursorData) error { - tableData := pterm.TableData{} - tableData = append(tableData, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", cursor.HasMore)}) - tableData = append(tableData, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", cursor.PageSize)}) - tableData = append(tableData, []string{pterm.LightCyan("Next"), func() string { - if cursor.Next == nil { - return "" - } - return *cursor.Next - }()}) - tableData = append(tableData, []string{pterm.LightCyan("Previous"), func() string { - if cursor.Previous == nil { - return "" - } - return *cursor.Previous - }()}) - - if err := pterm.DefaultTable. - WithWriter(writer). - WithData(tableData). - Render(); err != nil { - return err - } - - return nil -} - -type CursorArgs struct { - HasMore bool - Next *string - PageSize int64 - Previous *string -} - -func AddCursorRowsToTable(tableData [][]string, cursor CursorArgs) [][]string { - cursorRows := make([][]string, 0) - cursorRows = append(cursorRows, []string{pterm.LightCyan("HasMore"), fmt.Sprintf("%v", cursor.HasMore)}) - cursorRows = append(cursorRows, []string{pterm.LightCyan("PageSize"), fmt.Sprintf("%d", cursor.PageSize)}) - cursorRows = append(cursorRows, []string{pterm.LightCyan("Next"), func() string { - if cursor.Next == nil { - return "" - } - return *cursor.Next - }()}) - cursorRows = append(cursorRows, []string{pterm.LightCyan("Previous"), func() string { - if cursor.Previous == nil { - return "" - } - return *cursor.Previous - }()}) - - tableData = append(tableData, cursorRows...) - - return tableData -} diff --git a/pkg/printer/log.go b/pkg/printer/log.go deleted file mode 100644 index be87bac2..00000000 --- a/pkg/printer/log.go +++ /dev/null @@ -1,63 +0,0 @@ -package printer - -import ( - "fmt" - "io" - - "github.com/pterm/pterm" - - "github.com/formancehq/fctl/internal/membershipclient/v3/models/components" - "github.com/formancehq/go-libs/v4/time" - - fctl "github.com/formancehq/fctl/v3/pkg" -) - -func LogCursor(writer io.Writer, cursor *components.LogCursorData, withData bool) error { - header := []string{"Identifier", "User", "Date", "Action"} - - if withData { - header = append(header, "Data") - } - tableData := fctl.Map(cursor.GetData(), func(log components.Log) []string { - line := []string{ - log.GetSeq(), - func() string { - if log.GetUserID() == "" { - return "SYSTEM" - } - return log.GetUserID() - }(), - log.GetDate().Format(time.DateFormat), - log.GetAction(), - } - - if withData { - line = append(line, func() string { - data := log.GetData() - if data == (components.LogData{}) { - return "" - } - return fmt.Sprintf("%v", data) - }()) - - } - - return line - }) - tableData = fctl.Prepend(tableData, header) - - if err := pterm.DefaultTable. - WithHasHeader(). - WithWriter(writer). - WithData(tableData). - Render(); err != nil { - return err - } - - return Cursor(writer, &CursorData{ - HasMore: cursor.GetHasMore(), - PageSize: cursor.GetPageSize(), - Next: cursor.GetNext(), - Previous: cursor.GetPrevious(), - }) -} diff --git a/pkg/profile.go b/pkg/profile.go deleted file mode 100644 index 4b496fe6..00000000 --- a/pkg/profile.go +++ /dev/null @@ -1,274 +0,0 @@ -package fctl - -import ( - "io/fs" - "os" - "path/filepath" - "sort" - "time" - - "github.com/pkg/errors" - "github.com/spf13/cobra" - - "github.com/formancehq/go-libs/v4/oidc/client" -) - -type ErrInvalidAuthentication struct { - err error -} - -func (e ErrInvalidAuthentication) Error() string { - return e.err.Error() -} - -func (e ErrInvalidAuthentication) Unwrap() error { - return e.err -} - -func (e ErrInvalidAuthentication) Is(err error) bool { - _, ok := err.(*ErrInvalidAuthentication) - return ok -} - -func IsInvalidAuthentication(err error) bool { - return errors.Is(err, &ErrInvalidAuthentication{}) -} - -func newErrInvalidAuthentication(err error) *ErrInvalidAuthentication { - return &ErrInvalidAuthentication{ - err: err, - } -} - -const AuthClient = "fctl" - -type Profile struct { - MembershipURI string `json:"membershipURI"` - RootTokens *Tokens `json:"rootTokens"` - - DefaultOrganization string `json:"defaultOrganization"` - DefaultStack string `json:"defaultStack"` -} - -func (p *Profile) UpdateRootToken(tokens *Tokens) { - p.RootTokens = tokens -} - -func (p *Profile) GetMembershipURI() string { - return p.MembershipURI -} - -func (p *Profile) GetDefaultOrganization() string { - return p.DefaultOrganization -} - -func (p *Profile) GetDefaultStack() string { - return p.DefaultStack -} - -func (p *Profile) GetRootToken() (*AccessToken, error) { - return &p.RootTokens.Access, nil -} - -func (p *Profile) GetClaims() (AccessTokenClaims, error) { - return p.RootTokens.Access.Claims, nil -} - -func (p *Profile) SetDefaultOrganization(o string) { - p.DefaultOrganization = o -} - -func (p *Profile) IsConnected() bool { - return p.RootTokens != nil -} - -func LoadCurrentProfile(cmd *cobra.Command, cfg Config) (*Profile, string, error) { - currentProfileName := GetCurrentProfileName(cmd, cfg) - profile, err := LoadProfile(cmd, currentProfileName) - if errors.Is(err, &fs.PathError{}) || errors.Is(err, os.ErrNotExist) { - return &Profile{ - MembershipURI: DefaultMembershipURI, - }, currentProfileName, nil - } - - return profile, currentProfileName, err -} - -func LoadAndAuthenticateCurrentProfileWithConfig(cmd *cobra.Command, cfg Config) (*Profile, string, client.RelyingParty, error) { - - profile, profileName, err := LoadCurrentProfile(cmd, cfg) - if err != nil { - return nil, "", nil, err - } - - if !profile.IsConnected() { - return nil, "", nil, newErrInvalidAuthentication(errors.New("not authenticated, please run 'fctl login'")) - } - - relyingParty := NewLazyRelyingParty(cmd.Context(), GetHttpClient(cmd), profile.GetMembershipURI()) - - return profile, profileName, relyingParty, nil -} - -func LoadAndAuthenticateCurrentProfile(cmd *cobra.Command) (*Config, *Profile, string, client.RelyingParty, error) { - cfg, err := LoadConfig(cmd) - if err != nil { - return nil, nil, "", nil, err - } - - profile, profileName, relyingParty, err := LoadAndAuthenticateCurrentProfileWithConfig(cmd, *cfg) - if err != nil { - return nil, nil, "", nil, err - } - - return cfg, profile, profileName, relyingParty, nil -} - -type CurrentProfile Profile - -func ListProfiles(cmd *cobra.Command, filters ...func(string) bool) ([]string, error) { - - ret := make([]string, 0) - - dir, err := os.ReadDir(GetFilePath(cmd, "profiles")) - if err != nil { - return nil, err - } - -l: - for _, d := range dir { - if d.IsDir() { - name := d.Name() - for _, filter := range filters { - if !filter(name) { - continue l - } - } - - ret = append(ret, name) - } - } - - sort.Strings(ret) - return ret, nil -} - -func LoadProfile(cmd *cobra.Command, name string) (*Profile, error) { - return ReadJSONFile[Profile](cmd, filepath.Join("profiles", name, "profile.json")) -} - -func WriteProfile(cmd *cobra.Command, name string, profile Profile) error { - profileDir := GetFilePath(cmd, filepath.Join("profiles", name)) - if err := os.MkdirAll(profileDir, 0700); err != nil { - return err - } - - return WriteJSONFile(filepath.Join(profileDir, "profile.json"), profile) -} - -func DeleteProfile(cmd *cobra.Command, name string) error { - profileDir := GetFilePath(cmd, filepath.Join("profiles", name)) - return os.RemoveAll(profileDir) -} - -func RenameProfile(cmd *cobra.Command, oldName, newName string) error { - oldProfileDir := GetFilePath(cmd, filepath.Join("profiles", oldName)) - newProfileDir := GetFilePath(cmd, filepath.Join("profiles", newName)) - return os.Rename(oldProfileDir, newProfileDir) -} - -func ResetProfile(cmd *cobra.Command, name string) error { - profile, err := LoadProfile(cmd, name) - if err != nil { - return err - } - profile.MembershipURI = DefaultMembershipURI - - return WriteProfile(cmd, name, *profile) -} - -func WriteOrganizationToken(cmd *cobra.Command, profileName string, token AccessToken) error { - profileDir := GetFilePath(cmd, filepath.Join("profiles", profileName, "organizations", token.Claims.OrganizationID)) - if err := os.MkdirAll(profileDir, 0700); err != nil { - return err - } - - return WriteJSONFile(filepath.Join(profileDir, "accesses.json"), token) -} - -func ReadOrganizationToken(cmd *cobra.Command, profileName, organizationID string) (*AccessToken, error) { - ret, err := ReadJSONFile[AccessToken](cmd, filepath.Join("profiles", profileName, "organizations", organizationID, "accesses.json")) - if err != nil { - if errors.Is(err, &fs.PathError{}) || errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - return ret, nil -} - -func WriteStackToken(cmd *cobra.Command, profileName, stackID string, token AccessToken) error { - profileDir := GetFilePath(cmd, filepath.Join("profiles", profileName, "organizations", token.Claims.OrganizationID, "stacks", stackID)) - if err := os.MkdirAll(profileDir, 0700); err != nil { - return err - } - - return WriteJSONFile(filepath.Join(profileDir, "accesses.json"), token) -} - -func ReadStackToken(cmd *cobra.Command, profileName, organizationID, stackID string) (*AccessToken, error) { - ret, err := ReadJSONFile[AccessToken](cmd, filepath.Join("profiles", profileName, "organizations", organizationID, "stacks", stackID, "accesses.json")) - if err != nil { - if errors.Is(err, &fs.PathError{}) || errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - return ret, nil -} - -func WriteAppToken(cmd *cobra.Command, profileName, appAlias string, token AccessToken) error { - profileDir := GetFilePath(cmd, filepath.Join("profiles", profileName, "organizations", token.Claims.OrganizationID, "apps", appAlias)) - if err := os.MkdirAll(profileDir, 0700); err != nil { - return err - } - - return WriteJSONFile(filepath.Join(profileDir, "accesses.json"), token) -} - -func ReadAppToken(cmd *cobra.Command, profileName, organizationID, appAlias string) (*AccessToken, error) { - ret, err := ReadJSONFile[AccessToken](cmd, filepath.Join("profiles", profileName, "organizations", organizationID, "apps", appAlias, "accesses.json")) - if err != nil { - if errors.Is(err, &fs.PathError{}) || errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - return ret, nil -} - -type CachedStackAPIToken struct { - AccessToken string `json:"accessToken"` - TokenType string `json:"tokenType"` - Expiry time.Time `json:"expiry"` -} - -func WriteCachedStackAPIToken(cmd *cobra.Command, profileName, organizationID, stackID string, token CachedStackAPIToken) error { - dir := GetFilePath(cmd, filepath.Join("profiles", profileName, "organizations", organizationID, "stacks", stackID)) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - return WriteJSONFile(filepath.Join(dir, "api_token_cache.json"), token) -} - -func ReadCachedStackAPIToken(cmd *cobra.Command, profileName, organizationID, stackID string) (*CachedStackAPIToken, error) { - ret, err := ReadJSONFile[CachedStackAPIToken](cmd, filepath.Join("profiles", profileName, "organizations", organizationID, "stacks", stackID, "api_token_cache.json")) - if err != nil { - if errors.Is(err, &fs.PathError{}) || errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - return ret, nil -} diff --git a/pkg/ptr.go b/pkg/ptr.go deleted file mode 100644 index 69310dfb..00000000 --- a/pkg/ptr.go +++ /dev/null @@ -1,22 +0,0 @@ -package fctl - -func BoolToString(v bool) string { - if !v { - return "No" - } - return "Yes" -} - -func BoolPointerToString(v *bool) string { - if v == nil { - return "No" - } - return BoolToString(*v) -} - -func StringPointerToString(v *string) string { - if v == nil { - return "" - } - return *v -} diff --git a/pkg/relyingparty.go b/pkg/relyingparty.go deleted file mode 100644 index 76dd65ff..00000000 --- a/pkg/relyingparty.go +++ /dev/null @@ -1,117 +0,0 @@ -package fctl - -import ( - "context" - "fmt" - "net/http" - "sync" - - "github.com/go-jose/go-jose/v4" - "github.com/pterm/pterm" - "golang.org/x/oauth2" - - "github.com/formancehq/go-libs/v4/oidc/client" - httphelper "github.com/formancehq/go-libs/v4/oidc/http" -) - -func GetAuthRelyingParty(ctx context.Context, httpClient *http.Client, membershipURI string) (client.RelyingParty, error) { - pterm.Debug.Println("Getting auth relying party on membership URI:", membershipURI) - return client.NewRelyingPartyOIDC( - ctx, - membershipURI, - AuthClient, - "", - "", - []string{}, - client.WithHTTPClient(httpClient), - ) -} - -// lazyRelyingParty defers the OIDC discovery call until a method (other than -// HttpClient) is actually invoked. This avoids hitting the .well-known/openid-configuration -// endpoint when a cached stack API token makes it unnecessary. -// -// Uses sync.Mutex instead of sync.Once so that transient discovery failures -// (e.g. network timeouts) are retried on the next call rather than cached -// permanently. -type lazyRelyingParty struct { - ctx context.Context - httpClient *http.Client - membershipURI string - - mu sync.Mutex - delegate client.RelyingParty -} - -func (l *lazyRelyingParty) init() error { - l.mu.Lock() - defer l.mu.Unlock() - if l.delegate != nil { - return nil - } - var err error - l.delegate, err = GetAuthRelyingParty(l.ctx, l.httpClient, l.membershipURI) - return err -} - -func (l *lazyRelyingParty) mustDelegate() client.RelyingParty { - if err := l.init(); err != nil { - panic(fmt.Sprintf("OIDC discovery failed: %v", err)) - } - return l.delegate -} - -// HttpClient returns the http client without triggering OIDC discovery. -func (l *lazyRelyingParty) HttpClient() *http.Client { - return l.httpClient -} - -func (l *lazyRelyingParty) OAuthConfig() *oauth2.Config { - return l.mustDelegate().OAuthConfig() -} -func (l *lazyRelyingParty) Issuer() string { - return l.mustDelegate().Issuer() -} -func (l *lazyRelyingParty) IsPKCE() bool { - return l.mustDelegate().IsPKCE() -} -func (l *lazyRelyingParty) CookieHandler() *httphelper.CookieHandler { - return l.mustDelegate().CookieHandler() -} -func (l *lazyRelyingParty) IsOAuth2Only() bool { - return l.mustDelegate().IsOAuth2Only() -} -func (l *lazyRelyingParty) Signer() jose.Signer { - return l.mustDelegate().Signer() -} -func (l *lazyRelyingParty) GetEndSessionEndpoint() string { - return l.mustDelegate().GetEndSessionEndpoint() -} -func (l *lazyRelyingParty) GetRevokeEndpoint() string { - return l.mustDelegate().GetRevokeEndpoint() -} -func (l *lazyRelyingParty) UserinfoEndpoint() string { - return l.mustDelegate().UserinfoEndpoint() -} -func (l *lazyRelyingParty) GetDeviceAuthorizationEndpoint() string { - return l.mustDelegate().GetDeviceAuthorizationEndpoint() -} -func (l *lazyRelyingParty) GetIntrospectionEndpoint() string { - return l.mustDelegate().GetIntrospectionEndpoint() -} -func (l *lazyRelyingParty) IDTokenVerifier() *client.Verifier { - return l.mustDelegate().IDTokenVerifier() -} -func (l *lazyRelyingParty) ErrorHandler() func(http.ResponseWriter, *http.Request, string, string, string) { - return l.mustDelegate().ErrorHandler() -} - -var _ client.RelyingParty = (*lazyRelyingParty)(nil) - -func NewLazyRelyingParty(ctx context.Context, httpClient *http.Client, membershipURI string) client.RelyingParty { - return &lazyRelyingParty{ - ctx: ctx, - httpClient: httpClient, - membershipURI: membershipURI, - } -} diff --git a/pkg/stack.go b/pkg/stack.go deleted file mode 100644 index 8bca3dae..00000000 --- a/pkg/stack.go +++ /dev/null @@ -1,42 +0,0 @@ -package fctl - -import ( - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/formancehq/go-libs/v4/collectionutils" -) - -func StackCompletion(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - cfg, err := LoadConfig(cmd) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - profile, _, err := LoadCurrentProfile(cmd, *cfg) - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - - organizationID, _ := ResolveOrganizationID(cmd, *profile) - - organizationClaims := profile.RootTokens.ID.Claims.Organizations - organizationClaims = collectionutils.Filter(organizationClaims, func(s OrganizationAccess) bool { - return strings.HasPrefix(s.ID, organizationID) - }) - stackClaims := collectionutils.Map(organizationClaims, func(s OrganizationAccess) []StackAccess { - return s.Stacks - }) - stackList := collectionutils.Flatten(stackClaims) - stackList = collectionutils.Filter(stackList, func(s StackAccess) bool { - return toComplete == "" || strings.HasPrefix(s.ID, toComplete) - }) - - ret := collectionutils.Map(stackList, func(from StackAccess) string { - return fmt.Sprintf("%s\t%s", from.ID, from.DisplayName) - }) - - return ret, cobra.ShellCompDirectiveNoFileComp -} diff --git a/pkg/theme.go b/pkg/theme.go deleted file mode 100644 index 950c5f0b..00000000 --- a/pkg/theme.go +++ /dev/null @@ -1,22 +0,0 @@ -package fctl - -import ( - "github.com/pterm/pterm" -) - -var ( - StyleGreen = pterm.NewStyle(pterm.FgLightGreen) - StyleRed = pterm.NewStyle(pterm.FgLightRed) - StyleCyan = pterm.NewStyle(pterm.FgLightCyan) - - BasicText = pterm.DefaultBasicText - BasicTextRed = pterm.DefaultBasicText.WithStyle(StyleRed) - BasicTextCyan = pterm.DefaultBasicText.WithStyle(StyleCyan) - Section = pterm.SectionPrinter{ - Style: &pterm.ThemeDefault.SectionStyle, - Level: 1, - TopPadding: 0, - BottomPadding: 0, - IndentCharacter: "#", - } -) diff --git a/pkg/utils.go b/pkg/utils.go deleted file mode 100644 index d528c09e..00000000 --- a/pkg/utils.go +++ /dev/null @@ -1,126 +0,0 @@ -package fctl - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - - "github.com/spf13/cobra" -) - -func StructToMap(obj interface{}) (newMap map[string]interface{}, err error) { - data, err := json.Marshal(obj) - if err != nil { - return - } - - err = json.Unmarshal(data, &newMap) - return -} - -func Map[SRC any, DST any](srcs []SRC, mapper func(SRC) DST) []DST { - ret := make([]DST, 0) - for _, src := range srcs { - ret = append(ret, mapper(src)) - } - return ret -} - -func MapMap[KEY comparable, VALUE any, DST any](srcs map[KEY]VALUE, mapper func(KEY, VALUE) DST) []DST { - ret := make([]DST, 0) - for k, v := range srcs { - ret = append(ret, mapper(k, v)) - } - return ret -} - -func Prepend[V any](array []V, items ...V) []V { - return append(items, array...) -} - -func ContainValue[V comparable](array []V, value V) bool { - for _, v := range array { - if v == value { - return true - } - } - return false -} - -var ( - ErrOpeningBrowser = errors.New("opening browser") -) - -func OpenURL(urlString string) error { - var ( - cmd string - args []string - ) - - if _, err := url.Parse(urlString); err != nil { - return fmt.Errorf("invalid URL: %s", urlString) - } - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - - _, err := exec.LookPath(cmd) - if err == nil { - args = append(args, urlString) - return exec.Command(cmd, args...).Start() //nolint:gosec - } - - return ErrOpeningBrowser -} - -func ReadJSONFile[V any](cmd *cobra.Command, filePath string) (*V, error) { - f, err := os.Open(GetFilePath(cmd, filePath)) - if err != nil { - return nil, err - } - defer func() { - _ = f.Close() - }() - - v := new(V) - if err := json.NewDecoder(f).Decode(v); err != nil { - return nil, err - } - - return v, nil -} - -func WriteJSONFile(filePath string, data any) error { - dir := path.Dir(filePath) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - f, err := os.OpenFile(filepath.Clean(filePath), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer func() { - _ = f.Close() - }() - - enc := json.NewEncoder(f) - enc.SetIndent("", " ") - if err := enc.Encode(data); err != nil { - return err - } - return nil -} diff --git a/plugins/cloud/apps.go b/plugins/cloud/apps.go new file mode 100644 index 00000000..74196785 --- /dev/null +++ b/plugins/cloud/apps.go @@ -0,0 +1,602 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const appsFileLimit = 4 << 20 + +func executeApps(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, path []string) (json.RawMessage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // This guard precedes normalization, file access and every service request. + if req.Flags["experimental"] != "true" { + return nil, fmt.Errorf("cloud apps requires --experimental") + } + if len(path) > 0 && path[0] == "cloud" { + path = path[1:] + } + if len(path) > 0 && path[0] == "apps" { + path = path[1:] + } + req.CommandPath = append([]string{"apps"}, path...) + req, err := pluginsdk.NormalizeRequest(pluginsdk.Manifest{ProtocolVersion: pluginsdk.ProtocolVersion, Root: appsManifest()}, req) + if err != nil { + return nil, err + } + if client == nil || client.HTTPClient() == nil { + return nil, fmt.Errorf("apps requires an injected Deploy client") + } + if err := appsValidateRequest(req); err != nil { + return nil, err + } + cmd := strings.Join(path, "/") + timeout, err := appsTimeout(req) + if err != nil { + return nil, err + } + if cmd == "manifests/create" || cmd == "manifests/versions/push" { + return appsUpload(ctx, client, req, cmd) + } + method, segments, q, body, err := appsRoute(req, cmd) + if err != nil { + return nil, err + } + if strings.HasSuffix(cmd, "download") { + return appsDownload(ctx, client, req, httpclient.Path(segments...), q) + } + result, err := client.Do(ctx, method, httpclient.Path(segments...), q, body, nil) + return appsResult(ctx, client, req, cmd, body, result, err, timeout) +} + +func appsValidateRequest(req pluginsdk.ExecuteRequest) error { + for _, key := range []string{"id", "app-id", "manifest-id"} { + if v := req.Flags[key]; v != "" && (strings.TrimSpace(v) == "" || v == "." || v == "..") { + return fmt.Errorf("invalid --%s", key) + } + } + if req.Flags["data"] != "" && req.Body == nil { + return fmt.Errorf("host must decode --data into Body") + } + command, err := pluginsdk.FindCommand(pluginsdk.Manifest{Root: appsManifest()}, req.CommandPath) + if err != nil { + return err + } + acceptsBody := slices.ContainsFunc(command.Flags, func(f pluginsdk.FlagSpec) bool { return f.Body }) + if req.Body != nil && !acceptsBody { + return fmt.Errorf("this apps command does not accept a body") + } + return nil +} + +func appsTimeout(req pluginsdk.ExecuteRequest) (time.Duration, error) { + if req.Flags["wait-timeout"] == "" { + return 30 * time.Minute, nil + } + timeout, err := time.ParseDuration(req.Flags["wait-timeout"]) + if err != nil || timeout <= 0 || timeout > 24*time.Hour { + return 0, fmt.Errorf("--wait-timeout must be positive and at most 24h") + } + return timeout, nil +} + +type appsOperation struct { + method string + segments []string + fields map[string]string + required []string +} + +func appsRoute(req pluginsdk.ExecuteRequest, cmd string) (string, []string, url.Values, json.RawMessage, error) { + operations := map[string]appsOperation{ + "list": {method: "GET", segments: []string{"apps"}}, + "show": {method: "GET", segments: []string{"apps", ":id"}}, + "create": {method: "POST", segments: []string{"apps"}, fields: map[string]string{"name": "name", "stack-id": "stackId"}, required: []string{"name"}}, + "delete": {method: "DELETE", segments: []string{"apps", ":id"}}, + "bind-manifest": {method: "PUT", segments: []string{"apps", ":app-id", "manifest"}, fields: map[string]string{"manifest-id": "manifestId"}, required: []string{"manifestId"}}, + "unbind-manifest": {method: "DELETE", segments: []string{"apps", ":app-id", "manifest"}}, + "manifests/list": {method: "GET", segments: []string{"manifests"}}, + "manifests/show": {method: "GET", segments: []string{"manifests", ":id"}}, + "manifests/update": {method: "PATCH", segments: []string{"manifests", ":id"}, fields: map[string]string{"name": "name"}, required: []string{"name"}}, + "manifests/delete": {method: "DELETE", segments: []string{"manifests", ":id"}}, + "manifests/download": {method: "GET", segments: []string{"manifests", ":id", "versions", ":version"}}, + "manifests/versions/list": {method: "GET", segments: []string{"manifests", ":manifest-id", "versions"}}, + "manifests/versions/show": {method: "GET", segments: []string{"manifests", ":manifest-id", "versions", ":version"}}, + "deployments/list": {method: "GET", segments: []string{"deployments"}}, + "deployments/show": {method: "GET", segments: []string{"deployments", ":id"}}, + "deployments/download": {method: "GET", segments: []string{"deployments", ":id"}}, + "deployments/logs": {method: "GET", segments: []string{"deployments", ":id", "logs"}}, + "deployments/create": {method: "POST", segments: []string{"deployments"}, fields: map[string]string{"app-id": "appId", "manifest-id": "manifestId", "manifest-version": "manifestVersion"}, required: []string{"appId", "manifestId", "manifestVersion"}}, + "variables/list": {method: "GET", segments: []string{"apps", ":id", "variables"}}, + "variables/create": {method: "POST", segments: []string{"apps", ":id", "variables"}}, + "variables/delete": {method: "DELETE", segments: []string{"apps", ":app-id", "variables", ":id"}}, + } + op, ok := operations[cmd] + if !ok { + return "", nil, nil, nil, fmt.Errorf("unsupported apps command") + } + for i, segment := range op.segments { + if flag, ok := strings.CutPrefix(segment, ":"); ok { + op.segments[i] = req.Flags[flag] + } + } + q, err := appsQuery(req, cmd) + if err != nil { + return "", nil, nil, nil, err + } + var body json.RawMessage + if op.fields != nil { + body, err = appsBody(req, op.fields, op.required...) + } + if cmd == "variables/create" { + body, err = appsVariableBody(req) + } + return op.method, op.segments, q, body, err +} + +func appsQuery(req pluginsdk.ExecuteRequest, cmd string) (url.Values, error) { + q := url.Values{} + if strings.HasSuffix(cmd, "list") { + n, err := strconv.ParseUint(req.Flags["page-size"], 10, 32) + if err != nil || n == 0 { + return nil, fmt.Errorf("--page-size must be positive") + } + q.Set("pageSize", req.Flags["page-size"]) + if req.Flags["cursor"] != "" { + q.Set("cursor", req.Flags["cursor"]) + } + } + if v := req.Flags["version"]; v != "" && v != "latest" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 { + return nil, fmt.Errorf("--version must be positive or latest") + } + } + if cmd == "delete" { + q.Set("wait", "false") + } + if cmd == "deployments/list" && req.Flags["app-id"] != "" { + q.Set("appId", req.Flags["app-id"]) + } + if req.Flags["include-state"] == "true" { + q.Set("include", "state") + } + return q, nil +} + +func appsResult(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, cmd string, body, result json.RawMessage, err error, timeout time.Duration) (json.RawMessage, error) { + if strings.HasPrefix(cmd, "variables/") && cmd != "variables/delete" { + return appsVariableResult(ctx, body, result, err) + } + if err != nil { + return result, err + } + if req.Flags["wait"] != "true" { + if cmd == "deployments/create" && appsCreationFailed(result) { + return result, fmt.Errorf("deployment creation returned an errored status") + } + return result, nil + } + id, err := appsDeploymentID(result, cmd) + if err != nil || id == "" { + return result, err + } + polled, waitErr := appsWait(ctx, client, id, timeout) + if polled == nil { + return result, waitErr + } + return polled, waitErr +} + +func appsCreationFailed(result json.RawMessage) bool { + var response struct { + Data struct { + Status string `json:"status"` + } `json:"data"` + } + return json.Unmarshal(result, &response) == nil && response.Data.Status == "errored" +} + +func appsDeploymentID(result json.RawMessage, cmd string) (string, error) { + var envelope struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + DestroyDeploymentID string `json:"destroyDeploymentId"` + } + if json.Unmarshal(result, &envelope) != nil { + return "", fmt.Errorf("invalid deployment response") + } + if cmd == "delete" { + return envelope.DestroyDeploymentID, nil + } + if envelope.Data.ID == "" { + return "", fmt.Errorf("deployment response is missing its ID") + } + return envelope.Data.ID, nil +} + +func appsVariableResult(ctx context.Context, body, result json.RawMessage, err error) (json.RawMessage, error) { + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("variable operation failed (sensitive response suppressed)") + } + var payload struct { + Variable struct { + Value string `json:"value"` + } `json:"variable"` + } + if body != nil { + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("invalid variable payload") + } + } + return appsRedact(result, payload.Variable.Value) +} + +func appsObject(raw json.RawMessage) (map[string]json.RawMessage, error) { + values := map[string]json.RawMessage{} + if raw != nil { + if json.Unmarshal(raw, &values) != nil || values == nil { + return nil, fmt.Errorf("body must be a JSON object") + } + } + return values, nil +} +func appsBody(req pluginsdk.ExecuteRequest, fields map[string]string, required ...string) (json.RawMessage, error) { + values, err := appsObject(req.Body) + if err != nil { + return nil, err + } + for flag, key := range fields { + if err := appsBodyFlag(req, values, flag, key); err != nil { + return nil, err + } + } + for key, raw := range values { + if !slices.Contains(slices.Collect(maps.Values(fields)), key) { + return nil, fmt.Errorf("unknown request field %q", key) + } + if err := appsValidateField(key, raw, slices.Contains(required, key)); err != nil { + return nil, err + } + } + for _, key := range required { + if _, ok := values[key]; !ok { + return nil, fmt.Errorf("%s is required", key) + } + } + return json.Marshal(values) +} +func appsBodyFlag(req pluginsdk.ExecuteRequest, values map[string]json.RawMessage, flag, key string) error { + if req.Flags[flag] == "" && !req.ChangedFlags[flag] { + return nil + } + if _, ok := values[key]; ok { + return fmt.Errorf("provide %s using either body or --%s", key, flag) + } + if key == "manifestVersion" { + n, err := strconv.ParseInt(req.Flags[flag], 10, 64) + if err != nil || n <= 0 { + return fmt.Errorf("manifestVersion must be a positive integer") + } + values[key] = json.RawMessage(strconv.FormatInt(n, 10)) + return nil + } + raw, err := json.Marshal(req.Flags[flag]) + if err != nil { + return err + } + values[key] = raw + return nil +} +func appsValidateField(key string, raw json.RawMessage, required bool) error { + if key == "manifestVersion" { + var n int64 + if json.Unmarshal(raw, &n) != nil || n <= 0 { + return fmt.Errorf("manifestVersion must be a positive integer") + } + return nil + } + var value string + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be a string", key) + } + if required && strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not be empty", key) + } + return nil +} + +func appsVariableBody(req pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if req.Body != nil { + outer, err := appsObject(req.Body) + if err != nil { + return nil, err + } + if len(outer) != 1 || outer["variable"] == nil { + return nil, fmt.Errorf("body requires a variable object") + } + req.Body = outer["variable"] + } + inner, err := appsBody(req, map[string]string{"key": "key", "value": "value", "description": "description"}, "key", "value") + if err != nil { + return nil, err + } + return json.Marshal(map[string]json.RawMessage{"variable": inner}) +} +func appsText(data []byte) error { + if len(data) == 0 || len(data) > appsFileLimit || !utf8.Valid(data) || bytes.IndexByte(data, 0) >= 0 { + return fmt.Errorf("YAML must be non-empty UTF-8 text within 4 MiB") + } + return nil +} +func appsReadFile(ctx context.Context, path string) (data []byte, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Reject devices and FIFOs before opening: opening a FIFO can block. + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > appsFileLimit { + return nil, fmt.Errorf("--path must be a regular file within 4 MiB") + } + f, err := os.Open(path) // #nosec G304 -- explicit user-selected local YAML input, bounded to a regular file + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, f.Close()) }() + stat, err := f.Stat() + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() || stat.Size() > appsFileLimit { + return nil, fmt.Errorf("--path must be a regular file within 4 MiB") + } + data, err = io.ReadAll(io.LimitReader(f, appsFileLimit+1)) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return data, appsText(data) +} +func appsUpload(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, cmd string) (json.RawMessage, error) { + q := url.Values{} + segments := []string{"manifests"} + if cmd == "manifests/create" { + if strings.TrimSpace(req.Flags["name"]) == "" { + return nil, fmt.Errorf("--name is required") + } + q.Set("name", req.Flags["name"]) + } else { + segments = append(segments, req.Flags["manifest-id"], "versions") + } + data, contentType, err := appsUploadContent(ctx, req) + if err != nil { + return nil, err + } + raw, _, err := appsRaw(ctx, client, http.MethodPost, httpclient.Path(segments...), q, data, contentType, "application/json") + if err != nil { + return nil, err + } + if !json.Valid(raw) { + return nil, fmt.Errorf("deploy returned invalid JSON") + } + return raw, nil +} + +func appsUploadContent(ctx context.Context, req pluginsdk.ExecuteRequest) ([]byte, string, error) { + contentType := "application/json" + data := []byte(req.Body) + if req.Flags["path"] != "" { + if req.Body != nil { + return nil, "", fmt.Errorf("choose --path or --data") + } + var err error + data, err = appsReadFile(ctx, req.Flags["path"]) + if err != nil { + return nil, "", err + } + contentType = "application/yaml" + } else { + obj, err := appsObject(req.Body) + if err != nil || req.Body == nil { + return nil, "", fmt.Errorf("provide --path or --data manifest content") + } + if raw, ok := obj["yaml"]; ok { + var yaml string + if len(obj) != 1 || json.Unmarshal(raw, &yaml) != nil { + return nil, "", fmt.Errorf("YAML wrapper must contain only a yaml string") + } + data = []byte(yaml) + if err := appsText(data); err != nil { + return nil, "", err + } + contentType = "application/yaml" + } + } + return data, contentType, nil +} + +// appsRaw handles historical YAML media types without changing the public SDK. +func appsRaw(ctx context.Context, client *httpclient.Client, method, path string, q url.Values, body []byte, contentType, accept string) (raw []byte, media string, err error) { + u := client.Endpoint() + path + if len(q) > 0 { + u += "?" + q.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req.Header.Set("Accept", accept) + if body != nil { + req.Header.Set("Content-Type", contentType) + } + res, err := client.HTTPClient().Do(req) + if err != nil { + return nil, "", err + } + defer func() { err = errors.Join(err, res.Body.Close()) }() + raw, err = io.ReadAll(io.LimitReader(res.Body, appsFileLimit+1)) + if err != nil { + return nil, "", err + } + if len(raw) > appsFileLimit { + return nil, "", fmt.Errorf("deploy payload exceeds 4 MiB") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, "", &httpclient.Error{StatusCode: res.StatusCode} + } + return raw, strings.Split(res.Header.Get("Content-Type"), ";")[0], nil +} +func appsDownload(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, path string, q url.Values) (result json.RawMessage, err error) { + accept := "application/yaml" + if strings.HasPrefix(path, "/manifests/") { + accept = "application/x-yaml" + } + raw, media, err := appsRaw(ctx, client, http.MethodGet, path, q, nil, "", accept) + if err != nil { + return nil, err + } + if media != "application/yaml" && media != "application/x-yaml" && media != "text/yaml" { + return nil, fmt.Errorf("deploy did not return the requested YAML payload") + } + if err := appsText(raw); err != nil { + return nil, err + } + out := req.Flags["out"] + if out == "" { + return json.Marshal(map[string]string{"yaml": string(raw)}) + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := appsWriteFile(ctx, out, raw); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"path": out, "bytes": len(raw), "mediaType": media}) +} +func appsWriteFile(ctx context.Context, out string, raw []byte) (err error) { + f, err := os.CreateTemp(filepath.Dir(out), ".fctl-apps-*") + if err != nil { + return err + } + temp := f.Name() + defer func() { + if removeErr := os.Remove(temp); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, removeErr) + } + }() + if _, err = f.Write(raw); err != nil { + return errors.Join(err, f.Close()) + } + if err = f.Sync(); err != nil { + return errors.Join(err, f.Close()) + } + if err = f.Close(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err = os.Rename(temp, out); err != nil { + return err + } + return nil +} + +func appsWait(ctx context.Context, client *httpclient.Client, id string, timeout time.Duration) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var last json.RawMessage + for { + result, err := client.Do(ctx, http.MethodGet, httpclient.Path("deployments", id), nil, nil, nil) + if err != nil { + return last, err + } + last = result + var response struct { + Data struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"data"` + } + if json.Unmarshal(result, &response) != nil || response.Data.ID != id || response.Data.Status == "" { + return result, fmt.Errorf("invalid deployment polling metadata") + } + switch response.Data.Status { + case "applied", "planned_and_finished": + return result, nil + case "errored": + return result, fmt.Errorf("deployment %s failed", id) + } + timer := time.NewTimer(2 * time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return last, fmt.Errorf("wait for deployment %s: %w", id, ctx.Err()) + case <-timer.C: + } + } +} +func appsRedact(raw json.RawMessage, secrets ...string) (json.RawMessage, error) { + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, errors.New("invalid variable response") + } + return json.Marshal(appsRedactValue(value, secrets)) +} +func appsRedactValue(value any, secrets []string) any { + switch x := value.(type) { + case map[string]any: + for key, child := range x { + if strings.EqualFold(key, "value") { + x[key] = "REDACTED" + } else { + x[key] = appsRedactValue(child, secrets) + } + } + case []any: + for i, child := range x { + x[i] = appsRedactValue(child, secrets) + } + case string: + return appsRedactString(x, secrets) + } + return value +} +func appsRedactString(value string, secrets []string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "REDACTED") + } + } + return value +} diff --git a/plugins/cloud/apps_manifest.go b/plugins/cloud/apps_manifest.go new file mode 100644 index 00000000..a270a132 --- /dev/null +++ b/plugins/cloud/apps_manifest.go @@ -0,0 +1,38 @@ +package cloud + +import "github.com/formancehq/fctl/pkg/pluginsdk" + +// appsManifest targets the Deploy audience selected by the host, not Membership. +func appsManifest() pluginsdk.CommandSpec { + str := func(name string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "string", Usage: name} + } + id := func(name string) pluginsdk.FlagSpec { f := str(name); f.Required = true; return f } + body := pluginsdk.FlagSpec{Name: "data", Type: "string", Body: true, Usage: "JSON body: inline, @file or - for stdin; YAML upload: {\"yaml\":\"...\"}"} + page := []pluginsdk.FlagSpec{str("cursor"), {Name: "page-size", Type: "uint32", Default: "100", Usage: "Positive page size"}} + wait := func(defaultValue string) []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{{Name: "wait", Type: "bool", Default: defaultValue, Usage: "Wait for a terminal deployment status"}, {Name: "wait-timeout", Type: "string", Default: "30m", Usage: "Positive bounded wait duration (maximum 24h)"}} + } + leaf := func(name string, confirm bool, flags ...pluginsdk.FlagSpec) pluginsdk.CommandSpec { + s := pluginsdk.CommandSpec{Use: name, Short: "Deploy service: " + name, Runnable: true, Confirm: confirm, Flags: flags} + if confirm { + s.Flags = append(s.Flags, pluginsdk.FlagSpec{Name: "confirm", Type: "bool", Default: "false", Usage: "Confirm destructive operation"}) + } + return s + } + group := func(name string, children ...pluginsdk.CommandSpec) pluginsdk.CommandSpec { + return pluginsdk.CommandSpec{Use: name, Short: "Manage app " + name, Subcommands: children} + } + return pluginsdk.CommandSpec{Use: "apps", Short: "Manage experimental Deploy apps", Service: "cloud-apps", Flags: []pluginsdk.FlagSpec{ + {Name: "experimental", Type: "bool", Default: "false", Persistent: true, RequireTrue: true, Usage: "Enable experimental Deploy commands"}, + {Name: "deploy-app-alias", Type: "string", Default: "deploy", Persistent: true, Usage: "Membership application alias used by the host to resolve Deploy access"}, + }, Subcommands: []pluginsdk.CommandSpec{ + leaf("list", false, page...), leaf("show", false, id("id")), leaf("create", false, body, str("name"), str("stack-id")), + leaf("delete", true, append([]pluginsdk.FlagSpec{id("id")}, wait("false")...)...), + leaf("bind-manifest", false, id("app-id"), body, str("manifest-id")), leaf("unbind-manifest", true, id("app-id")), + group("manifests", leaf("list", false, page...), leaf("show", false, id("id")), leaf("create", false, body, str("name"), str("path")), leaf("update", false, id("id"), body, str("name")), leaf("delete", true, id("id")), leaf("download", false, id("id"), pluginsdk.FlagSpec{Name: "version", Type: "string", Default: "latest", Usage: "Positive version or latest"}, str("out")), + group("versions", leaf("list", false, append([]pluginsdk.FlagSpec{id("manifest-id")}, page...)...), leaf("push", false, id("manifest-id"), body, str("path")), leaf("show", false, id("manifest-id"), pluginsdk.FlagSpec{Name: "version", Type: "string", Default: "latest", Usage: "Positive version or latest"}))), + group("deployments", leaf("list", false, append(page, str("app-id"))...), leaf("show", false, id("id"), pluginsdk.FlagSpec{Name: "include-state", Type: "bool", Default: "false", Usage: "Include live state"}), leaf("logs", false, id("id")), leaf("download", false, id("id"), str("out")), leaf("create", false, append([]pluginsdk.FlagSpec{body, str("app-id"), str("manifest-id"), str("manifest-version")}, wait("true")...)...)), + group("variables", leaf("list", false, append([]pluginsdk.FlagSpec{id("id")}, page...)...), leaf("create", false, id("id"), body, str("key"), str("value"), str("description")), leaf("delete", true, id("app-id"), id("id"))), + }} +} diff --git a/plugins/cloud/apps_test.go b/plugins/cloud/apps_test.go new file mode 100644 index 00000000..e9ec5dfc --- /dev/null +++ b/plugins/cloud/apps_test.go @@ -0,0 +1,533 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "go/parser" + "go/token" + "io" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const appsEnvelope = `{"cursor":{"pageSize":100,"hasMore":false},"data":[{"id":"app","name":"Books","stackId":"stack","version":9007199254740993}]}` +const appsYAML = "name: Books\nvariables:\n amount: 9007199254740993\n" + +type appsRouteCase struct { + command, method, path, query, body, wantBody, media string + flags map[string]string +} + +func appsRequest(command string, flags map[string]string, body string) pluginsdk.ExecuteRequest { + flags = maps.Clone(flags) + if flags == nil { + flags = map[string]string{} + } + flags["experimental"] = "true" + req := pluginsdk.ExecuteRequest{CommandPath: append([]string{"cloud", "apps"}, strings.Fields(command)...), Flags: flags, Context: map[string]string{"organizations": `["org1","org2"]`}} + if body != "" { + req.Body = json.RawMessage(body) + } + return req +} +func appsTestClient(t *testing.T, handler http.HandlerFunc) *httpclient.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL+"/deploy/prefix", server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} +func appsEqualJSON(t *testing.T, got, want []byte) { + t.Helper() + decode := func(raw []byte) any { + var v any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&v); err != nil { + t.Fatal(err) + } + return v + } + if !reflect.DeepEqual(decode(got), decode(want)) { + t.Fatalf("JSON = %s; want %s", got, want) + } +} + +//nolint:gocognit // table-driven protocol tests keep route and payload assertions together. +func TestAppsEveryRunnableRoute(t *testing.T) { + cases := []appsRouteCase{ + {command: "list", method: "GET", path: "/apps", query: "cursor=a%2Bb&pageSize=25", flags: map[string]string{"cursor": "a+b", "page-size": "25"}}, + {command: "show", method: "GET", path: "/apps/app", flags: map[string]string{"id": "app"}}, + {command: "create", method: "POST", path: "/apps", flags: map[string]string{"name": "Books", "stack-id": "stack"}, wantBody: `{"name":"Books","stackId":"stack"}`}, + {command: "delete", method: "DELETE", path: "/apps/app", query: "wait=false", flags: map[string]string{"id": "app", "confirm": "true"}}, + {command: "bind-manifest", method: "PUT", path: "/apps/app/manifest", flags: map[string]string{"app-id": "app", "manifest-id": "mf"}, wantBody: `{"manifestId":"mf"}`}, + {command: "unbind-manifest", method: "DELETE", path: "/apps/app/manifest", flags: map[string]string{"app-id": "app", "confirm": "true"}}, + {command: "manifests list", method: "GET", path: "/manifests", query: "pageSize=100"}, + {command: "manifests show", method: "GET", path: "/manifests/mf", flags: map[string]string{"id": "mf"}}, + {command: "manifests create", method: "POST", path: "/manifests", query: "name=Books", flags: map[string]string{"name": "Books"}, body: `{"yaml":"name: Books\nvariables:\n amount: 9007199254740993\n"}`, wantBody: appsYAML, media: "application/yaml"}, + {command: "manifests update", method: "PATCH", path: "/manifests/mf", flags: map[string]string{"id": "mf", "name": "Books"}, wantBody: `{"name":"Books"}`}, + {command: "manifests delete", method: "DELETE", path: "/manifests/mf", flags: map[string]string{"id": "mf", "confirm": "true"}}, + {command: "manifests download", method: "GET", path: "/manifests/mf/versions/latest", flags: map[string]string{"id": "mf"}, media: "application/x-yaml"}, + {command: "manifests versions list", method: "GET", path: "/manifests/mf/versions", query: "pageSize=100", flags: map[string]string{"manifest-id": "mf"}}, + {command: "manifests versions show", method: "GET", path: "/manifests/mf/versions/9007199254740993", flags: map[string]string{"manifest-id": "mf", "version": "9007199254740993"}}, + {command: "manifests versions push", method: "POST", path: "/manifests/mf/versions", flags: map[string]string{"manifest-id": "mf"}, body: `{"stack":{"modules":{"ledger":{"version":"v3"}},"metadata":{"amount":9007199254740993}}}`, wantBody: `{"stack":{"modules":{"ledger":{"version":"v3"}},"metadata":{"amount":9007199254740993}}}`}, + {command: "deployments list", method: "GET", path: "/deployments", query: "appId=app&pageSize=100", flags: map[string]string{"app-id": "app"}}, + {command: "deployments show", method: "GET", path: "/deployments/dep", query: "include=state", flags: map[string]string{"id": "dep", "include-state": "true"}}, + {command: "deployments logs", method: "GET", path: "/deployments/dep/logs", flags: map[string]string{"id": "dep"}}, + {command: "deployments download", method: "GET", path: "/deployments/dep", flags: map[string]string{"id": "dep"}, media: "application/yaml"}, + {command: "deployments create", method: "POST", path: "/deployments", flags: map[string]string{"app-id": "app", "manifest-id": "mf", "manifest-version": "9007199254740993", "wait": "false"}, wantBody: `{"appId":"app","manifestId":"mf","manifestVersion":9007199254740993}`}, + {command: "variables list", method: "GET", path: "/apps/app/variables", query: "pageSize=100", flags: map[string]string{"id": "app"}}, + {command: "variables create", method: "POST", path: "/apps/app/variables", flags: map[string]string{"id": "app", "key": "API_KEY", "value": "synthetic-secret", "description": "Example"}, wantBody: `{"variable":{"key":"API_KEY","value":"synthetic-secret","description":"Example"}}`}, + {command: "variables delete", method: "DELETE", path: "/apps/app/variables/var", flags: map[string]string{"id": "var", "app-id": "app", "confirm": "true"}}, + } + covered := map[string]bool{} + for _, tc := range cases { + covered[tc.command] = true + t.Run(tc.command, func(t *testing.T) { + calls := 0 + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != tc.method || r.URL.EscapedPath() != "/deploy/prefix"+tc.path || r.URL.RawQuery != tc.query { + t.Errorf("request = %s %s", r.Method, r.URL) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if tc.wantBody == "" { + if len(raw) != 0 { + t.Errorf("unexpected body") + } + } else if tc.media == "application/yaml" { + if string(raw) != tc.wantBody { + t.Errorf("YAML body = %q", raw) + } + } else { + appsEqualJSON(t, raw, []byte(tc.wantBody)) + } + if tc.wantBody != "" { + media := tc.media + if media == "" { + media = "application/json" + } + if r.Header.Get("Content-Type") != media { + t.Errorf("content type = %s", r.Header.Get("Content-Type")) + } + } + if strings.HasSuffix(tc.command, "download") { + if r.Header.Get("Accept") != tc.media { + t.Errorf("accept = %s", r.Header.Get("Accept")) + } + w.Header().Set("Content-Type", tc.media) + appsTestWrite(t, w, appsYAML) + } else { + w.Header().Set("Content-Type", "application/json") + appsTestWrite(t, w, appsEnvelope) + } + }) + req := appsRequest(tc.command, tc.flags, tc.body) + result, err := executeApps(t.Context(), client, req, req.CommandPath) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Errorf("calls = %d", calls) + } + if strings.HasSuffix(tc.command, "download") { + var out struct { + YAML string `json:"yaml"` + } + if json.Unmarshal(result, &out) != nil || out.YAML != appsYAML { + t.Fatalf("YAML lost: %s", result) + } + } else { + appsEqualJSON(t, result, []byte(appsEnvelope)) + } + }) + } + var walk func(pluginsdk.CommandSpec, []string) + walk = func(spec pluginsdk.CommandSpec, path []string) { + path = append(path, pluginsdk.CommandName(spec)) + if spec.Runnable && !covered[strings.Join(path[1:], " ")] { + t.Errorf("untested runnable command %v", path) + } + for _, child := range spec.Subcommands { + walk(child, slices.Clone(path)) + } + } + walk(appsManifest(), nil) +} + +func TestAppsManifestHostGuard(t *testing.T) { + m := pluginsdk.Manifest{Service: "cloud", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{Use: "cloud", Subcommands: []pluginsdk.CommandSpec{appsManifest()}}} + req := appsRequest("list", nil, "") + req.Flags = nil + if _, err := pluginsdk.NormalizeRequest(m, req); err == nil { + t.Fatal("experimental host guard missing") + } + req.Flags = map[string]string{"experimental": "true"} + normalized, err := pluginsdk.NormalizeRequest(m, req) + if err != nil { + t.Fatal(err) + } + if normalized.Flags["deploy-app-alias"] != "deploy" { + t.Fatal("alias default missing") + } + service, err := pluginsdk.CommandService(m, req.CommandPath) + if err != nil || service != "cloud-apps" { + t.Fatalf("service=%s error=%v", service, err) + } +} +func TestAppsValidationBeforeHTTP(t *testing.T) { + cases := []struct { + command, body string + flags map[string]string + }{ + {command: "list", flags: map[string]string{"experimental": "false"}}, + {command: "list", flags: map[string]string{"page-size": "0"}}, + {command: "show", flags: map[string]string{"id": ".."}}, + {command: "show", body: `{"name":"ignored"}`, flags: map[string]string{"id": "app"}}, + {command: "delete", flags: map[string]string{"id": "app"}}, + {command: "unbind-manifest", flags: map[string]string{"app-id": "app"}}, + {command: "manifests delete", flags: map[string]string{"id": "mf"}}, + {command: "variables delete", flags: map[string]string{"id": "var", "app-id": "app"}}, + {command: "create"}, {command: "create", body: `[]`}, {command: "create", body: `{"name":1}`}, {command: "create", body: `{"name":null}`}, + {command: "create", body: `{"name":"Books","unknown":true}`}, {command: "create", body: `{"name":"Books"}`, flags: map[string]string{"name": "Books"}}, + {command: "create", flags: map[string]string{"data": "-"}}, + {command: "bind-manifest", flags: map[string]string{"app-id": "app"}}, + {command: "manifests update", flags: map[string]string{"id": "mf"}}, + {command: "manifests create", flags: map[string]string{"name": "Books"}}, + {command: "manifests create", body: `{"yaml":12}`, flags: map[string]string{"name": "Books"}}, + {command: "manifests create", body: `{"yaml":"x","extra":true}`, flags: map[string]string{"name": "Books"}}, + {command: "manifests create", body: `{"yaml":"x"}`, flags: map[string]string{"name": "Books", "path": "/missing"}}, + {command: "manifests versions push", flags: map[string]string{"manifest-id": "mf", "path": "/missing"}}, + {command: "manifests versions show", flags: map[string]string{"manifest-id": "mf", "version": "0"}}, + {command: "deployments create", body: `{"appId":"app","manifestId":"mf","manifestVersion":1.5}`}, + {command: "deployments create", body: `{"appId":"app","manifestId":"mf","manifestVersion":9223372036854775808}`}, + {command: "deployments create", flags: map[string]string{"manifest-version": "-1"}}, + {command: "deployments create", flags: map[string]string{"wait-timeout": "0s"}}, + {command: "deployments create", flags: map[string]string{"wait-timeout": "25h"}}, + {command: "variables create", flags: map[string]string{"id": "app"}, body: `{"key":"KEY","value":"secret"}`}, + {command: "variables create", flags: map[string]string{"id": "app"}, body: `{"variable":null}`}, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%d/%s", i, tc.command), func(t *testing.T) { + calls := 0 + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + req := appsRequest(tc.command, tc.flags, tc.body) + if v, ok := tc.flags["experimental"]; ok { + req.Flags["experimental"] = v + } + if _, err := executeApps(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("validation accepted invalid input") + } + if calls != 0 { + t.Fatalf("sent %d requests", calls) + } + }) + } + // Experimental rejection does not even need a valid client or operation. + if _, err := executeApps(t.Context(), nil, pluginsdk.ExecuteRequest{}, []string{"bogus"}); err == nil || !strings.Contains(err.Error(), "experimental") { + t.Fatal(err) + } +} + +//nolint:gocognit // table-driven protocol tests keep route and payload assertions together. +func TestAppsYAMLFiles(t *testing.T) { + for _, cmd := range []string{"manifests create", "manifests versions push"} { + t.Run(cmd, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(path, []byte(appsYAML), 0600); err != nil { + t.Fatal(err) + } + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + if string(raw) != appsYAML || r.Header.Get("Content-Type") != "application/yaml" { + t.Errorf("file encoded incorrectly: %q", raw) + } + appsTestWrite(t, w, `{"data":{"manifestId":"mf","version":9007199254740993}}`) + }) + req := appsRequest(cmd, map[string]string{"name": "Books", "manifest-id": "mf", "path": path}, "") + if cmd == "manifests create" { + delete(req.Flags, "manifest-id") + } else { + delete(req.Flags, "name") + } + if _, err := executeApps(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + }) + } + for _, data := range [][]byte{{0, 1, 2}, {0xff}, nil, bytes.Repeat([]byte("a"), appsFileLimit+1)} { + path := filepath.Join(t.TempDir(), "bad.yaml") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if _, err := appsReadFile(t.Context(), path); err == nil { + t.Fatal("invalid text accepted") + } + } + if _, err := appsReadFile(t.Context(), t.TempDir()); err == nil { + t.Fatal("directory accepted") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := appsReadFile(ctx, "/missing"); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +//nolint:gocognit // verifies artifact permissions, cleanup and preservation on rejected media types. +func TestAppsDownloadAtomicPrivateFile(t *testing.T) { + out := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(out, []byte("old"), 0644); err != nil { // #nosec G306 -- synthetic world-readable fixture verifies replacement gets private permissions + t.Fatal(err) + } + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + appsTestWrite(t, w, appsYAML) + }) + req := appsRequest("deployments download", map[string]string{"id": "dep", "out": out}, "") + result, err := executeApps(t.Context(), client, req, req.CommandPath) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(out) // #nosec G304 -- reads only the artifact in t.TempDir() + if err != nil { + t.Fatal(err) + } + stat, err := os.Stat(out) + if err != nil { + t.Fatal(err) + } + if string(raw) != appsYAML || stat.Mode().Perm() != 0600 || !bytes.Contains(result, []byte(`"bytes":`)) || bytes.Contains(result, []byte("variables")) { + t.Fatalf("artifact: %s mode %v result %s", raw, stat.Mode(), result) + } + entries, err := os.ReadDir(filepath.Dir(out)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatal("temporary artifact leaked") + } + for _, media := range []string{"application/json", "text/event-stream"} { + t.Run(media, func(t *testing.T) { + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", media) + appsTestWrite(t, w, `{"data":{}}`) + }) + if _, err := executeApps(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("fabricated download from incompatible response") + } + raw, err := os.ReadFile(out) // #nosec G304 -- reads only the artifact in t.TempDir() + if err != nil { + t.Fatal(err) + } + if string(raw) != appsYAML { + t.Fatal("file changed after download failure") + } + }) + } +} + +//nolint:gocognit // table-driven protocol tests keep route and payload assertions together. +func TestAppsWaitActualStatusesAndSingleWrite(t *testing.T) { + for _, status := range []string{"applied", "planned_and_finished", "errored", "pending", ""} { + t.Run(status, func(t *testing.T) { + writes, reads := 0, 0 + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + writes++ + appsTestWrite(t, w, `{"data":{"id":"dep","status":"pending"}}`) + } else { + reads++ + appsTestWrite(t, w, fmt.Sprintf(`{"data":{"id":"dep","status":%q,"counter":9007199254740993}}`, status)) + } + }) + req := appsRequest("deployments create", map[string]string{"wait-timeout": "50ms"}, `{"appId":"app","manifestId":"mf","manifestVersion":1}`) + result, err := executeApps(t.Context(), client, req, req.CommandPath) + success := status == "applied" || status == "planned_and_finished" + if (err == nil) != success { + t.Fatalf("status=%s result=%s err=%v", status, result, err) + } + if writes != 1 || reads != 1 { + t.Fatalf("writes=%d reads=%d", writes, reads) + } + if !bytes.Contains(result, []byte("9007199254740993")) { + t.Fatal("last observed metadata lost") + } + if status == "pending" && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout = %v", err) + } + }) + } + t.Run("delete polls destroy deployment", func(t *testing.T) { + calls := []string{} + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if r.Method == "DELETE" { + appsTestWrite(t, w, `{"destroyDeploymentId":"destroy"}`) + } else { + appsTestWrite(t, w, `{"data":{"id":"destroy","status":"applied"}}`) + } + }) + req := appsRequest("delete", map[string]string{"id": "app", "wait": "true", "confirm": "true"}, "") + if _, err := executeApps(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + if len(calls) != 2 || !strings.HasSuffix(calls[1], "/deployments/destroy") { + t.Fatalf("calls=%v", calls) + } + }) +} + +//nolint:gocognit // table-driven protocol tests keep route and payload assertions together. +func TestAppsVariablesSensitiveAndJSONBody(t *testing.T) { + for _, code := range []int{201, 400} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + calls := 0 + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + appsEqualJSON(t, raw, []byte(`{"variable":{"key":"API_KEY","value":"synthetic-secret"}}`)) + w.WriteHeader(code) + appsTestWrite(t, w, `{"data":{"id":"v","value":"synthetic-secret","revision":9007199254740993},"errorMessage":"synthetic-secret"}`) + }) + req := appsRequest("variables create", map[string]string{"id": "app"}, `{"variable":{"key":"API_KEY","value":"synthetic-secret"}}`) + result, err := executeApps(t.Context(), client, req, req.CommandPath) + if code == 201 { + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(result, []byte("9007199254740993")) { + t.Fatal("number rounded") + } + } else if err == nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatal("sensitive diagnostic escaped") + } + if calls != 1 { + t.Fatal("retried a write") + } + if bytes.Contains(result, []byte(`synthetic-secret`)) { + t.Fatal("value leaked") + } + }) + } +} +func TestAppsLogsRejectSSEWithoutFabrication(t *testing.T) { + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + appsTestWrite(t, w, "data: synthetic log\n\n") + }) + req := appsRequest("deployments logs", map[string]string{"id": "dep"}, "") + if data, err := executeApps(t.Context(), client, req, req.CommandPath); err == nil || data != nil { + t.Fatal("fabricated JSON from SSE") + } +} +func TestAppsImportsArePublic(t *testing.T) { + for _, name := range []string{"apps.go", "apps_manifest.go"} { + file, err := parser.ParseFile(token.NewFileSet(), name, nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if strings.Contains(path, "github.com/") && !strings.HasPrefix(path, "github.com/formancehq/fctl/pkg/pluginsdk") { + t.Fatalf("private or third-party dependency %s", path) + } + } + } +} + +func appsTestWrite(t *testing.T, w io.Writer, value string) { + t.Helper() + if _, err := io.WriteString(w, value); err != nil { + t.Error(err) + } +} + +func TestAppsWritesAndPollingFailuresRetainEvidence(t *testing.T) { + t.Run("write failure is not retried", func(t *testing.T) { + calls := 0 + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusConflict) + appsTestWrite(t, w, `{"errorCode":"CONFLICT","errorMessage":"Already exists"}`) + }) + req := appsRequest("create", nil, `{"name":"Books"}`) + _, err := executeApps(t.Context(), client, req, req.CommandPath) + var serviceErr *httpclient.Error + if !errors.As(err, &serviceErr) || serviceErr.StatusCode != http.StatusConflict || calls != 1 { + t.Fatalf("err=%v calls=%d", err, calls) + } + }) + t.Run("poll failure retains accepted deployment", func(t *testing.T) { + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + appsTestWrite(t, w, `{"data":{"id":"dep","status":"pending"}}`) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + }) + req := appsRequest("deployments create", nil, `{"appId":"app","manifestId":"mf","manifestVersion":1}`) + result, err := executeApps(t.Context(), client, req, req.CommandPath) + if err == nil { + t.Fatal("poll failure hidden") + } + appsEqualJSON(t, result, []byte(`{"data":{"id":"dep","status":"pending"}}`)) + }) + t.Run("errored response without wait", func(t *testing.T) { + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { + appsTestWrite(t, w, `{"data":{"id":"dep","status":"errored"}}`) + }) + req := appsRequest("deployments create", map[string]string{"wait": "false"}, `{"appId":"app","manifestId":"mf","manifestVersion":1}`) + result, err := executeApps(t.Context(), client, req, req.CommandPath) + if err == nil || !bytes.Contains(result, []byte("errored")) { + t.Fatalf("result=%s err=%v", result, err) + } + }) +} + +func TestAppsFreshManifestAndCallerIsolation(t *testing.T) { + spec := appsManifest() + spec.Flags[0].RequireTrue = false + spec.Subcommands[0].Use = "changed" + fresh := appsManifest() + if !fresh.Flags[0].RequireTrue || fresh.Subcommands[0].Use != "list" { + t.Fatal("manifest shares mutable state") + } + req := appsRequest("list", nil, "") + originalFlags := maps.Clone(req.Flags) + originalContext := maps.Clone(req.Context) + client := appsTestClient(t, func(w http.ResponseWriter, r *http.Request) { appsTestWrite(t, w, appsEnvelope) }) + if _, err := executeApps(t.Context(), client, req, []string{"list"}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(req.Flags, originalFlags) || !reflect.DeepEqual(req.Context, originalContext) { + t.Fatal("caller state mutated") + } +} diff --git a/plugins/cloud/cloud.go b/plugins/cloud/cloud.go new file mode 100644 index 00000000..197bb6fb --- /dev/null +++ b/plugins/cloud/cloud.go @@ -0,0 +1,228 @@ +// Package cloud implements Cloud commands using only the public plugin SDK. +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +type plugin struct{ client *http.Client } + +// New creates a Cloud plugin with the host's authenticated Membership client. +func New(client *http.Client) pluginsdk.Plugin { return &plugin{client: client} } +func (*plugin) GetManifest(ctx context.Context) (pluginsdk.Manifest, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.Manifest{}, err + } + return manifest(), nil +} +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse, error) { + if err := ctx.Err(); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + req, err := pluginsdk.NormalizeRequest(manifest(), req) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + if p.client == nil { + return pluginsdk.ExecuteResponse{}, fmt.Errorf("cloud plugin requires an HTTP client") + } + client, err := httpclient.New(req.Endpoint, p.client) + if err != nil { + return pluginsdk.ExecuteResponse{}, err + } + for _, arg := range req.Args { + if err := validID(arg); err != nil { + return pluginsdk.ExecuteResponse{}, err + } + } + var data json.RawMessage + switch req.CommandPath[1] { + case "me": + data, err = executeMe(ctx, client, req) + case "organizations": + data, err = executeOrganizations(ctx, client, req) + case "regions": + data, err = executeRegions(ctx, client, req) + case "apps": + data, err = executeApps(ctx, client, req, req.CommandPath) + case "stack": + var org string + org, err = requireOrg(ctx, client, req) + if err == nil { + req.Context = maps.Clone(req.Context) + if req.Context == nil { + req.Context = make(map[string]string) + } + req.Context["organization"] = org + data, err = executeStack(ctx, client, req, req.CommandPath) + } + default: + err = fmt.Errorf("unsupported cloud command %q", req.CommandPath) + } + return pluginsdk.ExecuteResponse{Data: data}, err +} + +func validID(id string) error { + if strings.TrimSpace(id) == "" || id == "." || id == ".." { + return fmt.Errorf("identifier must be non-empty and cannot be a dot or double dot") + } + return nil +} + +// orgID selects a target without changing the request or its host context. +func orgID(req pluginsdk.ExecuteRequest) (string, error) { + id := req.Context["organization"] + if id != "" { + return id, validID(id) + } + var ids []string + if raw := req.Context["organizations"]; raw != "" { + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return "", fmt.Errorf("invalid organizations context: %w", err) + } + } + slices.Sort(ids) + ids = slices.Compact(ids) + if len(ids) == 1 { + return ids[0], validID(ids[0]) + } + return "", fmt.Errorf("choose a Cloud organization with --organization ORGANIZATION_ID\nList organizations: fctl cloud organizations list") +} +func requireOrg(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest) (string, error) { + if req.Context["organization"] != "" || req.Context["organizations"] != "" { + return orgID(req) + } + data, err := client.Do(ctx, http.MethodGet, "/organizations", nil, nil, nil) + if err != nil { + return "", err + } + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("list organizations: %w", err) + } + ids := make([]string, 0, len(result.Data)) + for _, org := range result.Data { + ids = append(ids, org.ID) + } + slices.Sort(ids) + ids = slices.Compact(ids) + if len(ids) == 1 { + return ids[0], validID(ids[0]) + } + return "", fmt.Errorf("choose a Cloud organization with --organization ORGANIZATION_ID\nList organizations: fctl cloud organizations list") +} +func queryFlags(req pluginsdk.ExecuteRequest, names map[string]string) (url.Values, error) { + q := url.Values{} + for flag, param := range names { + if v := req.Flags[flag]; v != "" { + if flag == "page-size" && v == "0" { + return nil, fmt.Errorf("--page-size must be positive") + } + q.Set(param, v) + } + } + return q, nil +} + +// bodyFields accepts host-decoded inline/@file JSON, with optional convenience flags. +// Explicit body fields and explicit flags cannot overwrite each other silently. +func bodyFields(req pluginsdk.ExecuteRequest, fields map[string]string, required ...string) (json.RawMessage, error) { + values, err := cloudBodyObject(req) + if err != nil { + return nil, err + } + for flag, key := range fields { + if err := applyBodyFlag(req, values, flag, key); err != nil { + return nil, err + } + } + for _, key := range required { + v, ok := values[key] + if !ok || string(v) == "null" || string(v) == `""` { + return nil, fmt.Errorf("%s is required in --data or its convenience flag", key) + } + } + for key, raw := range values { + if err := validateBodyField(key, raw, slices.Contains(required, key)); err != nil { + return nil, err + } + } + return json.Marshal(values) +} +func cloudBodyObject(req pluginsdk.ExecuteRequest) (map[string]json.RawMessage, error) { + if req.Flags["data"] != "" && req.Body == nil { + return nil, fmt.Errorf("--data must be decoded into Body by the host") + } + values := make(map[string]json.RawMessage) + if req.Body != nil { + if err := json.Unmarshal(req.Body, &values); err != nil || values == nil { + return nil, fmt.Errorf("--data must be a JSON object") + } + } + return values, nil +} +func applyBodyFlag(req pluginsdk.ExecuteRequest, values map[string]json.RawMessage, flag, key string) error { + value, ok := req.Flags[flag] + if !ok || (!req.ChangedFlags[flag] && value == "") { + return nil + } + if _, exists := values[key]; exists { + return fmt.Errorf("provide %s using either --data or --%s", key, flag) + } + raw, err := encodeBodyFlag(flag, value) + if err != nil { + return err + } + values[key] = raw + return nil +} +func encodeBodyFlag(flag, value string) (json.RawMessage, error) { + if !strings.HasSuffix(flag, "policy-id") { + return json.Marshal(value) + } + n, err := strconv.ParseInt(value, 10, 64) + if err != nil || n <= 0 { + return nil, fmt.Errorf("--%s must be a positive integer", flag) + } + return json.RawMessage(strconv.FormatInt(n, 10)), nil +} +func validateBodyField(key string, raw json.RawMessage, required bool) error { + switch key { + case "name", "domain", "description", "ownerID", "type", "clientID", "clientSecret": + var value string + if string(raw) == "null" || json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("%s must be a string", key) + } + if required && strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not be empty", key) + } + case "defaultPolicyID", "policyId": + if key == "defaultPolicyID" && string(raw) == "null" { + return nil + } + var value int64 + if json.Unmarshal(raw, &value) != nil || value <= 0 { + return fmt.Errorf("%s must be a positive integer", key) + } + } + return nil +} + +func apiPath(segments ...string) string { + return httpclient.Path(segments...) +} diff --git a/plugins/cloud/cloud_test.go b/plugins/cloud/cloud_test.go new file mode 100644 index 00000000..94cb8974 --- /dev/null +++ b/plugins/cloud/cloud_test.go @@ -0,0 +1,623 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "os/exec" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +const cloudEnvelope = ` {"data":{"id":"result","amount":123456789012345678901234567890},"cursor":{"next":"opaque"}} ` + +type cloudRouteCase struct { + command, method, path, query string + args []string + flags map[string]string + body, wantBody string +} + +func TestCloudRoutes(t *testing.T) { + cases := []cloudRouteCase{ + {command: "me info", method: "GET", path: "/api/me"}, + {command: "me invitations list", method: "GET", path: "/api/me/invitations", flags: map[string]string{"status": "pending"}, query: "organization=org&status=pending"}, + {command: "me invitations accept", method: "POST", path: "/api/me/invitations/inv/accept", args: []string{"inv"}}, + {command: "me invitations decline", method: "POST", path: "/api/me/invitations/inv/reject", args: []string{"inv"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations list", method: "GET", path: "/api/organizations", query: "expand=false"}, + {command: "organizations describe", method: "GET", path: "/api/organizations/other", args: []string{"other"}, query: "expand=false"}, + {command: "organizations create", method: "POST", path: "/api/organizations", args: []string{"Books"}, flags: map[string]string{"domain": "books.test", "default-policy-id": "42", "owner-id": "owner"}, wantBody: `{"name":"Books","domain":"books.test","defaultPolicyID":42,"ownerID":"owner"}`}, + {command: "organizations create", method: "POST", path: "/api/organizations", body: `{"name":"Books","defaultPolicyID":1234567890123456789}`, wantBody: `{"name":"Books","defaultPolicyID":1234567890123456789}`}, + {command: "organizations delete", method: "DELETE", path: "/api/organizations/org", flags: map[string]string{"confirm": "true"}}, + {command: "organizations history", method: "GET", path: "/api/organizations/org/logs", flags: map[string]string{"cursor": "a+b", "page-size": "20"}, query: "cursor=a%2Bb&pageSize=20"}, + {command: "organizations users list", method: "GET", path: "/api/organizations/org/users"}, + {command: "organizations users show", method: "GET", path: "/api/organizations/org/users/user", args: []string{"user"}}, + {command: "organizations users link", method: "PUT", path: "/api/organizations/org/users/user", args: []string{"user"}, flags: map[string]string{"policy-id": "9", "confirm": "true"}, wantBody: `{"policyId":9}`}, + {command: "organizations users unlink", method: "DELETE", path: "/api/organizations/org/users/user", args: []string{"user"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations invitations list", method: "GET", path: "/api/organizations/org/invitations", flags: map[string]string{"status": "PENDING"}, query: "status=PENDING"}, + {command: "organizations invitations send", method: "POST", path: "/api/organizations/org/invitations", args: []string{"a+b@example.test"}, query: "email=a%2Bb%40example.test"}, + {command: "organizations invitations delete", method: "DELETE", path: "/api/organizations/org/invitations/inv", args: []string{"inv"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations policies list", method: "GET", path: "/api/organizations/org/policies"}, + {command: "organizations policies show", method: "GET", path: "/api/organizations/org/policies/2", args: []string{"2"}}, + {command: "organizations policies create", method: "POST", path: "/api/organizations/org/policies", flags: map[string]string{"name": "readers", "description": "Read only"}, wantBody: `{"name":"readers","description":"Read only"}`}, + {command: "organizations policies delete", method: "DELETE", path: "/api/organizations/org/policies/2", args: []string{"2"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations policies add-scope", method: "PUT", path: "/api/organizations/org/policies/2/scopes/3", args: []string{"2", "3"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations policies remove-scope", method: "DELETE", path: "/api/organizations/org/policies/2/scopes/3", args: []string{"2", "3"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations oauth-clients list", method: "GET", path: "/api/organizations/org/clients"}, + {command: "organizations oauth-clients show", method: "GET", path: "/api/organizations/org/clients/client", args: []string{"client"}}, + {command: "organizations oauth-clients create", method: "POST", path: "/api/organizations/org/clients", flags: map[string]string{"name": "reader"}, wantBody: `{"name":"reader"}`}, + {command: "organizations oauth-clients delete", method: "DELETE", path: "/api/organizations/org/clients/client", args: []string{"client"}, flags: map[string]string{"confirm": "true"}}, + {command: "organizations authentication-provider show", method: "GET", path: "/api/organizations/org/authentication-provider"}, + {command: "organizations authentication-provider delete", method: "DELETE", path: "/api/organizations/org/authentication-provider", flags: map[string]string{"confirm": "true"}}, + {command: "organizations authentication-provider configure", method: "PUT", path: "/api/organizations/org/authentication-provider", args: []string{"github", "GitHub", "client", "secret"}, flags: map[string]string{"confirm": "true"}, wantBody: `{"type":"github","name":"GitHub","clientID":"client","clientSecret":"secret","config":{}}`}, + {command: "organizations authentication-provider configure", method: "PUT", path: "/api/organizations/org/authentication-provider", flags: map[string]string{"confirm": "true", "type": "oidc", "name": "SSO", "provider-client-id": "client", "provider-client-secret": "secret", "oidc-issuer": "https://idp.test"}, wantBody: `{"type":"oidc","name":"SSO","clientID":"client","clientSecret":"secret","config":{"issuer":"https://idp.test"}}`}, + {command: "organizations applications list", method: "GET", path: "/api/organizations/org/applications", query: "expand=false"}, + {command: "organizations applications show", method: "GET", path: "/api/organizations/org/applications/app", args: []string{"app"}}, + {command: "organizations applications enable", method: "PUT", path: "/api/organizations/org/applications/app", args: []string{"app"}}, + {command: "organizations applications disable", method: "DELETE", path: "/api/organizations/org/applications/app", args: []string{"app"}, flags: map[string]string{"confirm": "true"}}, + {command: "regions list", method: "GET", path: "/api/organizations/org/regions"}, + {command: "regions show", method: "GET", path: "/api/organizations/org/regions/eu", args: []string{"eu"}}, + {command: "regions versions", method: "GET", path: "/api/organizations/org/regions/eu/versions", args: []string{"eu"}}, + {command: "regions create", method: "POST", path: "/api/organizations/org/regions", flags: map[string]string{"name": "private"}, wantBody: `{"name":"private"}`}, + {command: "regions create", method: "POST", path: "/api/organizations/org/regions", args: []string{"private"}, wantBody: `{"name":"private"}`}, + {command: "regions delete", method: "DELETE", path: "/api/organizations/org/regions/eu", args: []string{"eu"}, flags: map[string]string{"confirm": "true"}}, + {command: "stack list", method: "GET", path: "/api/organizations/org/stacks"}, + } + for _, tc := range cases { + t.Run(tc.command+" "+strings.Join(tc.args, " ")+tc.body, func(t *testing.T) { + checkCloudRoute(t, tc) + }) + } +} +func assertJSON(t *testing.T, got, want []byte) { + t.Helper() + var a, b any + d := json.NewDecoder(strings.NewReader(string(got))) + d.UseNumber() + if err := d.Decode(&a); err != nil { + t.Fatalf("invalid JSON %s: %v", got, err) + } + d = json.NewDecoder(strings.NewReader(string(want))) + d.UseNumber() + if err := d.Decode(&b); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(a, b) { + t.Errorf("JSON=%s, want %s", got, want) + } +} + +func TestCloudUpdatePreservesOmittedFields(t *testing.T) { + for _, tc := range []cloudUpdateCase{ + {"organizations update", "org", `{"data":{"name":"old","domain":"books.test","defaultPolicyID":1234567890123456789,"id":"org"}}`, `{"name":"new","domain":"books.test","defaultPolicyID":1234567890123456789}`}, + {"organizations policies update", "2", `{"data":{"name":"old","description":"retained","id":2,"protected":false}}`, `{"name":"new","description":"retained"}`}, + {"organizations oauth-clients update", "client", `{"data":{"name":"old","description":"retained","secret":{"clear":"hidden"}}}`, `{"name":"new","description":"retained"}`}, + } { + t.Run(tc.command, func(t *testing.T) { + checkCloudUpdate(t, tc) + }) + } +} +func TestCloudOrgSelectionAndIsolation(t *testing.T) { + for _, tc := range []cloudSelectionCase{ + {"selected", nil, map[string]string{"organization": "chosen"}, "chosen"}, + {"default", nil, map[string]string{"organization": "default"}, "default"}, + {"unique", nil, map[string]string{"organizations": `["only"]`}, "only"}, + } { + t.Run(tc.name, func(t *testing.T) { + checkCloudSelection(t, tc) + }) + } + for _, ids := range []string{`[]`, `["b","a"]`, `not json`} { + req := pluginsdk.ExecuteRequest{Context: map[string]string{"organizations": ids}} + _, err := orgID(req) + if err == nil { + t.Fatalf("accepted ambiguous/invalid organizations %s", ids) + } + if ids == `["b","a"]` && (!strings.Contains(err.Error(), "cloud organizations list") || strings.Contains(err.Error(), "a, b")) { + t.Errorf("organization selection error must give concise discovery instructions: %v", err) + } + } +} +func TestCloudOrganizationDiscovery(t *testing.T) { + for _, tc := range []cloudDiscoveryCase{{`{"data":[{"id":"only"}]}`, true}, {`{"data":[{"id":"a"},{"id":"b"}]}`, false}, {`{"data":[]}`, false}} { + t.Run(tc.response, func(t *testing.T) { + checkCloudDiscovery(t, tc) + }) + } +} +func TestCloudRejectsInvalidRequestsBeforeHTTP(t *testing.T) { + cases := []pluginsdk.ExecuteRequest{ + {CommandPath: []string{"cloud", "regions", "list"}, Flags: map[string]string{"organization": "org"}}, + {CommandPath: []string{"cloud", "stack", "list"}, Flags: map[string]string{"stack": "stack"}}, + {CommandPath: []string{"cloud", "organizations", "oauth-clients", "create"}, Flags: map[string]string{"data": "@unread.json"}}, + {CommandPath: []string{"cloud", "regions", "create"}, Body: json.RawMessage(`{"name":12}`)}, + {CommandPath: []string{"cloud", "regions", "create"}, Body: json.RawMessage(`{"name":" "}`)}, + {CommandPath: []string{"cloud", "organizations", "users", "link"}, Args: []string{"user"}, Flags: map[string]string{"confirm": "true"}, Body: json.RawMessage(`{"policyId":"9"}`)}, + {CommandPath: []string{"cloud", "organizations", "authentication-provider", "configure"}, Flags: map[string]string{"confirm": "true"}, Body: json.RawMessage(`{"type":"other","name":"SSO","clientID":"client","clientSecret":"secret"}`)}, + {CommandPath: []string{"cloud", "organizations", "authentication-provider", "configure"}, Flags: map[string]string{"confirm": "true"}, Body: json.RawMessage(`{"type":"oidc","name":"SSO","clientID":"client","clientSecret":"secret","config":{}}`)}, + + {CommandPath: []string{"cloud", "regions", "delete"}, Args: []string{"eu"}}, + {CommandPath: []string{"cloud", "organizations", "delete"}}, + {CommandPath: []string{"cloud", "me", "invitations", "decline"}, Args: []string{"inv"}}, + {CommandPath: []string{"cloud", "regions", "create"}, Flags: map[string]string{"data": "@unread.json"}}, + {CommandPath: []string{"cloud", "regions", "create"}, Body: json.RawMessage(`[]`)}, + {CommandPath: []string{"cloud", "regions", "create"}, Body: json.RawMessage(`null`)}, + {CommandPath: []string{"cloud", "regions", "create"}, Flags: map[string]string{"name": "flag"}, Body: json.RawMessage(`{"name":"body"}`)}, + {CommandPath: []string{"cloud", "organizations", "history"}, Flags: map[string]string{"page-size": "0"}}, + {CommandPath: []string{"cloud", "organizations", "policies", "show"}, Args: []string{"not-int"}}, + {CommandPath: []string{"cloud", "organizations", "users", "link"}, Args: []string{"user"}, Flags: map[string]string{"confirm": "true", "policy-id": "0"}}, + {CommandPath: []string{"cloud", "organizations", "invitations", "send"}}, + {CommandPath: []string{"cloud", "regions", "show"}, Args: []string{".."}}, + {CommandPath: []string{"cloud", "regions", "list"}, Flags: map[string]string{"unknown": "bad"}}, + } + for _, req := range cases { + t.Run(strings.Join(req.CommandPath, " ")+string(req.Body), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("invalid request reached server") })) + defer server.Close() + req.Context = map[string]string{"organization": "org"} + req.Endpoint = server.URL + "/api" + if _, err := New(server.Client()).Execute(t.Context(), req); err == nil { + t.Fatal("invalid request accepted") + } + }) + } +} +func TestCloudManifestProtocol(t *testing.T) { + p := New(nil) + m, err := p.GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if m.Name != "cloud" || m.Service != "cloud" || m.ProtocolVersion != pluginsdk.ProtocolVersion || m.Version == "" { + t.Fatalf("identity: %#v", m) + } + data, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var decoded pluginsdk.Manifest + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(m, decoded) { + t.Fatal("manifest failed JSON round trip") + } + checkCloudManifestCommand(t, m, m.Root, nil) + m.Root.Short = "mutated" + fresh, err := p.GetManifest(t.Context()) + if err != nil { + t.Fatal(err) + } + if fresh.Root.Short == "mutated" { + t.Fatal("manifest leaked mutable state") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := p.GetManifest(ctx); !errors.Is(err, context.Canceled) { + t.Errorf("manifest cancellation: %v", err) + } +} +func TestCloudEscapesIdentifiers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/api/organizations/org/regions/a%2Fb%3F%23" { + t.Errorf("escaped path=%s", r.URL.EscapedPath()) + } + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + _, err := New(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "regions", "show"}, Args: []string{"a/b?#"}, Context: map[string]string{"organization": "org"}, Endpoint: server.URL + "/api"}) + if err != nil { + t.Fatal(err) + } +} +func TestCloudImportClosure(t *testing.T) { + command := exec.CommandContext(t.Context(), "go", "list", "-deps", "-json", "github.com/formancehq/fctl/v4/plugins/cloud") + data, err := command.Output() + if err != nil { + t.Fatalf("inspect import closure: %v", err) + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + for { + var pkg struct { + ImportPath string + Standard bool + } + err := decoder.Decode(&pkg) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + if !pkg.Standard && pkg.ImportPath != "github.com/formancehq/fctl/v4/plugins/cloud" && !strings.HasPrefix(pkg.ImportPath, "github.com/formancehq/fctl/pkg/pluginsdk") { + t.Errorf("plugin has non-public or non-stdlib dependency: %s", pkg.ImportPath) + } + } +} + +func TestCloudHTTPErrorAndEmptyResponse(t *testing.T) { + for _, status := range []int{204, 403} { + t.Run(http.StatusText(status), func(t *testing.T) { + checkCloudHTTPStatus(t, status) + }) + } +} + +func TestCloudUpdatesExplicitEmptyFlags(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + cloudTestWrite(t, w, `{"data":{"name":"Books","domain":"old.test","defaultPolicyID":4}}`) + return + } + body := cloudTestRead(t, r.Body) + assertJSON(t, body, []byte(`{"name":"Books","domain":"","defaultPolicyID":4}`)) + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + req := pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "organizations", "update"}, Flags: map[string]string{"confirm": "true", "domain": ""}, ChangedFlags: map[string]bool{"domain": true}, Context: map[string]string{"organization": "org"}, Endpoint: server.URL + "/api"} + if _, err := New(server.Client()).Execute(t.Context(), req); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("calls=%d", calls) + } +} + +type organizationPolicyUpdateCase struct { + name, current, patch, flag, wantBody string + wantGets int32 + wantError bool +} + +func TestCloudOrganizationUpdateDefaultPolicy(t *testing.T) { + for _, tc := range []organizationPolicyUpdateCase{ + {name: "unset omitted", current: `{}`, patch: `{"name":"new","domain":"new.test"}`, wantGets: 1, wantError: true}, + {name: "null omitted", current: `{"defaultPolicyID":null}`, patch: `{"name":"new","domain":"new.test"}`, wantGets: 1, wantError: true}, + {name: "zero omitted", current: `{"defaultPolicyID":0}`, patch: `{"name":"new","domain":"new.test"}`, wantGets: 1, wantError: true}, + {name: "explicit null", current: `{"defaultPolicyID":4}`, patch: `{"name":"new","domain":"new.test","defaultPolicyID":null}`, wantError: true}, + {name: "explicit zero", current: `{"defaultPolicyID":4}`, patch: `{"name":"new","domain":"new.test","defaultPolicyID":0}`, wantError: true}, + {name: "zero flag", current: `{"defaultPolicyID":4}`, flag: "0", patch: `{"name":"new"}`, wantError: true}, + {name: "positive preserved", current: `{"defaultPolicyID":1234567890123456789}`, patch: `{"name":"new"}`, wantGets: 1, wantBody: `{"name":"new","domain":"old.test","defaultPolicyID":1234567890123456789}`}, + {name: "positive data from null", current: `{"defaultPolicyID":null}`, patch: `{"defaultPolicyID":9}`, wantGets: 1, wantBody: `{"name":"old","domain":"old.test","defaultPolicyID":9}`}, + {name: "positive flag changes", current: `{"defaultPolicyID":4}`, flag: "9", patch: `{"name":"new"}`, wantGets: 1, wantBody: `{"name":"new","domain":"old.test","defaultPolicyID":9}`}, + } { + t.Run(tc.name, func(t *testing.T) { checkOrganizationPolicyUpdate(t, tc) }) + } +} + +func checkOrganizationPolicyUpdate(t *testing.T, tc organizationPolicyUpdateCase) { + t.Helper() + var gets, puts atomic.Int32 + current := map[string]json.RawMessage{"name": json.RawMessage(`"old"`), "domain": json.RawMessage(`"old.test"`)} + var policy map[string]json.RawMessage + if err := json.Unmarshal([]byte(tc.current), &policy); err != nil { + t.Fatal(err) + } + maps.Copy(current, policy) + original := maps.Clone(current) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/organizations/org" { + t.Errorf("unexpected path %s", r.URL.Path) + } + switch r.Method { + case http.MethodGet: + gets.Add(1) + if err := json.NewEncoder(w).Encode(map[string]any{"data": current}); err != nil { + t.Error(err) + } + case http.MethodPut: + puts.Add(1) + assertJSON(t, cloudTestRead(t, r.Body), []byte(tc.wantBody)) + cloudTestWrite(t, w, cloudEnvelope) + default: + t.Errorf("unexpected method %s", r.Method) + } + })) + defer server.Close() + flags := map[string]string{"confirm": "true"} + if tc.flag != "" { + flags["default-policy-id"] = tc.flag + } + req := pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "organizations", "update"}, Flags: flags, Body: json.RawMessage(tc.patch), Context: map[string]string{"organization": "org"}, Endpoint: server.URL + "/api"} + _, err := New(server.Client()).Execute(t.Context(), req) + checkOrganizationPolicyUpdateResult(t, tc, err, gets.Load(), puts.Load()) + if !reflect.DeepEqual(current, original) { + t.Fatal("current organization fields changed") + } +} + +func checkOrganizationPolicyUpdateResult(t *testing.T, tc organizationPolicyUpdateCase, err error, gets, puts int32) { + t.Helper() + if (err != nil) != tc.wantError { + t.Fatalf("error=%v, wantError=%v", err, tc.wantError) + } + if tc.wantError && (!strings.Contains(err.Error(), "--default-policy-id") || !strings.Contains(err.Error(), "--data")) { + t.Errorf("error lacks explicit policy guidance: %v", err) + } + wantPuts := int32(1) + if tc.wantError { + wantPuts = 0 + } + if gets != tc.wantGets || puts != wantPuts { + t.Errorf("GET=%d PUT=%d, want GET=%d PUT=%d", gets, puts, tc.wantGets, wantPuts) + } +} +func TestCloudExecuteBoundaryFailures(t *testing.T) { + req := pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "me", "info"}, Endpoint: "https://membership.test"} + if _, err := New(nil).Execute(t.Context(), req); err == nil { + t.Fatal("nil client accepted") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := New(nil).Execute(ctx, req); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation=%v", err) + } + req.Endpoint = "https://user:password@membership.test" + if _, err := New(&http.Client{}).Execute(t.Context(), req); err == nil { + t.Fatal("endpoint credentials accepted") + } +} +func TestCloudAllConfirmLeavesRejectBeforeClient(t *testing.T) { + m := manifest() + var walk func(pluginsdk.CommandSpec, []string) + walk = func(spec pluginsdk.CommandSpec, path []string) { + path = append(path, pluginsdk.CommandName(spec)) + if spec.Confirm { + req := pluginsdk.ExecuteRequest{CommandPath: path, Flags: cloudContractFlags(t, m, path), Body: json.RawMessage(`{}`)} + for range spec.Args.Min { + req.Args = append(req.Args, "id") + } + _, err := New(nil).Execute(t.Context(), req) + if err == nil || !strings.Contains(err.Error(), "requires --confirm") { + t.Errorf("%v bypassed confirmation: %v", path, err) + } + } + for _, sub := range spec.Subcommands { + walk(sub, append([]string(nil), path...)) + } + } + walk(m.Root, nil) +} + +// Supply required leaf fields and inherited opt-in guards when exercising a manifest. +func cloudContractFlags(t *testing.T, m pluginsdk.Manifest, path []string) map[string]string { + t.Helper() + flags := make(map[string]string) + for i := range path { + spec, err := pluginsdk.FindCommand(m, path[:i+1]) + if err != nil { + t.Fatal(err) + } + for _, flag := range spec.Flags { + if i != len(path)-1 && !flag.Persistent { + continue + } + if flag.RequireTrue { + flags[flag.Name] = "true" + } else if flag.Required && !flag.Body { + flags[flag.Name] = "id" + } + } + } + return flags +} +func TestCloudAppsIntegratedDispatch(t *testing.T) { + m := manifest() + path := []string{"cloud", "apps", "list"} + service, err := pluginsdk.CommandService(m, path) + if err != nil || service != "cloud-apps" { + t.Fatalf("apps service=%s err=%v", service, err) + } + // Missing opt-in must fail before requiring an authenticated client. + if _, err := New(nil).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: path}); err == nil || !strings.Contains(err.Error(), "experimental") { + t.Fatalf("experimental guard=%v", err) + } + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/deploy/apps" || r.URL.RawQuery != "pageSize=100" { + t.Errorf("Deploy request=%s", r.URL) + } + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + response, err := New(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: path, Flags: map[string]string{"experimental": "true"}, Endpoint: server.URL + "/deploy"}) + if err != nil || calls != 1 || string(response.Data) != cloudEnvelope { + t.Fatalf("apps data=%s err=%v calls=%d", response.Data, err, calls) + } +} + +func checkCloudRoute(t *testing.T, tc cloudRouteCase) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkCloudRouteRequest(t, tc, &calls, w, r) + })) + defer server.Close() + req := pluginsdk.ExecuteRequest{CommandPath: append([]string{"cloud"}, strings.Fields(tc.command)...), Args: tc.args, Flags: tc.flags, Context: map[string]string{"organization": "org"}, Endpoint: server.URL + "/issuer/api"} + if tc.body != "" { + req.Body = json.RawMessage(tc.body) + } + response, err := New(server.Client()).Execute(t.Context(), req) + if err != nil || string(response.Data) != cloudEnvelope || calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response.Data, err, calls) + } + +} + +type cloudUpdateCase struct{ command, id, current, want string } + +func checkCloudUpdate(t *testing.T, tc cloudUpdateCase) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + if r.Method != "GET" { + t.Error("update must read resource first") + } + cloudTestWrite(t, w, tc.current) + return + } + if r.Method != "PUT" { + t.Error("update must PUT") + } + body := cloudTestRead(t, r.Body) + assertJSON(t, body, []byte(tc.want)) + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + req := pluginsdk.ExecuteRequest{CommandPath: append([]string{"cloud"}, strings.Fields(tc.command)...), Args: []string{tc.id}, Flags: map[string]string{"confirm": "true", "name": "new"}, Context: map[string]string{"organization": "org"}, Endpoint: server.URL + "/api"} + _, err := New(server.Client()).Execute(t.Context(), req) + if err != nil || calls != 2 { + t.Fatalf("update calls=%d error=%v", calls, err) + } + +} + +type cloudSelectionCase struct { + name string + flags, ctx map[string]string + want string +} + +func checkCloudSelection(t *testing.T, tc cloudSelectionCase) { + flags, host := maps.Clone(tc.flags), maps.Clone(tc.ctx) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/organizations/"+tc.want+"/regions" { + t.Errorf("wrong target: %s", r.URL) + } + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + req := pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "regions", "list"}, Flags: tc.flags, Context: tc.ctx, Endpoint: server.URL + "/api"} + if _, err := New(server.Client()).Execute(t.Context(), req); err != nil { + t.Fatal(err) + } + if !maps.Equal(tc.flags, flags) || !maps.Equal(tc.ctx, host) { + t.Fatal("plugin mutated caller context or flags") + } + +} + +type cloudDiscoveryCase struct { + response string + success bool +} + +func checkCloudDiscovery(t *testing.T, tc cloudDiscoveryCase) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + if r.URL.Path != "/api/organizations" { + t.Errorf("discovery route: %s", r.URL) + } + cloudTestWrite(t, w, tc.response) + return + } + if r.URL.Path != "/api/organizations/only/regions" { + t.Error("wrong discovered organization") + } + cloudTestWrite(t, w, cloudEnvelope) + })) + defer server.Close() + _, err := New(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "regions", "list"}, Endpoint: server.URL + "/api"}) + if tc.success { + if err != nil || calls != 2 { + t.Fatalf("discovery err=%v calls=%d", err, calls) + } + } else if err == nil || calls != 1 { + t.Fatalf("ambiguous discovery err=%v calls=%d", err, calls) + } + +} +func checkCloudHTTPStatus(t *testing.T, status int) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + if status == 403 { + cloudTestWrite(t, w, `{"errorCode":"FORBIDDEN","errorMessage":"denied"}`) + } + })) + defer server.Close() + response, err := New(server.Client()).Execute(t.Context(), pluginsdk.ExecuteRequest{CommandPath: []string{"cloud", "me", "info"}, Endpoint: server.URL + "/api"}) + if status == 204 { + if err != nil || string(response.Data) != "null" { + t.Fatalf("empty=%s err=%v", response.Data, err) + } + } else { + if err == nil || len(response.Data) != 0 || !strings.Contains(err.Error(), "FORBIDDEN") { + t.Fatalf("failure=%s err=%v", response.Data, err) + } + } + +} + +func checkCloudRouteRequest(t *testing.T, tc cloudRouteCase, calls *int, w http.ResponseWriter, r *http.Request) { + (*calls)++ + if r.Method != tc.method || r.URL.Path != "/issuer"+tc.path || r.URL.RawQuery != tc.query { + t.Errorf("request = %s %s, want %s /issuer%s?%s", r.Method, r.URL, tc.method, tc.path, tc.query) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if tc.wantBody == "" { + if len(data) != 0 { + t.Errorf("unexpected body: %s", data) + } + } else { + assertJSON(t, data, []byte(tc.wantBody)) + if r.Header.Get("Content-Type") != "application/json" { + t.Error("missing JSON content type") + } + } + if r.Header.Get("Accept") != "application/json" { + t.Error("missing JSON accept") + } + cloudTestWrite(t, w, cloudEnvelope) + +} + +func checkCloudManifestCommand(t *testing.T, m pluginsdk.Manifest, c pluginsdk.CommandSpec, path []string) { + t.Helper() + path = append(path, pluginsdk.CommandName(c)) + if c.Runnable { + req := pluginsdk.ExecuteRequest{CommandPath: path, Flags: cloudContractFlags(t, m, path), Body: json.RawMessage(`{}`)} + for range c.Args.Min { + req.Args = append(req.Args, "id") + } + if c.Confirm { + req.Flags["confirm"] = "true" + } + if _, err := pluginsdk.NormalizeRequest(m, req); err != nil { + t.Errorf("invalid manifest leaf %v: %v", path, err) + } + } + for _, sub := range c.Subcommands { + checkCloudManifestCommand(t, m, sub, append([]string(nil), path...)) + } +} +func cloudTestWrite(t *testing.T, w io.Writer, value string) { + t.Helper() + if _, err := io.WriteString(w, value); err != nil { + t.Error(err) + } +} +func cloudTestRead(t *testing.T, r io.Reader) []byte { + t.Helper() + data, err := io.ReadAll(r) + if err != nil { + t.Error(err) + } + return data +} diff --git a/plugins/cloud/interaction.go b/plugins/cloud/interaction.go new file mode 100644 index 00000000..f24aa072 --- /dev/null +++ b/plugins/cloud/interaction.go @@ -0,0 +1,308 @@ +package cloud + +import ( + "maps" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +// Only targets are inherited. Inputs belong to runnable leaves so the host can +// fill omissions without changing the plugin's noninteractive request contract. +func addCloudInteraction(command *pluginsdk.CommandSpec, parent []string, inputs map[string][]pluginsdk.InputSpec) { + path := append(append([]string{}, parent...), pluginsdk.CommandName(*command)) + key := strings.Join(path, " ") + switch key { + case "cloud", "cloud organizations list", "cloud organizations create": + command.Target = "identity" + case "cloud organizations", "cloud regions", "cloud stack", "cloud apps": + command.Target = "organization" + } + if command.Runnable { + command.Inputs = inputs[key] + } + for i := range command.Subcommands { + addCloudInteraction(&command.Subcommands[i], path, inputs) + } +} + +func cloudInteractionInputs() map[string][]pluginsdk.InputSpec { + inputs := organizationInteractionInputs() + maps.Copy(inputs, regionInteractionInputs()) + maps.Copy(inputs, stackInteractionInputs()) + maps.Copy(inputs, appInteractionInputs()) + for _, action := range []string{"accept", "decline"} { + inputs["cloud me invitations "+action] = []pluginsdk.InputSpec{ + argumentChoice("Invitation", 0, cloudChoices("cloud me invitations list", "id", "No invitations are available; inspect fctl cloud me invitations list.", "organizationName", "email", "id")), + } + } + return inputs +} + +func flagInput(title, flag string, required bool) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", Flag: flag, Required: required} +} + +func payloadFlagInput(title, flag string) pluginsdk.InputSpec { + input := flagInput(title, flag, true) + input.AlternativeFlag = "data" + return input +} + +func nameInput(title string) pluginsdk.InputSpec { + input := payloadFlagInput(title, "name") + input.AlternativeArgument = new(0) + return input +} + +func bodyInput(title, pointer string, required bool) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", BodyPointer: pointer, Required: required} +} + +func argumentInput(title string, index int) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "input", Argument: new(index), Required: true} +} + +func cloudChoices(path, value, empty string, labels ...string) *pluginsdk.ChoiceSource { + source := &pluginsdk.ChoiceSource{CommandPath: strings.Fields(path), ValueField: value, LabelFields: labels, EmptyMessage: empty} + if strings.HasPrefix(path, "cloud apps ") { + // Discovery must retain the user's explicit experimental opt-in. + source.Flags = map[string]string{"experimental": "$experimental"} + } + return source +} + +func flagChoice(title, flag string, source *pluginsdk.ChoiceSource) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "select", Flag: flag, Required: true, Source: source} +} + +func payloadFlagChoice(title, flag string, source *pluginsdk.ChoiceSource) pluginsdk.InputSpec { + input := flagChoice(title, flag, source) + input.AlternativeFlag = "data" + return input +} + +func argumentChoice(title string, index int, source *pluginsdk.ChoiceSource) pluginsdk.InputSpec { + return pluginsdk.InputSpec{Title: title, Kind: "select", Argument: new(index), Required: true, Source: source} +} + +func organizationPolicyChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud organizations policies list", "id", "No organization policies are available; create one with fctl cloud organizations policies create.", "name", "id") +} + +func organizationUserChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud organizations users list", "id", "No organization users are available; invite a user with fctl cloud organizations invitations send.", "name", "email", "id") +} + +func organizationInteractionInputs() map[string][]pluginsdk.InputSpec { + inputs := map[string][]pluginsdk.InputSpec{ + "cloud organizations create": {nameInput("Organization name")}, + "cloud organizations policies create": {payloadFlagInput("Policy name", "name")}, + "cloud organizations oauth-clients create": {payloadFlagInput("OAuth client name", "name")}, + "cloud organizations invitations send": {{Title: "Invitee email", Kind: "input", Flag: "email", Required: true, AlternativeArgument: new(0)}}, + "cloud organizations users link": {argumentInput("User ID to link", 0), payloadFlagChoice("Organization policy", "policy-id", organizationPolicyChoices())}, + "cloud organizations authentication-provider configure": providerInteractionInputs(), + // The list contains enabled applications, not a catalog of applications + // that could be enabled. Do not offer that list as an enable catalog. + "cloud organizations applications enable": {argumentInput("Application ID to enable", 0)}, + } + inputs["cloud organizations users link"][1].ValueType = "number" + for _, action := range []string{"describe", "update", "delete", "history"} { + inputs["cloud organizations "+action] = []pluginsdk.InputSpec{{ + Title: "Organization", Kind: "select", Context: "organization", Required: true, AlternativeArgument: new(0), + Source: cloudChoices("cloud organizations list", "id", "No organizations are available; create one with fctl cloud organizations create.", "name", "id"), + }} + } + for _, action := range []string{"show", "unlink"} { + inputs["cloud organizations users "+action] = []pluginsdk.InputSpec{argumentChoice("Organization user", 0, organizationUserChoices())} + } + inputs["cloud organizations invitations delete"] = []pluginsdk.InputSpec{ + argumentChoice("Invitation", 0, cloudChoices("cloud organizations invitations list", "id", "No invitations are available; send one with fctl cloud organizations invitations send.", "email", "id")), + } + for _, action := range []string{"show", "update", "delete", "add-scope", "remove-scope"} { + input := argumentChoice("Organization policy", 0, organizationPolicyChoices()) + input.ValueType = "number" + inputs["cloud organizations policies "+action] = []pluginsdk.InputSpec{input} + } + for _, action := range []string{"add-scope", "remove-scope"} { + input := argumentInput("Scope ID", 1) + input.ValueType = "number" + input.Description = "Positive scope ID. Inspect fctl cloud organizations policies show POLICY; the plugin has no scope catalog command." + inputs["cloud organizations policies "+action] = append(inputs["cloud organizations policies "+action], input) + } + for _, action := range []string{"show", "update", "delete"} { + inputs["cloud organizations oauth-clients "+action] = []pluginsdk.InputSpec{ + argumentChoice("OAuth client", 0, cloudChoices("cloud organizations oauth-clients list", "id", "No OAuth clients are available; create one with fctl cloud organizations oauth-clients create.", "name", "id")), + } + } + for _, action := range []string{"show", "disable"} { + inputs["cloud organizations applications "+action] = []pluginsdk.InputSpec{ + argumentChoice("Organization application", 0, cloudChoices("cloud organizations applications list", "id", "No applications are enabled; use fctl cloud organizations applications enable APPLICATION.", "name", "id")), + } + } + for _, path := range []string{"cloud organizations update", "cloud organizations policies update", "cloud organizations oauth-clients update"} { + name := payloadFlagInput("New name", "name") + name.Description = "Enter a new name, or use --data to update other fields without renaming." + inputs[path] = append(inputs[path], name) + } + return inputs +} + +func providerInteractionInputs() []pluginsdk.InputSpec { + kind := payloadFlagInput("Provider type", "type") + kind.Kind, kind.AlternativeArgument = "select", new(0) + for _, value := range []string{"github", "google", "microsoft", "oidc"} { + kind.Options = append(kind.Options, pluginsdk.InputOption{Label: value, Value: value}) + } + name := payloadFlagInput("Provider name", "name") + name.AlternativeArgument = new(1) + id := payloadFlagInput("OAuth client ID", "provider-client-id") + id.AlternativeArgument = new(2) + secret := payloadFlagInput("OAuth client secret", "provider-client-secret") + secret.AlternativeArgument, secret.Secret = new(3), true + config := bodyInput("Provider configuration (JSON)", "/config", false) + config.Kind, config.ValueType = "text", "json" + config.Description = `OIDC requires {"issuer":"https://issuer.example"}; Microsoft accepts {"tenant":"TENANT_ID"}. Leave empty for GitHub/Google or when using --oidc-issuer/--microsoft-tenant. Provide config through only one channel.` + return []pluginsdk.InputSpec{kind, name, id, secret, config} +} + +func regionChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud regions list", "id", "No regions are available; create a private region with fctl cloud regions create or ask an organization administrator for access.", "name", "id") +} + +func regionInteractionInputs() map[string][]pluginsdk.InputSpec { + inputs := map[string][]pluginsdk.InputSpec{"cloud regions create": {nameInput("Private region name")}} + for _, action := range []string{"show", "delete", "versions"} { + inputs["cloud regions "+action] = []pluginsdk.InputSpec{argumentChoice("Region", 0, regionChoices())} + } + return inputs +} + +func stackChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud stack list", "id", "No stacks are available; create one with fctl cloud stack create.", "name", "id") +} + +func stackContextInput(index int, all bool) pluginsdk.InputSpec { + source := stackChoices() + if all { + source.Flags = map[string]string{"all": "true"} + } + return pluginsdk.InputSpec{Title: "Cloud stack", Kind: "select", Context: "stack", Required: true, AlternativeArgument: new(index), Source: source} +} + +func stackActionContextInput(action string) pluginsdk.InputSpec { + input := stackContextInput(0, true) + switch action { + case "upgrade": + input.Source.MatchFields = map[string][]string{"state": {"ACTIVE"}, "status": {"READY"}} + input.Source.EmptyMessage = "No active, ready stacks are available to upgrade." + case "restore": + input.Source.MatchFields = map[string][]string{"state": {"DELETED"}} + input.Source.EmptyMessage = "No deleted stacks are available to restore." + case "enable": + input.Source.MatchFields = map[string][]string{"state": {"DISABLED"}} + input.Source.EmptyMessage = "No disabled stacks are available to enable." + case "disable": + input.Source.MatchFields = map[string][]string{"state": {"ACTIVE"}} + input.Source.EmptyMessage = "No active stacks are available to disable." + case "delete", "update": + input.Source.MatchFields = map[string][]string{"state": {"ACTIVE", "DISABLED"}} + input.Source.EmptyMessage = "No active or disabled stacks are available to " + action + "." + } + return input +} + +func stackInteractionInputs() map[string][]pluginsdk.InputSpec { + version := payloadFlagChoice("Stack catalog version", "version", cloudChoices("cloud regions versions", "name", "This region has no catalog versions; select another region or ask an organization administrator.", "name")) + version.Source.Args = []string{"$region"} + version.Source.PreferredPrefix = "v4." + version.Source.ExcludeTrueFields = []string{"deprecated"} + // A default is a suggested choice, never permission to skip the catalog or + // silently substitute another version when v4.0 is absent (e.g. v4.0-beta). + version.Default = stackDefaultVersion + version.Description = "Choose an exact version offered by this region." + inputs := map[string][]pluginsdk.InputSpec{ + "cloud stack create": {nameInput("Stack name"), payloadFlagChoice("Region", "region", regionChoices()), version}, + } + for _, action := range []string{"show", "update", "delete", "disable", "enable", "restore", "upgrade", "history", "info", "version"} { + inputs["cloud stack "+action] = []pluginsdk.InputSpec{stackActionContextInput(action)} + } + inputs["cloud stack show"][0].AlternativeFlag = "name" + name := payloadFlagInput("New stack name", "name") + name.Description = "Enter a new stack name, or use --data to update metadata without renaming." + inputs["cloud stack update"] = append(inputs["cloud stack update"], name) + upgrade := payloadFlagInput("Exact target catalog version", "version") + upgrade.AlternativeArgument = new(1) + upgrade.Description = "Enter an exact catalog version from fctl cloud regions versions REGION for this stack's region. The plugin validates it and never falls back to another version." + inputs["cloud stack upgrade"] = append(inputs["cloud stack upgrade"], upgrade) + inputs["cloud stack modules list"] = []pluginsdk.InputSpec{stackContextInput(0, true)} + inputs["cloud stack modules enable"] = []pluginsdk.InputSpec{stackContextInput(1, false), argumentInput("Module name to enable", 0)} + moduleSource := cloudChoices("cloud stack modules list", "name", "No modules are enabled on this stack; use fctl cloud stack modules enable MODULE.", "name") + moduleSource.Args = []string{"$stack"} + inputs["cloud stack modules disable"] = []pluginsdk.InputSpec{stackContextInput(1, false), argumentChoice("Enabled module", 0, moduleSource)} + inputs["cloud stack users list"] = []pluginsdk.InputSpec{stackContextInput(0, true)} + policy := payloadFlagChoice("Stack access policy", "policy-id", organizationPolicyChoices()) + policy.ValueType = "number" + inputs["cloud stack users link"] = []pluginsdk.InputSpec{stackContextInput(1, false), argumentChoice("Organization user", 0, organizationUserChoices()), policy} + userSource := cloudChoices("cloud stack users list", "id", "No users have access to this stack; link one with fctl cloud stack users link USER --policy-id POLICY.", "name", "email", "id") + userSource.Args = []string{"$stack"} + inputs["cloud stack users unlink"] = []pluginsdk.InputSpec{stackContextInput(1, false), argumentChoice("Stack user", 0, userSource)} + return inputs +} + +func appChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud apps list", "id", "No Deploy apps are available; create one with fctl cloud apps create --experimental.", "name", "id") +} + +func appManifestChoices() *pluginsdk.ChoiceSource { + return cloudChoices("cloud apps manifests list", "id", "No manifests are available; create one with fctl cloud apps manifests create --experimental.", "name", "id") +} + +func appManifestVersionChoices() *pluginsdk.ChoiceSource { + source := cloudChoices("cloud apps manifests versions list", "version", "No manifest versions are available; upload one with fctl cloud apps manifests versions push --experimental --manifest-id MANIFEST.", "version") + source.Flags["manifest-id"] = "$manifest-id" + return source +} + +func appManifestContentInput() pluginsdk.InputSpec { + input := flagInput("Manifest YAML file", "path", true) + input.AlternativeFlag = "data" + input.Description = "Path to a local YAML file; alternatively supply the manifest through --data." + return input +} + +func appInteractionInputs() map[string][]pluginsdk.InputSpec { + inputs := map[string][]pluginsdk.InputSpec{ + "cloud apps create": {payloadFlagInput("App name", "name")}, + "cloud apps bind-manifest": {flagChoice("App", "app-id", appChoices()), payloadFlagChoice("Manifest", "manifest-id", appManifestChoices())}, + "cloud apps unbind-manifest": {flagChoice("App", "app-id", appChoices())}, + // Upload names are query parameters, not fields in the manifest body. + "cloud apps manifests create": {flagInput("Manifest name", "name", true), appManifestContentInput()}, + "cloud apps manifests versions push": {flagChoice("Manifest", "manifest-id", appManifestChoices()), appManifestContentInput()}, + "cloud apps manifests versions list": {flagChoice("Manifest", "manifest-id", appManifestChoices())}, + "cloud apps manifests versions show": {flagChoice("Manifest", "manifest-id", appManifestChoices())}, + "cloud apps variables list": {flagChoice("App", "id", appChoices())}, + } + for _, action := range []string{"show", "delete"} { + inputs["cloud apps "+action] = []pluginsdk.InputSpec{flagChoice("App", "id", appChoices())} + } + for _, action := range []string{"show", "update", "delete", "download"} { + inputs["cloud apps manifests "+action] = []pluginsdk.InputSpec{flagChoice("Manifest", "id", appManifestChoices())} + } + inputs["cloud apps manifests update"] = append(inputs["cloud apps manifests update"], payloadFlagInput("Manifest name", "name")) + for _, action := range []string{"show", "logs", "download"} { + inputs["cloud apps deployments "+action] = []pluginsdk.InputSpec{ + flagChoice("Deployment", "id", cloudChoices("cloud apps deployments list", "id", "No deployments are available; create one with fctl cloud apps deployments create --experimental.", "name", "id", "status")), + } + } + version := payloadFlagChoice("Manifest version", "manifest-version", appManifestVersionChoices()) + version.ValueType = "number" + inputs["cloud apps deployments create"] = []pluginsdk.InputSpec{payloadFlagChoice("App", "app-id", appChoices()), payloadFlagChoice("Manifest", "manifest-id", appManifestChoices()), version} + secret := payloadFlagInput("Variable value", "value") + secret.Secret = true + inputs["cloud apps variables create"] = []pluginsdk.InputSpec{flagChoice("App", "id", appChoices()), payloadFlagInput("Variable key", "key"), secret} + variables := cloudChoices("cloud apps variables list", "id", "No variables are available for this app; create one with fctl cloud apps variables create --experimental.", "key", "id") + variables.Flags["id"] = "$app-id" + inputs["cloud apps variables delete"] = []pluginsdk.InputSpec{flagChoice("App", "app-id", appChoices()), flagChoice("Variable", "id", variables)} + return inputs +} diff --git a/plugins/cloud/interaction_test.go b/plugins/cloud/interaction_test.go new file mode 100644 index 00000000..3ed43767 --- /dev/null +++ b/plugins/cloud/interaction_test.go @@ -0,0 +1,644 @@ +package cloud + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +func interactionCommand(t *testing.T, path string) pluginsdk.CommandSpec { + t.Helper() + command, err := pluginsdk.FindCommand(manifest(), strings.Fields(path)) + if err != nil { + t.Fatal(err) + } + return command +} + +func TestCloudInteractionTargets(t *testing.T) { + for path, want := range map[string]string{ + "cloud": "identity", "cloud me": "identity", "cloud me info": "identity", + "cloud me invitations list": "identity", "cloud organizations list": "identity", "cloud organizations create": "identity", + "cloud organizations": "organization", "cloud organizations describe": "organization", "cloud organizations users show": "organization", + "cloud regions": "organization", "cloud regions create": "organization", + "cloud stack": "organization", "cloud stack create": "organization", "cloud stack modules disable": "organization", + "cloud apps": "organization", "cloud apps manifests create": "organization", + } { + t.Run(path, func(t *testing.T) { + words := strings.Fields(path) + var target string + for i := range words { + if current := interactionCommand(t, strings.Join(words[:i+1], " ")).Target; current != "" { + target = current + } + } + if target != want { + t.Fatalf("inherited target = %q, want %q", target, want) + } + }) + } +} + +func TestCloudInteractionLeafCoverage(t *testing.T) { + m := manifest() + inputs := cloudInteractionInputs() + t.Logf("interactive fields declared on %d Cloud leaves", len(inputs)) + for path := range inputs { + if !interactionCommand(t, path).Runnable { + t.Errorf("inputs attached to non-runnable command %s", path) + } + } + walkCloudInteraction(t, m, m.Root, nil) + for _, path := range []string{"cloud", "cloud me", "cloud me info", "cloud me invitations list", "cloud organizations list", "cloud stack list"} { + if inputs := interactionCommand(t, path).Inputs; len(inputs) != 0 { + t.Errorf("%s should not prompt for fields: %+v", path, inputs) + } + } +} + +func TestCloudPolicyChoicesUsePolicyIDs(t *testing.T) { + for _, path := range []string{"cloud organizations users link", "cloud stack users link"} { + t.Run(path, func(t *testing.T) { + fields := interactionCommand(t, path).Inputs + policy := fields[len(fields)-1] + if policy.Flag != "policy-id" || policy.ValueType != "number" || policy.Source.ValueField != "id" || !slices.Equal(policy.Source.CommandPath, strings.Fields("cloud organizations policies list")) { + t.Errorf("access policy must come from policy IDs, independently of user choices: %+v", policy) + } + }) + } +} + +func TestCloudDeployChoiceSourcesExecuteWithOptIn(t *testing.T) { + cases := []struct { + command string + index int + flags map[string]string + path string + data string + }{ + {"cloud apps bind-manifest", 0, nil, "/apps", `{"data":[{"id":"app","name":"Books"}]}`}, + {"cloud apps bind-manifest", 1, nil, "/manifests", `{"data":[{"id":"manifest","name":"Production"}]}`}, + {"cloud apps deployments create", 2, map[string]string{"manifest-id": "manifest"}, "/manifests/manifest/versions", `{"data":[{"version":9007199254740993}]}`}, + {"cloud apps variables delete", 1, map[string]string{"id": "app"}, "/apps/app/variables", `{"data":[{"id":"variable","key":"TOKEN"}]}`}, + } + for _, tc := range cases { + t.Run(tc.command+tc.path, func(t *testing.T) { + checkCloudDeployChoiceRequest(t, tc.command, tc.index, tc.flags, tc.path, tc.data) + }) + } +} + +func checkCloudDeployChoiceRequest(t *testing.T, command string, index int, flags map[string]string, path, data string) { + t.Helper() + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != path { + t.Errorf("discovery request = %s %s, want GET %s", r.Method, r.URL.Path, path) + } + cloudTestWrite(t, w, data) + })) + defer server.Close() + source := interactionCommand(t, command).Inputs[index].Source + if flags == nil { + flags = make(map[string]string) + } + request := pluginsdk.ExecuteRequest{CommandPath: source.CommandPath, Flags: flags, Endpoint: server.URL} + p := New(server.Client()) + if _, err := p.Execute(t.Context(), request); err == nil || calls != 0 { + t.Fatal("discovery bypassed the explicit experimental gate") + } + request.Flags["experimental"] = "true" + response, err := p.Execute(t.Context(), request) + if err != nil { + t.Fatal(err) + } + appsEqualJSON(t, response.Data, []byte(data)) + if calls != 1 { + t.Errorf("expected one read after explicit opt-in, got %d", calls) + } +} + +func walkCloudInteraction(t *testing.T, m pluginsdk.Manifest, command pluginsdk.CommandSpec, parent []string) { + t.Helper() + path := append(append([]string{}, parent...), pluginsdk.CommandName(command)) + if !command.Runnable && len(command.Inputs) != 0 { + t.Errorf("group %s has inherited inputs", strings.Join(path, " ")) + } + for _, input := range command.Inputs { + t.Run(strings.Join(path, " ")+"/"+input.Title, func(t *testing.T) { + checkCloudInputBinding(t, command, input) + if input.Source != nil { + checkCloudChoiceSource(t, m, input.Source) + } + }) + } + checkCloudRequiredInputCoverage(t, command, path) + for _, child := range command.Subcommands { + walkCloudInteraction(t, m, child, path) + } +} + +func checkCloudRequiredInputCoverage(t *testing.T, command pluginsdk.CommandSpec, path []string) { + t.Helper() + for index := range command.Args.Min { + if !slices.ContainsFunc(command.Inputs, func(input pluginsdk.InputSpec) bool { + return input.Argument != nil && *input.Argument == index && input.Required + }) { + t.Errorf("%s lacks a field for required argument %d", strings.Join(path, " "), index) + } + } + for _, flag := range command.Flags { + if flag.Required && !slices.ContainsFunc(command.Inputs, func(input pluginsdk.InputSpec) bool { return input.Flag == flag.Name && input.Required }) { + t.Errorf("%s lacks a field for required flag %s", strings.Join(path, " "), flag.Name) + } + } +} + +func checkCloudInputBinding(t *testing.T, command pluginsdk.CommandSpec, input pluginsdk.InputSpec) { + t.Helper() + bindings := 0 + for _, set := range []bool{input.Flag != "", input.Argument != nil, input.BodyPointer != "", input.Context != ""} { + if set { + bindings++ + } + } + if bindings != 1 || input.Title == "" { + t.Fatalf("expected one binding and a title: %+v", input) + } + if !slices.Contains([]string{"input", "text", "select", "confirm"}, input.Kind) || !slices.Contains([]string{"", "json", "bool", "number"}, input.ValueType) { + t.Errorf("invalid field kind/type: %+v", input) + } + if input.Flag != "" && !slices.ContainsFunc(command.Flags, func(flag pluginsdk.FlagSpec) bool { return flag.Name == input.Flag }) { + t.Errorf("field refers to absent leaf flag %q", input.Flag) + } + checkCloudInputAlternative(t, command, input) +} + +func checkCloudInputAlternative(t *testing.T, command pluginsdk.CommandSpec, input pluginsdk.InputSpec) { + t.Helper() + if input.AlternativeFlag != "" && !slices.ContainsFunc(command.Flags, func(flag pluginsdk.FlagSpec) bool { return flag.Name == input.AlternativeFlag }) { + t.Errorf("alternative refers to absent leaf flag %q", input.AlternativeFlag) + } + for _, index := range []*int{input.Argument, input.AlternativeArgument} { + if index != nil && (*index < 0 || *index >= command.Args.Max) { + t.Errorf("argument index %d is outside command arity", *index) + } + } + if input.Context != "" && input.Context != "stack" && input.Context != "organization" { + t.Errorf("unknown context binding %q", input.Context) + } + if input.BodyPointer != "" && !strings.HasPrefix(input.BodyPointer, "/") { + t.Errorf("invalid JSON pointer %q", input.BodyPointer) + } + if input.Kind == "select" && input.Source == nil && len(input.Options) == 0 { + t.Error("select has neither a source nor options") + } +} + +func checkCloudChoiceSource(t *testing.T, m pluginsdk.Manifest, source *pluginsdk.ChoiceSource) { + t.Helper() + if len(source.CommandPath) < 3 || source.CommandPath[0] != "cloud" { + t.Fatalf("source path is not fully rooted: %v", source.CommandPath) + } + command, err := pluginsdk.FindCommand(m, source.CommandPath) + if err != nil { + t.Fatal(err) + } + if !command.Runnable || command.Confirm || !slices.Contains([]string{"list", "versions"}, pluginsdk.CommandName(command)) { + t.Errorf("source must be a read catalog: %v", source.CommandPath) + } + if len(source.Args) < command.Args.Min || len(source.Args) > command.Args.Max { + t.Errorf("source arguments do not match arity: %+v", source) + } + for flag := range source.Flags { + if !cloudSourceHasFlag(m, source.CommandPath, flag) { + t.Errorf("source refers to unknown flag %s", flag) + } + } + if len(source.CommandPath) > 1 && source.CommandPath[1] == "apps" && source.Flags["experimental"] != "$experimental" { + t.Error("Deploy discovery must preserve the user's experimental opt-in") + } + if source.ValueField == "" || len(source.LabelFields) == 0 || source.EmptyMessage == "" { + t.Errorf("source lacks values, labels or empty-list guidance: %+v", source) + } +} + +func cloudSourceHasFlag(m pluginsdk.Manifest, path []string, name string) bool { + for i := range path { + command, err := pluginsdk.FindCommand(m, path[:i+1]) + if err != nil { + return false + } + for _, flag := range command.Flags { + if flag.Name == name && (flag.Persistent || i == len(path)-1) { + return true + } + } + } + return false +} + +func TestCloudCreationMinimumInputs(t *testing.T) { + for path, flags := range map[string][]string{ + "cloud organizations create": {"name"}, + "cloud regions create": {"name"}, + "cloud organizations policies create": {"name"}, + "cloud organizations oauth-clients create": {"name"}, + "cloud stack create": {"name", "region", "version"}, + "cloud organizations authentication-provider configure": {"type", "name", "provider-client-id", "provider-client-secret"}, + "cloud apps create": {"name"}, + "cloud apps manifests create": {"name", "path"}, + "cloud apps manifests versions push": {"manifest-id", "path"}, + "cloud apps deployments create": {"app-id", "manifest-id", "manifest-version"}, + "cloud apps variables create": {"id", "key", "value"}, + } { + t.Run(path, func(t *testing.T) { + command := interactionCommand(t, path) + var required []string + for _, input := range command.Inputs { + if input.Required { + required = append(required, input.Flag) + } + } + if !slices.Equal(required, flags) { + t.Errorf("required form flags = %v, want %v", required, flags) + } + }) + } + for _, path := range []string{"cloud organizations create", "cloud regions create", "cloud stack create"} { + name := interactionCommand(t, path).Inputs[0] + if name.Flag != "name" || name.AlternativeArgument == nil || *name.AlternativeArgument != 0 { + t.Errorf("%s does not support an already supplied positional name", path) + } + } +} + +func TestCloudSecretsAndProviderConfiguration(t *testing.T) { + provider := interactionCommand(t, "cloud organizations authentication-provider configure").Inputs + for index, input := range provider[:4] { + if input.AlternativeArgument == nil || *input.AlternativeArgument != index { + t.Errorf("provider flag %s does not defer to its positional alternative", input.Flag) + } + } + if !provider[3].Secret || provider[3].Default != "" { + t.Error("provider secret must be masked with no default") + } + config := provider[4] + if config.BodyPointer != "/config" || config.Kind != "text" || config.ValueType != "json" || config.Required || !strings.Contains(config.Description, "issuer") { + t.Errorf("provider configuration must be a guided optional JSON object: %+v", config) + } + variable := interactionCommand(t, "cloud apps variables create").Inputs[2] + if variable.Flag != "value" || !variable.Secret || variable.Default != "" { + t.Error("variable value must be masked with no default") + } +} + +func TestCloudStackContextAndDependentCatalogs(t *testing.T) { + inputs := cloudInteractionInputs() + for path, fields := range inputs { + if !strings.HasPrefix(path, "cloud stack ") || path == "cloud stack create" { + continue + } + stack := fields[0] + if stack.Context != "stack" || stack.Source == nil || !slices.Equal(stack.Source.CommandPath, []string{"cloud", "stack", "list"}) { + t.Errorf("%s lacks a Cloud stack context picker", path) + } + } + version := interactionCommand(t, "cloud stack create").Inputs[2] + if version.Flag != "version" || version.Default != "v4.0" || !version.Required || version.Kind != "select" || len(version.Options) != 0 { + t.Fatalf("version must require a catalog choice: %+v", version) + } + if !slices.Equal(version.Source.CommandPath, []string{"cloud", "regions", "versions"}) || !slices.Equal(version.Source.Args, []string{"$region"}) || version.Source.ValueField != "name" || version.Source.PreferredPrefix != "v4." { + t.Errorf("invalid current region-version source: %+v", version.Source) + } + deployment := interactionCommand(t, "cloud apps deployments create").Inputs[2] + if deployment.ValueType != "number" || deployment.Source.ValueField != "version" || deployment.Source.Flags["manifest-id"] != "$manifest-id" { + t.Errorf("manifest version must retain its integer and depend on the chosen manifest: %+v", deployment) + } + variable := interactionCommand(t, "cloud apps variables delete").Inputs[1] + if variable.Source.Flags["id"] != "$app-id" || slices.Contains(variable.Source.LabelFields, "value") { + t.Errorf("variable picker must depend on its app and exclude secrets: %+v", variable) + } +} + +func TestCloudStackCatalogExcludesDeprecatedVersions(t *testing.T) { + version := interactionCommand(t, "cloud stack create").Inputs[2] + if !slices.Equal(version.Source.ExcludeTrueFields, []string{"deprecated"}) { + t.Errorf("version choices must exclude deprecated catalog entries: %+v", version.Source) + } +} + +func TestCloudStackActionEligibility(t *testing.T) { + for _, tc := range []struct { + action string + eligible map[string][]string + emptyMessage string + }{ + {"upgrade", map[string][]string{"state": {"ACTIVE"}, "status": {"READY"}}, "No active, ready stacks are available to upgrade."}, + {"restore", map[string][]string{"state": {"DELETED"}}, "No deleted stacks are available to restore."}, + {"enable", map[string][]string{"state": {"DISABLED"}}, "No disabled stacks are available to enable."}, + {"disable", map[string][]string{"state": {"ACTIVE"}}, "No active stacks are available to disable."}, + {"delete", map[string][]string{"state": {"ACTIVE", "DISABLED"}}, "No active or disabled stacks are available to delete."}, + {"update", map[string][]string{"state": {"ACTIVE", "DISABLED"}}, "No active or disabled stacks are available to update."}, + } { + t.Run(tc.action, func(t *testing.T) { + source := interactionCommand(t, "cloud stack "+tc.action).Inputs[0].Source + if !maps.EqualFunc(source.MatchFields, tc.eligible, slices.Equal[[]string]) { + t.Errorf("%s must offer stacks matching %v, got %v", tc.action, tc.eligible, source.MatchFields) + } + if source.EmptyMessage != tc.emptyMessage { + t.Errorf("empty selection must explain the action's eligible state: %q", source.EmptyMessage) + } + if !maps.Equal(source.Flags, map[string]string{"all": "true"}) { + t.Errorf("action eligibility must inspect the full stack catalog: %v", source.Flags) + } + }) + } +} + +func TestCloudStackReadChoicesRetainDeletedStacks(t *testing.T) { + for _, action := range []string{"show", "history", "info", "version", "modules list", "users list"} { + t.Run(action, func(t *testing.T) { + source := interactionCommand(t, "cloud stack "+action).Inputs[0].Source + if len(source.MatchFields) != 0 || !maps.Equal(source.Flags, map[string]string{"all": "true"}) { + t.Errorf("%s must keep deleted stacks available for inspection: %+v", action, source) + } + }) + } +} + +func TestCloudManifestContentAcceptsExplicitBody(t *testing.T) { + for _, path := range []string{"cloud apps manifests create", "cloud apps manifests versions push"} { + command := interactionCommand(t, path) + content := command.Inputs[1] + if content.Flag != "path" || content.AlternativeFlag != "data" || !content.Required { + t.Errorf("%s must skip the file prompt when --data is explicit: %+v", path, content) + } + if !slices.ContainsFunc(command.Flags, func(flag pluginsdk.FlagSpec) bool { return flag.Name == content.AlternativeFlag && flag.Body }) { + t.Errorf("%s content alternative is not a body flag", path) + } + } +} + +func TestCloudPayloadFieldsAcceptExplicitData(t *testing.T) { + for path, flags := range map[string][]string{ + "cloud organizations create": {"name"}, + "cloud organizations update": {"name"}, + "cloud organizations policies create": {"name"}, + "cloud organizations policies update": {"name"}, + "cloud organizations oauth-clients create": {"name"}, + "cloud organizations oauth-clients update": {"name"}, + "cloud organizations users link": {"policy-id"}, + "cloud organizations authentication-provider configure": {"type", "name", "provider-client-id", "provider-client-secret"}, + "cloud regions create": {"name"}, + "cloud stack create": {"name", "region", "version"}, + "cloud stack update": {"name"}, + "cloud stack upgrade": {"version"}, + "cloud stack users link": {"policy-id"}, + "cloud apps create": {"name"}, + "cloud apps bind-manifest": {"manifest-id"}, + "cloud apps manifests update": {"name"}, + "cloud apps deployments create": {"app-id", "manifest-id", "manifest-version"}, + "cloud apps variables create": {"key", "value"}, + } { + t.Run(path, func(t *testing.T) { + command := interactionCommand(t, path) + for _, flag := range flags { + input := cloudFlagField(t, command, flag) + if input.AlternativeFlag != "data" { + t.Errorf("payload field --%s must defer to explicit --data: %+v", flag, input) + } + } + if !slices.ContainsFunc(command.Flags, func(flag pluginsdk.FlagSpec) bool { return flag.Name == "data" && flag.Body }) { + t.Error("payload alternatives require an existing body flag") + } + }) + } +} + +func cloudFlagField(t *testing.T, command pluginsdk.CommandSpec, flag string) pluginsdk.InputSpec { + t.Helper() + for _, input := range command.Inputs { + if input.Flag == flag { + return input + } + } + t.Fatalf("command %s has no field for --%s", command.Use, flag) + return pluginsdk.InputSpec{} +} + +func TestCloudResourceTargetsDoNotDeferToData(t *testing.T) { + cases := []struct { + path string + index int + }{ + {"cloud organizations update", 0}, + {"cloud organizations policies update", 0}, + {"cloud organizations oauth-clients update", 0}, + {"cloud organizations users link", 0}, + {"cloud stack update", 0}, + {"cloud stack upgrade", 0}, + {"cloud stack users link", 0}, + {"cloud stack users link", 1}, + {"cloud apps bind-manifest", 0}, + {"cloud apps manifests update", 0}, + {"cloud apps manifests versions push", 0}, + {"cloud apps variables create", 0}, + {"cloud apps variables delete", 0}, + {"cloud apps variables delete", 1}, + // A manifest upload body also does not supply the name query parameter. + {"cloud apps manifests create", 0}, + } + for _, tc := range cases { + input := interactionCommand(t, tc.path).Inputs[tc.index] + if input.AlternativeFlag == "data" || !input.Required { + t.Errorf("%s field %s must still resolve its target with --data: %+v", tc.path, input.Title, input) + } + } +} + +func TestCloudUpdateFormsRequireExplicitNameOrData(t *testing.T) { + for _, path := range []string{"cloud organizations update", "cloud organizations policies update", "cloud organizations oauth-clients update", "cloud stack update", "cloud apps manifests update"} { + name := cloudFlagField(t, interactionCommand(t, path), "name") + if !name.Required || name.AlternativeFlag != "data" || name.Default != "" || name.Source != nil { + t.Errorf("%s must require an explicit new name or JSON update, without a default mutation: %+v", path, name) + } + } +} + +func TestCloudStackShowUsesExplicitNameInsteadOfPicker(t *testing.T) { + stack := interactionCommand(t, "cloud stack show").Inputs[0] + if stack.Context != "stack" || stack.AlternativeFlag != "name" || stack.AlternativeArgument == nil || *stack.AlternativeArgument != 0 { + t.Errorf("show must permit --name and the existing positional stack ID: %+v", stack) + } +} + +func TestCloudExplicitJSONStackUpdatePreservesName(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + if r.URL.Path != "/organizations/org/stacks/stack" { + t.Errorf("update targeted %s", r.URL.Path) + } + switch r.Method { + case http.MethodGet: + cloudTestWrite(t, w, `{"data":{"id":"stack","name":"existing","metadata":{"owner":"previous"}}}`) + case http.MethodPut: + var body json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + appsEqualJSON(t, body, []byte(`{"name":"existing","metadata":{"owner":"accepted"}}`)) + cloudTestWrite(t, w, `{"data":{"id":"stack"}}`) + default: + t.Errorf("unexpected update method %s", r.Method) + } + })) + defer server.Close() + request := pluginsdk.ExecuteRequest{ + CommandPath: strings.Fields("cloud stack update"), + Flags: map[string]string{"data": `{"metadata":{"owner":"accepted"}}`}, ChangedFlags: map[string]bool{"data": true}, + Body: json.RawMessage(`{"metadata":{"owner":"accepted"}}`), Context: map[string]string{"organization": "org", "stack": "stack"}, Endpoint: server.URL, + } + if _, err := New(server.Client()).Execute(t.Context(), request); err != nil { + t.Fatal(err) + } + if !slices.Equal(methods, []string{http.MethodGet, http.MethodPut}) { + t.Errorf("update should read then preserve the omitted name: %v", methods) + } +} + +func TestCloudInteractionPreservesRequestContract(t *testing.T) { + decorated := manifest() + original := decorated + original.Root = pluginsdk.CommandSpec{Use: "cloud", Subcommands: []pluginsdk.CommandSpec{meManifest(), organizationsManifest(), regionsManifest(), stackManifest(), appsManifest()}} + for _, request := range []pluginsdk.ExecuteRequest{ + {CommandPath: strings.Fields("cloud organizations create"), Args: []string{"Books"}}, + {CommandPath: strings.Fields("cloud organizations create"), Body: json.RawMessage(`{"name":"Books","defaultPolicyID":9007199254740993}`)}, + {CommandPath: strings.Fields("cloud stack create"), Flags: map[string]string{"name": "Books", "region": "eu", "version": "v4.0-beta"}, ChangedFlags: map[string]bool{"version": true}}, + {CommandPath: strings.Fields("cloud apps variables create"), Flags: map[string]string{"experimental": "true", "id": "app"}, Body: json.RawMessage(`{"variable":{"key":"token","value":"synthetic-secret"}}`)}, + {CommandPath: strings.Fields("cloud organizations policies delete"), Args: []string{"4"}}, + {CommandPath: strings.Fields("cloud regions show")}, + } { + before, beforeErr := pluginsdk.NormalizeRequest(original, request) + after, afterErr := pluginsdk.NormalizeRequest(decorated, request) + if !reflect.DeepEqual(before, after) || errorString(beforeErr) != errorString(afterErr) { + t.Errorf("metadata changed normalization of %v: %v / %v", request.CommandPath, beforeErr, afterErr) + } + } +} + +func errorString(err error) string { + if err != nil { + return err.Error() + } + return "" +} + +func TestCloudInteractionManifestIsolation(t *testing.T) { + first := manifest() + before, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"cloud stack create", "cloud stack delete", "cloud apps variables delete", "cloud organizations authentication-provider configure"} { + command, err := pluginsdk.FindCommand(first, strings.Fields(path)) + if err != nil { + t.Fatal(err) + } + for _, input := range command.Inputs { + mutateCloudInputMetadata(input) + } + } + after, err := json.Marshal(manifest()) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a mutated manifest leaked interactive metadata into the next call") + } +} + +func mutateCloudInputMetadata(input pluginsdk.InputSpec) { + if input.AlternativeArgument != nil { + *input.AlternativeArgument = 99 + } + if input.Source != nil { + input.Source.CommandPath[0] = "changed" + if len(input.Source.ExcludeTrueFields) > 0 { + input.Source.ExcludeTrueFields[0] = "changed" + } + for key := range input.Source.Flags { + input.Source.Flags[key] = "changed" + } + for _, values := range input.Source.MatchFields { + for index := range values { + values[index] = "changed" + } + } + } + if len(input.Options) > 0 { + input.Options[0].Value = "changed" + } +} + +func TestCloudInteractiveCatalogVersionSubmission(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods = append(methods, r.Method) + writeCloudCatalogVersionFixture(t, w, r) + })) + defer server.Close() + p := New(server.Client()) + version := interactionCommand(t, "cloud stack create").Inputs[2] + request := pluginsdk.ExecuteRequest{CommandPath: version.Source.CommandPath, Args: []string{"eu"}, Context: map[string]string{"organization": "org"}, Endpoint: server.URL} + response, err := p.Execute(t.Context(), request) + if err != nil || !strings.Contains(string(response.Data), "v4.0-beta") { + t.Fatalf("resolve version catalog: %s / %v", response.Data, err) + } + if !slices.Equal(methods, []string{http.MethodGet}) { + t.Fatalf("reading choices performed writes: %v", methods) + } + request.CommandPath = strings.Fields("cloud stack create") + request.Args = []string{"Books"} + request.Flags = map[string]string{"region": "eu", "version": "v4.0-beta", "no-wait": "true"} + request.ChangedFlags = map[string]bool{"version": true} + if _, err := p.Execute(t.Context(), request); err != nil { + t.Fatal(err) + } + if !slices.Equal(methods, []string{http.MethodGet, http.MethodGet, http.MethodPost}) { + t.Errorf("catalog lookup should precede the single authorized creation: %v", methods) + } +} + +func writeCloudCatalogVersionFixture(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + switch r.URL.Path { + case "/organizations/org/regions/eu/versions": + if r.Method != http.MethodGet { + t.Errorf("catalog source mutated a resource: %s", r.Method) + } + cloudTestWrite(t, w, `{"data":[{"name":"v4.0-beta"}]}`) + case "/organizations/org/stacks": + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if string(body["version"]) != `"v4.0-beta"` { + t.Errorf("chosen catalog version was replaced: %s", body["version"]) + } + cloudTestWrite(t, w, `{"data":{"id":"created","organizationId":"org"}}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } +} diff --git a/plugins/cloud/manifest.go b/plugins/cloud/manifest.go new file mode 100644 index 00000000..346567cd --- /dev/null +++ b/plugins/cloud/manifest.go @@ -0,0 +1,37 @@ +package cloud + +import "github.com/formancehq/fctl/pkg/pluginsdk" + +func manifest() pluginsdk.Manifest { + m := pluginsdk.Manifest{Name: "cloud", Version: "0.1.0", Service: "cloud", ProtocolVersion: pluginsdk.ProtocolVersion, Root: pluginsdk.CommandSpec{ + Use: "cloud", Short: "Manage Formance Cloud", Subcommands: []pluginsdk.CommandSpec{meManifest(), organizationsManifest(), regionsManifest(), stackManifest(), appsManifest()}, + }} + addCloudInteraction(&m.Root, nil, cloudInteractionInputs()) + return m +} +func leaf(use, short string, args int, confirm bool) pluginsdk.CommandSpec { + s := pluginsdk.CommandSpec{Use: use, Short: short, Args: pluginsdk.ArgsSpec{Min: args, Max: args}, Runnable: true, Confirm: confirm} + if confirm { + s.Flags = append(s.Flags, pluginsdk.FlagSpec{Name: "confirm", Type: "bool", Default: "false", Usage: "Confirm this destructive operation"}) + } + return s +} +func leafBody(use, short string, args int, confirm bool) pluginsdk.CommandSpec { + s := leaf(use, short, args, confirm) + s.Flags = append(s.Flags, pluginsdk.FlagSpec{Name: "data", Type: "string", Body: true, Usage: "JSON object: inline, @file, or - for stdin"}) + return s +} +func stringFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "string", Usage: usage} +} +func boolFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "bool", Default: "false", Usage: usage} +} +func withFlags(s pluginsdk.CommandSpec, flags ...pluginsdk.FlagSpec) pluginsdk.CommandSpec { + s.Flags = append(s.Flags, flags...) + return s +} +func optionalArg(s pluginsdk.CommandSpec) pluginsdk.CommandSpec { s.Args.Min = 0; return s } +func paginationFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{stringFlag("cursor", "Pagination cursor"), {Name: "page-size", Type: "uint32", Default: "100", Usage: "Number of results (positive)"}} +} diff --git a/plugins/cloud/me.go b/plugins/cloud/me.go new file mode 100644 index 00000000..bb4c344d --- /dev/null +++ b/plugins/cloud/me.go @@ -0,0 +1,43 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func meManifest() pluginsdk.CommandSpec { + return pluginsdk.CommandSpec{Use: "me", Short: "Read the current user and manage invitations", Subcommands: []pluginsdk.CommandSpec{ + leaf("info", "Show the connected user", 0, false), + {Use: "invitations", Short: "Manage personal invitations", Subcommands: []pluginsdk.CommandSpec{ + withFlags(leaf("list", "List invitations", 0, false), stringFlag("status", "Invitation status")), + leaf("accept INVITATION", "Accept an invitation", 1, false), leaf("decline INVITATION", "Decline an invitation", 1, true), + }}, + }} +} +func executeMe(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if r.CommandPath[2] == "info" { + return c.Do(ctx, http.MethodGet, "/me", nil, nil, nil) + } + switch r.CommandPath[3] { + case "list": + q, err := queryFlags(r, map[string]string{"status": "status"}) + if err != nil { + return nil, err + } + if organization := r.Context["organization"]; organization != "" { + q.Set("organization", organization) + } + return c.Do(ctx, http.MethodGet, "/me/invitations", q, nil, nil) + case "accept": + return c.Do(ctx, http.MethodPost, apiPath("me", "invitations", r.Args[0], "accept"), nil, nil, nil) + case "decline": + return c.Do(ctx, http.MethodPost, apiPath("me", "invitations", r.Args[0], "reject"), nil, nil, nil) + default: + return nil, fmt.Errorf("unsupported invitation command") + } +} diff --git a/plugins/cloud/organizations.go b/plugins/cloud/organizations.go new file mode 100644 index 00000000..d1bddac8 --- /dev/null +++ b/plugins/cloud/organizations.go @@ -0,0 +1,473 @@ +package cloud + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func organizationsManifest() pluginsdk.CommandSpec { + name := stringFlag("name", "Organization or resource name") + history := withFlags(optionalArg(leaf("history [ORGANIZATION]", "List organization audit logs", 1, false)), paginationFlags()...) + history = withFlags(history, stringFlag("stack-id", "Filter by stack ID"), stringFlag("user-id", "Filter by user ID"), stringFlag("action", "Filter by action"), stringFlag("key", "Filter metadata key"), stringFlag("value", "Filter metadata value")) + return pluginsdk.CommandSpec{Use: "organizations", Short: "Manage organizations and access", Subcommands: []pluginsdk.CommandSpec{ + withFlags(leaf("list", "List organizations", 0, false), boolFlag("expand", "Include expanded organization information")), + withFlags(optionalArg(leaf("describe [ORGANIZATION]", "Show an organization", 1, false)), boolFlag("expand", "Include expanded organization information")), + withFlags(optionalArg(leafBody("create [NAME]", "Create an organization", 1, false)), name, stringFlag("domain", "Organization domain"), stringFlag("default-policy-id", "Default policy ID"), stringFlag("owner-id", "Owner user ID")), + withFlags(optionalArg(leafBody("update [ORGANIZATION]", "Update an organization, preserving omitted fields", 1, true)), name, stringFlag("domain", "Organization domain"), stringFlag("default-policy-id", "Default policy ID")), + optionalArg(leaf("delete [ORGANIZATION]", "Delete an organization", 1, true)), history, + {Use: "users", Short: "Manage organization users", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List organization users", 0, false), leaf("show USER", "Show an organization user", 1, false), withFlags(leafBody("link USER", "Link a user with a policy", 1, true), stringFlag("policy-id", "Policy ID")), leaf("unlink USER", "Unlink a user", 1, true), + }}, + {Use: "invitations", Short: "Manage organization invitations", Subcommands: []pluginsdk.CommandSpec{ + withFlags(leaf("list", "List organization invitations", 0, false), stringFlag("status", "Invitation status")), withFlags(optionalArg(leaf("send [EMAIL]", "Send an organization invitation", 1, false)), stringFlag("email", "Invitee email")), leaf("delete INVITATION", "Delete an invitation", 1, true), + }}, + {Use: "policies", Short: "Manage organization policies", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List policies", 0, false), leaf("show POLICY", "Show a policy", 1, false), + withFlags(leafBody("create", "Create a policy", 0, false), name, stringFlag("description", "Policy description")), + withFlags(leafBody("update POLICY", "Update a policy, preserving omitted fields", 1, true), name, stringFlag("description", "Policy description")), + leaf("delete POLICY", "Delete a policy", 1, true), leaf("add-scope POLICY SCOPE", "Add a scope to a policy", 2, true), leaf("remove-scope POLICY SCOPE", "Remove a scope from a policy", 2, true), + }}, + {Use: "oauth-clients", Short: "Manage organization OAuth clients", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List OAuth clients", 0, false), leaf("show CLIENT", "Show an OAuth client", 1, false), + withFlags(leafBody("create", "Create an OAuth client; save the returned secret", 0, false), name, stringFlag("description", "Client description")), + withFlags(leafBody("update CLIENT", "Update an OAuth client, preserving omitted fields", 1, true), name, stringFlag("description", "Client description")), + leaf("delete CLIENT", "Delete an OAuth client", 1, true), + }}, + {Use: "authentication-provider", Short: "Configure organization authentication", Subcommands: []pluginsdk.CommandSpec{ + leaf("show", "Show the authentication provider", 0, false), leaf("delete", "Delete the authentication provider", 0, true), + providerConfigureManifest(), + }}, + {Use: "applications", Short: "Manage applications enabled for an organization", Subcommands: []pluginsdk.CommandSpec{ + withFlags(leaf("list", "List organization applications", 0, false), boolFlag("expand", "Include application details")), leaf("show APPLICATION", "Show an organization application", 1, false), leaf("enable APPLICATION", "Enable an application", 1, false), leaf("disable APPLICATION", "Disable an application", 1, true), + }}, + }} +} +func providerConfigureManifest() pluginsdk.CommandSpec { + s := leafBody("configure [TYPE NAME CLIENT_ID CLIENT_SECRET]", "Configure an authentication provider", 4, true) + s.Args.Min = 0 + return withFlags(s, stringFlag("type", "github, google, microsoft or oidc"), stringFlag("name", "Provider name"), stringFlag("provider-client-id", "OAuth client ID"), stringFlag("provider-client-secret", "OAuth client secret"), stringFlag("oidc-issuer", "OIDC issuer URL"), stringFlag("microsoft-tenant", "Microsoft tenant ID")) +} +func executeOrganizations(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + action := r.CommandPath[2] + if action == "list" { + q, err := queryFlags(r, map[string]string{"expand": "expand"}) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodGet, "/organizations", q, nil, nil) + } + if action == "create" { + return createOrganization(ctx, c, r) + } + if len(r.CommandPath) == 3 && len(r.Args) > 0 { + r.Context = maps.Clone(r.Context) + if r.Context == nil { + r.Context = make(map[string]string) + } + r.Context["organization"] = r.Args[0] + } + org, err := requireOrg(ctx, c, r) + if err != nil { + return nil, err + } + path := apiPath("organizations", org) + switch action { + case "describe": + q, err := queryFlags(r, map[string]string{"expand": "expand"}) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodGet, path, q, nil, nil) + case "delete": + return c.Do(ctx, http.MethodDelete, path, nil, nil, nil) + case "update": + return updateOrganization(ctx, c, r, path) + case "history": + q, err := queryFlags(r, map[string]string{"cursor": "cursor", "page-size": "pageSize", "stack-id": "stackId", "user-id": "userId", "action": "action", "key": "key", "value": "value"}) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodGet, path+"/logs", q, nil, nil) + case "users": + return executeOrgUsers(ctx, c, r, org) + case "invitations": + return executeOrgInvitations(ctx, c, r, org) + case "policies": + return executeOrgPolicies(ctx, c, r, org) + case "oauth-clients": + return executeOrgClients(ctx, c, r, org) + case "authentication-provider": + return executeProvider(ctx, c, r, org) + case "applications": + return executeOrgApplications(ctx, c, r, org) + } + return nil, fmt.Errorf("unsupported organization command") +} + +const organizationPolicyGuidance = "organization update requires a positive default policy ID: provide --default-policy-id or defaultPolicyID in --data; Membership cannot preserve or clear an unset policy" + +func validateOrganizationDefaultPolicy(raw json.RawMessage) error { + var id int64 + if json.Unmarshal(raw, &id) != nil || id <= 0 { + return fmt.Errorf("%s", organizationPolicyGuidance) + } + return nil +} + +func organizationUpdatePatch(r pluginsdk.ExecuteRequest) (map[string]json.RawMessage, error) { + patch, err := bodyFields(r, map[string]string{"name": "name", "domain": "domain", "default-policy-id": "defaultPolicyID"}) + if err != nil { + if strings.Contains(err.Error(), "defaultPolicyID") || strings.Contains(err.Error(), "--default-policy-id") { + return nil, fmt.Errorf("%w; %s", err, organizationPolicyGuidance) + } + return nil, err + } + var changes map[string]json.RawMessage + if err := json.Unmarshal(patch, &changes); err != nil { + return nil, err + } + if len(changes) == 0 { + return nil, fmt.Errorf("provide fields to update using --data or flags") + } + if policy, explicit := changes["defaultPolicyID"]; explicit { + if err := validateOrganizationDefaultPolicy(policy); err != nil { + return nil, err + } + } + return changes, nil +} + +func updateOrganization(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, path string) (json.RawMessage, error) { + changes, err := organizationUpdatePatch(r) + if err != nil { + return nil, err + } + current, err := c.Do(ctx, http.MethodGet, path, nil, nil, nil) + if err != nil { + return nil, err + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if json.Unmarshal(current, &envelope) != nil || envelope.Data == nil { + return nil, fmt.Errorf("response is missing resource data") + } + values := make(map[string]json.RawMessage) + for _, key := range []string{"name", "domain", "defaultPolicyID"} { + if value, ok := envelope.Data[key]; ok { + values[key] = value + } + } + maps.Copy(values, changes) + if err := validateOrganizationDefaultPolicy(values["defaultPolicyID"]); err != nil { + return nil, err + } + if value := values["name"]; len(value) == 0 || string(value) == `""` || string(value) == "null" { + return nil, fmt.Errorf("name is required") + } + body, err := json.Marshal(values) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodPut, path, nil, body, nil) +} +func executeOrgUsers(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + method, path := http.MethodGet, apiPath("organizations", org, "users") + var body json.RawMessage + if len(r.Args) > 0 { + path = apiPath("organizations", org, "users", r.Args[0]) + } + switch r.CommandPath[3] { + case "unlink": + method = http.MethodDelete + case "link": + var err error + body, err = bodyFields(r, map[string]string{"policy-id": "policyId"}, "policyId") + if err != nil { + return nil, err + } + method = http.MethodPut + } + return c.Do(ctx, method, path, nil, body, nil) +} +func executeOrgInvitations(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + path := apiPath("organizations", org, "invitations") + switch r.CommandPath[3] { + case "list": + q, err := queryFlags(r, map[string]string{"status": "status"}) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodGet, path, q, nil, nil) + case "delete": + return c.Do(ctx, http.MethodDelete, apiPath("organizations", org, "invitations", r.Args[0]), nil, nil, nil) + case "send": + email := r.Flags["email"] + if len(r.Args) > 0 { + if email != "" && email != r.Args[0] { + return nil, fmt.Errorf("email argument conflicts with --email") + } + email = r.Args[0] + } + if strings.TrimSpace(email) == "" { + return nil, fmt.Errorf("email is required") + } + return c.Do(ctx, http.MethodPost, path, url.Values{"email": {email}}, nil, nil) + } + return nil, fmt.Errorf("unsupported invitation command") +} +func executeOrgPolicies(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + for _, id := range r.Args { + n, err := strconv.ParseInt(id, 10, 64) + if err != nil || n <= 0 { + return nil, fmt.Errorf("policy and scope IDs must be positive integers") + } + } + path := apiPath("organizations", org, "policies") + method := http.MethodGet + var body json.RawMessage + if len(r.Args) > 0 { + path = apiPath("organizations", org, "policies", r.Args[0]) + } + switch r.CommandPath[3] { + case "create": + var err error + body, err = bodyFields(r, map[string]string{"name": "name", "description": "description"}, "name") + if err != nil { + return nil, err + } + method = http.MethodPost + case "update": + return updateResource(ctx, c, r, path, map[string]string{"name": "name", "description": "description"}, []string{"name", "description"}, "name") + case "delete": + method = http.MethodDelete + case "add-scope": + method = http.MethodPut + path = apiPath("organizations", org, "policies", r.Args[0], "scopes", r.Args[1]) + case "remove-scope": + method = http.MethodDelete + path = apiPath("organizations", org, "policies", r.Args[0], "scopes", r.Args[1]) + } + return c.Do(ctx, method, path, nil, body, nil) +} +func executeOrgClients(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + path := apiPath("organizations", org, "clients") + method := http.MethodGet + var body json.RawMessage + if len(r.Args) > 0 { + path = apiPath("organizations", org, "clients", r.Args[0]) + } + switch r.CommandPath[3] { + case "create": + var err error + body, err = bodyFields(r, map[string]string{"name": "name", "description": "description"}) + if err != nil { + return nil, err + } + method = http.MethodPost + case "update": + return updateResource(ctx, c, r, path, map[string]string{"name": "name", "description": "description"}, []string{"name", "description"}, "name") + case "delete": + method = http.MethodDelete + } + return c.Do(ctx, method, path, nil, body, nil) +} +func executeProvider(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + path := apiPath("organizations", org, "authentication-provider") + switch r.CommandPath[3] { + case "show": + return c.Do(ctx, http.MethodGet, path, nil, nil, nil) + case "delete": + return c.Do(ctx, http.MethodDelete, path, nil, nil, nil) + case "configure": + body, err := providerBody(r) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodPut, path, nil, body, nil) + } + return nil, fmt.Errorf("unsupported authentication provider command") +} +func providerBody(r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + r, err := providerArguments(r) + if err != nil { + return nil, err + } + body, err := bodyFields(r, map[string]string{"type": "type", "name": "name", "provider-client-id": "clientID", "provider-client-secret": "clientSecret"}, "type", "name", "clientID", "clientSecret") + if err != nil { + return nil, err + } + var values map[string]json.RawMessage + if err := json.Unmarshal(body, &values); err != nil { + return nil, err + } + var kind string + if err := json.Unmarshal(values["type"], &kind); err != nil { + return nil, fmt.Errorf("provider type must be a string") + } + config, err := providerConfigFlags(r, kind) + if err != nil { + return nil, err + } + if _, exists := values["config"]; exists { + if len(config) > 0 { + return nil, fmt.Errorf("provide config using either --data or provider flags") + } + } else { + raw, err := json.Marshal(config) + if err != nil { + return nil, err + } + values["config"] = raw + } + if kind == "oidc" { + if err := validateOIDCConfig(values["config"]); err != nil { + return nil, err + } + } + return json.Marshal(values) +} +func providerArguments(r pluginsdk.ExecuteRequest) (pluginsdk.ExecuteRequest, error) { + if len(r.Args) == 0 { + return r, nil + } + if len(r.Args) > 4 { + return r, fmt.Errorf("configure accepts at most TYPE NAME CLIENT_ID CLIENT_SECRET; supply remaining values with flags or JSON") + } + r.Flags = cloneFlags(r.Flags) + names := []string{"type", "name", "provider-client-id", "provider-client-secret"} + for i, argument := range r.Args { + name := names[i] + if r.ChangedFlags[name] || r.Flags[name] != "" { + return r, fmt.Errorf("argument conflicts with --%s", name) + } + r.Flags[name] = argument + } + return r, nil +} +func providerConfigFlags(r pluginsdk.ExecuteRequest, kind string) (map[string]string, error) { + config := map[string]string{} + switch kind { + case "google", "github": + case "microsoft": + if tenant := r.Flags["microsoft-tenant"]; tenant != "" { + config["tenant"] = tenant + } + case "oidc": + if issuer := r.Flags["oidc-issuer"]; issuer != "" { + if err := httpclient.ValidateURL(issuer); err != nil { + return nil, fmt.Errorf("invalid OIDC issuer: %w", err) + } + config["issuer"] = issuer + } + default: + return nil, fmt.Errorf("unsupported provider type %q", kind) + } + return config, nil +} +func validateOIDCConfig(raw json.RawMessage) error { + var config struct { + Issuer string `json:"issuer"` + } + if json.Unmarshal(raw, &config) != nil || config.Issuer == "" { + return fmt.Errorf("OIDC config requires issuer") + } + return httpclient.ValidateURL(config.Issuer) +} + +// PUT replaces these resources. Read and merge only writable API fields. +func updateResource(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, path string, flags map[string]string, writable []string, required ...string) (json.RawMessage, error) { + patch, err := bodyFields(r, flags) + if err != nil { + return nil, err + } + var changes map[string]json.RawMessage + if err := json.Unmarshal(patch, &changes); err != nil { + return nil, err + } + if len(changes) == 0 { + return nil, fmt.Errorf("provide fields to update using --data or flags") + } + current, err := c.Do(ctx, http.MethodGet, path, nil, nil, nil) + if err != nil { + return nil, err + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if json.Unmarshal(current, &envelope) != nil || envelope.Data == nil { + return nil, fmt.Errorf("response is missing resource data") + } + values := make(map[string]json.RawMessage) + for _, key := range writable { + if value, ok := envelope.Data[key]; ok { + values[key] = value + } + } + maps.Copy(values, changes) + for _, key := range required { + if value, ok := values[key]; !ok || string(value) == `""` || (string(value) == "null" && key != "defaultPolicyID") { + return nil, fmt.Errorf("%s is required", key) + } + } + body, err := json.Marshal(values) + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodPut, path, nil, body, nil) +} +func cloneFlags(flags map[string]string) map[string]string { + cloned := maps.Clone(flags) + if cloned == nil { + cloned = make(map[string]string) + } + return cloned +} +func errConflictingName() error { return fmt.Errorf("name argument conflicts with --name") } + +func createOrganization(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + if len(r.Args) > 0 { + r.Flags = cloneFlags(r.Flags) + if r.Flags["name"] != "" { + return nil, errConflictingName() + } + r.Flags["name"] = r.Args[0] + } + body, err := bodyFields(r, map[string]string{"name": "name", "domain": "domain", "default-policy-id": "defaultPolicyID", "owner-id": "ownerID"}, "name") + if err != nil { + return nil, err + } + return c.Do(ctx, http.MethodPost, "/organizations", nil, body, nil) +} +func executeOrgApplications(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + path := apiPath("organizations", org, "applications") + method := http.MethodGet + switch r.CommandPath[3] { + case "show": + path = apiPath("organizations", org, "applications", r.Args[0]) + case "enable": + method = http.MethodPut + path = apiPath("organizations", org, "applications", r.Args[0]) + case "disable": + method = http.MethodDelete + path = apiPath("organizations", org, "applications", r.Args[0]) + } + q := url.Values{} + if r.CommandPath[3] == "list" { + var err error + q, err = queryFlags(r, map[string]string{"expand": "expand"}) + if err != nil { + return nil, err + } + } + return c.Do(ctx, method, path, q, nil, nil) +} diff --git a/plugins/cloud/provider_arguments_test.go b/plugins/cloud/provider_arguments_test.go new file mode 100644 index 00000000..e7f2120b --- /dev/null +++ b/plugins/cloud/provider_arguments_test.go @@ -0,0 +1,157 @@ +package cloud + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "reflect" + "slices" + "strings" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" +) + +type providerArgumentsTransport func(*http.Request) (*http.Response, error) + +func (transport providerArgumentsTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestProviderArgumentsPrefixesWithFlags(t *testing.T) { + values := []string{"github", "Fixture provider", "fixture-client", "fixture-secret"} + names := []string{"type", "name", "provider-client-id", "provider-client-secret"} + for count := range len(values) + 1 { + t.Run(fmt.Sprintf("prefix-%d", count), func(t *testing.T) { + flags := map[string]string{"confirm": "true"} + changed := map[string]bool{} + for i := count; i < len(names); i++ { + flags[names[i]], changed[names[i]] = values[i], true + } + request := pluginsdk.ExecuteRequest{ + CommandPath: strings.Fields("cloud organizations authentication-provider configure"), + Args: slices.Clone(values[:count]), Flags: flags, ChangedFlags: changed, + Context: map[string]string{"organization": "fixture-org"}, Endpoint: "https://membership.example.test/api", + } + checkProviderArgumentsFixture(t, request) + }) + } +} + +// The injected transport handles the complete request in memory. These tests +// never open a socket or write to a real authentication provider. +func checkProviderArgumentsFixture(t *testing.T, request pluginsdk.ExecuteRequest) { + t.Helper() + calls := 0 + client := &http.Client{Transport: providerArgumentsTransport(func(r *http.Request) (*http.Response, error) { + calls++ + if r.Method != http.MethodPut || r.URL.String() != "https://membership.example.test/api/organizations/fixture-org/authentication-provider" { + t.Errorf("unexpected fixture route: %s %s", r.Method, r.URL) + } + body, err := io.ReadAll(r.Body) + if closeErr := r.Body.Close(); closeErr != nil { + t.Error(closeErr) + } + if err != nil { + return nil, err + } + assertJSON(t, body, []byte(`{"type":"github","name":"Fixture provider","clientID":"fixture-client","clientSecret":"fixture-secret","config":{}}`)) + return &http.Response{ + StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":{"name":"Fixture provider"}}`)), Request: r, + }, nil + })} + if _, err := New(client).Execute(t.Context(), request); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Errorf("expected one fixture request, got %d", calls) + } +} + +func TestProviderArgumentsRejectDuplicateBindings(t *testing.T) { + values := []string{"github", "Fixture provider", "fixture-client", "fixture-secret"} + for index, name := range []string{"type", "name", "provider-client-id", "provider-client-secret"} { + for _, duplicate := range []struct { + label, value string + changed bool + }{{"same-value", values[index], false}, {"different-value", "conflicting-value", true}, {"explicit-empty", "", true}} { + t.Run(name+"/"+duplicate.label, func(t *testing.T) { + request := pluginsdk.ExecuteRequest{ + Args: slices.Clone(values[:index+1]), Flags: map[string]string{name: duplicate.value}, ChangedFlags: map[string]bool{name: duplicate.changed}, + } + checkProviderArgumentConflict(t, request, name, values[3]) + }) + } + } +} + +func checkProviderArgumentConflict(t *testing.T, request pluginsdk.ExecuteRequest, flag, secret string) { + t.Helper() + before := maps.Clone(request.Flags) + _, err := providerArguments(request) + if err == nil || !strings.Contains(err.Error(), "argument conflicts with --"+flag) { + t.Fatalf("duplicate binding must fail for --%s: %v", flag, err) + } + if strings.Contains(err.Error(), secret) { + t.Error("conflict error exposed the secret argument") + } + if !maps.Equal(request.Flags, before) { + t.Error("conflict handling mutated caller flags") + } +} + +func TestProviderArgumentsRejectTooMany(t *testing.T) { + _, err := providerArguments(pluginsdk.ExecuteRequest{Args: []string{"github", "name", "client", "fixture-secret", "extra"}}) + if err == nil || !strings.Contains(err.Error(), "at most") { + t.Fatalf("expected an arity error, got %v", err) + } +} + +func TestProviderArgumentsPreserveCallerRequest(t *testing.T) { + request := pluginsdk.ExecuteRequest{ + Args: []string{"oidc", "Fixture provider"}, + Flags: map[string]string{ + "type": "", "name": "", "provider-client-id": "fixture-client", "provider-client-secret": "fixture-secret", "oidc-issuer": "https://issuer.example.test", + }, + ChangedFlags: map[string]bool{"provider-client-id": true, "provider-client-secret": true, "oidc-issuer": true}, + Body: json.RawMessage(`{}`), Context: map[string]string{"organization": "fixture-org"}, + } + before := request + before.Flags, before.ChangedFlags, before.Context = maps.Clone(request.Flags), maps.Clone(request.ChangedFlags), maps.Clone(request.Context) + before.Args, before.Body = slices.Clone(request.Args), slices.Clone(request.Body) + result, err := providerArguments(request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(request, before) { + t.Fatal("argument binding mutated the caller's request") + } + if result.Flags["type"] != "oidc" || result.Flags["name"] != "Fixture provider" || result.Flags["oidc-issuer"] != request.Flags["oidc-issuer"] { + t.Error("argument binding lost the prefix or the remaining provider flags") + } + result.Flags["name"] = "changed" + if request.Flags["name"] != "" { + t.Error("bound flags still alias the caller's map") + } +} + +func TestProviderArgumentsPrefixWithJSON(t *testing.T) { + body, err := providerBody(pluginsdk.ExecuteRequest{ + Args: []string{"oidc", "Fixture provider"}, + Body: json.RawMessage(`{"clientID":"fixture-client","clientSecret":"fixture-secret","config":{"issuer":"https://issuer.example.test"}}`), + }) + if err != nil { + t.Fatal(err) + } + assertJSON(t, body, []byte(`{"type":"oidc","name":"Fixture provider","clientID":"fixture-client","clientSecret":"fixture-secret","config":{"issuer":"https://issuer.example.test"}}`)) +} + +func TestProviderArgumentsIncompletePrefixStillRequiresFields(t *testing.T) { + _, err := providerBody(pluginsdk.ExecuteRequest{Args: []string{"github", "Fixture provider"}, Flags: map[string]string{"provider-client-id": "fixture-client"}}) + if err == nil || !strings.Contains(err.Error(), "clientSecret is required") { + t.Fatalf("missing secret must remain an error: %v", err) + } +} diff --git a/plugins/cloud/regions.go b/plugins/cloud/regions.go new file mode 100644 index 00000000..9db3828d --- /dev/null +++ b/plugins/cloud/regions.go @@ -0,0 +1,48 @@ +package cloud + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func regionsManifest() pluginsdk.CommandSpec { + return pluginsdk.CommandSpec{Use: "regions", Short: "Manage Cloud regions", Subcommands: []pluginsdk.CommandSpec{ + leaf("list", "List regions", 0, false), leaf("show REGION", "Show a region", 1, false), leaf("versions REGION", "List region versions", 1, false), + withFlags(optionalArg(leafBody("create [NAME]", "Create a private region; save the returned secret", 1, false)), stringFlag("name", "Region name")), + leaf("delete REGION", "Delete a region", 1, true), + }} +} +func executeRegions(ctx context.Context, c *httpclient.Client, r pluginsdk.ExecuteRequest) (json.RawMessage, error) { + org, err := requireOrg(ctx, c, r) + if err != nil { + return nil, err + } + method, path := http.MethodGet, apiPath("organizations", org, "regions") + var body json.RawMessage + switch r.CommandPath[2] { + case "show": + path = apiPath("organizations", org, "regions", r.Args[0]) + case "versions": + path = apiPath("organizations", org, "regions", r.Args[0], "versions") + case "delete": + method, path = http.MethodDelete, apiPath("organizations", org, "regions", r.Args[0]) + case "create": + if len(r.Args) > 0 { + r.Flags = cloneFlags(r.Flags) + if r.Flags["name"] != "" { + return nil, errConflictingName() + } + r.Flags["name"] = r.Args[0] + } + method = http.MethodPost + body, err = bodyFields(r, map[string]string{"name": "name"}, "name") + if err != nil { + return nil, err + } + } + return c.Do(ctx, method, path, nil, body, nil) +} diff --git a/plugins/cloud/stack.go b/plugins/cloud/stack.go new file mode 100644 index 00000000..60ab1617 --- /dev/null +++ b/plugins/cloud/stack.go @@ -0,0 +1,708 @@ +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +const stackDefaultVersion = "v4.0" + +func executeStack(ctx context.Context, client *httpclient.Client, request pluginsdk.ExecuteRequest, path []string) (json.RawMessage, error) { + path = stackRelativePath(path) + request.CommandPath = append([]string{"stack"}, path...) + request, err := pluginsdk.NormalizeRequest(pluginsdk.Manifest{ProtocolVersion: pluginsdk.ProtocolVersion, Root: stackManifest()}, request) + if err != nil { + return nil, err + } + organization, err := orgID(request) + if err != nil { + return nil, err + } + if client == nil { + return nil, fmt.Errorf("stack commands require a Membership client") + } + data, err := executeStackCommand(ctx, client, request, organization, strings.Join(path, "/")) + if err != nil { + return nil, err + } + return data, nil +} + +func stackRelativePath(path []string) []string { + if len(path) > 0 && path[0] == "cloud" { + path = path[1:] + } + if len(path) > 0 && path[0] == "stack" { + path = path[1:] + } + return path +} + +func executeStackCommand(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org, command string) (json.RawMessage, error) { + if command != "history" && req.Flags["data"] != "" && req.Body == nil { + return nil, fmt.Errorf("host must supply the already-read JSON request body") + } + switch command { + case "create": + return createStack(ctx, client, req, org) + case "list": + query := make(url.Values) + for _, flag := range []string{"all", "deleted"} { + if req.ChangedFlags[flag] { + query.Set(flag, req.Flags[flag]) + } + } + return client.Do(ctx, http.MethodGet, stackCollectionPath(org), query, nil, nil) + case "show": + return showStack(ctx, client, req, org) + case "update": + return updateStack(ctx, client, req, org) + case "upgrade": + return upgradeStack(ctx, client, req, org) + case "restore": + return restoreStack(ctx, client, req, org) + case "history": + return stackHistory(ctx, client, req, org) + case "users/link": + return linkStackUser(ctx, client, req, org) + default: + return simpleStackCommand(ctx, client, req, org, command) + } +} + +func stackCollectionPath(org string) string { return httpclient.Path("organizations", org, "stacks") } + +func selectedStack(req pluginsdk.ExecuteRequest, index int) (string, error) { + id := req.Context["stack"] + if len(req.Args) > index { + id = req.Args[index] + } + if id == "" { + var targets map[string][]string + if raw := req.Context["stacks"]; raw != "" { + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + return "", fmt.Errorf("invalid stacks context: %w", err) + } + } + org, err := orgID(req) + if err != nil { + return "", err + } + ids := slices.Clone(targets[org]) + slices.Sort(ids) + ids = slices.Compact(ids) + if len(ids) != 1 { + return "", fmt.Errorf("select a stack with its ID or --stack; available IDs: %s", strings.Join(ids, ", ")) + } + id = ids[0] + } + return id, validID(id) +} + +func stackResourcePath(org, id string, suffix ...string) string { + return httpclient.Path(append([]string{"organizations", org, "stacks", id}, suffix...)...) +} + +func simpleStackCommand(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org, command string) (json.RawMessage, error) { + index := 0 + if slices.Contains([]string{"modules/enable", "modules/disable", "users/unlink"}, command) { + index = 1 + } + id, err := selectedStack(req, index) + if err != nil { + return nil, err + } + method, suffix, query := http.MethodGet, []string{}, make(url.Values) + switch command { + case "info", "version": + case "delete": + method = http.MethodDelete + if req.ChangedFlags["force"] { + query.Set("force", req.Flags["force"]) + } + case "enable", "disable", "restore": + method, suffix = http.MethodPut, []string{command} + case "modules/list", "users/list": + suffix = []string{strings.Split(command, "/")[0]} + case "modules/enable", "modules/disable": + method, suffix = http.MethodPost, []string{"modules"} + if command == "modules/disable" { + method = http.MethodDelete + } + if strings.TrimSpace(req.Args[0]) == "" { + return nil, fmt.Errorf("module name must not be empty") + } + query.Set("name", req.Args[0]) + case "users/unlink": + if strings.TrimSpace(req.Args[0]) == "" { + return nil, fmt.Errorf("user ID must not be empty") + } + method, suffix = http.MethodDelete, []string{"users", req.Args[0]} + default: + return nil, fmt.Errorf("unknown stack command %q", command) + } + return client.Do(ctx, method, stackResourcePath(org, id, suffix...), query, nil, nil) +} + +func stackObject(body json.RawMessage, allowed ...string) (map[string]json.RawMessage, error) { + fields := make(map[string]json.RawMessage) + if body == nil { + return fields, nil + } + if len(bytes.TrimSpace(body)) == 0 || bytes.TrimSpace(body)[0] != '{' { + return nil, fmt.Errorf("stack request body must be a JSON object") + } + if err := json.Unmarshal(body, &fields); err != nil { + return nil, err + } + for key, value := range fields { + if !slices.Contains(allowed, key) { + return nil, fmt.Errorf("unknown stack request field %q", key) + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, fmt.Errorf("stack request field %s must not be null", key) + } + } + return fields, nil +} + +func stackSetString(fields map[string]json.RawMessage, key, value string) error { + if existing, ok := fields[key]; ok { + var current string + if err := json.Unmarshal(existing, ¤t); err != nil { + return fmt.Errorf("%s must be a string", key) + } + if current != value { + return fmt.Errorf("conflicting %s values in body and arguments", key) + } + } + encoded, err := json.Marshal(value) + if err != nil { + return err + } + fields[key] = encoded + return nil +} + +func stackString(fields map[string]json.RawMessage, key string) (string, error) { + if fields[key] == nil { + return "", nil + } + var value string + if err := json.Unmarshal(fields[key], &value); err != nil { + return "", fmt.Errorf("%s must be a string", key) + } + return value, nil +} + +func stackNamedBody(req pluginsdk.ExecuteRequest, create bool) (map[string]json.RawMessage, error) { + allowed := []string{"name", "metadata"} + if create { + allowed = append(allowed, "regionID", "version") + } + fields, err := stackObject(req.Body, allowed...) + if err != nil { + return nil, err + } + if create && len(req.Args) > 0 { + if err := stackSetString(fields, "name", req.Args[0]); err != nil { + return nil, err + } + } + if req.ChangedFlags["name"] || req.Flags["name"] != "" { + if err := stackSetString(fields, "name", req.Flags["name"]); err != nil { + return nil, err + } + } + if raw := fields["metadata"]; raw != nil { + if err := stackValidateMetadata(raw); err != nil { + return nil, fmt.Errorf("metadata must be an object of strings") + } + } + return fields, nil +} + +func stackValidateMetadata(raw json.RawMessage) error { + var metadata map[string]json.RawMessage + if err := json.Unmarshal(raw, &metadata); err != nil { + return err + } + for key, value := range metadata { + if len(bytes.TrimSpace(value)) == 0 || bytes.TrimSpace(value)[0] != '"' { + return fmt.Errorf("metadata value %s must be a string", key) + } + } + return nil +} + +func createStack(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + timeout, err := stackWaitTimeout(req) + if err != nil { + return nil, err + } + fields, err := stackNamedBody(req, true) + if err != nil { + return nil, err + } + name, err := stackString(fields, "name") + if err != nil { + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("stack name is required: use NAME, --name or --data") + } + region, err := stackCreateRegion(ctx, client, req, org, fields) + if err != nil { + return nil, err + } + version, err := stackRequestedVersion(req, fields, -1) + if err != nil { + return nil, err + } + if err := stackValidateVersion(ctx, client, org, region, version); err != nil { + return nil, err + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + result, err := client.Do(ctx, http.MethodPost, stackCollectionPath(org), nil, body, nil) + if err != nil { + return nil, err + } + created, err := stackResponse(result) + if err != nil { + return nil, err + } + if created.ID == "" || (created.OrganizationID != "" && created.OrganizationID != org) { + return nil, fmt.Errorf("created stack response has no stable ID or a different organization") + } + if req.Flags["no-wait"] == "true" { + return result, nil + } + return waitStack(ctx, client, org, created.ID, version, timeout) +} + +func stackCreateRegion(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string, fields map[string]json.RawMessage) (string, error) { + if req.Flags["region"] != "" { + if err := stackSetString(fields, "regionID", req.Flags["region"]); err != nil { + return "", err + } + } + region, err := stackString(fields, "regionID") + if err != nil { + return "", err + } + if region == "" { + data, err := client.Do(ctx, http.MethodGet, httpclient.Path("organizations", org, "regions"), nil, nil, nil) + if err != nil { + return "", err + } + var catalog struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return "", err + } + if len(catalog.Data) != 1 || catalog.Data[0].ID == "" { + return "", fmt.Errorf("select a region with --region or --data regionID") + } + region = catalog.Data[0].ID + } + if err := validID(region); err != nil { + return "", fmt.Errorf("invalid region ID: %w", err) + } + if err := stackSetString(fields, "regionID", region); err != nil { + return "", err + } + return region, nil +} + +func stackRequestedVersion(req pluginsdk.ExecuteRequest, fields map[string]json.RawMessage, argIndex int) (string, error) { + if argIndex >= 0 && len(req.Args) > argIndex { + if err := stackSetString(fields, "version", req.Args[argIndex]); err != nil { + return "", err + } + } + if req.ChangedFlags["version"] { + if err := stackSetString(fields, "version", req.Flags["version"]); err != nil { + return "", err + } + } + version, err := stackString(fields, "version") + if err != nil { + return "", err + } + if version == "" { + version = stackDefaultVersion + } + if strings.TrimSpace(version) == "" { + return "", fmt.Errorf("stack version must not be empty") + } + if err := stackSetString(fields, "version", version); err != nil { + return "", err + } + return version, nil +} + +func stackValidateVersion(ctx context.Context, client *httpclient.Client, org, region, version string) error { + data, err := client.Do(ctx, http.MethodGet, httpclient.Path("organizations", org, "regions", region, "versions"), nil, nil, nil) + if err != nil { + return err + } + var catalog struct { + Data []struct { + Name string `json:"name"` + Deprecated bool `json:"deprecated"` + } `json:"data"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return err + } + for _, entry := range catalog.Data { + if entry.Name == version && !entry.Deprecated { + return nil + } + } + return fmt.Errorf("region %s does not offer requested catalog version %s; no alternative version was selected", region, version) +} + +type stackMetadata struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + RegionID string `json:"regionID"` + Status string `json:"status"` + State string `json:"state"` + Version string `json:"version"` +} + +func stackResponse(data json.RawMessage) (stackMetadata, error) { + var envelope struct { + Data *stackMetadata `json:"data"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return stackMetadata{}, err + } + if envelope.Data == nil { + return stackMetadata{}, fmt.Errorf("membership response is missing stack data") + } + return *envelope.Data, nil +} + +func stackWaitTimeout(req pluginsdk.ExecuteRequest) (time.Duration, error) { + value := req.Flags["wait-timeout"] + if value == "" { + value = "10m" + } + timeout, err := time.ParseDuration(value) + if err != nil || timeout <= 0 { + return 0, fmt.Errorf("wait-timeout must be a positive duration") + } + return timeout, nil +} + +func waitStack(ctx context.Context, client *httpclient.Client, org, id, version string, timeout time.Duration) (json.RawMessage, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + for { + result, err := client.Do(ctx, http.MethodGet, stackResourcePath(org, id), nil, nil, nil) + if err != nil { + return nil, fmt.Errorf("waiting for stack %s (inspect with cloud stack show %s): %w", id, id, err) + } + ready, err := stackReadiness(result, org, id, version) + if err != nil { + return nil, err + } + if ready { + return result, nil + } + if err := stackPollPause(ctx); err != nil { + return nil, fmt.Errorf("waiting for stack %s (inspect with cloud stack show %s): %w", id, id, ctx.Err()) + } + } +} + +func stackReadiness(result json.RawMessage, org, id, version string) (bool, error) { + state, err := stackResponse(result) + if err != nil { + return false, err + } + if state.ID != id || (state.OrganizationID != "" && state.OrganizationID != org) { + return false, fmt.Errorf("membership readiness response targets a different stack") + } + if slices.Contains([]string{"DISABLED", "DELETED"}, state.Status) || slices.Contains([]string{"DISABLED", "DELETED"}, state.State) { + return false, fmt.Errorf("stack %s entered %s/%s while waiting", id, state.State, state.Status) + } + return state.Status == "READY" && (version == "" || state.Version == version), nil +} + +func stackPollPause(ctx context.Context) error { + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func showStack(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + name := req.Flags["name"] + if name == "" { + id, err := selectedStack(req, 0) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodGet, stackResourcePath(org, id), nil, nil, nil) + } + if len(req.Args) != 0 { + return nil, fmt.Errorf("use either a stack ID or --name") + } + data, err := client.Do(ctx, http.MethodGet, stackCollectionPath(org), nil, nil, nil) + if err != nil { + return nil, err + } + var envelope struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err + } + var match json.RawMessage + for _, raw := range envelope.Data { + var stack struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &stack); err != nil { + return nil, err + } + if stack.Name == name { + if match != nil { + return nil, fmt.Errorf("stack name %q is ambiguous; use its ID", name) + } + match = raw + } + } + if match == nil { + return nil, fmt.Errorf("stack named %q not found", name) + } + return json.Marshal(struct { + Data json.RawMessage `json:"data"` + }{match}) +} + +func updateStack(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + id, err := selectedStack(req, 0) + if err != nil { + return nil, err + } + changes, err := stackNamedBody(req, false) + if err != nil { + return nil, err + } + if len(changes) == 0 { + return nil, fmt.Errorf("provide --name or --data to update the stack") + } + current, err := client.Do(ctx, http.MethodGet, stackResourcePath(org, id), nil, nil, nil) + if err != nil { + return nil, err + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(current, &envelope); err != nil { + return nil, err + } + fields := make(map[string]json.RawMessage) + for _, key := range []string{"name", "metadata"} { + if raw := envelope.Data[key]; raw != nil && string(raw) != "null" { + fields[key] = raw + } + } + maps.Copy(fields, changes) + name, err := stackString(fields, "name") + if err != nil { + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("stack name must not be empty") + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodPut, stackResourcePath(org, id), nil, body, nil) +} + +func upgradeStack(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + timeout, err := stackWaitTimeout(req) + if err != nil { + return nil, err + } + id, err := selectedStack(req, 0) + if err != nil { + return nil, err + } + fields, err := stackObject(req.Body, "version") + if err != nil { + return nil, err + } + version, err := stackRequestedVersion(req, fields, 1) + if err != nil { + return nil, err + } + current, err := client.Do(ctx, http.MethodGet, stackResourcePath(org, id), nil, nil, nil) + if err != nil { + return nil, err + } + state, err := stackResponse(current) + if err != nil { + return nil, err + } + if state.ID != id || (state.OrganizationID != "" && state.OrganizationID != org) { + return nil, fmt.Errorf("membership upgrade response targets a different stack") + } + if state.RegionID == "" { + return nil, fmt.Errorf("membership stack response is missing regionID") + } + if state.Version == version { + return waitExistingStackVersion(ctx, client, req, org, id, version, current, timeout) + } + if err := stackValidateVersion(ctx, client, org, state.RegionID, version); err != nil { + return nil, err + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + result, err := client.Do(ctx, http.MethodPut, stackResourcePath(org, id, "upgrade"), nil, body, nil) + if err != nil || req.Flags["no-wait"] == "true" { + return result, err + } + return waitStack(ctx, client, org, id, version, timeout) +} + +func waitExistingStackVersion(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org, id, version string, current json.RawMessage, timeout time.Duration) (json.RawMessage, error) { + if req.Flags["no-wait"] == "true" { + return current, nil + } + ready, err := stackReadiness(current, org, id, version) + if err != nil { + return nil, err + } + if ready { + return current, nil + } + return waitStack(ctx, client, org, id, version, timeout) +} + +func restoreStack(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + timeout, err := stackWaitTimeout(req) + if err != nil { + return nil, err + } + id, err := selectedStack(req, 0) + if err != nil { + return nil, err + } + result, err := client.Do(ctx, http.MethodPut, stackResourcePath(org, id, "restore"), nil, nil, nil) + if err != nil { + return nil, err + } + state, err := stackResponse(result) + if err != nil { + return nil, err + } + if state.ID != id || (state.OrganizationID != "" && state.OrganizationID != org) { + return nil, fmt.Errorf("membership restore response targets a different stack") + } + if req.Flags["no-wait"] == "true" { + return result, nil + } + return waitStack(ctx, client, org, id, state.Version, timeout) +} + +func linkStackUser(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + id, err := selectedStack(req, 1) + if err != nil { + return nil, err + } + if strings.TrimSpace(req.Args[0]) == "" { + return nil, fmt.Errorf("user ID must not be empty") + } + fields, err := stackObject(req.Body, "policyId") + if err != nil { + return nil, err + } + if flag := req.Flags["policy-id"]; flag != "" { + value, err := strconv.ParseInt(flag, 10, 64) + if err != nil || value <= 0 { + return nil, fmt.Errorf("policy-id must be a positive 64-bit integer") + } + encoded := json.RawMessage(strconv.FormatInt(value, 10)) + if existing := fields["policyId"]; existing != nil && !bytes.Equal(bytes.TrimSpace(existing), encoded) { + return nil, fmt.Errorf("conflicting policyId in body and --policy-id") + } + fields["policyId"] = encoded + } + var policy int64 + if err := json.Unmarshal(fields["policyId"], &policy); err != nil || policy <= 0 { + return nil, fmt.Errorf("provide a positive policyId with --policy-id or --data") + } + body, err := json.Marshal(fields) + if err != nil { + return nil, err + } + return client.Do(ctx, http.MethodPut, stackResourcePath(org, id, "users", req.Args[0]), nil, body, nil) +} + +func stackHistory(ctx context.Context, client *httpclient.Client, req pluginsdk.ExecuteRequest, org string) (json.RawMessage, error) { + id, err := selectedStack(req, 0) + if err != nil { + return nil, err + } + size, err := strconv.ParseUint(req.Flags["page-size"], 10, 32) + if err != nil || size < 1 || size > 1000 { + return nil, fmt.Errorf("page-size must be between 1 and 1000") + } + query := url.Values{"stackId": {id}, "pageSize": {strconv.FormatUint(size, 10)}} + if cursor := req.Flags["cursor"]; cursor != "" { + if req.Flags["action"] != "" || req.Flags["user-id"] != "" || req.Flags["data"] != "" { + return nil, fmt.Errorf("cursor cannot be combined with log filters") + } + query.Set("cursor", cursor) + } + if action := req.Flags["action"]; action != "" { + if !strings.HasPrefix(action, "stacks.") { + return nil, fmt.Errorf("stack history actions must use the stacks prefix") + } + query.Set("action", action) + } + if user := req.Flags["user-id"]; user != "" { + query.Set("userId", user) + } + if filter := req.Flags["data"]; filter != "" { + key, value, found := strings.Cut(filter, "=") + if !found || key == "" { + return nil, fmt.Errorf("data filter must be key=value") + } + query.Set("key", key) + query.Set("value", value) + } + return client.Do(ctx, http.MethodGet, httpclient.Path("organizations", org, "logs"), query, nil, nil) +} diff --git a/plugins/cloud/stack_manifest.go b/plugins/cloud/stack_manifest.go new file mode 100644 index 00000000..890aa66e --- /dev/null +++ b/plugins/cloud/stack_manifest.go @@ -0,0 +1,69 @@ +package cloud + +import "github.com/formancehq/fctl/pkg/pluginsdk" + +func stackManifest() pluginsdk.CommandSpec { + create := stackCommand("create [NAME]", "Create a stack", 0, 1, false) + create.Flags = append(stackBodyFlags(), stackStringFlag("name", "", "Name of the new stack"), stackStringFlag("region", "", "Region ID; required when more than one region is available"), stackStringFlag("version", "v4.0", "Exact catalog version to create (defaults to v4.0; no fallback)")) + create.Flags = append(create.Flags, stackWaitFlags()...) + show := stackCommand("show [STACK]", "Show stack metadata from Membership", 0, 1, false) + show.Flags = []pluginsdk.FlagSpec{stackStringFlag("name", "", "Find a stack by its exact name instead of its ID")} + update := stackCommand("update [STACK]", "Update stack name or metadata; omitted fields are preserved", 0, 1, false) + update.Flags = append(stackBodyFlags(), stackStringFlag("name", "", "New stack name")) + remove := stackCommand("delete [STACK]", "Delete a stack after explicit confirmation", 0, 1, true) + remove.Flags = append(remove.Flags, stackBoolFlag("force", "Delete even when the stack is not empty")) + disable := stackCommand("disable [STACK]", "Disable a stack after explicit confirmation", 0, 1, true) + restore := stackCommand("restore [STACK]", "Restore a deleted stack after explicit confirmation", 0, 1, true) + restore.Flags = append(restore.Flags, stackWaitFlags()...) + upgrade := stackCommand("upgrade [STACK] [VERSION]", "Upgrade a stack to a catalog version", 0, 2, true) + upgrade.Flags = append(upgrade.Flags, stackBodyFlags()...) + upgrade.Flags = append(upgrade.Flags, stackStringFlag("version", "v4.0", "Exact target catalog version (defaults to v4.0; no fallback)")) + upgrade.Flags = append(upgrade.Flags, stackWaitFlags()...) + list := stackCommand("list", "List stacks in the selected organization", 0, 0, false) + list.Flags = []pluginsdk.FlagSpec{stackBoolFlag("all", "Include disabled and deleted stacks"), stackBoolFlag("deleted", "Include deleted stacks (deprecated; prefer --all)")} + history := stackCommand("history [STACK]", "Read organization audit logs for this stack", 0, 1, false) + history.Flags = []pluginsdk.FlagSpec{ + {Name: "page-size", Type: "uint32", Default: "10", Usage: "Number of logs per page (1 through 1000)"}, + stackStringFlag("cursor", "", "Opaque pagination cursor; cannot be combined with other log filters"), + stackStringFlag("action", "", "Stack action filter, such as stacks.create"), + stackStringFlag("user-id", "", "Actor ID; SYSTEM selects system logs"), + stackStringFlag("data", "", "Log data filter as key=value (key is a JSONB text path)"), + } + moduleEnable := stackCommand("enable MODULE [STACK]", "Enable a module on the selected stack", 1, 2, false) + moduleDisable := stackCommand("disable MODULE [STACK]", "Disable a module after explicit confirmation", 1, 2, true) + link := stackCommand("link USER [STACK]", "Link a user to a stack policy", 1, 2, false) + link.Flags = append(stackBodyFlags(), stackStringFlag("policy-id", "", "Positive stack policy ID (integer)")) + return pluginsdk.CommandSpec{Use: "stack", Short: "Manage stacks through Membership", Subcommands: []pluginsdk.CommandSpec{ + create, list, show, update, remove, disable, + stackCommand("enable [STACK]", "Enable a disabled stack after explicit confirmation", 0, 1, true), + restore, upgrade, history, + stackCommand("info [STACK]", "Show Membership stack metadata; does not contact the gateway", 0, 1, false), + stackCommand("version [STACK]", "Show the desired version recorded by Membership; component versions require gateway access", 0, 1, false), + {Use: "modules", Short: "Manage stack modules", Subcommands: []pluginsdk.CommandSpec{stackCommand("list [STACK]", "List stack modules", 0, 1, false), moduleEnable, moduleDisable}}, + {Use: "users", Short: "Manage stack user access", Subcommands: []pluginsdk.CommandSpec{stackCommand("list [STACK]", "List stack user access", 0, 1, false), link, stackCommand("unlink USER [STACK]", "Remove stack user access after explicit confirmation", 1, 2, true)}}, + }} +} + +func stackCommand(use, short string, minArgs, maxArgs int, confirm bool) pluginsdk.CommandSpec { + spec := pluginsdk.CommandSpec{Use: use, Short: short, Runnable: true, Args: pluginsdk.ArgsSpec{Min: minArgs, Max: maxArgs}, Confirm: confirm} + if confirm { + spec.Flags = []pluginsdk.FlagSpec{stackBoolFlag("confirm", "Confirm this operation")} + } + return spec +} + +func stackStringFlag(name, value, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "string", Default: value, Usage: usage} +} + +func stackBoolFlag(name, usage string) pluginsdk.FlagSpec { + return pluginsdk.FlagSpec{Name: name, Type: "bool", Default: "false", Usage: usage} +} + +func stackBodyFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{{Name: "data", Type: "string", Body: true, Usage: "JSON request object: inline JSON, @file or - for stdin; read by the host"}} +} + +func stackWaitFlags() []pluginsdk.FlagSpec { + return []pluginsdk.FlagSpec{stackBoolFlag("no-wait", "Return after Membership accepts the operation"), stackStringFlag("wait-timeout", "10m", "Maximum readiness wait, such as 30s or 10m; cancellation stops polling")} +} diff --git a/plugins/cloud/stack_test.go b/plugins/cloud/stack_test.go new file mode 100644 index 00000000..8925e382 --- /dev/null +++ b/plugins/cloud/stack_test.go @@ -0,0 +1,617 @@ +package cloud + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" +) + +func stackTestClient(t *testing.T, handler http.HandlerFunc) *httpclient.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := httpclient.New(server.URL+"/membership", server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func stackTestRequest(path string, args ...string) pluginsdk.ExecuteRequest { + return pluginsdk.ExecuteRequest{CommandPath: strings.Fields(path), Args: args, Flags: map[string]string{}, ChangedFlags: map[string]bool{}, Context: map[string]string{"organization": "org", "stack": "selected"}} +} + +func stackTestJSON(t *testing.T, w http.ResponseWriter, status int, body string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := io.WriteString(w, body); err != nil { + t.Error(err) + } +} + +func TestStackMembershipRoutes(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + command, method, suffix, query string + args []string + }{ + {"show", "GET", "/selected", "", nil}, + {"info", "GET", "/selected", "", nil}, + {"version", "GET", "/explicit", "", []string{"explicit"}}, + {"delete", "DELETE", "/selected", "force=false", nil}, + {"disable", "PUT", "/selected/disable", "", nil}, + {"enable", "PUT", "/selected/enable", "", nil}, + {"modules list", "GET", "/selected/modules", "", nil}, + {"modules enable", "POST", "/explicit/modules", "name=ledger", []string{"ledger", "explicit"}}, + {"modules disable", "DELETE", "/selected/modules", "name=ledger", []string{"ledger"}}, + {"users list", "GET", "/selected/users", "", nil}, + {"users unlink", "DELETE", "/selected/users/user%2Fid", "", []string{"user/id"}}, + } { + t.Run(tc.command, func(t *testing.T) { + var calls atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + stackAssertRoute(t, r, tc.method, "/membership/organizations/org/stacks"+tc.suffix, tc.query) + stackTestJSON(t, w, http.StatusOK, `{"data":{"counter":123456789012345678901234567890}}`) + }) + req := stackTestRequest(tc.command, tc.args...) + if slicesStackConfirm(tc.command) { + req.Flags["confirm"] = "true" + } + if tc.command == "delete" { + req.Flags["force"], req.ChangedFlags["force"] = "false", true + } + result, err := executeStack(t.Context(), client, req, req.CommandPath) + if err != nil || string(result) != `{"data":{"counter":123456789012345678901234567890}}` || calls.Load() != 1 { + t.Fatalf("result=%s calls=%d err=%v", result, calls.Load(), err) + } + }) + } +} + +func stackAssertRoute(t *testing.T, r *http.Request, method, path, query string) { + t.Helper() + if r.Method != method || r.URL.EscapedPath() != path || r.URL.RawQuery != query { + t.Errorf("wrong route: %s %s", r.Method, r.URL.RequestURI()) + } +} + +func slicesStackConfirm(command string) bool { + return command == "delete" || command == "disable" || command == "enable" || command == "restore" || command == "modules disable" || command == "users unlink" +} + +func TestStackDestructiveConfirmationBeforeHTTP(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(http.ResponseWriter, *http.Request) { t.Error("unconfirmed command reached Membership") }) + for _, command := range []string{"delete", "disable", "enable", "restore", "upgrade", "modules disable", "users unlink"} { + args := []string{} + if strings.Contains(command, " ") { + args = []string{"resource"} + } + req := stackTestRequest(command, args...) + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Errorf("%s lacked confirmation", command) + } + } +} + +func TestStackRestoreWaitsOnMembershipOnly(t *testing.T) { + t.Parallel() + var restores, polls atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/restore") { + stackAssertRoute(t, r, "PUT", "/membership/organizations/org/stacks/selected/restore", "") + restores.Add(1) + stackTestJSON(t, w, 202, `{"data":{"id":"selected","version":"v3.3","status":"PROGRESSING"}}`) + return + } + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/selected", "") + polls.Add(1) + stackTestJSON(t, w, 200, `{"data":{"id":"selected","version":"v3.3","status":"READY"}}`) + }) + req := stackTestRequest("restore") + req.Flags["confirm"] = "true" + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + if restores.Load() != 1 || polls.Load() != 1 { + t.Fatalf("restores=%d polls=%d", restores.Load(), polls.Load()) + } +} + +func TestStackUpgradeSameVersionWaitsForReadyAndVersion(t *testing.T) { + t.Parallel() + var reads atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/selected", "") + status, version := "PROGRESSING", "v4.0" + switch reads.Add(1) { + case 2: + status, version = "READY", "v3.3" + case 3: + status = "READY" + } + stackTestJSON(t, w, 200, `{"data":{"id":"selected","regionID":"eu","status":"`+status+`","version":"`+version+`"}}`) + }) + req := stackTestRequest("upgrade") + req.Flags["confirm"], req.Flags["wait-timeout"] = "true", "5s" + data, err := executeStack(t.Context(), client, req, req.CommandPath) + if err != nil || reads.Load() != 3 { + t.Fatalf("data=%s reads=%d err=%v", data, reads.Load(), err) + } + ready, err := stackReadiness(data, "org", "selected", "v4.0") + if err != nil || !ready { + t.Fatalf("upgrade returned before readiness: data=%s err=%v", data, err) + } +} + +func TestStackUpgradeSameVersionNoWaitReturnsCurrent(t *testing.T) { + t.Parallel() + const current = `{"data":{"id":"selected","regionID":"eu","status":"PROGRESSING","version":"v4.0","counter":9007199254740993}}` + var reads atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/selected", "") + reads.Add(1) + stackTestJSON(t, w, 200, current) + }) + req := stackTestRequest("upgrade") + req.Flags["confirm"], req.Flags["no-wait"] = "true", "true" + data, err := executeStack(t.Context(), client, req, req.CommandPath) + if err != nil || string(data) != current || reads.Load() != 1 { + t.Fatalf("data=%s reads=%d err=%v", data, reads.Load(), err) + } +} + +func TestStackUpgradeSameVersionRejectsDisabled(t *testing.T) { + t.Parallel() + for _, state := range []string{`"status":"DISABLED"`, `"status":"READY","state":"DISABLED"`} { + t.Run(state, func(t *testing.T) { + var reads atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/selected", "") + reads.Add(1) + stackTestJSON(t, w, 200, `{"data":{"id":"selected","regionID":"eu","version":"v4.0",`+state+`}}`) + }) + req := stackTestRequest("upgrade") + req.Flags["confirm"] = "true" + data, err := executeStack(t.Context(), client, req, req.CommandPath) + if err == nil || !strings.Contains(err.Error(), "DISABLED") || len(data) != 0 || reads.Load() != 1 { + t.Fatalf("data=%s reads=%d err=%v", data, reads.Load(), err) + } + }) + } +} + +func TestStackUpgradeSameVersionWaitCanceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var reads atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/selected", "") + if reads.Add(1) == 2 { + cancel() + } + stackTestJSON(t, w, 200, `{"data":{"id":"selected","regionID":"eu","status":"PROGRESSING","version":"v4.0"}}`) + }) + req := stackTestRequest("upgrade") + req.Flags["confirm"] = "true" + data, err := executeStack(ctx, client, req, req.CommandPath) + if !errors.Is(err, context.Canceled) || len(data) != 0 || reads.Load() != 2 { + t.Fatalf("data=%s reads=%d err=%v", data, reads.Load(), err) + } +} + +func TestStackCreateCatalogRejectionNeverCreates(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, requested, catalog string }{ + {"different line", "v4.0", `{"data":[{"name":"v3.3"}]}`}, + {"patch is not exact", "v4.0", `{"data":[{"name":"v4.0.0"}]}`}, + {"deprecated", "v4.0", `{"data":[{"name":"v4.0","deprecated":true}]}`}, + {"requested version absent", "v4.1", `{"data":[{"name":"v4.0"}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/membership/organizations/org/regions/eu/versions" { + t.Errorf("catalog rejection sent %s %s", r.Method, r.URL.Path) + } + stackTestJSON(t, w, 200, tc.catalog) + }) + req := stackTestRequest("create", "sandbox") + req.Flags["region"], req.Flags["version"], req.ChangedFlags["version"] = "eu", tc.requested, true + if result, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil || len(result) != 0 { + t.Fatalf("created with rejected catalog: %s %v", result, err) + } + }) + } +} + +func TestStackCreateNoWaitUsesBodyAndDoesNotPoll(t *testing.T) { + t.Parallel() + var creates, reads atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + reads.Add(1) + if !strings.HasSuffix(r.URL.Path, "/regions/eu/versions") { + t.Errorf("no-wait polled %s", r.URL.Path) + } + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case "POST": + creates.Add(1) + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if string(body["version"]) != `"v4.0"` || string(body["regionID"]) != `"eu"` || string(body["metadata"]) != `{"purpose":"test"}` { + t.Errorf("wrong create body: %v", body) + } + stackTestJSON(t, w, 202, `{"data":{"id":"created","organizationId":"org","status":"PROGRESSING","count":9007199254740993}}`) + default: + t.Errorf("unexpected %s", r.Method) + } + }) + req := stackTestRequest("create") + req.Body = json.RawMessage(`{"name":"sandbox","regionID":"eu","metadata":{"purpose":"test"}}`) + req.Flags["no-wait"] = "true" + result, err := executeStack(t.Context(), client, req, []string{"cloud", "stack", "create"}) + if err != nil || !strings.Contains(string(result), "9007199254740993") || creates.Load() != 1 || reads.Load() != 1 { + t.Fatalf("result=%s creates=%d reads=%d err=%v", result, creates.Load(), reads.Load(), err) + } +} + +func TestStackCreateWaitUsesStableReturnedID(t *testing.T) { + t.Parallel() + var creates, polls atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/membership/organizations/org/regions/eu/versions": + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case "/membership/organizations/org/stacks": + if r.Method != "POST" { + t.Error("wait re-listed stacks instead of using created ID") + } + creates.Add(1) + stackTestJSON(t, w, 202, `{"data":{"id":"stable","organizationId":"org","status":"READY","version":"v3.3"}}`) + case "/membership/organizations/org/stacks/stable": + poll := polls.Add(1) + version := "v3.3" + if poll > 1 { + version = "v4.0" + } + stackTestJSON(t, w, 200, `{"data":{"id":"stable","organizationId":"org","status":"READY","version":"`+version+`","counter":9007199254740993}}`) + default: + t.Errorf("outside Membership route: %s", r.URL.Path) + } + }) + req := stackTestRequest("create", "same-name") + req.Flags["region"], req.Flags["wait-timeout"] = "eu", "5s" + result, err := executeStack(t.Context(), client, req, req.CommandPath) + if err != nil || !strings.Contains(string(result), `"version":"v4.0"`) || creates.Load() != 1 || polls.Load() != 2 { + t.Fatalf("result=%s creates=%d polls=%d err=%v", result, creates.Load(), polls.Load(), err) + } +} + +func TestStackCreateWaitCancellationNeverRecreates(t *testing.T) { + t.Parallel() + for _, timeout := range []bool{false, true} { + t.Run(map[bool]string{false: "caller cancellation", true: "wait timeout"}[timeout], func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var creates, polls atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/versions"): + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case r.Method == "POST": + creates.Add(1) + stackTestJSON(t, w, 202, `{"data":{"id":"stable"}}`) + case r.Method == "GET": + polls.Add(1) + stackTestJSON(t, w, 200, `{"data":{"id":"stable","status":"PROGRESSING"}}`) + if !timeout { + cancel() + } + default: + t.Errorf("unexpected route %s", r.URL) + } + }) + req := stackTestRequest("create", "sandbox") + req.Flags["region"], req.Flags["wait-timeout"] = "eu", "50ms" + result, err := executeStack(ctx, client, req, req.CommandPath) + want := context.Canceled + if timeout { + want = context.DeadlineExceeded + } + stackAssertCanceled(t, result, err, want, creates.Load(), polls.Load()) + }) + } +} + +func stackAssertCanceled(t *testing.T, result json.RawMessage, err, want error, creates, polls int32) { + t.Helper() + if !errors.Is(err, want) || len(result) != 0 || creates != 1 || polls != 1 || !strings.Contains(err.Error(), "stable") { + t.Fatalf("result=%s err=%v creates=%d polls=%d", result, err, creates, polls) + } +} + +func TestStackUpdatePreservesMetadata(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + stackTestJSON(t, w, 200, `{"data":{"name":"before","metadata":{"keep":"yes"},"counter":9007199254740993}}`) + return + } + var fields map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&fields); err != nil { + t.Error(err) + } + if len(fields) != 2 || string(fields["name"]) != `"after"` || string(fields["metadata"]) != `{"keep":"yes"}` { + t.Errorf("update lost fields or sent server fields: %v", fields) + } + stackTestJSON(t, w, 200, `{"data":{"name":"after"}}`) + }) + req := stackTestRequest("update") + req.Flags["name"] = "after" + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } +} + +func TestStackHistoryPaginationAndFilters(t *testing.T) { + t.Parallel() + want := url.Values{"stackId": {"selected"}, "pageSize": {"25"}, "action": {"stacks.update"}, "userId": {"SYSTEM"}, "key": {"metadata,env"}, "value": {"a=b"}} + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/membership/organizations/org/logs" || r.URL.RawQuery != want.Encode() { + t.Errorf("wrong logs query: %s", r.URL) + } + stackTestJSON(t, w, 200, `{"data":{"data":[],"next":"opaque","pageSize":25,"hasMore":true}}`) + }) + req := stackTestRequest("history") + req.Flags = map[string]string{"page-size": "25", "action": "stacks.update", "user-id": "SYSTEM", "data": "metadata,env=a=b"} + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + req.Flags["cursor"] = "cursor" + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("cursor combined with filters") + } +} + +func TestStackUserPolicyKeepsExactInteger(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if r.Method != "PUT" || !strings.HasSuffix(r.URL.Path, "/stacks/selected/users/u1") || string(body) != `{"policyId":9007199254740993}` { + t.Errorf("policy request: %s %s %s", r.Method, r.URL, body) + } + w.WriteHeader(http.StatusNoContent) + }) + req := stackTestRequest("users link", "u1") + req.Flags["policy-id"] = "9007199254740993" + if result, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil || string(result) != "null" { + t.Fatalf("result=%s err=%v", result, err) + } +} + +func TestStackUpgradeNoWaitAndAlreadyCurrent(t *testing.T) { + t.Parallel() + var upgrades atomic.Int32 + version := "v3.3" + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/versions"): + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case strings.HasSuffix(r.URL.Path, "/upgrade"): + if r.Method != "PUT" { + t.Error("upgrade method") + } + upgrades.Add(1) + version = "v4.0" + w.WriteHeader(http.StatusAccepted) + default: + stackTestJSON(t, w, 200, `{"data":{"id":"selected","regionID":"eu","version":"`+version+`"}}`) + } + }) + req := stackTestRequest("upgrade") + req.Flags["confirm"], req.Flags["no-wait"] = "true", "true" + for range 2 { + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + } + if upgrades.Load() != 1 { + t.Fatal("repeated upgrade recreated or upgraded current stack") + } +} + +func TestStackInvalidBodyAndWaitSettingsDoNotWrite(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(http.ResponseWriter, *http.Request) { t.Error("invalid request reached HTTP") }) + for _, body := range []string{`[]`, `{"name":null}`, `{"name":"x","unknown":1}`, `{"name":"x","metadata":{"bad":1}}`, `{"name":"x","metadata":{"bad":null}}`} { + req := stackTestRequest("create") + req.Body = json.RawMessage(body) + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Errorf("accepted %s", body) + } + } + req := stackTestRequest("create", "x") + req.Flags["wait-timeout"] = "-1s" + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("accepted negative wait timeout") + } +} + +func TestStackSelectsUniqueHostTargetWithoutMutatingRequest(t *testing.T) { + t.Parallel() + req := stackTestRequest("show") + delete(req.Context, "stack") + req.Context["stacks"] = `{"org":["one","one"],"other":["two"]}` + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks/one", "") + stackTestJSON(t, w, 200, `{"data":{"id":"one"}}`) + }) + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } + if req.Context["stack"] != "" || len(req.Flags) != 0 { + t.Fatal("execution mutated caller defaults") + } + req.Context["stacks"] = `{"org":["one","two"]}` + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil || !strings.Contains(err.Error(), "one, two") { + t.Fatalf("ambiguous target: %v", err) + } + req.Args = []string{".."} + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("dot segment accepted as a stack ID") + } +} + +func TestStackCreateSelectsUniqueRegion(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/membership/organizations/org/regions": + stackTestJSON(t, w, 200, `{"data":[{"id":"eu"}]}`) + case "/membership/organizations/org/regions/eu/versions": + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case "/membership/organizations/org/stacks": + stackTestJSON(t, w, 202, `{"data":{"id":"created","version":"v4.0"}}`) + default: + t.Errorf("unexpected route %s", r.URL) + } + }) + req := stackTestRequest("create") + req.Flags["name"], req.Flags["no-wait"] = "sandbox", "true" + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil { + t.Fatal(err) + } +} + +func TestStackCreateAcceptsExplicitCatalogVersions(t *testing.T) { + t.Parallel() + for _, version := range []string{"v3.3", "v4.1", "v4.0.0"} { + t.Run(version, func(t *testing.T) { + var creates atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + stackTestJSON(t, w, 200, `{"data":[{"name":"`+version+`"}]}`) + return + } + creates.Add(1) + var body struct { + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Version != version { + t.Errorf("explicit version changed: %q %v", body.Version, err) + } + stackTestJSON(t, w, 202, `{"data":{"id":"created"}}`) + }) + req := stackTestRequest("create", "sandbox") + req.Flags["region"], req.Flags["no-wait"], req.Flags["version"] = "eu", "true", version + req.ChangedFlags["version"] = true + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err != nil || creates.Load() != 1 { + t.Fatalf("explicit catalog version failed: calls=%d %v", creates.Load(), err) + } + }) + } +} + +func TestStackCreateAmbiguousRegionNeverCreates(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || !strings.HasSuffix(r.URL.Path, "/regions") { + t.Errorf("ambiguous region caused write: %s %s", r.Method, r.URL) + } + stackTestJSON(t, w, 200, `{"data":[{"id":"eu"},{"id":"us"}]}`) + }) + req := stackTestRequest("create", "sandbox") + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("ambiguous region was selected arbitrarily") + } +} + +func TestStackShowByNamePreservesRawMetadataAndRejectsDuplicates(t *testing.T) { + t.Parallel() + var duplicate atomic.Bool + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + stackAssertRoute(t, r, "GET", "/membership/organizations/org/stacks", "") + body := `{"data":[{"id":"one","name":"sandbox","counter":9007199254740993}]}` + if duplicate.Load() { + body = `{"data":[{"id":"one","name":"sandbox"},{"id":"two","name":"sandbox"}]}` + } + stackTestJSON(t, w, 200, body) + }) + req := stackTestRequest("show") + req.Flags["name"] = "sandbox" + data, err := executeStack(t.Context(), client, req, req.CommandPath) + if err != nil || !strings.Contains(string(data), "9007199254740993") { + t.Fatalf("raw metadata lost: %s %v", data, err) + } + duplicate.Store(true) + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("duplicate name selected arbitrarily") + } +} + +func TestStackCreateWaitFailureNeverRetriesWrite(t *testing.T) { + t.Parallel() + var creates, polls atomic.Int32 + client := stackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/versions"): + stackTestJSON(t, w, 200, `{"data":[{"name":"v4.0"}]}`) + case r.Method == "POST": + creates.Add(1) + stackTestJSON(t, w, 202, `{"data":{"id":"stable"}}`) + default: + polls.Add(1) + stackTestJSON(t, w, 503, `{"errorCode":"UNAVAILABLE"}`) + } + }) + req := stackTestRequest("create", "sandbox") + req.Flags["region"] = "eu" + if data, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil || len(data) != 0 || creates.Load() != 1 || polls.Load() != 1 { + t.Fatalf("data=%s err=%v creates=%d polls=%d", data, err, creates.Load(), polls.Load()) + } +} + +func TestStackBodyConflictsDoNotReachHTTP(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(http.ResponseWriter, *http.Request) { t.Error("body conflict caused HTTP") }) + req := stackTestRequest("create", "first") + req.Body = json.RawMessage(`{"name":"different","regionID":"eu"}`) + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("conflicting name silently overwritten") + } + req = stackTestRequest("create", "first") + req.Flags["data"] = `{"metadata":{"x":"y"}}` + if _, err := executeStack(t.Context(), client, req, req.CommandPath); err == nil { + t.Fatal("unread host body ignored") + } +} + +func TestStackWaitRejectsChangedIdentity(t *testing.T) { + t.Parallel() + client := stackTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + stackTestJSON(t, w, 200, `{"data":{"id":"different","status":"READY","version":"v4.0"}}`) + }) + if _, err := waitStack(t.Context(), client, "org", "stable", "v4.0", time.Second); err == nil { + t.Fatal("poll accepted another resource") + } +} diff --git a/renovate.json b/renovate.json index 73098e6a..7175fea4 100644 --- a/renovate.json +++ b/renovate.json @@ -2,9 +2,5 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>formancehq/shared-config//renovate/presets/golang" - ], - "ignoreDeps": [ - "github.com/formancehq/fctl/internal/membershipclient" - ], - "ignorePaths": ["**/membershipclient/**"] + ] } diff --git a/tests/integration/auth_discovery_only_test.go b/tests/integration/auth_discovery_only_test.go new file mode 100644 index 00000000..eea89403 --- /dev/null +++ b/tests/integration/auth_discovery_only_test.go @@ -0,0 +1,84 @@ +package integration_test + +import ( + "errors" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func TestAuthFreshHelpHasNoEmbeddedCommands(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + out, trace, err := executeExternalCLI(t, []string{"--config-dir", t.TempDir(), "auth", "--help"}) + if err != nil || trace != "" || !strings.Contains(out, "plugins sync --service auth") || strings.Contains(out, "clients") { + t.Fatalf("unprepared Auth help: %s %s %v", out, trace, err) + } +} + +func TestAuthMissingReleaseNeverFallsBack(t *testing.T) { + catalogue := filepath.Join(t.TempDir(), "empty.json") + writeAuthCatalogue(t, catalogue) + t.Setenv("FCTL_PLUGIN_CATALOGUE", catalogue) + out, _, err := executeExternalCLI(t, []string{"--config-dir", t.TempDir(), "--auth-mode", "none", "--auth-url", "http://127.0.0.1:1", "auth", "clients", "list"}) + // An explicit catalogue first discovers the exact service version. Use a real + // fixture below to distinguish missing release from a failed service endpoint. + if err == nil || out != "" { + t.Fatalf("Auth without a release executed: %s %v", out, err) + } + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + out, _, err = executeExternalCLI(t, append(authDistributionArgs(t.TempDir(), server.URL), "auth", "clients", "create")) + if !errors.Is(err, pluginmanager.ErrNoRelease) || out != "" || mutations.Load() != 0 { + t.Fatalf("missing Auth release fell back: %s %v writes=%d", out, err, mutations.Load()) + } +} + +func TestAuthFirstCommandDiscoversAndCachesExecutable(t *testing.T) { + registry, reads := authOCIRegistry(t) + manager, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + binary := buildDistributionAuth(t, "1.0.0") + release := publishDistributionAuth(t, manager, registry.URL, binary, "1.0.0", 1) + catalogue := filepath.Join(t.TempDir(), "catalogue.json") + writeAuthCatalogue(t, catalogue, release) + t.Setenv("FCTL_PLUGIN_CATALOGUE", catalogue) + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + out := runAuthDistribution(t, args, "auth", "clients", "list") + if !strings.Contains(out, "distributed-client") || reads.Load() != 3 { + t.Fatalf("automatic Auth preparation: %s reads=%d", out, reads.Load()) + } + out = runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"service": "auth"`) { + t.Fatalf("missing prepared lock: %s", out) + } + registry.Close() + server.Close() + assertAuthOffline(t, args) +} + +func TestAuthUnpreparedFlagsUseStandardParsing(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "https://invalid.example/catalogue") + for _, tail := range [][]string{{"auth"}, {"auth", "--help=true"}, {"auth", "--help"}} { + args := append([]string{"--config-dir", t.TempDir()}, tail...) + out, trace, err := executeExternalCLI(t, args) + if err != nil || trace != "" || !strings.Contains(out, "plugins sync --service auth") { + t.Fatalf("unprepared help %v: %s %s %v", tail, out, trace, err) + } + } + out, _, err := executeExternalCLI(t, []string{"--config-dir", t.TempDir(), "auth", "--typo", "--help"}) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --typo") || out != "" { + t.Fatalf("unknown flag bypassed validation: %s %v", out, err) + } +} diff --git a/tests/integration/auth_distribution_test.go b/tests/integration/auth_distribution_test.go new file mode 100644 index 00000000..c60b4d4c --- /dev/null +++ b/tests/integration/auth_distribution_test.go @@ -0,0 +1,356 @@ +package integration_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// This deliberately small executable tests the public transport and distribution +// lifecycle independently of private product sources. Product integration tests +// can additionally build github.com/formancehq/auth/misc/fctl-plugin. +func buildDistributionAuth(t *testing.T, version string) string { + t.Helper() + source := fmt.Sprintf(`package main +import ( + "context" + "fmt" + "net/http" + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) +type plugin struct { client *http.Client } +func (p *plugin) GetManifest(context.Context) (pluginsdk.Manifest,error) { + return pluginsdk.Manifest{Name:"auth", Service:"auth", Version:%q, ProtocolVersion:pluginsdk.ProtocolVersion, + Root:pluginsdk.CommandSpec{Use:"auth",Target:"stack",Short:"Distributed Auth fixture",Subcommands:[]pluginsdk.CommandSpec{ + {Use:"clients",Subcommands:[]pluginsdk.CommandSpec{{Use:"list",Runnable:true},{Use:"create",Runnable:true}, + {Use:"secrets",Subcommands:[]pluginsdk.CommandSpec{{Use:"create CLIENT_ID",Runnable:true,Args:pluginsdk.ArgsSpec{Min:1,Max:1},Flags:[]pluginsdk.FlagSpec{{Name:"data",Type:"string",Body:true}}}}}, + }}, + }}},nil +} +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse,error) { + client,err:=httpclient.New(req.Endpoint,p.client) + if err!=nil { return pluginsdk.ExecuteResponse{},err } + method:=http.MethodGet + if req.CommandPath[len(req.CommandPath)-1]=="create" { method=http.MethodPost } + path:="/clients" + if len(req.CommandPath)>2 && req.CommandPath[2]=="secrets" { path=fmt.Sprintf("/clients/%%s/secrets",req.Args[0]) } + data,err:=client.Do(ctx,method,path,nil,req.Body,nil) + return pluginsdk.ExecuteResponse{Data:data},err +} +func main() { transport.Serve(func(client *http.Client) pluginsdk.Plugin { return &plugin{client:client} }) } +`, version) + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "fctl-plugin-auth") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, path) //nolint:gosec // Fixed Go build command and test-owned source and output paths. + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Auth fixture: %v: %s", err, output) + } + return binary +} + +// Cache real executable metadata without contacting the service or changing +// authentication counters in the host's session and debug tests. +func cacheDistributionAuth(t *testing.T, dir string, target pluginmanager.Target) { + t.Helper() + binary := buildDistributionAuth(t, "1.0.0") + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect Auth fixture: %v %v", err, closeErr) + } + manager, err := pluginmanager.New(filepath.Join(dir, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + if _, err := manager.InstallLocal(t.Context(), binary, target, manifest); err != nil { + t.Fatal(err) + } +} + +func authDistributionAPI(t *testing.T, version *atomic.Value, mutations *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/gateway/auth/_info": + externalInfoResponse(t, w, version) + case "/gateway/auth/clients": + if r.Method == http.MethodPost { + mutations.Add(1) + } + externalFixtureResponse(t, w, `{"data":[{"name":"distributed-client"}]}`) + default: + t.Errorf("unexpected Auth request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} +func authDistributionArgs(dir, endpoint string) []string { + return []string{"--config-dir", dir, "--auth-mode", "none", "--auth-url", endpoint + "/gateway/auth", "--no-input", "-o", "json"} +} +func runAuthDistribution(t *testing.T, args []string, tail ...string) string { + t.Helper() + out, trace, err := executeExternalCLI(t, append(append([]string{}, args...), tail...)) + if err != nil { + t.Fatalf("%v: stdout=%s stderr=%s err=%v", tail, out, trace, err) + } + return out +} +func assertAuthOffline(t *testing.T, args []string) { + t.Helper() + out := runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"service": "auth"`) { + t.Fatalf("Auth lock: %s", out) + } + out = runAuthDistribution(t, args, "auth", "--help") + if !strings.Contains(out, "Distributed Auth fixture") { + t.Fatalf("offline manifest: %s", out) + } + out = runAuthDistribution(t, args, "__complete", "auth", "") + if !strings.Contains(out, "clients") { + t.Fatalf("offline completion: %s", out) + } +} +func assertAuthDrift(t *testing.T, args []string, mutations *atomic.Int32) { + t.Helper() + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "auth", "clients", "create")) + if err == nil || out != "" || mutations.Load() != 1 { + t.Fatalf("version drift reached mutation: %s %v writes=%d", out, err, mutations.Load()) + } +} +func TestAuthLocalDistributionCLI(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + binary := buildDistributionAuth(t, "1.0.0") + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + out := runAuthDistribution(t, args, "plugins", "install", "--service", "auth", "--binary", binary) + if !strings.Contains(out, `"source": "local"`) { + t.Fatalf("local lock: %s", out) + } + out = runAuthDistribution(t, args, "auth", "clients", "create") + if !strings.Contains(out, "distributed-client") || mutations.Load() != 1 { + t.Fatalf("Auth mutation: %s writes=%d", out, mutations.Load()) + } + version.Store("1.0.1") + assertAuthDrift(t, args, &mutations) + server.Close() + // Offline metadata must survive a missing cached executable: no subprocess + // can provide the help or completion manifest after removing these bytes. + manager, err := pluginmanager.New(filepath.Join(args[1], "plugins"), nil) + if err != nil { + t.Fatal(err) + } + locks, err := manager.List() + if err != nil || len(locks) != 1 { + t.Fatalf("local Auth lock: %+v %v", locks, err) + } + cached, err := manager.Binary(locks[0]) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(cached); err != nil { + t.Fatal(err) + } + assertAuthOffline(t, args) +} + +// Real loopback OCI upload/download requests, with checksum validation on upload. +func authOCIRegistry(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + var mu sync.Mutex + objects := map[string][]byte{} + reads := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + const base = "/v2/formance/auth/" + if !strings.HasPrefix(r.URL.Path, base) { + w.WriteHeader(http.StatusNotFound) + return + } + switch r.Method { + case http.MethodPost: + w.Header().Set("Location", base+"blobs/uploads/fixture") + w.WriteHeader(http.StatusAccepted) + case http.MethodPut: + storeAuthOCIUpload(t, w, r, objects) + case http.MethodGet: + reads.Add(1) + data, ok := objects[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + externalFixtureResponse(t, w, string(data)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + return server, reads +} +func publishDistributionAuth(t *testing.T, manager *pluginmanager.Manager, registry, binary, version string, revision int) pluginmanager.Release { + t.Helper() + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect Auth: %v %v", err, closeErr) + } + release, err := manager.Publish(t.Context(), registry+"/formance/auth", pluginmanager.Release{Service: "auth", ServiceVersion: version, Revision: revision, Platform: pluginmanager.CurrentPlatform(), Manifest: manifest}, binary) + if err != nil { + t.Fatal(err) + } + return release +} +func writeAuthCatalogue(t *testing.T, path string, releases ...pluginmanager.Release) { + t.Helper() + data, err := json.Marshal(pluginmanager.Catalogue{SchemaVersion: pluginmanager.SchemaVersion, Releases: releases}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAuthOCIDistributionCLI(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + registry, reads := authOCIRegistry(t) + manager, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + binary := buildDistributionAuth(t, "1.0.0") + v1 := publishDistributionAuth(t, manager, registry.URL, binary, "1.0.0", 1) + next := buildDistributionAuth(t, "1.0.1") + v2 := publishDistributionAuth(t, manager, registry.URL, next, "1.0.1", 1) + catalogue := filepath.Join(t.TempDir(), "catalogue.json") + writeAuthCatalogue(t, catalogue, v1, v2) + var version atomic.Value + version.Store("1.0.0") + var mutations atomic.Int32 + server := authDistributionAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + out := runAuthDistribution(t, args, "plugins", "sync", "--service", "auth", "--catalogue", catalogue) + if !strings.Contains(out, `"service": "auth"`) || reads.Load() != 3 { + t.Fatalf("OCI lock/download: %s reads=%d", out, reads.Load()) + } + runAuthDistribution(t, args, "auth", "clients", "create") + v1.Revision = 2 + writeAuthCatalogue(t, catalogue, v1, v2) + runAuthDistribution(t, args, "auth", "clients", "list") + out = runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"revision": 1`) || reads.Load() != 3 { + t.Fatalf("revision retained: %s reads=%d", out, reads.Load()) + } + // Reuse the Auth lock's catalogue, rather than the Ledger/default source. + out = runAuthDistribution(t, args, "plugins", "sync", "--service", "auth") + if !strings.Contains(out, `"revision": 2`) { + t.Fatalf("revision upgrade: %s", out) + } + version.Store("1.0.1") + runAuthDistribution(t, args, "auth", "clients", "list") + if reads.Load() != 6 { + t.Fatalf("exact new version not downloaded: %d", reads.Load()) + } + version.Store("1.0.2") + assertAuthDrift(t, args, &mutations) + registry.Close() + version.Store("1.0.1") + runAuthDistribution(t, args, "auth", "clients", "list") + server.Close() + assertAuthOffline(t, args) +} + +func TestAuthDistributionRejectsUnknownAndMismatchedServices(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + dir := t.TempDir() + args := authDistributionArgs(dir, "http://127.0.0.1:1") + for _, command := range []string{"install", "sync", "show"} { + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "plugins", command, "--service", "wallets")) + if err == nil || !strings.Contains(err.Error(), "unsupported external plugin service") || out != "" { + t.Fatalf("%s accepted unknown service: %s %v", command, out, err) + } + } + binary := buildDistributionAuth(t, "1.0.0") + for _, tail := range [][]string{ + {"plugins", "install", "--binary", binary, "--service-version", "1.0.0"}, + {"plugins", "install", "--service", "auth", "--binary", binary, "--service-version", "1.0.1"}, + } { + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), tail...)) + if err == nil || out != "" { + t.Fatalf("mismatch accepted: %s %v", out, err) + } + } + manager, err := pluginmanager.New(filepath.Join(dir, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + locks, err := manager.List() + if err != nil || len(locks) != 0 { + t.Fatalf("rejected installation wrote locks: %+v %v", locks, err) + } +} + +func storeAuthOCIUpload(t *testing.T, w http.ResponseWriter, r *http.Request, objects map[string][]byte) { + t.Helper() + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + path := r.URL.Path + digest := filepath.Base(path) + if strings.Contains(path, "/blobs/uploads/") { + digest = r.URL.Query().Get("digest") + path = "/v2/formance/auth/blobs/" + digest + } + checksum := sha256.Sum256(data) + if digest != "sha256:"+hex.EncodeToString(checksum[:]) { + t.Error("OCI upload checksum mismatch") + w.WriteHeader(http.StatusBadRequest) + return + } + objects[path] = data + w.Header().Set("Docker-Content-Digest", digest) + w.WriteHeader(http.StatusCreated) +} diff --git a/tests/integration/auth_product_test.go b/tests/integration/auth_product_test.go new file mode 100644 index 00000000..61367423 --- /dev/null +++ b/tests/integration/auth_product_test.go @@ -0,0 +1,153 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func buildAuthProduct(t *testing.T) string { + t.Helper() + binary := os.Getenv("FCTL_TEST_AUTH_PLUGIN_BINARY") + if binary == "" { + t.Skip("set FCTL_TEST_AUTH_PLUGIN_BINARY to test the independently built or published Auth executable") + } + if !filepath.IsAbs(binary) { + // Preserve paths relative to cmd, where these tests ran before extraction. + binary = filepath.Join("..", "..", "cmd", binary) + } + binary, err := filepath.Abs(binary) + if err != nil { + t.Fatal(err) + } + + return binary +} + +func authProductAPI(t *testing.T, version *atomic.Value, mutations *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/gateway/auth/_info": + externalInfoResponse(t, w, version) + case "/gateway/auth/clients": + if r.Method == http.MethodPost { + mutations.Add(1) + w.WriteHeader(http.StatusCreated) + externalFixtureResponse(t, w, `{"data":{"id":"product-id","name":"product-client"}}`) + } else { + externalFixtureResponse(t, w, `{"data":[{"id":"product-id","name":"distributed-client"}]}`) + } + default: + t.Errorf("unexpected Auth product request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func installAuthProduct(t *testing.T, args []string, source, binary, version string) { + t.Helper() + if source == "local" { + runAuthDistribution(t, args, "plugins", "install", "--service", "auth", "--binary", binary) + return + } + registry, _ := authOCIRegistry(t) + manager, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + release := publishDistributionAuth(t, manager, registry.URL, binary, version, 1) + catalogue := filepath.Join(t.TempDir(), "catalogue.json") + writeAuthCatalogue(t, catalogue, release) + runAuthDistribution(t, args, "plugins", "sync", "--service", "auth", "--catalogue", catalogue) +} + +func assertAuthProductExecution(t *testing.T, args []string, version *atomic.Value, mutations *atomic.Int32) { + t.Helper() + out := runAuthDistribution(t, args, "auth", "clients", "list") + if !json.Valid([]byte(out)) || !strings.Contains(out, "distributed-client") { + t.Fatalf("product response: %s", out) + } + out = runAuthDistribution(t, args, "auth", "clients", "create", "--data", `{"name":"product-client"}`) + if !json.Valid([]byte(out)) || mutations.Load() != 1 { + t.Fatalf("product mutation: %s writes=%d", out, mutations.Load()) + } + version.Store("1.0.1") + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), "auth", "clients", "create", "--data", `{"name":"must-not-write"}`)) + if err == nil || out != "" || mutations.Load() != 1 { + t.Fatalf("product drift: %s %v writes=%d", out, err, mutations.Load()) + } +} + +func assertAuthProductOffline(t *testing.T, args []string) { + t.Helper() + out := runAuthDistribution(t, args, "auth", "--help") + if !strings.Contains(out, "Manage Auth clients") { + t.Fatalf("product offline help: %s", out) + } + out = runAuthDistribution(t, args, "__complete", "auth", "") + if !strings.Contains(out, "clients") { + t.Fatalf("product offline completion: %s", out) + } +} + +// Exercise the product-owned executable through both supported sources. +func TestAuthProductExecutableDistribution(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + binary := buildAuthProduct(t) + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect Auth product: %v %v", err, closeErr) + } + for _, source := range []string{"local", "oci"} { + t.Run(source, func(t *testing.T) { + var version atomic.Value + version.Store(manifest.Version) + var mutations atomic.Int32 + server := authProductAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + installAuthProduct(t, args, source, binary, manifest.Version) + assertAuthProductExecution(t, args, &version, &mutations) + server.Close() + assertAuthProductOffline(t, args) + }) + } +} + +// Live distribution smoke test, explicitly enabled for a published version. +func TestAuthOfficialCatalogueDiscovery(t *testing.T) { + serviceVersion := os.Getenv("FCTL_TEST_AUTH_OFFICIAL_VERSION") + if serviceVersion == "" { + t.Skip("set FCTL_TEST_AUTH_OFFICIAL_VERSION to verify public catalogue and GHCR discovery") + } + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + var version atomic.Value + version.Store(serviceVersion) + var mutations atomic.Int32 + server := authProductAPI(t, &version, &mutations) + args := authDistributionArgs(t.TempDir(), server.URL) + assertAuthProductExecution(t, args, &version, &mutations) + out := runAuthDistribution(t, args, "plugins", "show", "--service", "auth") + if !strings.Contains(out, `"version": "`+serviceVersion+`"`) || !strings.Contains(out, `"revision": 1`) { + t.Fatalf("official Auth lock: %s", out) + } + server.Close() + assertAuthProductOffline(t, args) +} diff --git a/tests/integration/cloud_integration_test.go b/tests/integration/cloud_integration_test.go new file mode 100644 index 00000000..30d686f0 --- /dev/null +++ b/tests/integration/cloud_integration_test.go @@ -0,0 +1,871 @@ +package integration_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/spf13/cobra" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/cloud" + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +const ( + cliMembershipToken = "cli-membership-secret" + cliRefreshToken = "cli-refresh-secret" //nolint:gosec // Synthetic OAuth2 fixture token used only to test secret redaction. + cliDeviceCode = "cli-device-secret" + cliRotatedMembershipToken = "cli-rotated-membership-secret" //nolint:gosec // Synthetic Membership token for the cancellation regression. + cliRotatedRefreshToken = "cli-rotated-refresh-secret" //nolint:gosec // Synthetic refresh token for the cancellation regression. +) + +// Each execution creates a fresh root command; only the private config survives. +func TestCloudCLILifecycle(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t) + dir := t.TempDir() + cacheDistributionAuth(t, dir, pluginmanager.Target{Profile: "cloud", Organization: "org", Stack: "stack", Endpoint: f.issuer()}) + f.run(t, dir, "profiles", "add", "cloud", "--auth-mode", "cloud", "--issuer", f.issuer(), "--organization", "org", "--stack", "stack") + f.assertConnectionViews(t, dir, false) + out, stderr := f.run(t, dir, "login") + assertCLICloudJSON(t, out, `{"loggedIn":true}`) + f.assertDeviceInstructions(t, out, stderr) + f.assertRootSession(t, dir) + f.assertCounts(t, 1, 1, 0, 0, 0, 0) + f.assertConnectionViews(t, dir, true) + out, stderr = f.run(t, dir, "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + f.assertDeviceInstructions(t, out, stderr) + f.assertTargetSaved(t, dir, "org", "stack") + f.assertCounts(t, 3, 3, 1, 1, 0, 1) + out, stderr = f.run(t, dir, "auth", "clients", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + if stderr != "" { + t.Fatal("cached target requested authorization again") + } + out, stderr = f.run(t, dir, "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + if stderr != "" { + t.Fatal("cached target requested authorization again") + } + f.assertCounts(t, 3, 3, 1, 2, 1, 3) + f.assertConnectionViews(t, dir, true) + out, _ = f.run(t, dir, "logout") + assertCLICloudJSON(t, out, `{"loggedIn":false}`) + store := readCLICloudStore(t, dir) + if store.Active != "cloud" || store.Connections["cloud"].Session != nil { + t.Fatal("logout did not clear the root session and targets") + } + f.assertNoSecrets(t, string(readCLICloudData(t, dir))) + f.assertConnectionViews(t, dir, false) + f.assertLoggedOut(t, dir) +} + +func TestCloudCLILoginWithoutProfile(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t) + dir := t.TempDir() + out, stderr := f.run(t, dir, "login", "--issuer", f.issuer()) + assertCLICloudJSON(t, out, `{"loggedIn":true}`) + f.assertDeviceInstructions(t, out, stderr) + f.assertRootSession(t, dir) + entry := readCLICloudStore(t, dir).Connections["cloud"] + if entry.Options.AuthMode != "cloud" || entry.Options.Issuer != f.issuer() || entry.Options.Organization != "" || entry.Options.Stack != "" { + t.Fatal("login did not create a target-free Cloud profile") + } + f.assertConnectionViews(t, dir, true) + out, _ = f.run(t, dir, "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + f.assertTargetSaved(t, dir, "org", "stack") + f.assertCounts(t, 3, 3, 1, 1, 0, 1) +} + +func TestCloudCLITargetSelectionAndCache(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t, true) + dir := t.TempDir() + f.run(t, dir, "login", "--issuer", f.issuer()) + for _, flags := range [][]string{nil, {"--organization", "org"}, {"--organization", "missing", "--stack", "stack"}, {"--organization", "other", "--stack", "stack"}} { + f.assertTargetRefused(t, dir, flags) + } + f.assertCounts(t, 1, 1, 0, 0, 0, 0) + for _, target := range [][2]string{{"other", "otherstack"}, {"org", "second"}, {"other", "otherstack"}} { + out, _ := f.run(t, dir, "--organization", target[0], "--stack", target[1], "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + f.assertTargetSaved(t, dir, target[0], target[1]) + } + f.assertCounts(t, 5, 5, 2, 3, 0, 3) + f.assertRootSession(t, dir) + entry := readCLICloudStore(t, dir).Connections["cloud"] + if len(entry.Session.Targets) != 2 || entry.Options.Organization != "" || entry.Options.Stack != "" { + t.Fatal("target override replaced defaults or lost cached targets") + } + f.assertTargetRefused(t, dir, nil) + f.assertConnectionViews(t, dir, true) +} + +func TestCloudCLISavedTargetDefaults(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t, true) + dir := t.TempDir() + f.run(t, dir, "profiles", "add", "cloud", "--auth-mode", "cloud", "--issuer", f.issuer(), "--organization", "org", "--stack", "stack") + f.run(t, dir, "login") + f.run(t, dir, "ledger", "list") + f.assertTargetSaved(t, dir, "org", "stack") + f.run(t, dir, "--organization", "other", "--stack", "otherstack", "ledger", "list") + f.run(t, dir, "ledger", "list") + f.assertCounts(t, 5, 5, 2, 3, 0, 3) + f.assertConnectionViews(t, dir, true) + entry := readCLICloudStore(t, dir).Connections["cloud"] + if entry.Options.Organization != "org" || entry.Options.Stack != "stack" { + t.Fatal("override mutated saved target defaults") + } +} + +func TestCloudCLITargetRefreshPreservesOtherTargets(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t, true) + dir := t.TempDir() + f.run(t, dir, "login", "--issuer", f.issuer()) + f.run(t, dir, "--organization", "org", "--stack", "stack", "ledger", "list") + f.run(t, dir, "--organization", "other", "--stack", "otherstack", "ledger", "list") + if err := connection.Update(t.Context(), dir, func(store *connection.Store) error { + target := findCLICloudTarget(t, store.Connections["cloud"], "org", "stack") + target.MembershipToken.Expiry = time.Now().Add(-time.Hour) + return nil + }); err != nil { + t.Fatal(err) + } + out, stderr := f.run(t, dir, "--organization", "org", "--stack", "stack", "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + if stderr != "" { + t.Fatal("refresh repeated device authorization") + } + f.assertTargetSaved(t, dir, "org", "stack") + f.run(t, dir, "--organization", "other", "--stack", "otherstack", "ledger", "list") + f.assertTargetSaved(t, dir, "other", "otherstack") + f.assertCounts(t, 5, 5, 3, 4, 0, 4) + if f.targets[0].refreshes.Load() != 1 || f.targets[2].refreshes.Load() != 0 { + t.Fatal("refresh affected the wrong target") + } + f.assertRootSession(t, dir) +} + +func TestCloudCLIRefreshSavedAfterCancellation(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t) + dir := t.TempDir() + f.run(t, dir, "login", "--issuer", f.issuer()) + f.run(t, dir, "ledger", "list") + if err := connection.Update(t.Context(), dir, func(store *connection.Store) error { + for _, target := range store.Connections["cloud"].Session.Targets { + target.MembershipToken.Expiry = time.Now().Add(-time.Hour) + } + return nil + }); err != nil { + t.Fatal(err) + } + before := readCLICloudStore(t, dir).Connections["cloud"].Revision + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + f.cancelExchange.Store(&cancel) + f.cacheLedger(t, dir) + args := []string{"--no-browser", "--config-dir", dir, "--profile", "cloud", "ledger", "list"} + root := cmd.NewRootCommandWithArgs(ctx, args) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + err := root.ExecuteContext(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("command error = %v, want cancellation during Stack exchange", err) + } + f.assertNoSecrets(t, stdout.String()+stderr.String()+err.Error()) + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Error("canceled command produced output") + } + f.assertCounts(t, 3, 3, 2, 1, 0, 1) + target := f.targets[0] + if target.refreshes.Load() != 1 { + t.Fatal("target Membership was not refreshed exactly once") + } + entry := readCLICloudStore(t, dir).Connections["cloud"] + if entry.Revision == before { + t.Error("rotated credentials were not saved with a new revision") + } + saved := findCLICloudTarget(t, entry, "org", "stack") + token := saved.MembershipToken + if token == nil || token.RefreshToken != cliRotatedRefreshToken || token.AccessToken != cliRotatedMembershipToken { + t.Fatal("cancellation lost rotated target credentials on disk") + } + if token.TokenType != "Bearer" || !token.Expiry.After(time.Now()) || saved.StackToken != nil { + t.Fatal("invalid target tokens after canceled exchange") + } + f.assertRootSession(t, dir) +} + +func TestCloudCLILoginFailurePreservesConfiguration(t *testing.T) { + t.Parallel() + for _, profile := range []string{"fresh", "fresh explicit", "existing issuer override", "existing target change"} { + for _, mode := range []string{"failure", "cancellation"} { + t.Run(profile+"/"+mode, func(t *testing.T) { t.Parallel(); testCLICloudFailedLogin(t, profile, mode) }) + } + } +} + +func testCLICloudFailedLogin(t *testing.T, profile, mode string) { + t.Helper() + original, provider, dir, args := prepareCLICloudFailedLogin(t, profile) + before := snapshotCLICloudConfiguration(t, dir) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if mode == "failure" { + provider.failDevice.Store(true) + } else { + provider.cancelPoll.Store(&cancel) + } + devices, polls := provider.devices.Load(), provider.polls.Load() + out, stderr, err := executeCLICloudContext(ctx, cmd.NewRootCommand(), append([]string{"--no-browser", "--config-dir", dir}, args...)) + if err == nil { + t.Fatal("failed login unexpectedly succeeded") + } + if mode == "cancellation" && !errors.Is(err, context.Canceled) { + t.Fatalf("login error = %v, want cancellation", err) + } + original.assertNoSecrets(t, out+stderr+err.Error()) + provider.assertNoSecrets(t, out+stderr+err.Error()) + if out != "" { + t.Error("failed login printed success JSON") + } + expectedPolls := polls + if mode == "cancellation" { + expectedPolls++ + } + if provider.devices.Load() != devices+1 || provider.polls.Load() != expectedPolls { + t.Fatal("failure did not occur during the intended authentication phase") + } + assertCLICloudConfigurationUnchanged(t, dir, before) +} + +func prepareCLICloudFailedLogin(t *testing.T, profile string) (*cliCloudFixture, *cliCloudFixture, string, []string) { + t.Helper() + f := newCLICloudFixture(t, true) + dir := t.TempDir() + args := []string{"login", "--issuer", f.issuer()} + if profile == "fresh" { + return f, f, dir, args + } + if profile == "fresh explicit" { + return f, f, dir, append(args, "--profile", "new-cloud") + } + f.run(t, dir, "login", "--issuer", f.issuer(), "--organization", "org", "--stack", "stack") + f.run(t, dir, "ledger", "list") + f.assertTargetSaved(t, dir, "org", "stack") + f.run(t, dir, "profiles", "add", "local", "--auth-mode", "none", "--ledger-url", f.server.URL+"/unused") + f.run(t, dir, "profiles", "use", "local") + args = []string{"login", "--profile", "cloud"} + if profile == "existing issuer override" { + other := newCLICloudFixture(t, true) + return f, other, dir, append(args, "--issuer", other.issuer()) + } + return f, f, dir, append(args, "--organization", "other", "--stack", "otherstack") +} + +func executeCLICloudContext(ctx context.Context, root *cobra.Command, args []string) (string, string, error) { + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + err := root.ExecuteContext(ctx) + return stdout.String(), stderr.String(), err +} + +// nil records absence; a failed first login must not create connections.json. +func snapshotCLICloudConfiguration(t *testing.T, dir string) []byte { + t.Helper() + _, err := os.Stat(filepath.Join(dir, "connections.json")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + t.Fatal(err) + } + return readCLICloudData(t, dir) +} + +func assertCLICloudConfigurationUnchanged(t *testing.T, dir string, before []byte) { + t.Helper() + after := snapshotCLICloudConfiguration(t, dir) + if (before == nil) != (after == nil) || !bytes.Equal(before, after) { + t.Fatal("failed login changed connections.json, active selection or saved credentials") + } +} + +func TestCloudCLIInvalidLoginOptionsDoNotCreateProfile(t *testing.T) { + t.Parallel() + for _, flags := range [][]string{{"--output", "yaml"}, {"--timeout", "0s"}, {"--timeout", "-1s"}} { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t) + dir := t.TempDir() + args := append([]string{"--no-browser", "--config-dir", dir, "login", "--issuer", f.issuer()}, flags...) + out, stderr, err := executeRoot(t, args...) + if err == nil { + t.Fatal("invalid login options accepted") + } + if out != "" || stderr != "" || f.requests.Load() != 0 { + t.Fatal("invalid options started authentication") + } + assertCLICloudConfigurationUnchanged(t, dir, nil) + }) + } +} + +type cliCloudTarget struct { + organization, stack, idToken string + membership, refreshToken, stackToken, deviceCode string + refreshes atomic.Int32 +} + +type cliCloudFixture struct { + server *httptest.Server + idToken string + targets []*cliCloudTarget + organizations map[string]*cliCloudOrganization + devices, polls, exchanges, ledgers, clients, requests atomic.Int32 + stackVersions atomic.Int32 + cancelExchange atomic.Pointer[context.CancelFunc] + failDevice atomic.Bool + cancelPoll atomic.Pointer[context.CancelFunc] +} + +func newCLICloudFixture(t *testing.T, multiple ...bool) *cliCloudFixture { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "cli-key")) + if err != nil { + t.Fatal(err) + } + f := &cliCloudFixture{targets: []*cliCloudTarget{newCLICloudTarget("org", "stack")}} + if len(multiple) != 0 && multiple[0] { + f.targets = append(f.targets, newCLICloudTarget("org", "second"), newCLICloudTarget("other", "otherstack")) + } + mux := http.NewServeMux() + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.requests.Add(1); mux.ServeHTTP(w, r) })) + t.Cleanup(f.server.Close) + f.idToken = f.signIdentity(t, signer, f.targets, "root") + for _, target := range f.targets { + target.idToken = f.signIdentity(t, signer, []*cliCloudTarget{target}, target.organization+"/"+target.stack) + } + f.prepareOrganizations(t, signer) + f.registerDiscovery(t, mux, &key.PublicKey) + mux.HandleFunc("POST /membership/device", func(w http.ResponseWriter, r *http.Request) { f.device(t, w, r) }) + mux.HandleFunc("POST /membership/token", func(w http.ResponseWriter, r *http.Request) { f.membershipToken(t, w, r) }) + for _, target := range f.targets { + f.registerTarget(t, mux, target) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected Cloud request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + }) + return f +} + +func newCLICloudTarget(org, stack string) *cliCloudTarget { + return &cliCloudTarget{organization: org, stack: stack, membership: "scoped-" + stack + "-secret", refreshToken: "refresh-" + stack + "-secret", stackToken: "api-" + stack + "-secret", deviceCode: "device-" + stack + "-secret"} +} + +func (f *cliCloudFixture) issuer() string { return f.server.URL + "/membership" } + +func (f *cliCloudFixture) signIdentity(t *testing.T, signer jose.Signer, targets []*cliCloudTarget, identity string) string { + t.Helper() + organizations := map[string][]any{} + for _, target := range targets { + organizations[target.organization] = append(organizations[target.organization], map[string]any{"id": target.stack, "uri": f.server.URL + "/" + target.stack, "scopes": []string{"stack:Read", "stack:Write"}}) + } + var orgs []any + for org, stacks := range organizations { + orgs = append(orgs, map[string]any{"id": org, "scopes": []string{"organization:ReadStack"}, "stacks": stacks}) + } + raw, err := jwt.Signed(signer).Claims(map[string]any{"iss": f.issuer(), "aud": "fctl", "sub": "cli-user", "jti": identity, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), "org": orgs}).Serialize() + if err != nil { + t.Fatal(err) + } + return raw +} + +func (f *cliCloudFixture) registerDiscovery(t *testing.T, mux *http.ServeMux, key *rsa.PublicKey) { + mux.HandleFunc("GET /membership/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + writeCLICloudJSON(t, w, map[string]any{"issuer": f.issuer(), "token_endpoint": f.issuer() + "/token", "device_authorization_endpoint": f.issuer() + "/device", "jwks_uri": f.issuer() + "/keys", "id_token_signing_alg_values_supported": []string{"RS256"}}) + }) + mux.HandleFunc("GET /membership/keys", func(w http.ResponseWriter, _ *http.Request) { + writeCLICloudJSON(t, w, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: key, KeyID: "cli-key", Algorithm: "RS256", Use: "sig"}}}) + }) +} + +func (f *cliCloudFixture) registerTarget(t *testing.T, mux *http.ServeMux, target *cliCloudTarget) { + mux.HandleFunc("GET /membership/organizations/"+target.organization+"/stacks/"+target.stack, func(w http.ResponseWriter, r *http.Request) { + f.organizationStack(t, w, r, target) + }) + issuer := f.server.URL + "/" + target.stack + "/api/auth" + mux.HandleFunc("GET /"+target.stack+"/api/auth/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + writeCLICloudJSON(t, w, map[string]any{"issuer": issuer, "token_endpoint": issuer + "/token"}) + }) + mux.HandleFunc("POST /"+target.stack+"/api/auth/token", func(w http.ResponseWriter, r *http.Request) { f.exchange(t, w, r, target) }) + mux.HandleFunc("GET /"+target.stack+"/api/ledger/_info", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+target.stackToken { + t.Error("Ledger discovery used another target token") + w.WriteHeader(http.StatusUnauthorized) + return + } + writeCLIServiceInfo(t, w, "3.0.0") + }) + mux.HandleFunc("GET /"+target.stack+"/api/ledger/v3/{$}", func(w http.ResponseWriter, r *http.Request) { f.service(t, w, r, target, &f.ledgers) }) + mux.HandleFunc("GET /"+target.stack+"/api/auth/clients", func(w http.ResponseWriter, r *http.Request) { f.service(t, w, r, target, &f.clients) }) + mux.HandleFunc("GET /"+target.stack+"/api/auth/_info", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+target.stackToken { + t.Error("Auth version request used another target or root token") + w.WriteHeader(http.StatusUnauthorized) + return + } + writeCLIServiceInfo(t, w, "1.0.0") + }) +} + +func (f *cliCloudFixture) device(t *testing.T, w http.ResponseWriter, r *http.Request) { + f.devices.Add(1) + if !parseCLICloudForm(t, w, r) { + return + } + assertCLICloudBasic(t, r) + assertCLICloudForm(t, r, map[string]string{"client_id": "fctl"}) + if f.failDevice.Load() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + writeCLICloudJSON(t, w, map[string]string{"error": "access_denied", "error_description": cliMembershipToken}) + return + } + code := cliDeviceCode + if resource := r.PostForm.Get("resource"); resource != "" { + target := f.targetForResource(t, resource) + if target == nil { + w.WriteHeader(http.StatusBadRequest) + return + } + assertCLICloudForm(t, r, map[string]string{"organization_id": target.organization, "id_token_hint": f.idToken, "scope": "openid offline_access accesses"}) + assertCLICloudAbsentForm(t, r, "prompt") + code = target.deviceCode + } else if org := r.PostForm.Get("organization_id"); org != "" { + var ok bool + code, ok = f.organizationDevice(t, w, r, org) + if !ok { + return + } + } else { + assertCLICloudForm(t, r, map[string]string{"scope": "openid offline_access accesses on_behalf", "prompt": "no-org"}) + for _, name := range []string{"organization_id", "resource", "id_token_hint"} { + if _, exists := r.PostForm[name]; exists { + t.Errorf("root identity device request included %s", name) + } + } + } + writeCLICloudJSON(t, w, map[string]any{"device_code": code, "user_code": "ABCD-EFGH", "verification_uri": f.server.URL + "/verify", "expires_in": 600, "interval": 1}) +} + +func (f *cliCloudFixture) targetForResource(t *testing.T, resource string) *cliCloudTarget { + t.Helper() + for _, target := range f.targets { + if resource == "stack://"+target.organization+"/"+target.stack+"|stack:Read stack:Write" { + return target + } + } + t.Error("unknown scoped resource") + return nil +} + +func (f *cliCloudFixture) membershipToken(t *testing.T, w http.ResponseWriter, r *http.Request) { + if !parseCLICloudForm(t, w, r) { + return + } + assertCLICloudBasic(t, r) + if r.PostForm.Get("grant_type") == "refresh_token" { + f.refresh(t, w, r) + return + } + f.polls.Add(1) + assertCLICloudForm(t, r, map[string]string{"grant_type": "urn:ietf:params:oauth:grant-type:device_code"}) + if rejectCLICloudPollScope(t, w, r) { + return + } + assertCLICloudAbsentForm(t, r, "organization_id", "id_token_hint", "prompt") + if cancel := f.cancelPoll.Load(); cancel != nil { + (*cancel)() + <-r.Context().Done() + return + } + if f.organizationPoll(t, w, r) { + return + } + membership, refresh, id := cliMembershipToken, cliRefreshToken, f.idToken + if r.PostForm.Get("device_code") != cliDeviceCode { + target := f.targetForResource(t, r.PostForm.Get("resource")) + if target == nil { + w.WriteHeader(http.StatusBadRequest) + return + } + assertCLICloudForm(t, r, map[string]string{"device_code": target.deviceCode}) + membership, refresh, id = target.membership, target.refreshToken, target.idToken + } else if _, exists := r.PostForm["resource"]; exists { + t.Error("root poll included resource") + } + writeCLICloudJSON(t, w, map[string]any{"access_token": membership, "refresh_token": refresh, "id_token": id, "token_type": "Bearer", "expires_in": 3600}) +} + +func rejectCLICloudPollScope(t *testing.T, w http.ResponseWriter, r *http.Request) bool { + t.Helper() + if len(r.PostForm["scope"]) == 0 { + return false + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + writeCLICloudJSON(t, w, map[string]string{ + "error": "server_error", + "error_description": "requested scope " + r.PostForm.Get("scope") + " not granted", + }) + return true +} + +func (f *cliCloudFixture) refresh(t *testing.T, w http.ResponseWriter, r *http.Request) { + for _, target := range f.targets { + if r.PostForm.Get("refresh_token") == target.refreshToken { + target.refreshes.Add(1) + writeCLICloudJSON(t, w, map[string]any{"access_token": cliRotatedMembershipToken, "refresh_token": cliRotatedRefreshToken, "token_type": "Bearer", "expires_in": 3600}) + return + } + } + t.Error("refresh used root or unknown target token") + w.WriteHeader(http.StatusUnauthorized) +} + +func (f *cliCloudFixture) exchange(t *testing.T, w http.ResponseWriter, r *http.Request, target *cliCloudTarget) { + f.exchanges.Add(1) + if !parseCLICloudForm(t, w, r) { + return + } + assertion := target.membership + if target.refreshes.Load() != 0 { + assertion = cliRotatedMembershipToken + } + assertCLICloudForm(t, r, map[string]string{"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion, "scope": "openid email"}) + if cancel := f.cancelExchange.Load(); cancel != nil { + (*cancel)() + <-r.Context().Done() + return + } + writeCLICloudJSON(t, w, map[string]any{"access_token": target.stackToken, "token_type": "Bearer", "expires_in": 3600}) +} + +func (f *cliCloudFixture) service(t *testing.T, w http.ResponseWriter, r *http.Request, target *cliCloudTarget, counter *atomic.Int32) { + counter.Add(1) + if r.Header.Get("Authorization") != "Bearer "+target.stackToken { + t.Error("service request used another target or root token") + w.WriteHeader(http.StatusUnauthorized) + return + } + writeCLICloudJSON(t, w, map[string]any{"data": []any{}}) +} + +func parseCLICloudForm(t *testing.T, w http.ResponseWriter, r *http.Request) bool { + t.Helper() + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return false + } + return true +} + +func assertCLICloudBasic(t *testing.T, r *http.Request) { + t.Helper() + id, secret, ok := r.BasicAuth() + if !ok || id != "fctl" || secret != "" { + t.Error("expected public client Basic authentication for fctl") + } +} + +func assertCLICloudForm(t *testing.T, r *http.Request, want map[string]string) { + t.Helper() + for name, value := range want { + if r.PostForm.Get(name) != value { + t.Errorf("incorrect %s form field at %s", name, r.URL.Path) + } + } +} + +func writeCLICloudJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Error(err) + } +} + +func (f *cliCloudFixture) run(t *testing.T, dir string, args ...string) (string, string) { + t.Helper() + execute := executeRoot + if slices.Contains(args, "ledger") { + f.cacheLedger(t, dir) + } + if slices.Contains(args, "ledger") || slices.Contains(args, "auth") { + execute = executeCLICloudProcess + } + out, stderr, err := execute(t, append([]string{"--no-browser", "--config-dir", dir}, args...)...) + f.assertNoSecrets(t, out+stderr) + if err != nil { + f.assertNoSecrets(t, err.Error()) + t.Fatalf("%v failed: %v\n%s", args, err, stderr) + } + + if !json.Valid([]byte(out)) { + t.Fatalf("%v did not produce JSON", args) + } + return out, stderr +} + +func (f *cliCloudFixture) assertNoSecrets(t *testing.T, output string) { + t.Helper() + for _, org := range f.organizations { + for _, secret := range []string{org.membership, org.refreshToken, org.deviceCode, org.idToken} { + if strings.Contains(output, secret) { + t.Error("CLI output exposed organization credentials") + } + } + } + for _, target := range f.targets { + for _, secret := range []string{target.membership, target.refreshToken, target.stackToken, target.deviceCode, target.idToken} { + if strings.Contains(output, secret) { + t.Error("CLI output exposed target credentials") + } + } + } + for _, secret := range []string{cliMembershipToken, cliRefreshToken, cliDeviceCode, cliRotatedMembershipToken, cliRotatedRefreshToken, f.idToken} { + if strings.Contains(output, secret) { + t.Error("CLI output exposed a token secret") + } + } +} + +func assertCLICloudJSON(t *testing.T, out, want string) { + t.Helper() + var gotValue, wantValue any + if err := json.Unmarshal([]byte(out), &gotValue); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(want), &wantValue); err != nil { + t.Fatal(err) + } + gotJSON, err := json.Marshal(gotValue) + if err != nil { + t.Fatal(err) + } + wantJSON, err := json.Marshal(wantValue) + if err != nil { + t.Fatal(err) + } + if string(gotJSON) != string(wantJSON) { + t.Fatalf("JSON result = %s, want %s", gotJSON, wantJSON) + } +} + +func readCLICloudStore(t *testing.T, dir string) connection.Store { + t.Helper() + path := filepath.Join(dir, "connections.json") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("config permissions = %v, want 0600", info.Mode().Perm()) + } + data := readCLICloudData(t, dir) + var store connection.Store + if err := json.Unmarshal(data, &store); err != nil { + t.Fatal(err) + } + return store +} + +func (f *cliCloudFixture) assertRootSession(t *testing.T, dir string) { + t.Helper() + store := readCLICloudStore(t, dir) + s := store.Connections["cloud"].Session + if s == nil || s.MembershipToken == nil { + t.Fatal("login did not persist root Membership session") + } + if store.Active != "cloud" || s.IDToken != f.idToken || s.StackToken != nil || s.StackURL != "" { + t.Fatal("saved root identity contains scoped credentials") + } + if s.Options.Issuer != f.issuer() || s.Options.ClientID != "fctl" || s.Options.Organization != "" || s.Options.Stack != "" { + t.Fatal("root session lost target-free identity options") + } + if s.MembershipToken.AccessToken != cliMembershipToken || s.MembershipToken.RefreshToken != cliRefreshToken || !s.MembershipToken.Expiry.After(time.Now()) { + t.Fatal("root credentials were lost or replaced by target credentials") + } +} + +func findCLICloudTarget(t *testing.T, entry connection.Entry, organization, stack string) *cloud.Session { + t.Helper() + if entry.Session != nil { + for _, target := range entry.Session.Targets { + if target != nil && target.Options.Organization == organization && target.Options.Stack == stack { + return target + } + } + } + t.Fatal("requested target was not persisted") + return nil +} + +func (f *cliCloudFixture) assertTargetSaved(t *testing.T, dir, org, stack string) { + t.Helper() + f.assertOrganizationSaved(t, dir, org) + saved := findCLICloudTarget(t, readCLICloudStore(t, dir).Connections["cloud"], org, stack) + target := f.targetForResource(t, "stack://"+org+"/"+stack+"|stack:Read stack:Write") + expectedMembership, expectedRefresh := target.membership, target.refreshToken + if target.refreshes.Load() != 0 { + expectedMembership, expectedRefresh = cliRotatedMembershipToken, cliRotatedRefreshToken + } + if saved.MembershipToken == nil || saved.StackToken == nil { + t.Fatal("target credentials not persisted") + } + if saved.IDToken != target.idToken || saved.StackURL != f.server.URL+"/"+stack || saved.MembershipToken.AccessToken != expectedMembership || saved.MembershipToken.RefreshToken != expectedRefresh { + t.Fatal("saved target identity or Membership credentials differ") + } + if saved.StackToken.AccessToken != target.stackToken || !saved.StackToken.Expiry.After(time.Now()) { + t.Fatal("exchanged target token was not persisted") + } +} + +func (f *cliCloudFixture) assertDeviceInstructions(t *testing.T, out, stderr string) { + t.Helper() + if !strings.Contains(stderr, f.server.URL+"/verify") || !strings.Contains(stderr, "ABCD-EFGH") { + t.Fatal("device URL and user code missing from stderr") + } + if strings.Contains(out, "/verify") || strings.Contains(out, "ABCD-EFGH") { + t.Fatal("device instructions leaked into JSON stdout") + } +} + +func (f *cliCloudFixture) assertTargetRefused(t *testing.T, dir string, flags []string) { + t.Helper() + f.cacheLedger(t, dir) + before := [3]int32{f.devices.Load(), f.exchanges.Load(), f.ledgers.Load()} + args := append([]string{"--no-browser", "--config-dir", dir}, flags...) + out, stderr, err := executeRoot(t, append(args, "ledger", "list")...) + if err == nil { + t.Fatal("ambiguous or invalid target accepted") + } + f.assertNoSecrets(t, out+stderr+err.Error()) + after := [3]int32{f.devices.Load(), f.exchanges.Load(), f.ledgers.Load()} + if before != after || out != "" || stderr != "" { + t.Fatal("invalid target authorized a device or contacted services") + } +} + +func (f *cliCloudFixture) assertConnectionViews(t *testing.T, dir string, loggedIn bool) { + t.Helper() + out, _ := f.run(t, dir, "profiles", "show") + var options connection.Options + if err := json.Unmarshal([]byte(out), &options); err != nil { + t.Fatal(err) + } + saved := readCLICloudStore(t, dir).Connections["cloud"].Options + if options.AuthMode != "cloud" || options.Issuer != f.server.URL+"/membership" || options.Organization != saved.Organization || options.Stack != saved.Stack { + t.Fatal("connection show lost saved Cloud settings") + } + out, _ = f.run(t, dir, "profiles", "list") + var rows []struct { + Name string + Active bool + LoggedIn bool + } + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Name != "cloud" || !rows[0].Active || rows[0].LoggedIn != loggedIn { + t.Fatal("connection list reported incorrect login state") + } +} + +func (f *cliCloudFixture) assertCounts(t *testing.T, devices, polls, exchanges, ledgers, clients, stackVersions int32) { + t.Helper() + got := [6]int32{f.devices.Load(), f.polls.Load(), f.exchanges.Load(), f.ledgers.Load(), f.clients.Load(), f.stackVersions.Load()} + want := [6]int32{devices, polls, exchanges, ledgers, clients, stackVersions} + if got != want { + t.Fatalf("device/poll/exchange/ledger/auth/stack-version requests = %v, want %v", got, want) + } +} + +func (f *cliCloudFixture) assertLoggedOut(t *testing.T, dir string) { + t.Helper() + f.cacheLedger(t, dir) + before := f.requests.Load() + for _, args := range [][]string{{"ledger", "list"}, {"auth", "clients", "list"}} { + out, stderr, err := executeRoot(t, append([]string{"--no-browser", "--config-dir", dir}, args...)...) + if err == nil || !strings.Contains(err.Error(), "not logged in") { + t.Fatalf("%v did not refuse the logged-out session: %v", args, err) + } + f.assertNoSecrets(t, out+stderr+err.Error()) + if out != "" || stderr != "" { + t.Error("logged-out service command produced output") + } + } + if f.requests.Load() != before { + t.Fatal("logged-out commands contacted the provider or services") + } +} + +func readCLICloudData(t *testing.T, dir string) []byte { + t.Helper() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }() + data, err := root.ReadFile("connections.json") + if err != nil { + t.Fatal(err) + } + return data +} + +func (f *cliCloudFixture) cacheLedger(t *testing.T, dir string) { + t.Helper() + for _, target := range f.targets { + cacheDistributionLedger(t, dir, pluginmanager.Target{Profile: "cloud", Organization: target.organization, Stack: target.stack, Endpoint: f.issuer()}) + } +} diff --git a/tests/integration/cloud_organization_test.go b/tests/integration/cloud_organization_test.go new file mode 100644 index 00000000..8d4c1fe5 --- /dev/null +++ b/tests/integration/cloud_organization_test.go @@ -0,0 +1,167 @@ +package integration_test + +import ( + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +type cliCloudOrganization struct { + id, idToken, membership, refreshToken, deviceCode string + devices, polls atomic.Int32 +} + +func (f *cliCloudFixture) prepareOrganizations(t *testing.T, signer jose.Signer) { + t.Helper() + f.organizations = make(map[string]*cliCloudOrganization) + for _, target := range f.targets { + if f.organizations[target.organization] != nil { + continue + } + org := &cliCloudOrganization{ + id: target.organization, refreshToken: "organization-refresh-" + target.organization, + deviceCode: "organization-device-" + target.organization, + } + var targets []*cliCloudTarget + for _, candidate := range f.targets { + if candidate.organization == org.id { + targets = append(targets, candidate) + } + } + org.idToken = f.signIdentity(t, signer, targets, "organization/"+org.id) + raw, err := jwt.Signed(signer).Claims(map[string]any{ + "iss": f.issuer(), "aud": "fctl", "sub": "cli-user", "jti": "organization-access/" + org.id, + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + "organization_id": org.id, "scope": "openid offline_access organization:ReadStack", + }).Serialize() + if err != nil { + t.Fatal(err) + } + org.membership = raw + f.organizations[org.id] = org + } +} + +func (f *cliCloudFixture) organizationDevice(t *testing.T, w http.ResponseWriter, r *http.Request, id string) (string, bool) { + t.Helper() + org := f.organizations[id] + if org == nil { + t.Error("device grant requested an unauthorized organization") + w.WriteHeader(http.StatusForbidden) + return "", false + } + org.devices.Add(1) + assertCLICloudForm(t, r, map[string]string{ + "organization_id": id, "id_token_hint": f.idToken, + "scope": "openid offline_access organization:ReadStack", + }) + assertCLICloudAbsentForm(t, r, "resource", "prompt") + return org.deviceCode, true +} + +func (f *cliCloudFixture) organizationPoll(t *testing.T, w http.ResponseWriter, r *http.Request) bool { + t.Helper() + for _, org := range f.organizations { + if r.PostForm.Get("device_code") != org.deviceCode { + continue + } + org.polls.Add(1) + assertCLICloudAbsentForm(t, r, "scope", "resource") + writeCLICloudJSON(t, w, map[string]any{ + "access_token": org.membership, "refresh_token": org.refreshToken, + "id_token": org.idToken, "token_type": "Bearer", "expires_in": 3600, + }) + return true + } + return false +} + +func (f *cliCloudFixture) organizationStack(t *testing.T, w http.ResponseWriter, r *http.Request, target *cliCloudTarget) { + t.Helper() + f.stackVersions.Add(1) + org := f.organizations[target.organization] + if r.Header.Get("Authorization") != "Bearer "+org.membership { + w.WriteHeader(http.StatusUnauthorized) + return + } + writeCLICloudJSON(t, w, map[string]any{"data": map[string]string{ + "id": target.stack, "organizationId": org.id, "version": "v4.0", + }}) +} + +func assertCLICloudAbsentForm(t *testing.T, r *http.Request, names ...string) { + t.Helper() + for _, name := range names { + if r.PostForm.Has(name) { + t.Errorf("unexpected %s form field at %s", name, r.URL.Path) + } + } +} + +func (f *cliCloudFixture) assertOrganizationSaved(t *testing.T, dir, id string) { + t.Helper() + root := readCLICloudStore(t, dir).Connections["cloud"].Session + if root == nil || root.Organizations[id] == nil { + t.Fatal("organization grant was not persisted") + } + saved, org := root.Organizations[id], f.organizations[id] + if saved.Options.Organization != id || saved.Options.Stack != "" || saved.Options.Issuer != f.issuer() || saved.Options.ClientID != "fctl" || saved.StackToken != nil || saved.StackURL != "" { + t.Fatal("organization grant contains another identity or stack credentials") + } + if saved.IDToken != org.idToken || saved.MembershipToken == nil || saved.MembershipToken.AccessToken != org.membership || saved.MembershipToken.RefreshToken != org.refreshToken || !saved.MembershipToken.Expiry.After(time.Now()) { + t.Fatal("saved organization credentials differ from the verified grant") + } + if org.devices.Load() != 1 || org.polls.Load() != 1 { + t.Fatal("organization authorization was repeated instead of reusing its cached grant") + } +} + +func TestCloudCLIOrganizationGrantSharedAcrossStacks(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t, true) + dir := t.TempDir() + f.run(t, dir, "login", "--issuer", f.issuer()) + for _, stack := range []string{"stack", "second", "stack"} { + out, _ := f.run(t, dir, "--organization", "org", "--stack", stack, "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + f.assertTargetSaved(t, dir, "org", stack) + } + f.assertCounts(t, 4, 4, 2, 3, 0, 3) + f.assertRootSession(t, dir) + root := readCLICloudStore(t, dir).Connections["cloud"].Session + if len(root.Organizations) != 1 || len(root.Targets) != 2 || f.organizations["other"].devices.Load() != 0 { + t.Fatal("stack selection authorized another organization or lost cached grants") + } +} + +func TestCloudStackVersionFixtureRequiresOrganizationBearer(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t, true) + for _, token := range []string{"", cliMembershipToken, f.targets[0].membership, f.targets[0].stackToken, f.organizations["other"].membership, f.organizations["org"].membership} { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, f.issuer()+"/organizations/org/stacks/stack", nil) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + response, err := f.server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + want := http.StatusUnauthorized + if token == f.organizations["org"].membership { + want = http.StatusOK + } + if response.StatusCode != want { + t.Errorf("Membership stack route returned %d, want %d", response.StatusCode, want) + } + if err := response.Body.Close(); err != nil { + t.Error(err) + } + } +} diff --git a/tests/integration/cloud_process_test.go b/tests/integration/cloud_process_test.go new file mode 100644 index 00000000..a8be2ae2 --- /dev/null +++ b/tests/integration/cloud_process_test.go @@ -0,0 +1,51 @@ +package integration_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "slices" + "testing" + + "github.com/formancehq/fctl/v4/cmd" +) + +// The re-executed race test binary captures both bootstrap and execution output +// without replacing process-wide writers shared by parallel fixture tests. +func executeCLICloudProcess(t *testing.T, args ...string) (string, string, error) { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + process := exec.CommandContext(t.Context(), binary, append([]string{"-test.run=^TestCLICloudProcess$", "--"}, args...)...) //nolint:gosec // Re-executes the current test binary with test-owned CLI arguments. + process.Env = append(os.Environ(), "FCTL_INTEGRATION_CLOUD_PROCESS=1") + var stdout, stderr bytes.Buffer + process.Stdout, process.Stderr = &stdout, &stderr + err = process.Run() + return stdout.String(), stderr.String(), err +} + +func TestCLICloudProcess(t *testing.T) { + if os.Getenv("FCTL_INTEGRATION_CLOUD_PROCESS") != "1" { + return + } + separator := slices.Index(os.Args, "--") + if separator == -1 { + t.Fatal("CLI helper process requires an argument separator") + } + args := os.Args[separator+1:] + root := cmd.NewRootCommandWithArgs(t.Context(), args) + root.SetOut(os.Stdout) + root.SetErr(os.Stderr) + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil { + if _, writeErr := fmt.Fprintln(os.Stderr, err); writeErr != nil { + os.Exit(2) + } + os.Exit(1) + } + // Do not append the Go test runner's PASS line to the CLI's JSON stdout. + os.Exit(0) +} diff --git a/tests/integration/debug_test.go b/tests/integration/debug_test.go new file mode 100644 index 00000000..497f1426 --- /dev/null +++ b/tests/integration/debug_test.go @@ -0,0 +1,236 @@ +package integration_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func TestDebugLedgerKeepsJSONOnStdout(t *testing.T) { + t.Parallel() + const payload = `{"data":[{"name":"books","amount":123456789012345678901234567890,"metadata":{"password":"debug-metadata-private"}}],"hasMore":false}` + server := httptest.NewServer(debugLedgerHandler(t, payload)) + t.Cleanup(server.Close) + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL + "/prefix"}) + args := []string{"--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL + "/prefix", "ledger", "list"} + baseline, stderr, err := executeRoot(t, args...) + if err != nil || stderr != "" { + t.Fatalf("without debug: stderr=%q err=%v", stderr, err) + } + var formatted bytes.Buffer + if err := json.Indent(&formatted, []byte(payload), "", " "); err != nil { + t.Fatal(err) + } + if baseline != formatted.String()+"\n" { + t.Fatalf("baseline changed JSON or integer precision: %s", baseline) + } + for _, flag := range []string{"-d", "--debug"} { + t.Run(flag, func(t *testing.T) { + out, trace, err := executeRoot(t, append([]string{flag}, args...)...) + if err != nil || out != baseline { + t.Fatalf("debug changed result: stdout=%q err=%v", out, err) + } + assertDebugTraceContains(t, trace, `request "GET"`, "/prefix/v3/", "response 200", "response body:", "123456789012345678901234567890", "[REDACTED]") + assertDebugTraceHides(t, trace, "debug-metadata-private", "debug-cookie-private") + }) + } +} + +func debugLedgerHandler(t *testing.T, payload string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/prefix/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "3.0.0"}) + return + } + if r.Method != http.MethodGet || r.URL.EscapedPath() != "/prefix/v3/" || r.URL.RawQuery != "pageSize=100" { + t.Errorf("unexpected ledger request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "" { + t.Error("anonymous debug request sent credentials") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "session=debug-cookie-private") + if _, err := io.WriteString(w, payload); err != nil { + t.Error(err) + } + }) +} + +func TestDebugClientCredentialsRedactsAuthentication(t *testing.T) { + const clientID = "debug-client" + const clientSecret = "debug-client-private" //nolint:gosec // Synthetic fixture credential used only to verify trace redaction. + const accessToken = "debug-access-private" //nolint:gosec // Synthetic fixture token used only to verify trace redaction. + t.Setenv("FCTL_CLIENT_SECRET", clientSecret) + var tokenCalls, ledgerCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/_info": + writeCLICloudJSON(t, w, map[string]string{"version": "3.0.0"}) + case "/token": + tokenCalls.Add(1) + assertDebugClientCredentials(t, r, clientID, clientSecret) + writeCLICloudJSON(t, w, map[string]any{"access_token": accessToken, "token_type": "Bearer", "expires_in": 3600, "refresh_token": "debug-refresh-private", "id_token": "debug-identity-private"}) //nolint:gosec // Synthetic OAuth response verifies that traces hide every token. + case "/v3/": + ledgerCalls.Add(1) + if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+accessToken { + t.Error("Ledger request lost its bearer credentials") + } + writeCLICloudJSON(t, w, map[string]any{"data": []any{}}) + default: + t.Errorf("unexpected authentication route: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL}) + out, trace, err := executeRoot(t, "-d", "--config-dir", dir, "--auth-mode", "client-credentials", "--client-id", clientID, "--token-url", server.URL+"/token", "--ledger-url", server.URL, "ledger", "list") + if err != nil { + t.Fatal(err) + } + assertCLICloudJSON(t, out, `{"data":[]}`) + assertDebugTraceContains(t, trace, `request "POST"`, "/token", "client_credentials", `request "GET"`, "/v3/", "response 200", "access_token", "[REDACTED]") + basic := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + assertDebugTraceHides(t, trace, clientSecret, accessToken, "debug-refresh-private", "debug-identity-private", basic) + if tokenCalls.Load() != 1 || ledgerCalls.Load() != 1 { + t.Fatalf("debug repeated authentication or service call: token=%d ledger=%d", tokenCalls.Load(), ledgerCalls.Load()) + } +} + +func assertDebugClientCredentials(t *testing.T, r *http.Request, clientID, clientSecret string) { + t.Helper() + id, secret, ok := r.BasicAuth() + if r.Method != http.MethodPost || !ok || id != clientID || secret != clientSecret { + t.Error("debug tracing changed client credentials") + } + if err := r.ParseForm(); err != nil { + t.Error(err) + return + } + if r.PostForm.Get("grant_type") != "client_credentials" { + t.Error("debug tracing consumed the OAuth form") + } +} + +func TestDebugCloudAuthenticationAndServices(t *testing.T) { + t.Parallel() + f := newCLICloudFixture(t) + dir := t.TempDir() + cacheDistributionAuth(t, dir, pluginmanager.Target{Profile: "cloud", Organization: "org", Stack: "stack", Endpoint: f.issuer()}) + out, trace := f.run(t, dir, "--debug", "login", "--issuer", f.issuer()) + assertCLICloudJSON(t, out, `{"loggedIn":true}`) + assertDebugTraceContains(t, trace, "/membership/.well-known/openid-configuration", "/membership/device", "/membership/token", "response 200", "device_code", "access_token", "[REDACTED]") + f.assertNoSecrets(t, trace) + out, trace = f.run(t, dir, "-d", "--organization", "org", "--stack", "stack", "ledger", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + assertDebugTraceContains(t, trace, "/membership/token", "/stack/api/auth/token", "/stack/api/ledger/v3/", "assertion", "[REDACTED]") + f.assertNoSecrets(t, trace) + out, trace = f.run(t, dir, "--debug", "--organization", "org", "--stack", "stack", "auth", "clients", "list") + assertCLICloudJSON(t, out, `{"data":[]}`) + assertDebugTraceContains(t, trace, "/stack/api/auth/clients", `request "GET"`, "response 200", "[REDACTED]") + f.assertNoSecrets(t, trace) +} + +func TestDebugServiceErrorKeepsStdoutEmpty(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "3.0.0"}) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + writeCLICloudJSON(t, w, map[string]string{"errorCode": "UNAVAILABLE", "errorMessage": "debug-error-private"}) + })) + t.Cleanup(server.Close) + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL}) + out, trace, err := executeRoot(t, "--debug", "--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL, "ledger", "list") + if err == nil || out != "" || !strings.Contains(err.Error(), "UNAVAILABLE") { + t.Fatalf("debug error changed CLI result: stdout=%q err=%v", out, err) + } + assertDebugTraceContains(t, trace, "response 503", "response body:", "[REDACTED]") + assertDebugTraceHides(t, trace, "debug-error-private") +} + +func TestDebugAuthSecretCreationRedactsOnlyTrace(t *testing.T) { + t.Parallel() + const clearValue = "auth-created-private-value" + const payload = `{"data":{"id":"s1","name":"debug-secret","clear":"auth-created-private-value"}}` + var calls atomic.Int32 + server := debugAuthSecretServer(t, payload, &calls) + dir := t.TempDir() + cacheDistributionAuth(t, dir, pluginmanager.Target{Endpoint: server.URL + "/prefix"}) + out, trace, err := executeRoot(t, "-d", "--config-dir", dir, "--auth-mode", "none", "--auth-url", server.URL+"/prefix", "auth", "clients", "secrets", "create", "c1", "--data", `{"name":"debug-secret"}`) + if err != nil { + t.Fatal(err) + } + var formatted bytes.Buffer + if err := json.Indent(&formatted, []byte(payload), "", " "); err != nil { + t.Fatal(err) + } + if out != formatted.String()+"\n" || !strings.Contains(out, clearValue) { + t.Fatal("stdout did not preserve the requested one-time clear secret") + } + assertDebugTraceContains(t, trace, `request "POST"`, "/prefix/clients/c1/secrets", "response 200", `"clear":"[REDACTED]"`) + assertDebugTraceHides(t, trace, clearValue) + if calls.Load() != 1 { + t.Fatalf("debug repeated secret creation: %d calls", calls.Load()) + } +} + +func debugAuthSecretServer(t *testing.T, payload string, calls *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/prefix/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "1.0.0"}) + return + } + calls.Add(1) + if r.Method != http.MethodPost || r.URL.EscapedPath() != "/prefix/clients/c1/secrets" { + t.Errorf("unexpected Auth request: %s %s", r.Method, r.URL.RequestURI()) + } + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name != "debug-secret" { + t.Errorf("secret creation body changed: name=%q err=%v", body.Name, err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := io.WriteString(w, payload); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + return server +} + +func assertDebugTraceContains(t *testing.T, trace string, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(trace, value) { + t.Errorf("debug stderr missing %q: %s", value, trace) + } + } +} + +func assertDebugTraceHides(t *testing.T, trace string, secrets ...string) { + t.Helper() + for _, secret := range secrets { + if strings.Contains(trace, secret) { + t.Error("debug stderr exposed a fixture secret") + } + } +} diff --git a/tests/integration/external_distribution_test.go b/tests/integration/external_distribution_test.go new file mode 100644 index 00000000..680772c7 --- /dev/null +++ b/tests/integration/external_distribution_test.go @@ -0,0 +1,212 @@ +package integration_test + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "sigs.k8s.io/yaml" + + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +// Route only the official catalogue URL to a test-owned YAML file. All OCI and +// service traffic still uses real HTTP; no production registry is contacted. +type officialCatalogueTransport struct { + base http.RoundTripper + path string + reads atomic.Int32 + t *testing.T +} + +func (f *officialCatalogueTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.String() != pluginmanager.DefaultCatalogue { + return f.base.RoundTrip(request) + } + f.reads.Add(1) + if request.Header.Get("Authorization") != "" { + f.t.Error("public catalogue received service credentials") + } + data, err := os.ReadFile(f.path) //nolint:gosec // The fixture reads its test-owned catalogue file, never a request path. + if err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(data)))}, nil +} + +// This serves the OCI upload/download protocol over real HTTP. Published layers +// are actual Go executables, which fctl downloads and starts after resolving the +// deployed version. Manager unit tests separately cover registry failure modes. +func externalOCIRegistry(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + var mu sync.Mutex + objects := make(map[string][]byte) + reads := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch r.Method { + case http.MethodPost: + w.Header().Set("Location", "/v2/formance/ledger/blobs/uploads/test") + w.WriteHeader(http.StatusAccepted) + case http.MethodPut: + data, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + path := r.URL.Path + if strings.Contains(path, "/blobs/uploads/") { + path = "/v2/formance/ledger/blobs/" + r.URL.Query().Get("digest") + } + objects[path] = data + w.WriteHeader(http.StatusCreated) + case http.MethodGet: + reads.Add(1) + data, found := objects[r.URL.Path] + if !found { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := w.Write(data); err != nil { + t.Error(err) + } + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + t.Cleanup(server.Close) + return server, reads +} + +func publishExternalLedger(t *testing.T, manager *pluginmanager.Manager, registry string, version string) pluginmanager.Release { + t.Helper() + binary := buildExternalLedger(t, version) + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect published plugin: %v %v", err, closeErr) + } + release, err := manager.Publish(t.Context(), registry+"/formance/ledger", pluginmanager.Release{ + Service: "ledger", ServiceVersion: version, Revision: 1, Platform: pluginmanager.CurrentPlatform(), Manifest: manifest, + }, binary) + if err != nil { + t.Fatal(err) + } + return release +} + +//nolint:gocognit // A single chronological end-to-end lifecycle proves version and revision transitions. +func TestExternalLedgerDistributionCLI(t *testing.T) { + registry, reads := externalOCIRegistry(t) + publisher, err := pluginmanager.New(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + v300 := publishExternalLedger(t, publisher, registry.URL, "3.0.0") + v301 := publishExternalLedger(t, publisher, registry.URL, "3.0.1") + catalogue := filepath.Join(t.TempDir(), "registry.yaml") + writeCatalogue := func(releases ...pluginmanager.Release) { + t.Helper() + data, err := yaml.Marshal(pluginmanager.Catalogue{SchemaVersion: 1, Releases: releases}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogue, data, 0o600); err != nil { + t.Fatal(err) + } + } + writeCatalogue(v300, v301) + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + transport := &officialCatalogueTransport{base: http.DefaultTransport, path: catalogue, t: t} + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = transport.base }) + var version atomic.Value + version.Store("3.0.0") + ledger := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var err error + switch r.URL.Path { + case "/_info": + externalInfoResponse(t, w, &version) + case "/v3/": + _, err = fmt.Fprint(w, `{"data":[{"name":"books"}]}`) + default: + w.WriteHeader(http.StatusNotFound) + } + if err != nil { + t.Error(err) + } + })) + defer ledger.Close() + args := []string{"--config-dir", t.TempDir(), "--auth-mode", "none", "--ledger-url", ledger.URL, "--no-input", "-o", "json"} + list := append(append([]string{}, args...), "ledger", "list") + assertList := func() { + t.Helper() + out, trace, err := executeExternalCLI(t, list) + if err != nil || !strings.Contains(out, "books") { + t.Fatalf("OCI Ledger CLI: %s %s %v", out, trace, err) + } + } + assertList() + if transport.reads.Load() != 1 { + t.Fatalf("default discovery must fetch the YAML catalogue once: %d", transport.reads.Load()) + } + initialReads := reads.Load() + if initialReads != 3 { + t.Fatalf("expected OCI manifest/config/executable, got %d reads", initialReads) + } + v300.Revision = 2 + writeCatalogue(v300, v301) + assertList() + show := append(append([]string{}, args...), "plugins", "show") + out, _, err := executeExternalCLI(t, show) + if err != nil || !strings.Contains(out, `"revision": 1`) || reads.Load() != initialReads { + t.Fatalf("automatic use must retain plugin revision lock: %s %v", out, err) + } + syncArgs := append(append([]string{}, args...), "plugins", "sync") + out, _, err = executeExternalCLI(t, syncArgs) + if err != nil || !strings.Contains(out, `"revision": 2`) { + t.Fatalf("explicit sync upgrades plugin revision: %s %v", out, err) + } + version.Store("3.0.1") + assertList() + if reads.Load() != initialReads+3 { + t.Fatalf("service version change must fetch matching plugin: reads=%d", reads.Load()) + } + registry.Close() + assertList() // Prepared commands need no catalogue/registry downloads. + version.Store("3.0.2") + // A new target without an exact release must fail before a service command. + unprepared := append([]string{}, list...) + unprepared[1] = t.TempDir() + if out, trace, err := executeExternalCLI(t, unprepared); err == nil || out != "" || trace != "" { + t.Fatalf("unpublished service version must fail without embedded Ledger: %s %s %v", out, trace, err) + } + out, _, err = executeExternalCLI(t, list) + if err == nil || !strings.Contains(err.Error(), "exact service version") || out != "" { + t.Fatalf("missing exact release must fail: %s %v", out, err) + } + ledger.Close() + catalogueReads := transport.reads.Load() + args = append(args, "ledger", "--help") + out, trace, err := executeExternalCLI(t, args) + if err != nil || trace != "" || !strings.Contains(out, "External Ledger pilot") || transport.reads.Load() != catalogueReads { + t.Fatalf("offline downloaded manifest help: %s %s %v", out, trace, err) + } +} diff --git a/tests/integration/external_plugin_test.go b/tests/integration/external_plugin_test.go new file mode 100644 index 00000000..dc930300 --- /dev/null +++ b/tests/integration/external_plugin_test.go @@ -0,0 +1,240 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func buildExternalLedger(t *testing.T, version string) string { + t.Helper() + dir := t.TempDir() + // A synthetic public-SDK plugin exercises host transport and rendering without + // importing the Ledger product implementation. + source := fmt.Sprintf(`package main +import ( + "context" + "fmt" + "net/http" + "net/url" + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/httpclient" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" +) +type plugin struct { client *http.Client } +func (p *plugin) GetManifest(context.Context) (pluginsdk.Manifest,error) { + return pluginsdk.Manifest{Name:"ledger",Service:"ledger",Version:%q,ProtocolVersion:pluginsdk.ProtocolVersion, + Root:pluginsdk.CommandSpec{Use:"ledger",Target:"stack",Short:"External Ledger pilot",Flags:[]pluginsdk.FlagSpec{{Name:"ledger",Type:"string",Persistent:true}},Subcommands:[]pluginsdk.CommandSpec{ + {Use:"list",Runnable:true}, + {Use:"bulk",Runnable:true,Flags:[]pluginsdk.FlagSpec{{Name:"data",Type:"string",Body:true}}}, + }}},nil +} +func (p *plugin) Execute(ctx context.Context, req pluginsdk.ExecuteRequest) (pluginsdk.ExecuteResponse,error) { + client,err:=httpclient.New(req.Endpoint,p.client) + if err!=nil { return pluginsdk.ExecuteResponse{},err } + method,path,query:=http.MethodGet,"/v3/",url.Values{"pageSize":{"100"}} + if req.CommandPath[len(req.CommandPath)-1]=="bulk" { method=http.MethodPost;path=httpclient.Path("v3",req.Flags["ledger"],"bulk");query=nil } + data,err:=client.Do(ctx,method,path,query,req.Body,nil) + // Return an explicit fixture error alongside a result to exercise host partial output. + if method==http.MethodPost && err==nil { err=fmt.Errorf("synthetic partial failure") } + return pluginsdk.ExecuteResponse{Data:data},err +} +func main() { transport.Serve(func(client *http.Client) pluginsdk.Plugin { return &plugin{client:client} }) } +`, version) + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "plugin") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, path) //nolint:gosec // Fixed Go build command and test-owned source/output paths. + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build external Ledger: %v: %s", err, output) + } + return binary +} + +func executeExternalCLI(t *testing.T, args []string) (string, string, error) { + t.Helper() + root := cmd.NewRootCommandWithArgs(t.Context(), args) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + err := root.ExecuteContext(t.Context()) + return stdout.String(), stderr.String(), err +} + +func TestExternalLedgerCLI(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + binary := buildExternalLedger(t, "3.0.0") + for _, mode := range []string{"none", "client-credentials"} { + t.Run(mode, func(t *testing.T) { testExternalLedgerMode(t, binary, mode) }) + } +} + +func testExternalLedgerMode(t *testing.T, binary, mode string) { + var mutations atomic.Int32 + var version atomic.Value + version.Store("3.0.0") + server := externalLedgerAPI(t, mode, &mutations, &version) + defer server.Close() + args := []string{"--config-dir", t.TempDir(), "--auth-mode", mode, "--ledger-url", server.URL + "/gateway/ledger", "--no-input", "-o", "json"} + if mode == "client-credentials" { + t.Setenv("FCTL_CLIENT_SECRET", "host-secret") + args = append(args, "--client-id", "test", "--token-url", server.URL+"/token") + } + install := append(append([]string{}, args...), "plugins", "install", "--binary", binary, "--service-version", "3.0.0") + out, trace, err := executeExternalCLI(t, install) + if err != nil || !strings.Contains(out, `"source": "local"`) { + t.Fatalf("install: %s %s %v", out, trace, err) + } + testExternalLedgerRead(t, args) + testExternalLedgerBulk(t, args, &mutations, &version) + server.Close() + testExternalOfflineMetadata(t, args) +} + +func externalLedgerAPI(t *testing.T, mode string, mutations *atomic.Int32, version *atomic.Value) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/token" { + externalTokenResponse(t, w, r) + return + } + if mode == "client-credentials" && r.Header.Get("Authorization") != "Bearer host-access-token" { + t.Error("service request did not use host authentication") + } + switch r.URL.Path { + case "/gateway/ledger/_info": + externalInfoResponse(t, w, version) + case "/gateway/ledger/v3/": + externalFixtureResponse(t, w, `{"data":[{"name":"books","createdAt":"2026-10-09T00:00:00Z"}]}`) + case "/gateway/ledger/v3/books/bulk": + if r.Method != http.MethodPost { + t.Errorf("mutation method = %s", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil || !bytes.Equal(body, []byte(externalBulkBody)) { + t.Errorf("host changed request JSON: %s %v", body, err) + } + mutations.Add(1) + externalFixtureResponse(t, w, `{"data":[{"responseType":"ERROR","errorCode":"TEST","id":90071992547409930001}]}`) + default: + t.Errorf("unexpected external request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func externalTokenResponse(t *testing.T, w http.ResponseWriter, r *http.Request) { + t.Helper() + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("client_secret") != "host-secret" && r.Header.Get("Authorization") == "" { + t.Error("OAuth2 credentials were not supplied by the host") + } + externalFixtureResponse(t, w, `{"access_token":"host-access-token","token_type":"Bearer","expires_in":3600}`) +} +func externalFixtureResponse(t *testing.T, w http.ResponseWriter, data string) { + t.Helper() + if _, err := fmt.Fprint(w, data); err != nil { + t.Error(err) + } +} +func externalInfoResponse(t *testing.T, w http.ResponseWriter, version *atomic.Value) { + t.Helper() + if err := json.NewEncoder(w).Encode(map[string]any{"version": version.Load()}); err != nil { + t.Error(err) + } +} + +func testExternalLedgerRead(t *testing.T, args []string) { + t.Helper() + list := append(append([]string{}, args...), "-d", "ledger", "list") + out, trace, err := executeExternalCLI(t, list) + if err != nil || !strings.Contains(out, "books") || !strings.Contains(trace, "HTTP") { + t.Fatalf("external list/debug: %s %s %v", out, trace, err) + } + if strings.Contains(trace, "host-secret") || strings.Contains(trace, "host-access-token") { + t.Fatal("debug trace disclosed host credentials") + } +} + +const externalBulkBody = `[{"action":"CREATE_TRANSACTION","data":{"postings":[{"source":"world","destination":"bank","amount":9007199254740993,"asset":"USD/2"}]}}]` + +func testExternalLedgerBulk(t *testing.T, args []string, mutations *atomic.Int32, version *atomic.Value) { + t.Helper() + bulk := append(append([]string{}, args...), "ledger", "--ledger", "books", "bulk", "--data", externalBulkBody) + out, _, err := executeExternalCLI(t, bulk) + if err == nil || !strings.Contains(out, "90071992547409930001") || mutations.Load() != 1 || !json.Valid([]byte(out)) { + t.Fatalf("partial results/exact integer/no retry: %s %v mutations=%d", out, err, mutations.Load()) + } + version.Store("3.0.1") + out, _, err = executeExternalCLI(t, bulk) + if err == nil || !strings.Contains(err.Error(), "service reports 3.0.1") || out != "" || mutations.Load() != 1 { + t.Fatalf("version drift must precede writes: %q %v mutations=%d", out, err, mutations.Load()) + } +} +func testExternalOfflineMetadata(t *testing.T, args []string) { + t.Helper() + help := append(append([]string{}, args...), "ledger", "--help") + out, trace, err := executeExternalCLI(t, help) + if err != nil || !strings.Contains(out, "External Ledger pilot") || trace != "" { + t.Fatalf("offline cached help: %s %s %v", out, trace, err) + } + completion := append(append([]string{}, args...), "__complete", "ledger", "") + out, trace, err = executeExternalCLI(t, completion) + if err != nil || !strings.Contains(out, "bulk") { + t.Fatalf("offline cached completion: %s %s %v", out, trace, err) + } +} + +// Prepare executable metadata without contacting the service or consuming host +// authentication counters. The service itself still verifies the version at runtime. +func cacheDistributionLedger(t *testing.T, dir string, target pluginmanager.Target) { + t.Helper() + manager, err := pluginmanager.New(filepath.Join(dir, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Load(target, "ledger"); err == nil { + return + } else if !errors.Is(err, pluginmanager.ErrNotInstalled) { + t.Fatal(err) + } + binary := buildExternalLedger(t, "3.0.0") + instance, err := transport.Open(t.Context(), binary, nil, "") + if err != nil { + t.Fatal(err) + } + manifest, err := instance.GetManifest(t.Context()) + closeErr := instance.Close() + if err != nil || closeErr != nil { + t.Fatalf("inspect Ledger fixture: %v %v", err, closeErr) + } + if _, err := manager.InstallLocal(t.Context(), binary, target, manifest); err != nil { + t.Fatal(err) + } +} diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go new file mode 100644 index 00000000..672905c6 --- /dev/null +++ b/tests/integration/helpers_test.go @@ -0,0 +1,31 @@ +package integration_test + +import ( + "bytes" + "net/http" + "slices" + "testing" + + "github.com/formancehq/fctl/v4/cmd" +) + +func executeRoot(t *testing.T, args ...string) (string, string, error) { + t.Helper() + root := cmd.NewRootCommand() + // Service metadata comes from the selected target's external plugin cache. + if slices.Contains(args, "auth") || slices.Contains(args, "ledger") { + root = cmd.NewRootCommandWithArgs(t.Context(), args) + } + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"--no-browser"}, args...)) + err := root.ExecuteContext(t.Context()) + return stdout.String(), stderr.String(), err +} + +func writeCLIServiceInfo(t *testing.T, w http.ResponseWriter, version string) { + t.Helper() + // The SDK guard reads the service's full API version from the info envelope. + writeCLICloudJSON(t, w, map[string]any{"data": map[string]string{"version": version}}) +} diff --git a/tests/integration/legacy_streaming_test.go b/tests/integration/legacy_streaming_test.go new file mode 100644 index 00000000..429a0948 --- /dev/null +++ b/tests/integration/legacy_streaming_test.go @@ -0,0 +1,246 @@ +package integration_test + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/formancehq/fctl/pkg/pluginsdk" + "github.com/formancehq/fctl/pkg/pluginsdk/transport" + + "github.com/formancehq/fctl/v4/cmd" +) + +const largeLegacyLogs = 20_000 + +func TestLegacyInlineImportWithStreamManifest(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + t.Setenv("FCTL_PROFILE", "") + api := &legacyStreamAPI{t: t, digest: sha256.New()} + server := httptest.NewServer(api) + defer server.Close() + out, trace, err := executeExternalCLI(t, []string{"--config-dir", t.TempDir(), "--auth-mode", "none", + "--ledger-url", server.URL, "--no-input", "-o", "json", "legacy", "ledger", "import", "books", + "--data", `[{"id":1,"payload":{"amount":90071992547409931234567890}}]`}) + if err != nil || trace != "" || !strings.Contains(out, `"imported": 1`) { + t.Fatalf("inline legacy import changed: stdout=%s stderr=%s err=%v", out, trace, err) + } +} + +func largeLegacySource(t *testing.T) (string, []byte) { + t.Helper() + path := filepath.Join(t.TempDir(), "logs.ndjson") + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) //nolint:gosec // Test-owned temporary source. + if err != nil { + t.Fatal(err) + } + digest := sha256.New() + writer := io.MultiWriter(file, digest) + payload := strings.Repeat("x", 4096) + for i := range largeLegacyLogs { + if _, err := fmt.Fprintf(writer, "{\"id\":%d,\"payload\":{\"amount\":90071992547409931234567890,\"unknown\":\"%s\"}}\n", i, payload); err != nil { + t.Fatal(err) + } + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil || info.Size() <= 64<<20 { + t.Fatalf("fixture is not a large file: %v %v", info, err) + } + return path, digest.Sum(nil) +} + +type legacyStreamAPI struct { + t *testing.T + source string + mu sync.Mutex + digest hash.Hash + logs int +} + +func (s *legacyStreamAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/_info": + writeCLIServiceInfo(s.t, w, "2.4.15") + case "/v2/books/logs/import": + // Bound each batch, while the total source greatly exceeds unary limits. + body, err := io.ReadAll(io.LimitReader(r.Body, (4<<20)+1)) + if err != nil || len(body) > 4<<20 || r.Method != http.MethodPost { + s.t.Errorf("invalid stream batch: %d bytes %s %v", len(body), r.Method, err) + w.WriteHeader(http.StatusBadRequest) + return + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.digest.Write(body); err != nil { + s.t.Error(err) + } + s.logs += bytes.Count(body, []byte{'\n'}) + w.WriteHeader(http.StatusNoContent) + case "/v2/books/logs/export": + file, err := os.Open(s.source) + if err != nil { + s.t.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer func() { + if err := file.Close(); err != nil { + s.t.Error(err) + } + }() + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := io.Copy(w, file); err != nil { + s.t.Error(err) + } + default: + s.t.Errorf("unexpected legacy request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func TestLargeLegacyCLIImportExport(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + t.Setenv("FCTL_PROFILE", "") + source, expected := largeLegacySource(t) + api := &legacyStreamAPI{t: t, source: source, digest: sha256.New()} + server := httptest.NewServer(api) + defer server.Close() + base := []string{"--config-dir", t.TempDir(), "--auth-mode", "none", "--ledger-url", server.URL, "--no-input", "-o", "json"} + args := append(append([]string{}, base...), "legacy", "ledger", "import", "books", source) + out, trace, err := executeExternalCLI(t, args) + var result struct { + Imported int `json:"imported"` + } + if err != nil || json.Unmarshal([]byte(out), &result) != nil || result.Imported != largeLegacyLogs || trace != "" { + t.Fatalf("large import result=%s trace=%s err=%v", out, trace, err) + } + api.mu.Lock() + actual, logs := api.digest.Sum(nil), api.logs + api.mu.Unlock() + if logs != largeLegacyLogs || !bytes.Equal(actual, expected) { + t.Fatalf("large import changed records: logs=%d digest=%x expected=%x", logs, actual, expected) + } + destination := filepath.Join(t.TempDir(), "export.ndjson") + args = append(append([]string{}, base...), "legacy", "ledger", "export", "--ledger", "books", "--file", destination) + out, trace, err = executeExternalCLI(t, args) + if err != nil || trace != "" { + t.Fatalf("large export result=%s trace=%s err=%v", out, trace, err) + } + verifyStreamFile(t, destination, expected) + // A hash writer proves stdout receives only NDJSON, never the final JSON + // summary, terminal styling or progress. It does not retain the export. + args = append(append([]string{}, base...), "legacy", "ledger", "export", "--ledger", "books") + root := cmd.NewRootCommandWithArgs(t.Context(), args) + digest := sha256.New() + var stderr bytes.Buffer + root.SetOut(digest) + root.SetErr(&stderr) + root.SetArgs(args) + if err := root.ExecuteContext(t.Context()); err != nil || stderr.Len() != 0 || !bytes.Equal(digest.Sum(nil), expected) { + t.Fatalf("stdout export changed: digest=%x stderr=%s err=%v", digest.Sum(nil), stderr.String(), err) + } + t.Logf("large CLI source: %d logs; file export and stdout SHA-256 verified", largeLegacyLogs) +} + +func verifyStreamFile(t *testing.T, path string, expected []byte) { + t.Helper() + file, err := os.Open(path) //nolint:gosec // Test-owned temporary export. + if err != nil { + t.Fatal(err) + } + digest := sha256.New() + _, err = io.Copy(digest, file) + err = errors.Join(err, file.Close()) + if err != nil || !bytes.Equal(digest.Sum(nil), expected) { + t.Fatalf("export changed: digest=%x expected=%x err=%v", digest.Sum(nil), expected, err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("export permissions: %v %v", info, err) + } +} + +func TestLargeLegacyExecutableImportExport(t *testing.T) { + source, expected := largeLegacySource(t) + api := &legacyStreamAPI{t: t, source: source, digest: sha256.New()} + server := httptest.NewServer(api) + defer server.Close() + binary := filepath.Join(t.TempDir(), "fctl-plugin-legacy") + build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "./cmd/fctl-plugin-legacy") //nolint:gosec // Fixed build command for the repository-owned plugin. + build.Dir = filepath.Join("..", "..", "misc", "fctl-plugin") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build legacy executable: %s %v", out, err) + } + client, err := transport.Open(t.Context(), binary, server.Client(), server.URL) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := client.Close(); err != nil { + t.Error(err) + } + }() + importLegacyExecutableFile(t, client, source, api, expected, server.URL) + digest := sha256.New() + updates := 0 + result, err := client.ExecuteStream(t.Context(), pluginsdk.ExecuteRequest{ + CommandPath: []string{"legacy", "ledger", "export"}, Flags: map[string]string{"ledger": "books"}, Endpoint: server.URL, + }, pluginsdk.ExecutionStreams{Output: digest, Report: func(event pluginsdk.ProgressEvent) error { + updates++ + return event.Validate() + }}) + if err != nil || !bytes.Equal(digest.Sum(nil), expected) || updates < 2 || !json.Valid(result.Data) { + t.Fatalf("native stream digest=%x updates=%d result=%s err=%v", digest.Sum(nil), updates, result.Data, err) + } +} + +func importLegacyExecutableFile(t *testing.T, client *transport.Client, source string, api *legacyStreamAPI, expected []byte, endpoint string) { + t.Helper() + file, err := os.Open(source) //nolint:gosec // Test-owned generated source. + if err != nil { + t.Fatal(err) + } + defer func() { + if err := file.Close(); err != nil { + t.Error(err) + } + }() + info, err := file.Stat() + if err != nil { + t.Fatal(err) + } + updates := 0 + result, err := client.ExecuteStream(t.Context(), pluginsdk.ExecuteRequest{ + CommandPath: []string{"legacy", "ledger", "import"}, Args: []string{"books", source}, Endpoint: endpoint, + }, pluginsdk.ExecutionStreams{Input: file, InputSize: new(info.Size()), Report: func(event pluginsdk.ProgressEvent) error { + updates++ + return event.Validate() + }}) + var summary struct { + Imported int `json:"imported"` + } + if err != nil || json.Unmarshal(result.Data, &summary) != nil || summary.Imported != largeLegacyLogs || updates < 2 { + t.Fatalf("native import progress=%d result=%s err=%v", updates, result.Data, err) + } + api.mu.Lock() + actual, logs := api.digest.Sum(nil), api.logs + api.mu.Unlock() + if logs != largeLegacyLogs || !bytes.Equal(actual, expected) { + t.Fatalf("native import changed records: logs=%d digest=%x expected=%x", logs, actual, expected) + } +} diff --git a/tests/integration/plugin_regressions_test.go b/tests/integration/plugin_regressions_test.go new file mode 100644 index 00000000..5bec723b --- /dev/null +++ b/tests/integration/plugin_regressions_test.go @@ -0,0 +1,218 @@ +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func TestPluginCloudImplicitTargetInstallsAndUsesCache(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + f := newCLICloudFixture(t) + var infoRequests atomic.Int32 + addPluginCloudInfoRoute(t, f, &infoRequests) + dir := t.TempDir() + f.run(t, dir, "login", "--issuer", f.issuer()) + binary := buildExternalLedger(t, "3.0.0") + args := []string{"--config-dir", dir, "--profile", "cloud", "--no-browser", "--no-input", "-o", "json"} + install := append(append([]string{}, args...), "plugins", "install", "--binary", binary) + out, trace, err := executeExternalCLI(t, install) + if err != nil { + t.Fatalf("install with the sole verified Cloud target: %v", err) + } + f.assertNoSecrets(t, out+trace) + manager, err := pluginmanager.New(filepath.Join(dir, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + target := pluginmanager.Target{Profile: "cloud", Organization: "org", Stack: "stack", Endpoint: f.issuer()} + lock, err := manager.Load(target, "ledger") + if err != nil || lock.ServiceVersion != "3.0.0" || !lock.Local { + t.Fatalf("plugin was not locked to the resolved Cloud target: %+v, %v", lock, err) + } + if infoRequests.Load() != 1 { + t.Fatalf("installation did not discover Ledger exactly once: %d", infoRequests.Load()) + } + help := append(append([]string{}, args...), "ledger", "--help") + out, trace, err = executeExternalCLI(t, help) + if err != nil || trace != "" || !strings.Contains(out, "External Ledger pilot") || infoRequests.Load() != 1 { + t.Fatalf("implicit target help did not use cached external metadata: %q %q %v", out, trace, err) + } + list := append(append([]string{}, args...), "ledger", "list") + for invocation := range 2 { + assertPluginCloudList(t, f, list, invocation == 0) + } + // Each Cloud execution inspects the service and then lets the external plugin + // verify its full API version. Installation performs one additional read. + if infoRequests.Load() != 5 { + t.Fatalf("commands silently fell back to embedded Ledger: info requests=%d", infoRequests.Load()) + } + f.assertCounts(t, 3, 3, 1, 2, 0, 2) + f.assertTargetSaved(t, dir, "org", "stack") + entry := readCLICloudStore(t, dir).Connections["cloud"] + if entry.Options.Organization != "" || entry.Options.Stack != "" { + t.Fatal("implicit plugin selection changed the saved profile defaults") + } +} + +func assertPluginCloudList(t *testing.T, f *cliCloudFixture, args []string, authorizeOrganization bool) { + t.Helper() + out, trace, err := executeCLICloudProcess(t, args...) + if err != nil { + t.Fatalf("execute the cached plugin with the implicit Cloud target: %q %v", trace, err) + } + if authorizeOrganization { + f.assertDeviceInstructions(t, out, trace) + } else if trace != "" { + t.Fatal("cached organization grant requested authorization again") + } + f.assertNoSecrets(t, out+trace) + assertCLICloudJSON(t, out, `{"data":[]}`) +} + +func addPluginCloudInfoRoute(t *testing.T, f *cliCloudFixture, requests *atomic.Int32) { + t.Helper() + original := f.server.Config.Handler + // Install this immutable wrapper before the fixture receives any requests. + f.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/stack/api/ledger/_info" { + original.ServeHTTP(w, r) + return + } + requests.Add(1) + if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+f.targets[0].stackToken { + t.Error("Ledger version discovery did not use the resolved target's host credentials") + w.WriteHeader(http.StatusUnauthorized) + return + } + writeCLIServiceInfo(t, w, "3.0.0") + }) +} + +func TestPluginBootstrapCancellationReturnsError(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", filepath.Join(t.TempDir(), "unused-catalogue.json")) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + started, stopped := make(chan struct{}), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/_info" { + t.Errorf("unexpected bootstrap request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + close(started) + cancel() + <-r.Context().Done() + close(stopped) + })) + defer server.Close() + args := []string{"--config-dir", t.TempDir(), "--auth-mode", "none", "--ledger-url", server.URL, "--no-input", "ledger", "list"} + // Cancellation happens while NewRootCommandWithArgs is discovering the + // version. A panic here is a regression, rather than an expected test result. + assertCancelledPluginCommand(ctx, t, args) + select { + case <-started: + default: + t.Fatal("cancellation did not occur during Ledger discovery") + } + select { + case <-stopped: + case <-time.After(5 * time.Second): + t.Fatal("canceled bootstrap retained its HTTP request") + } +} + +func TestPluginBootstrapAlreadyCancelledReturnsError(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", filepath.Join(t.TempDir(), "unused-catalogue.json")) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + args := []string{"--config-dir", t.TempDir(), "--auth-mode", "none", "--ledger-url", "http://127.0.0.1:1", "--no-input", "ledger", "list"} + assertCancelledPluginCommand(ctx, t, args) +} + +func assertCancelledPluginCommand(ctx context.Context, t *testing.T, args []string) { + t.Helper() + root := cmd.NewRootCommandWithArgs(ctx, args) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + if err := root.ExecuteContext(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled bootstrap returned %v", err) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("canceled bootstrap printed output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestPluginMissingBinaryPreservesOfflineMetadata(t *testing.T) { + t.Setenv("FCTL_PLUGIN_CATALOGUE", "") + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + dir := t.TempDir() + binary := buildExternalLedger(t, "3.0.0") + args := []string{"--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL, "--no-input", "-o", "json"} + install := append(append([]string{}, args...), "plugins", "install", "--binary", binary, "--service-version", "3.0.0") + if out, trace, err := executeExternalCLI(t, install); err != nil || !json.Valid([]byte(out)) || trace != "" { + t.Fatalf("install local plugin for offline metadata checks: %q %q %v", out, trace, err) + } + removeCachedPluginBinary(t, dir, server.URL) + server.Close() + for _, test := range []struct { + name string + args []string + want string + }{ + {"help", []string{"ledger", "--help"}, "External Ledger pilot"}, + {"completion", []string{"__complete", "led"}, "ledger\tExternal Ledger pilot"}, + } { + t.Run(test.name, func(t *testing.T) { + out, _, err := executeExternalCLI(t, append(append([]string{}, args...), test.args...)) + if err != nil || !strings.Contains(out, test.want) { + t.Fatalf("missing binary changed cached external metadata: %q %v", out, err) + } + }) + } + out, trace, err := executeExternalCLI(t, append(append([]string{}, args...), "ledger", "list")) + if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "verify plugin executable") || out != "" || trace != "" { + t.Fatalf("missing binary did not fail verification before execution: %q %q %v", out, trace, err) + } + if requests.Load() != 0 { + t.Fatalf("offline metadata or failed verification issued HTTP requests: %d", requests.Load()) + } +} + +func removeCachedPluginBinary(t *testing.T, directory, endpoint string) { + t.Helper() + manager, err := pluginmanager.New(filepath.Join(directory, "plugins"), nil) + if err != nil { + t.Fatal(err) + } + lock, err := manager.Load(pluginmanager.Target{Endpoint: endpoint}, "ledger") + if err != nil { + t.Fatal(err) + } + binary, err := manager.BinaryContext(t.Context(), lock) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } +} diff --git a/tests/integration/plugins_architecture_test.go b/tests/integration/plugins_architecture_test.go new file mode 100644 index 00000000..d0879919 --- /dev/null +++ b/tests/integration/plugins_architecture_test.go @@ -0,0 +1,29 @@ +package integration_test + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +// TestPluginDependencyClosure guards the extraction point, including transitive +// imports. A service plugin must remain usable without the host CLI and config. +func TestPluginDependencyClosure(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-deps", "../../plugins/...", "github.com/formancehq/fctl/pkg/pluginsdk/...") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("read plugin dependency closure: %v: %s", err, output) + } + for dependency := range strings.FieldsSeq(string(output)) { + if strings.HasPrefix(dependency, "github.com/formancehq/fctl/v4/internal/") || + strings.HasPrefix(dependency, "github.com/formancehq/fctl/v4/cmd") || + dependency == "github.com/spf13/cobra" || dependency == "github.com/spf13/pflag" { + t.Errorf("plugin dependency on host implementation: %s", dependency) + } + } +} diff --git a/tests/integration/presentation_test.go b/tests/integration/presentation_test.go new file mode 100644 index 00000000..ed0da11c --- /dev/null +++ b/tests/integration/presentation_test.go @@ -0,0 +1,192 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync/atomic" + "testing" + + "github.com/formancehq/fctl/v4/cmd" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +const presentationPayload = `{"data":[{"id":"ledger-1","name":"books","amount":90071992547409930001}],"hasMore":false,"next":"opaque-next"}` + +func presentationLedgerServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/prefix/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "3.0.0"}) + return + } + if r.Method != http.MethodGet || r.URL.EscapedPath() != "/prefix/v3/" { + t.Errorf("unexpected presentation request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "" { + t.Error("anonymous presentation request sent credentials") + } + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, presentationPayload); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + return server +} + +func TestPresentationCLINonTerminalJSONContract(t *testing.T) { + t.Parallel() + server := presentationLedgerServer(t) + var want bytes.Buffer + if err := json.Indent(&want, []byte(presentationPayload), "", " "); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + flags []string + }{ + {"default buffer is JSON", nil}, + {"explicit JSON never colored", []string{"-o", "json", "--color", "always"}}, + {"auto buffer is JSON", []string{"-o", "auto", "--color", "always"}}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL + "/prefix"}) + args := append([]string{"--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL + "/prefix"}, tc.flags...) + out, stderr, err := executeRoot(t, append(args, "ledger", "list")...) + if err != nil || stderr != "" || out != want.String()+"\n" || strings.Contains(out, "\x1b") { + t.Fatalf("machine JSON changed: stdout=%q stderr=%q err=%v", out, stderr, err) + } + }) + } +} + +func TestPresentationCLITableColorIsExplicit(t *testing.T) { + t.Parallel() + server := presentationLedgerServer(t) + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL + "/prefix"}) + args := []string{"--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL + "/prefix", "-otable"} + plain, stderr, err := executeRoot(t, append(args, "--color", "never", "ledger", "list")...) + if err != nil || stderr != "" || strings.Contains(plain, "\x1b") || json.Valid([]byte(plain)) { + t.Fatalf("plain table: stdout=%q stderr=%q err=%v", plain, stderr, err) + } + assertPresentationFields(t, plain, "books", "ledger-1", "90071992547409930001", "opaque-next", "false") + colored, stderr, err := executeRoot(t, append(args, "--color", "always", "ledger", "list")...) + if err != nil || stderr != "" || !strings.Contains(colored, "\x1b[") { + t.Fatalf("colored table missing styles: stderr=%q err=%v", stderr, err) + } + if stripPresentationANSI(colored) != plain { + t.Fatal("color changed table fields or layout") + } +} + +func TestPresentationCLIRejectsOptionsBeforeMutation(t *testing.T) { + t.Parallel() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, `{"data":{"id":1}}`); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + for _, flags := range [][]string{{"-o", "yaml"}, {"--output", ""}, {"--color", "invalid"}, {"--color", ""}} { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + dir := t.TempDir() + cacheDistributionLedger(t, dir, pluginmanager.Target{Endpoint: server.URL}) + args := append([]string{"--config-dir", dir, "--auth-mode", "none", "--ledger-url", server.URL}, flags...) + args = append(args, "ledger", "--ledger", "books", "bulk", "--data", `{"postings":[{"source":"world","destination":"users:1","asset":"USD/2","amount":1}]}`) + out, stderr, err := executeRoot(t, args...) + if err == nil || out != "" || stderr != "" || requests.Load() != 0 { + t.Fatalf("invalid presentation reached service: requests=%d stdout=%q stderr=%q err=%v", requests.Load(), out, stderr, err) + } + }) + } +} + +func TestPresentationCLIRejectsOptionsBeforeConnectionWrite(t *testing.T) { + t.Parallel() + for _, flags := range [][]string{{"-o", "yaml"}, {"--color", "invalid"}} { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + dir := t.TempDir() + args := append([]string{"--config-dir", dir}, flags...) + args = append(args, "profiles", "add", "local", "--auth-mode", "none", "--ledger-url", "http://localhost:9000") + out, stderr, err := executeRoot(t, args...) + if err == nil || out != "" || stderr != "" { + t.Fatalf("invalid presentation allowed a local write: stdout=%q stderr=%q err=%v", out, stderr, err) + } + if _, err := os.Stat(filepath.Join(dir, "connections.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid presentation changed the connection store: %v", err) + } + }) + } +} + +func TestPresentationCLIStyledHelpGroupsAndNestedPaths(t *testing.T) { + t.Parallel() + out, stderr, err := executeRoot(t, "--color", "always", "--help") + if err != nil || stderr != "" { + t.Fatalf("styled help: stderr=%q err=%v", stderr, err) + } + for _, group := range []string{"Cloud:", "Modules:", "Profiles:"} { + assertPresentationFields(t, out, "\x1b[1;36m"+group+"\x1b[0m") + } + assertPresentationFields(t, stripPresentationANSI(out), "cloud", "ledger", "auth", "profiles", "login", "logout") + for _, path := range [][]string{{"cloud", "stack"}, {"cloud", "stack", "modules"}, {"cloud", "stack", "create"}} { + args := append([]string{"--color", "never"}, path...) + out, stderr, err := executeRoot(t, append(args, "--help")...) + if err != nil || stderr != "" || strings.Contains(out, "\x1b") { + t.Fatalf("nested help %v: stderr=%q err=%v", path, stderr, err) + } + assertPresentationFields(t, out, "fctl "+strings.Join(path, " "), "Usage:") + } +} + +func TestPresentationCLIStackVersionFlagIsSeparateFromRoot(t *testing.T) { + t.Parallel() + root := cmd.NewRootCommand() + root.InitDefaultVersionFlag() + create, _, err := root.Find([]string{"cloud", "stack", "create"}) + if err != nil { + t.Fatal(err) + } + rootVersion, stackVersion := root.Flags().Lookup("version"), create.Flags().Lookup("version") + if rootVersion == nil || stackVersion == nil { + t.Fatal("version flags missing") + } + if rootVersion.Value.Type() != "bool" || stackVersion.Value.Type() != "string" || stackVersion.DefValue != "v4.0" { + t.Fatal("stack catalog version collided with root version flag") + } + out, stderr, err := executeRoot(t, "cloud", "stack", "create", "--version", "v4.1", "--help") + if err != nil || stderr != "" { + t.Fatalf("stack version string flag: stderr=%q err=%v", stderr, err) + } + assertPresentationFields(t, out, "fctl cloud stack create", "--version string") + out, stderr, err = executeRoot(t, "--version") + if err != nil || stderr != "" || !strings.HasPrefix(out, "fctl version ") || strings.Contains(out, "Usage:") { + t.Fatalf("root version flag changed: stdout=%q stderr=%q err=%v", out, stderr, err) + } +} + +func assertPresentationFields(t *testing.T, out string, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(out, value) { + t.Errorf("output missing %q: %s", value, out) + } + } +} + +func stripPresentationANSI(out string) string { + return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(out, "") +} diff --git a/tests/integration/profiles_test.go b/tests/integration/profiles_test.go new file mode 100644 index 00000000..5909a4cc --- /dev/null +++ b/tests/integration/profiles_test.go @@ -0,0 +1,214 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/formancehq/fctl/v4/internal/connection" + "github.com/formancehq/fctl/v4/internal/pluginmanager" +) + +func TestProfilesPreserveExistingStoreAndSelectionPrecedence(t *testing.T) { + dir, existing := existingProfiles(t) + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := root.Close(); err != nil { + t.Error(err) + } + }) + for _, tc := range []struct { + name string + env string + args []string + want connection.Options + }{ + {"default", "", nil, connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}}, + {"environment", "other", nil, connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9001"}}, + {"long flag", "other", []string{"--profile", "cloud"}, connection.Options{AuthMode: "cloud", Issuer: "https://cloud.example.com"}}, + {"short flag", "other", []string{"-p", "local"}, connection.Options{AuthMode: "none", LedgerURL: "http://localhost:9000"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("FCTL_PROFILE", tc.env) + assertProfileSettings(t, dir, tc.args, tc.want) + data, err := root.ReadFile("connections.json") + if err != nil || !bytes.Equal(data, existing) { + t.Fatalf("profile selection changed the existing store: %v", err) + } + }) + } +} + +func TestProfilesSwitchPreservesCloudSession(t *testing.T) { + t.Parallel() + dir, _ := existingProfiles(t) + if _, _, err := executeRoot(t, "--config-dir", dir, "profiles", "use", "other"); err != nil { + t.Fatal(err) + } + store, err := connection.Load(dir) + if err != nil { + t.Fatal(err) + } + session := store.Connections["cloud"].Session + if store.Active != "other" || session == nil || session.MembershipToken == nil || session.MembershipToken.RefreshToken != "fixture-refresh-token" { + t.Fatal("switching profiles lost the default selection or existing Cloud session") + } +} + +func existingProfiles(t *testing.T) (string, []byte) { + t.Helper() + dir := t.TempDir() + existing := []byte(`{ + "active":"local", + "connections":{ + "local":{"revision":"local-revision","options":{"authMode":"none","ledgerURL":"http://localhost:9000"}}, + "other":{"revision":"other-revision","options":{"authMode":"none","ledgerURL":"http://localhost:9001"}}, + "cloud":{"revision":"cloud-revision","options":{"authMode":"cloud","issuer":"https://cloud.example.com"}, + "session":{"id_token":"fixture-id-token","membership_token":{"access_token":"fixture-access-token","refresh_token":"fixture-refresh-token","token_type":"Bearer"}}} + } + }`) + if err := os.WriteFile(filepath.Join(dir, "connections.json"), existing, 0o600); err != nil { + t.Fatal(err) + } + return dir, existing +} + +func assertProfileSettings(t *testing.T, dir string, args []string, want connection.Options) { + t.Helper() + args = append([]string{"--config-dir", dir}, args...) + stdout, stderr, err := executeRoot(t, append(args, "profiles", "show")...) + if err != nil || stderr != "" { + t.Fatalf("profile selection failed: %v, stderr=%q", err, stderr) + } + var got connection.Options + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("settings = %+v, want %+v", got, want) + } + if strings.Contains(stdout, "fixture-") { + t.Fatal("profile show exposed saved tokens") + } +} + +func TestProfileLifecycle(t *testing.T) { + t.Parallel() + dir := t.TempDir() + run := func(args ...string) string { + t.Helper() + out, stderr, err := executeRoot(t, append([]string{"--config-dir", dir}, args...)...) + if err != nil { + t.Fatal(err) + } + if stderr != "" { + t.Fatalf("unexpected stderr: %s", stderr) + } + if !json.Valid([]byte(out)) { + t.Fatalf("invalid JSON: %s", out) + } + return out + } + run("profiles", "add", "local", "--auth-mode", "none", "--ledger-url", "http://localhost:9000") + run("profiles", "add", "other", "--auth-mode", "none", "--ledger-url", "http://localhost:9001") + run("profiles", "use", "other") + if out := run("profiles", "show"); !strings.Contains(out, "9001") { + t.Fatal(out) + } + if out := run("--profile", "local", "profiles", "show"); !strings.Contains(out, "9000") { + t.Fatal(out) + } + if out := run("profiles", "list"); !strings.Contains(out, `"loggedIn": false`) { + t.Fatal(out) + } + run("profiles", "add", "other", "--replace", "--auth-mode", "none", "--ledger-url", "http://localhost:9002") + run("profiles", "delete", "other", "--confirm") + if out := run("profiles", "list"); strings.Contains(out, `"name": "other"`) { + t.Fatal(out) + } + info, err := os.Stat(filepath.Join(dir, "connections.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode: %v", info.Mode()) + } +} + +func TestProfileErrorsAndLoginBoundaries(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if _, _, err := executeRoot(t, "--config-dir", dir, "profiles", "add", "local", "--auth-mode", "none", "--ledger-url", "http://localhost:9000"); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"profiles", "add", "local", "--auth-mode", "none", "--ledger-url", "http://localhost:9000"}, + {"profiles", "add", "../escape", "--auth-mode", "none", "--ledger-url", "http://localhost:9000"}, + {"profiles", "delete", "local"}, + {"profiles", "delete", "missing", "--confirm"}, + {"profiles", "use", "missing"}, + {"--profile", "missing", "profiles", "show"}, + {"login", "--profile", "local"}, + } { + if _, _, err := executeRoot(t, append([]string{"--config-dir", dir}, args...)...); err == nil { + t.Fatalf("accepted invalid command %v", args) + } + } + if _, _, err := executeRoot(t, "--config-dir", dir, "logout"); err != nil { + t.Fatal(err) + } +} + +func TestExternalServicesUseProfile(t *testing.T) { + t.Parallel() + server := profileServicesServer(t) + dir := t.TempDir() + if _, _, err := executeRoot(t, "--config-dir", dir, "profiles", "add", "local", "--stack-url", server.URL+"/prefix", "--auth-mode", "none"); err != nil { + t.Fatal(err) + } + cacheDistributionAuth(t, dir, pluginmanager.Target{Profile: "local", Endpoint: server.URL + "/prefix/api/auth"}) + cacheDistributionLedger(t, dir, pluginmanager.Target{Profile: "local", Endpoint: server.URL + "/prefix/api/ledger"}) + for _, args := range [][]string{{"ledger", "list"}, {"auth", "clients", "list"}} { + out, _, err := executeRoot(t, append([]string{"--config-dir", dir}, args...)...) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"data": []`) { + t.Fatal(out) + } + } +} + +func profileServicesServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "" { + t.Error("anonymous connection sent credentials") + } + if r.Method == http.MethodGet && r.URL.Path == "/prefix/api/auth/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "1.0.0"}) + return + } + if r.URL.Path == "/prefix/api/ledger/_info" { + writeCLICloudJSON(t, w, map[string]string{"version": "3.0.0"}) + return + } + if r.URL.Path != "/prefix/api/ledger/v3/" && r.URL.Path != "/prefix/api/auth/clients" { + t.Errorf("unexpected route: %s", r.URL.Path) + } + if _, err := w.Write([]byte(`{"data":[]}`)); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + return server +}